How does Hibernate TRANSACTIONAL CacheConcurrencyStrategy work

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

In my previous post, I introduced the READ_WRITE second-level cache concurrency mechanism. In this article, I am going to continue this topic with the TRANSACTIONAL strategy.

Write-through caching

While the READ_WRITE CacheConcurrencyStartegy is an asynchronous write-through caching mechanism (since changes are being propagated only after the current database transaction is completed), the TRANSACTIONAL CacheConcurrencyStartegy is synchronized with the current XA transaction.

To enlist two sources of data (the database and the second-level cache) in the same global transaction, we need to use the Java Transaction API and a JTA transaction manager must coordinate the participating XA resources.

In the following example, I’m going to use Bitronix Transaction Manager, since it’s automatically discovered by EhCache and it also supports the one-phase commit (1PC) optimization.

The EhCache second-level cache implementation offers two failure recovery options: xa_strict and xa.

xa_strict

In this mode, the second-level cache exposes an XAResource interface, so it can participate in the two-phase commit (2PC) protocol.

Transactional XAStrict CacheConcurrencyStrategy

The entity state is modified both in the database and in the cache, but these changes are isolated from other concurrent transactions and they become visible once the current XA transaction gets committed.

The database and the cache remain consistent even in case of an application crash.

xa

If only one data source participates in a globaltransaction, the transaction manager can apply the one-phase commit optimization. The second-level cache is managed through a Synchronization transaction callback. The second-level cache doesn’t actively participates in deciding the transaction outcome, as it merely executes according to the current database transaction outcome:

Transactional XA CacheConcurrencyStrategy

This mode trades durability for latency and in case of a server crash (happening in between the database transaction commit and the second-level cache transaction callback), the two data sources will drift apart. This issue can be mitigated if our entities employ an optimistic concurrency control mechanism, so even if we read stale data, we will not lose updates upon writing.

Isolation level

To the validate the TRANSACTIONAL concurrency strategy isolation level, we are going to use the following test case:

doInTransaction((entityManager) -> {
    Repository repository = entityManager.find(
        Repository.class, repositoryReference.getId());
        
    assertEquals("High-Performance Java Persistence", 
        repository.getName());
        
    executeSync(() -> {
        doInTransaction(_entityManager -> {
            Repository _repository = entityManager.find(
                Repository.class, 
                repositoryReference.getId());
            
            _repository.setName(
                "High-Performance Hibernate");
                
            LOGGER.info("Updating repository name to {}", 
                _repository.getName());
        });
    });

    repository = entityManager.find(
        Repository.class, 
        repositoryReference.getId());
        
    assertEquals("High-Performance Java Persistence", 
        repository.getName());

    LOGGER.info("Detaching repository");
    entityManager.detach(repository);
    assertFalse(entityManager.contains(repository));

    repository = entityManager.find(
        Repository.class, repositoryReference.getId());

    assertEquals("High-Performance Hibernate", 
        repository.getName());
});
  • Alice loads a Repository entity into its current Persistence Context
  • Bob loads the same Repository and then modifies it
  • After Bob’s transaction is committed, Alice still sees the old Repository data, because the Persistence Context provides application-level repeatable reads
  • When Alice evicts the Repository from the first-level cache and fetches it anew, she will see Bob’s changes

The second-level cache doesn’t offer repeatable reads guarantees since the first-level cache already does this anyway.

Next, we’ll investigate if dirty reads or lost updates are possible and for this we are going to use the following test:

final AtomicReference<Future<?>> 
    bobTransactionOutcomeHolder = new AtomicReference<>();

doInTransaction((entityManager) -> {
    Repository repository = entityManager.find(
        Repository.class, repositoryReference.getId());

    repository.setName("High-Performance Hibernate");
    entityManager.flush();

    Future<?> bobTransactionOutcome = executeAsync(() -> {
        doInTransaction((_entityManager) -> {
            Repository _repository = entityManager.find(
                Repository.class, 
                repositoryReference.getId());

            _repository.setName(
                "High-Performance Hibernate Book");

            aliceLatch.countDown();
            awaitOnLatch(bobLatch);
        });
    });

    bobTransactionOutcomeHolder.set(
        bobTransactionOutcome);
    sleep(500);
    awaitOnLatch(aliceLatch);
});

