How to address the OptimisticLockException in JPA and Hibernate

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

Application-level repeatable reads are suitable for preventing lost updates in web conversations. Enabling entity-level optimistic locking is fairly easy. You just have to mark one logical-clock property (usually an integer counter) with the JPA @Version annotation and Hibernate takes care of the rest.

The catch

Optimistic locking discards all incoming changes that are relative to an older entity version. But everything has a cost and optimistic locking makes no difference.

The optimistic concurrency control mechanism takes an all-or-nothing approach even for non-overlapping changes. If two concurrent transactions are changing distinct entity property subsets, then there’s no risk of losing updates.

Two concurrent updates, starting from the same entity version are always going to collide. It’s only the first update that’s going to succeed, the second one failing with an optimistic locking exception. This strict policy acts as if all changes are overlapping. For highly concurrent write scenarios, this single-version check strategy can lead to a large number of roll-backed updates.

Time for testing

Let’s say we have the following Product entity:

Product entity with version property

This entity is updated by three users (e.g. Alice, Bob, and Vlad), each one updating a distinct property subset. The following diagram depicts their actions:

OptimisticLocking One Root One Version

The SQL DML statement sequence goes like this:

#create tables
Query:{[create table product (id bigint not null, description varchar(255) not null, likes integer not null, name varchar(255) not null, price numeric(19,2) not null, quantity bigint not null, version integer not null, primary key (id))][]} 
Query:{[alter table product add constraint UK_jmivyxk9rmgysrmsqw15lqr5b  unique (name)][]} 

#insert product
Query:{[insert into product (description, likes, name, price, quantity, version, id) values (?, ?, ?, ?, ?, ?, ?)][Plasma TV,0,TV,199.99,7,0,1]} 

#Alice selects the product
Query:{[select optimistic0_.id as id1_0_0_, optimistic0_.description as descript2_0_0_, optimistic0_.likes as likes3_0_0_, optimistic0_.name as name4_0_0_, optimistic0_.price as price5_0_0_, optimistic0_.quantity as quantity6_0_0_, optimistic0_.version as version7_0_0_ from product optimistic0_ where optimistic0_.id=?][1]} 
#Bob selects the product
Query:{[select optimistic0_.id as id1_0_0_, optimistic0_.description as descript2_0_0_, optimistic0_.likes as likes3_0_0_, optimistic0_.name as name4_0_0_, optimistic0_.price as price5_0_0_, optimistic0_.quantity as quantity6_0_0_, optimistic0_.version as version7_0_0_ from product optimistic0_ where optimistic0_.id=?][1]} 
#Vlad selects the product
Query:{[select optimistic0_.id as id1_0_0_, optimistic0_.description as descript2_0_0_, optimistic0_.likes as likes3_0_0_, optimistic0_.name as name4_0_0_, optimistic0_.price as price5_0_0_, optimistic0_.quantity as quantity6_0_0_, optimistic0_.version as version7_0_0_ from product optimistic0_ where optimistic0_.id=?][1]} 

#Alice updates the product
Query:{[update product set description=?, likes=?, name=?, price=?, quantity=?, version=? where id=? and version=?][Plasma TV,0,TV,199.99,6,1,1,0]} 

#Bob updates the product
Query:{[update product set description=?, likes=?, name=?, price=?, quantity=?, version=? where id=? and version=?][Plasma TV,1,TV,199.99,7,1,1,0]} 
c.v.h.m.l.c.OptimisticLockingOneRootOneVersionTest - Bob: Optimistic locking failure
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.vladmihalcea.hibernate.masterclass.laboratory.concurrency.OptimisticLockingOneRootOneVersionTest$Product#1]

#Vlad updates the product
Query:{[update product set description=?, likes=?, name=?, price=?, quantity=?, version=? where id=? and version=?][Plasma HDTV,0,TV,199.99,7,1,1,0]} 
c.v.h.m.l.c.OptimisticLockingOneRootOneVersionTest - Vlad: Optimistic locking failure
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.vladmihalcea.hibernate.masterclass.laboratory.concurrency.OptimisticLockingOneRootOneVersionTest$Product#1]

Because there’s only one entity version, it’s just the first transaction that’s going to succeed. The second and the third updates are discarded since they reference an older entity version.

Divide et impera

If there are more than one writing patterns, we can divide the original entity into several sub-entities. Instead of only one optimistic locking counter, we now have one distinct counter per each sub-entity. In our example, the quantity can be moved to ProductStock and the likes to ProductLiking.

Optimistic Locking One Product Entity Multiple Versions

Whenever we change the product quantity, it’s only the ProductStock version that’s going to be checked, so other competing quantity updates are prevented. But now, we can concurrently update both the main entity (e.g. Product) and each individual sub-entity (e.g. ProductStock and ProductLiking):

Optimistic Locking One Root Entity Multiple Versions

Running the previous test case yields the following output:

