In any multi-user applications, it's common for multiple transactions to try to access and modify the same data simultaneously. This can lead to data inconsistency issues like dirty reads, non-repeatable reads, and phantom reads. To prevent these issues, we need to use locking mechanisms in our application. In this blog post, we'll explore two common locking strategies: pessimistic locking and optimistic locking, and how to implement them in a Spring Boot application using Spring Data JPA.
Let's consider a simple use case: an e-commerce application where we need to manage product inventory. A common operation is to purchase products, which decreases the available stock quantity.
Here's our Product
entity:
@Entity public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String productCode; private String name; private BigDecimal price; private Integer stockQuantity; }
Now, imagine two customers trying to purchase the same product simultaneously during a flash sale.
Customer A wants to buy 3 units of "iPhone 15 Pro". Customer B wants to buy 2 units of the same "iPhone 15 Pro".
Let's say there are only 4 units left in stock.
The final stock is 2 units, which is incorrect. We've actually sold 5 units (3 + 2) when we only had 4 in stock. This is a classic "lost update" problem that leads to overselling. Let's see how locking can solve this.
Pessimistic locking assumes that conflicts are likely to happen. It works by locking the database record when it's read, preventing any other transaction from acquiring a lock on the same record until the first transaction completes (commits or rolls back). This is like saying, "I expect problems, so I'll lock the record just in case."
This strategy is implemented using database-level locks. When a transaction reads a row with the intention to update it, it acquires a write lock (an exclusive lock) on that row. Other transactions that try to read the same row with an intent to update will be blocked until the lock is released.
Spring Data JPA makes it easy to apply pessimistic locking. You can use the @Lock
annotation on your repository methods.
public interface ProductRepository extends JpaRepository<Product, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Product> findByProductCode(String productCode); }
Here, LockModeType.PESSIMISTIC_WRITE
tells the JPA provider to obtain a write lock on the database row when the entity is fetched. Any other transaction trying to read the same row with a write lock will be blocked until the current transaction completes.
Here's how our purchaseProduct
service method would look:
@Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; @Transactional public void purchaseProduct(String productCode, Integer quantity) { Product product = productRepository.findByProductCode(productCode) .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStockQuantity() < quantity) { throw new RuntimeException("Insufficient stock"); } // Simulate some processing time (payment processing, etc.). During this time, //the lock is held by the current transaction and other threads trying to read the same product will be blocked. try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } product.setStockQuantity(product.getStockQuantity() - quantity); productRepository.save(product); } }
When the findByProductCode
method is called, Hibernate (the default JPA provider in Spring Boot) will generate SQL similar to this (for PostgreSQL, MySQL, etc.):
SELECT * FROM product WHERE product_code = ? FOR UPDATE
The FOR UPDATE
clause is what instructs the database to lock the selected rows. If another transaction tries to execute the same query for the same product code, it will have to wait until the first transaction is committed or rolled back.
JPA provides different pessimistic lock modes for different scenarios:
Acquires a shared lock that prevents data from being modified but allows other transactions to read the data. This is useful when you want to ensure data consistency during reads without blocking other readers.
@Lock(LockModeType.PESSIMISTIC_READ) Optional<Product> findByProductCode(String productCode);
Acquires an exclusive lock that prevents other transactions from reading or writing the data. This is the most restrictive mode and is what we've been using in our examples.
@Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Product> findByProductCode(String productCode);
Similar to PESSIMISTIC_WRITE
but also increments the version field (if present) on the entity. This combines pessimistic and optimistic locking strategies.
@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT) Optional<Product> findByProductCode(String productCode);
When using pessimistic locks, transactions may wait indefinitely if a lock cannot be acquired. You can configure lock timeouts using @QueryHints
:
public interface ProductRepository extends JpaRepository<Product, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "3000")}) Optional<Product> findByProductCode(String productCode); }
The timeout value is in milliseconds. If the lock cannot be acquired within this time, a LockTimeoutException
is thrown.
You should handle these exceptions appropriately:
@Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; @Transactional public void purchaseProduct(String productCode, Integer quantity) { try { Product product = productRepository.findByProductCode(productCode) .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStockQuantity() < quantity) { throw new RuntimeException("Insufficient stock"); } product.setStockQuantity(product.getStockQuantity() - quantity); productRepository.save(product); } catch (PessimisticLockException | LockTimeoutException e) { throw new RuntimeException("Unable to acquire lock on product. Please try again.", e); } } }
However, pessimistic locking can lead to performance bottlenecks and deadlocks if not used carefully.
Optimistic locking, as the name suggests, assumes that conflicts are rare. It doesn't lock the data when it's read. Instead, it checks if the data has been modified by another transaction before committing its own changes. This is like saying, "I'll assume everything is fine, but I'll double-check before I finish."
This strategy is typically implemented by adding a version column to the database table. When a transaction reads a row, it also reads the version number. When it's time to update, the transaction checks if the version number in the database is still the same as the one it read.
OptimisticLockException
).To enable optimistic locking, you just need to add a field to your entity and annotate it with @Version
.
@Entity public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String productCode; private String name; private BigDecimal price; private Integer stockQuantity; @Version private int version; }
The @Version
annotation can be used on fields of type int
, Integer
, short
, Short
, long
, Long
, or java.sql.Timestamp
.
Now, when you update the entity, JPA will automatically handle the version check. You don't need any special annotations on your repository methods.
@Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; @Transactional public void purchaseProduct(String productCode, Integer quantity) { Product product = productRepository.findByProductCode(productCode) .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStockQuantity() < quantity) { throw new RuntimeException("Insufficient stock"); } product.setStockQuantity(product.getStockQuantity() - quantity); productRepository.save(product); } }
When an optimistic lock conflict occurs, a StaleObjectStateException
(from Hibernate) or a more generic OptimisticLockingFailureException
(from Spring) will be thrown. Depending on the use case, we can implement the logic to retry or send error back to the client.
When the save
method is called to update the entity, the generated SQL will look something like this:
UPDATE product SET stock_quantity = ?, version = ? WHERE id = ? AND version = ?
Hibernate increments the version number and adds a WHERE
clause to check that the version in the database is the same as the one the application read. If the WHERE
clause doesn't find a row to update (because the version has changed), the update fails, and an exception is thrown.
While using @Lock
annotations on repository methods is convenient, you can also apply locks programmatically using EntityManager
. This gives you more fine-grained control over when and how locks are applied:
@Service @RequiredArgsConstructor public class ProductService { private final EntityManager entityManager; @Transactional public void purchaseProductWithEntityManager(Long productId, Integer quantity) { Product product = entityManager.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE); if (product == null) { throw new RuntimeException("Product not found"); } if (product.getStockQuantity() < quantity) { throw new RuntimeException("Insufficient stock"); } product.setStockQuantity(product.getStockQuantity() - quantity); } }
You can also upgrade the lock on an already loaded entity:
@Transactional public void upgradeLock(Long productId) { Product product = entityManager.find(Product.class, productId); entityManager.lock(product, LockModeType.PESSIMISTIC_WRITE); }
This is useful when you initially read data without a lock and later decide you need to modify it.
Feature | Pessimistic Locking | Optimistic Locking |
---|---|---|
Assumption | Conflicts are likely to happen. | Conflicts are rare. |
Mechanism | Locks data on read. | Checks for conflicts on write. |
Implementation | Database-level locks (SELECT ... FOR UPDATE ). | Version column in the table. |
Concurrency | Lower (transactions block each other). | Higher (transactions don't block each other). |
Performance | Can be a bottleneck if transactions are long. | Better for read-heavy and long-running tasks. |
Conflict Handling | Transactions wait for the lock to be released. | Transactions fail and must be retried. |
Best For | High contention, short transactions. | Low contention, long-running transactions. |
Both pessimistic and optimistic locking are powerful tools for managing concurrency in database applications. I tend to use optimistic locking in most of the time for better performance and scalability unless I have a good reason to use pessimistic locking.
For more in-depth tutorials on Java, Spring, and modern software development, check out my content:
🔗 Blog: https://codewiz.info
🔗 LinkedIn: https://www.linkedin.com/in/code-wiz-740370302/
🔗 Medium: https://medium.com/@code.wizzard01
🔗 Github: https://github.com/CodeWizzard01
Learn parallel task execution in Java and Spring Boot using CompletableFuture, @Async, Virtual Threads, and Structured Concurrency for better performance.
Comprehensive guide to Spring Data JPA with practical examples and best practices. Learn how to effectively use JPA in Spring Boot applications.
Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.