Locking Strategies in Concurrent Applications: Pessimistic vs. Optimistic Locking with Spring Boot

    Locking Strategies in Concurrent Applications: Pessimistic vs. Optimistic Locking with Spring Boot

    01/10/2025

    Introduction

    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.

    Use Case: E-commerce Inventory Management

    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.

    1. Customer A's transaction reads the stock quantity (4 units).
    2. Customer B's transaction reads the stock quantity (4 units).
    3. Customer A's transaction calculates the new stock (4 - 3 = 1) and updates the database.
    4. Customer B's transaction calculates the new stock (4 - 2 = 2) and updates the database.

    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

    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.

    How it works

    Transaction A Transaction B Database 1. Read Product with lock 2. Lock Acquired 3. Update Stock 4. Commit & Release Lock 1. Read Product with lock Blocked Lock available 2. Lock Acquired 3. Update Stock 4. Commit & Release Lock

    Implementation with Spring Data JPA

    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); } }

    Generated SQL Query

    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.

    Pessimistic Lock Modes

    JPA provides different pessimistic lock modes for different scenarios:

    PESSIMISTIC_READ

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

    PESSIMISTIC_WRITE

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

    PESSIMISTIC_FORCE_INCREMENT

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

    Handling Lock Timeouts

    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); } } }

    When to Use Pessimistic Locking

    • High contention environments: When it's very likely that multiple transactions will try to modify the same data at the same time.
    • Short transactions: The lock is held for the duration of the transaction, so it's best if the transaction is quick to avoid blocking other transactions for too long.
    • When data consistency is critical: If the cost of a conflict is very high, pessimistic locking provides a strong guarantee of consistency.

    However, pessimistic locking can lead to performance bottlenecks and deadlocks if not used carefully.

    Optimistic Locking

    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.

    • If the versions match, the transaction proceeds with the update and increments the version number.
    • If the versions don't match, it means another transaction has modified the data in the meantime. The current transaction is then rolled back, and an exception is thrown (usually an OptimisticLockException).

    How it works

    Transaction A Transaction B Database 1. Read Product (version=1) 2. Update Stock 3. Commit (UPDATE... WHERE version=1) Success! (version is now 2) 1. Read Product (version=1) 2. Update Stock 3. Commit (UPDATE... WHERE version=1) Fail! (version is 2)

    Implementation with Spring Data JPA

    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); } }

    Handling Optimistic Lock Exceptions

    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.

    Generated SQL Query

    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.

    When to Use Optimistic Locking

    • Low contention environments: When conflicts are rare, and it's unlikely that multiple transactions will modify the same data.
    • Long-running transactions or "conversations": When data is read, displayed to a user for modification, and then written back. The user might take a long time, and holding a database lock during this time is not practical.
    • Read-heavy systems: Optimistic locking doesn't impose any overhead on read operations, making it a good choice for systems where reads are much more frequent than writes.

    Programmatic Locking with EntityManager

    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.

    Summary

    FeaturePessimistic LockingOptimistic Locking
    AssumptionConflicts are likely to happen.Conflicts are rare.
    MechanismLocks data on read.Checks for conflicts on write.
    ImplementationDatabase-level locks (SELECT ... FOR UPDATE).Version column in the table.
    ConcurrencyLower (transactions block each other).Higher (transactions don't block each other).
    PerformanceCan be a bottleneck if transactions are long.Better for read-heavy and long-running tasks.
    Conflict HandlingTransactions wait for the lock to be released.Transactions fail and must be retried.
    Best ForHigh contention, short transactions.Low contention, long-running transactions.

    Best Practices

    • Keep transactions short: Especially with pessimistic locking, holding locks for extended periods can severely impact performance.
    • Handle exceptions gracefully: Always implement proper exception handling and retry logic for both locking strategies.
    • Use appropriate isolation levels: Combine locking strategies with proper transaction isolation levels for your use case.
    • Consider hybrid approaches: For complex scenarios, you might use pessimistic locking for critical sections and optimistic locking elsewhere.
    • Test under load: Always test your locking strategy under realistic concurrent load to identify potential bottlenecks or deadlocks.

    Conclusion

    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

    Summarise

    Transform Your Learning

    Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.

    Instant video summaries
    Smart insights extraction
    Channel tracking