#create tables
Query:{[create table product (id bigint not null, description varchar(255) not null, name varchar(255) not null, price numeric(19,2) not null, version integer not null, primary key (id))][]}
Query:{[create table product_liking (likes integer not null, version integer not null, product_id bigint not null, primary key (product_id))][]} 
Query:{[create table product_stock (quantity bigint not null, version integer not null, product_id bigint not null, primary key (product_id))][]} #insert product
Query:{[alter table product add constraint UK_jmivyxk9rmgysrmsqw15lqr5b  unique (name)][]} Query:{[insert into product (description, name, price, version, id) values (?, ?, ?, ?, ?)][Plasma TV,TV,199.99,0,1]} 
Query:{[alter table product_liking add constraint FK_4oiot8iambqw53dwcldltqkco foreign key (product_id) references product][]} Query:{[insert into product_liking (likes, product_id) values (?, ?)][0,1]} 
Query:{[alter table product_stock add constraint FK_hj4kvinsv4h5gi8xi09xbdl46 foreign key (product_id) references product][]} Query:{[insert into product_stock (quantity, product_id) values (?, ?)][7,1]} 

#insert product
Query:{[insert into product (description, name, price, version, id) values (?, ?, ?, ?, ?)][Plasma TV,TV,199.99,0,1]}
Query:{[insert into product_liking (likes, version, product_id) values (?, ?, ?)][0,0,1]} 
Query:{[insert into product_stock (quantity, version, product_id) values (?, ?, ?)][7,0,1]} #Alice selects the product

#Alice selects the product
Query:{[select optimistic0_.id as id1_0_0_, optimistic0_.description as descript2_0_0_, optimistic0_.name as name3_0_0_, optimistic0_.price as price4_0_0_, optimistic0_.version as version5_0_0_ from product optimistic0_ where optimistic0_.id=?][1]} 
Query:{[select optimistic0_.product_id as product_3_1_0_, optimistic0_.likes as likes1_1_0_, optimistic0_.version as version2_1_0_ from product_liking optimistic0_ where optimistic0_.product_id=?][1]}
Query:{[select optimistic0_.product_id as product_3_2_0_, optimistic0_.quantity as quantity1_2_0_, optimistic0_.version as version2_2_0_ from product_stock optimistic0_ where optimistic0_.product_id=?][1]}

#Bob selects the product
Query:{[select optimistic0_.id as id1_0_0_, optimistic0_.description as descript2_0_0_, optimistic0_.name as name3_0_0_, optimistic0_.price as price4_0_0_, optimistic0_.version as version5_0_0_ from product optimistic0_ where optimistic0_.id=?][1]} 
Query:{[select optimistic0_.product_id as product_3_1_0_, optimistic0_.likes as likes1_1_0_, optimistic0_.version as version2_1_0_ from product_liking optimistic0_ where optimistic0_.product_id=?][1]}
Query:{[select optimistic0_.product_id as product_3_2_0_, optimistic0_.quantity as quantity1_2_0_, optimistic0_.version as version2_2_0_ from product_stock optimistic0_ where optimistic0_.product_id=?][1]}

#Vlad selects the product
Query:{[select optimistic0_.id as id1_0_0_, optimistic0_.description as descript2_0_0_, optimistic0_.name as name3_0_0_, optimistic0_.price as price4_0_0_, optimistic0_.version as version5_0_0_ from product optimistic0_ where optimistic0_.id=?][1]} 
Query:{[select optimistic0_.product_id as product_3_1_0_, optimistic0_.likes as likes1_1_0_, optimistic0_.version as version2_1_0_ from product_liking optimistic0_ where optimistic0_.product_id=?][1]}
Query:{[select optimistic0_.product_id as product_3_2_0_, optimistic0_.quantity as quantity1_2_0_, optimistic0_.version as version2_2_0_ from product_stock optimistic0_ where optimistic0_.product_id=?][1]}

#Alice updates the product
Query:{[update product_stock set quantity=?, version=? where product_id=? and version=?][6,1,1,0]} 

#Bob updates the product
Query:{[update product_liking set likes=?, version=? where product_id=? and version=?][1,1,1,0]} 

#Vlad updates the product
Query:{[update product set description=?, name=?, price=?, version=? where id=? and version=?][Plasma HDTV,TV,199.99,1,1,0]}

All three concurrent transactions are successful because we no longer have only one logical-clock version but three of them, according to three distinct write responsibilities.

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

When designing the persistence domain model, you have to take into consideration both the querying and writing responsibility patterns.

Breaking a larger entity into several sub-entities can help you scale updates while reducing the chance of optimistic locking failures. If you wary of possible performance issues (due to entity state fragmentation) you should then know that Hibernate offers several optimization techniques for overcoming the scattered entity info side-effect.

You can always join all sub-entities in a single SQL query, in case you need all entity related data.

The second-level caching is also a good solution for fetching sub-entities without hitting the database. Because we split the root entity into several entities, the cache can be better utilized. A stock update is only going to invalidate the associated ProductStock cache entry, without interfering with Product and ProductLiking cache regions.

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.