Follow @vlad_mihalcea Imagine having a tool that can automatically detect if you are using JPA and Hibernate properly. Hypersistence Optimizer is that tool!

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.

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:

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)

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.

Code available on GitHub.

Insert details about how the information is going to be processed DOWNLOAD NOW