How does Hibernate TRANSACTIONAL CacheConcurrencyStrategy work

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-though 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.

TransactionalXAStrictCacheConcurrencyStrategy

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:

TransactionalXACacheConcurrencyStrategy

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("Hibernate-Master-Class", 
        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("Hibernate-Master-Class", 
        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)
    

If you enjoyed this article, I bet you are going to love my book 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 less 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.

Code available on GitHub.

If you liked this article, you might want to subscribe to my newsletter too.

Advertisements

3 thoughts on “How does Hibernate TRANSACTIONAL CacheConcurrencyStrategy work

  1. In the first test It’s not clear for me if the 2nd transaction (Bob’s thread) actually update the 2LC.

    looking at log output:

    INFO  [Bob]: n.t.d.l.CommonsQueryLoggingListener - Name:, Time:1, Num:1, Query:{[update repository set name=? where id=?][High-Performance Hibernate,10]} 
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionStore - put: cache [com.vladmihalcea.hibernate.model.cache.Repository] key [com.vladmihalcea.hibernate.model.cache.Repository#10] was in, replaced with soft lock
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionContext - 1 participating cache(s), committing transaction 2
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionContext - committing soft locked values of cache com.vladmihalcea.hibernate.model.cache.Repository
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionStore - committing 1 soft lock(s) in cache com.vladmihalcea.hibernate.model.cache.Repository
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionContext - committed transaction 2
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionContext - unfreezing and unlocking 1 soft lock(s)
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionContext - unfroze Soft Lock [clustered: false, isolation: rc, key: com.vladmihalcea.hibernate.model.cache.Repository#10]
    DEBUG [Bob]: n.s.e.t.l.LocalTransactionContext - unlocked Soft Lock [clustered: false, isolation: rc, key: com.vladmihalcea.hibernate.model.cache.Repository#10]
    

    The entry is put on the 2lc only after the tx commit. Right? If yes the 2lc is locked by the 1st tx (Alice).

    1. I think it does because otherwise we’d have to see a SELECT in Alice’s transaction. The cache has only two possibilities to cope with stale date: either invalidate or update the cache entries. The TRANSACTIONAL one is write-through and when Bob’s latch is released, Alice doesn’t have any lock on the cache entries (her’s thread is no even enrolled din a transaction towards the end).

      Makes sense?

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s