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:
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:
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
.
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):
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 11th of October about High-Performance SQL.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.