doInTransaction((entityManager) -> {
    LOGGER.info("Reload entity after Alice's update");
    Repository repository = entityManager.find(
        Repository.class, repositoryReference.getId());
    assertEquals("High-Performance Hibernate", 
        repository.getName());
});

bobLatch.countDown();
bobTransactionOutcomeHolder.get().get();

doInTransaction((entityManager) -> {
    LOGGER.info("Reload entity after Bob's update");
    Repository repository = entityManager.find(
        Repository.class, repositoryReference.getId());
    assertEquals("High-Performance Hibernate Book", 
        repository.getName());
});

This test will emulate two concurrent transactions, trying to update the same Repository entity. This use case is run on PostgreSQL, using the default READ_COMMITTED transaction isolation level.

Running this test generates the following output:

  • Alice loads the Repository entity
    [Alice]: n.s.e.TransactionController - begun transaction 4
    [Alice]: n.s.e.t.l.LocalTransactionStore - get: cache [com.vladmihalcea.hibernate.model.cache.Repository] key [com.vladmihalcea.hibernate.model.cache.Repository#11] not soft locked, returning underlying element
    
  • Alice changes the Repository name
  • Alice flushes the current Persistent Context, so an UPDATE statement is executed. Because Alice’s transaction has not yet committed, a lock will prevent other concurrent transactions from modifying the same Repository row
    [Alice]: n.t.d.l.CommonsQueryLoggingListener - Name:, Time:1, Num:1, Query:{[update repository set name=? where id=?][High-Performance Hibernate,11]} 
    [Alice]: n.s.e.t.l.LocalTransactionStore - put: cache [com.vladmihalcea.hibernate.model.cache.Repository] key [com.vladmihalcea.hibernate.model.cache.Repository#11] was in, replaced with soft lock
    
  • Bob starts a new transaction and loads the same Repository entity
    [Bob]: n.s.e.TransactionController - begun transaction 5
    [Bob]: n.s.e.t.l.LocalTransactionStore - get: cache [com.vladmihalcea.hibernate.model.cache.Repository] key [com.vladmihalcea.hibernate.model.cache.Repository#11] soft locked, returning soft locked element
    
  • Bob also changes the Repository name.
  • The aliceLatch is used to demonstrate that Bob’s transaction is blocked, waiting for Alice’s to release the Repository row-level lock
    [Alice]: c.v.HibernateCacheTest - Wait 500 ms!
    
  • Alice’s thread wakes after having waited for 500 ms and her transaction is committed
    [Alice]: n.s.e.t.l.LocalTransactionContext - 1 participating cache(s), committing transaction 4
    [Alice]: n.s.e.t.l.LocalTransactionContext - committing soft locked values of cache com.vladmihalcea.hibernate.model.cache.Repository
    [Alice]: n.s.e.t.l.LocalTransactionStore - committing 1 soft lock(s) in cache com.vladmihalcea.hibernate.model.cache.Repository
    [Alice]: n.s.e.t.l.LocalTransactionContext - committed transaction 4
    [Alice]: n.s.e.t.l.LocalTransactionContext - unfreezing and unlocking 1 soft lock(s)
    [Alice]: n.s.e.t.l.LocalTransactionContext - unfroze Soft Lock [clustered: false, isolation: rc, key: com.vladmihalcea.hibernate.model.cache.Repository#11]
    [Alice]: n.s.e.t.l.LocalTransactionContext - unlocked Soft Lock [clustered: false, isolation: rc, key: com.vladmihalcea.hibernate.model.cache.Repository#11]
    
  • Alice starts a new transaction and checks that the Repository name is the one she’s just set
    [Alice]: c.v.HibernateCacheTest - Reload entity after Alice's update
    [Alice]: n.s.e.TransactionController - begun transaction 6
    [Alice]: n.s.e.t.l.LocalTransactionStore - get: cache [com.vladmihalcea.hibernate.model.cache.Repository] key [com.vladmihalcea.hibernate.model.cache.Repository#11] not soft locked, returning underlying element
    WARN  [Alice]: b.t.t.Preparer - executing transaction with 0 enlisted resource
    [Alice]: n.s.e.t.l.LocalTransactionContext - 0 participating cache(s), committing transaction 6
    [Alice]: n.s.e.t.l.LocalTransactionContext - committed transaction 6
    [Alice]: n.s.e.t.l.LocalTransactionContext - unfreezing and unlocking 0 soft lock(s)
    
  • Alice thread allows Bob’s thread to continue and she starts waiting on the bobLatch for Bob to finish his transaction
  • Bob can simply issue a database UPDATE and a second-level cache entry modification, without noticing that Alice has changed the Repository since he first loaded it
    [Bob]: n.t.d.l.CommonsQueryLoggingListener - Name:, Time:1, Num:1, Query:{[update repository set name=? where id=?][High-Performance Hibernate Book,11]} 
    [Bob]: n.s.e.t.l.LocalTransactionStore - put: cache [com.vladmihalcea.hibernate.model.cache.Repository] key [com.vladmihalcea.hibernate.model.cache.Repository#11] was in, replaced with soft lock
    [Bob]: n.s.e.t.l.LocalTransactionContext - 1 participating cache(s), committing transaction 5
    [Bob]: n.s.e.t.l.LocalTransactionContext - committing soft locked values of cache com.vladmihalcea.hibernate.model.cache.Repository
    [Bob]: n.s.e.t.l.LocalTransactionStore - committing 1 soft lock(s) in cache com.vladmihalcea.hibernate.model.cache.Repository
    [Bob]: n.s.e.t.l.LocalTransactionContext - committed transaction 5
    [Bob]: n.s.e.t.l.LocalTransactionContext - unfreezing and unlocking 1 soft lock(s)
    [Bob]: n.s.e.t.l.LocalTransactionContext - unfroze Soft Lock [clustered: false, isolation: rc, key: com.vladmihalcea.hibernate.model.cache.Repository#11]
    [Bob]: n.s.e.t.l.LocalTransactionContext - unlocked Soft Lock [clustered: false, isolation: rc, key: com.vladmihalcea.hibernate.model.cache.Repository#11]
    
  • After Bob manages to update the Repository database and cache records, Alice starts a new transaction and she can see Bob’s changes
    [Alice]: c.v.HibernateCacheTest - Reload entity after Bob's update
    [Alice]: o.h.e.t.i.TransactionCoordinatorImpl - Skipping JTA sync registration due to auto join checking
    [Alice]: o.h.e.t.i.TransactionCoordinatorImpl - successfully registered Synchronization
    [Alice]: n.s.e.TransactionController - begun transaction 7
    [Alice]: n.s.e.t.l.LocalTransactionStore - get: cache [com.vladmihalcea.hibernate.model.cache.Repository] key [com.vladmihalcea.hibernate.model.cache.Repository#11] not soft locked, returning underlying element
    WARN  [Alice]: b.t.t.Preparer - executing transaction with 0 enlisted resource
    [Alice]: n.s.e.t.l.LocalTransactionContext - 0 participating cache(s), committing transaction 7
    [Alice]: n.s.e.t.l.LocalTransactionContext - committed transaction 7
    [Alice]: n.s.e.t.l.LocalTransactionContext - unfreezing and unlocking 0 soft lock(s)
    

I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

The TRANSACTIONAL CacheConcurrencyStrategy employes a READ_COMMITTED transaction isolation, preventing dirty reads while still allowing the lost updates phenomena. Adding optimistic locking can eliminate the lost update anomaly since the database transaction will rollback on version mismatches. Once the database transaction fails, the current XA transaction is rolled back, causing the cache to discard all uncommitted changes.

If the READ_WRITE concurrency strategy implies less overhead, the TRANSACTIONAL synchronization mechanism is appealing for higher write-read ratios (requiring fewer database hits compared to its READ_WRITE counterpart). The inherent performance penalty must be compared against the READ_WRITE extra database access when deciding which mode is more suitable for a given data access pattern.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.