Software Development

An entity modelling strategy for scaling optimistic locking

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:

optimisticlockingoneproductentityoneversion

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:

optimisticlockingonerootentityoneversion1

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.

optimisticlockingoneproductentitymultipleversions2

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):

optimisticlockingonerootentitymultipleversions

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, product_id bigint not null, primary key (product_id))][]} 
Query:{[create table product_stock (quantity bigint not null, product_id bigint not null, primary key (product_id))][]} 
Query:{[alter table product add constraint UK_jmivyxk9rmgysrmsqw15lqr5b  unique (name)][]} 
Query:{[alter table product_liking add constraint FK_4oiot8iambqw53dwcldltqkco foreign key (product_id) references product][]} 
Query:{[alter table product_stock add constraint FK_hj4kvinsv4h5gi8xi09xbdl46 foreign key (product_id) references product][]} 

#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, product_id) values (?, ?)][0,1]} 
Query:{[insert into product_stock (quantity, product_id) values (?, ?)][7,1]} 

#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_2_1_0_, optimistic0_.likes as likes1_1_0_ from product_liking optimistic0_ where optimistic0_.product_id=?][1]} 
Query:{[select optimistic0_.product_id as product_2_2_0_, optimistic0_.quantity as quantity1_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_2_1_0_, optimistic0_.likes as likes1_1_0_ from product_liking optimistic0_ where optimistic0_.product_id=?][1]} 
Query:{[select optimistic0_.product_id as product_2_2_0_, optimistic0_.quantity as quantity1_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_2_1_0_, optimistic0_.likes as likes1_1_0_ from product_liking optimistic0_ where optimistic0_.product_id=?][1]} 
Query:{[select optimistic0_.product_id as product_2_2_0_, optimistic0_.quantity as quantity1_2_0_ from product_stock optimistic0_ where optimistic0_.product_id=?][1]} 

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

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

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

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

Vlad Mihalcea

Vlad Mihalcea is a software architect passionate about software integration, high scalability and concurrency challenges.
Subscribe
Notify of
guest

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

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button