Optimistic vs Pessimistic Locking in Spring (with Multi-Instance Microservices)

 

Optimistic vs Pessimistic Locking in Spring (with Multi-Instance Microservices)

When multiple users (or services) try to update the same data at the same time, we can easily end up with:

  • Lost updates

  • Inconsistent reads

  • Weird race conditions

To handle this, databases and ORMs provide locking mechanisms. In Spring/JPA, the two most common strategies are:

  • Pessimistic Locking – “Block others while I’m working”

  • Optimistic Locking – “Let others try, I’ll detect conflicts and retry”

In this post, we’ll cover:

  1. The problem: concurrent updates

  2. Pessimistic locking in Spring (with examples)

  3. Optimistic locking in Spring (with examples)

  4. How both behave when you have multiple instances of your Spring Boot service

  5. When to choose which


1. The Problem: Concurrent Updates

Imagine a Product stock table:

idnamestock
1iPhone10

Two requests come in at the same time to buy 3 units each:

  • Request A: reads stock = 10 → decides new stock = 7

  • Request B: reads stock = 10 → decides new stock = 7

If both update without any concurrency control, final stock will be 7 instead of 4 → ❌ lost update.

We need a way to coordinate writes.


2. Pessimistic Locking in Spring

๐Ÿ’ก Idea

“Lock now, work safely, then release.”

With pessimistic locking:

  • When one transaction reads a row for update, it locks that row at the database level.

  • Other transactions that try to lock the same row must wait (or fail with a timeout), until the first transaction commits or rolls back.

Under the hood, the DB does something like:

SELECT * FROM product WHERE id = 1 FOR UPDATE;

This row is locked until the transaction ends.


2.1. Entity Example

import jakarta.persistence.*; @Entity public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private int stock; // getters and setters }

2.2. Repository with Pessimistic Lock

import org.springframework.data.jpa.repository.*; import org.springframework.data.jpa.repository.Lock; import jakarta.persistence.LockModeType; import java.util.Optional; public interface ProductRepository extends JpaRepository<Product, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM Product p WHERE p.id = :id") Optional<Product> findByIdForUpdate(Long id); }
  • @Lock(LockModeType.PESSIMISTIC_WRITE) tells JPA/Hibernate to use a write lock.

  • For supported databases (MySQL, Postgres, etc.), Hibernate will generate SELECT ... FOR UPDATE.


2.3. Service: Decrease Stock with Pessimistic Lock

import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class InventoryService { private final ProductRepository productRepository; public InventoryService(ProductRepository productRepository) { this.productRepository = productRepository; } @Transactional public void purchase(Long productId, int quantity) { // This will lock the row in DB Product product = productRepository.findByIdForUpdate(productId) .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStock() < quantity) { throw new RuntimeException("Not enough stock"); } product.setStock(product.getStock() - quantity); // No need to explicitly save if entity is managed; // JPA will flush on transaction commit. } }

What happens at runtime?

  1. Transaction starts (@Transactional).

  2. findByIdForUpdate runs → DB locks that row.

  3. If another request tries to lock the same row:

    • It waits until the first transaction finishes

    • Or fails with a lock timeout if configured so.

  4. First transaction updates and commits → lock is released.

  5. Next transaction acquires the lock, reads latest value, proceeds.

This ensures no lost updates, but at the cost of:

  • Blocking

  • Possible deadlocks if you lock multiple rows in different orders


2.4. Pessimistic Locking with Multiple Instances

You might wonder: What if I run 5 instances of my Spring Boot microservice behind a load balancer?

Good news: pessimistic locking still works, because the locking is done at the database level, not in the Java process.

Sequence:

  1. Instance A (Pod 1) starts a transaction, queries SELECT ... FOR UPDATE.

  2. DB locks the row.

  3. Instance B (Pod 2) tries same query → DB makes it wait.

  4. When Instance A commits, DB releases the lock.

  5. Instance B continues, reading the updated row.

So even with multiple instances:

  • All of them talk to the same database

  • The database is the single source of truth

  • Row locks are shared across connections/instances

Important:
To avoid long locks:

  • Keep transactions short

  • Don’t call external services inside a long-running transaction

  • Consider lock timeout settings (DB and JPA properties)


3. Optimistic Locking in Spring

๐Ÿ’ก Idea

“Assume no conflict; detect if it happens and retry.”

Optimistic locking is based on a version field:

  • When you read an entity, you read its version.

  • When you update, Hibernate generates SQL like:

UPDATE product SET stock = ?, version = version + 1 WHERE id = ? AND version = ?;

If another transaction updated the same row meanwhile, the version changed → update affects 0 rows → Hibernate throws OptimisticLockException.


3.1. Entity with @Version

import jakarta.persistence.*; @Entity public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private int stock; @Version private Long version; // can be Long, Integer, etc. // getters and setters }
  • @Version is the key here.

  • Hibernate will automatically:

    • Populate it when first persisted

    • Increment it on each update

    • Use it in WHERE clause on update


3.2. Simple Update with Optimistic Locking

import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OptimisticInventoryService { private final ProductRepository productRepository; public OptimisticInventoryService(ProductRepository productRepository) { this.productRepository = productRepository; } @Transactional public void purchase(Long productId, int quantity) { Product product = productRepository.findById(productId) .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStock() < quantity) { throw new RuntimeException("Not enough stock"); } product.setStock(product.getStock() - quantity); // On commit, Hibernate will check version. } }

Scenario with concurrent updates:

  • Initial state: stock=10, version=1

๐Ÿ”น Transaction A:

  • Reads product → (stock=10, version=1)

  • Decreases stock → 7

  • On commit:
    UPDATE ... SET stock=7, version=2 WHERE id=1 AND version=1;
    → Succeeds → row changes to (stock=7, version=2)

๐Ÿ”น Transaction B (started at same time):

  • Reads product → (stock=10, version=1)

  • Decreases stock → 7

  • On commit:
    UPDATE ... SET stock=7, version=2 WHERE id=1 AND version=1;
    → Fails (because version is now 2, not 1)
    → Hibernate throws OptimisticLockException / ObjectOptimisticLockingFailureException

So instead of silently overwriting, you get an exception.


3.3. Handling OptimisticLockException (Retry Pattern)

You usually wrap the logic with retry:

import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Service; @Service public class RetryingInventoryService { private final ProductRepository productRepository; public RetryingInventoryService(ProductRepository productRepository) { this.productRepository = productRepository; } public void purchaseWithRetry(Long productId, int quantity) { int maxRetries = 3; int attempt = 0; while (true) { try { attempt++; doPurchase(productId, quantity); return; // success, exit loop } catch (ObjectOptimisticLockingFailureException e) { if (attempt >= maxRetries) { throw new RuntimeException("Could not complete purchase after retries", e); } // Optionally add small sleep/backoff here } } } @Transactional protected void doPurchase(Long productId, int quantity) { Product product = productRepository.findById(productId) .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStock() < quantity) { throw new RuntimeException("Not enough stock"); } product.setStock(product.getStock() - quantity); } }

This is the common optimistic locking pattern:

  1. Try to update

  2. If conflict → catch exception

  3. Reload latest data and retry (up to N times)


3.4. Optimistic Locking with Multiple Instances

Again, the concurrency control happens at the database level via the version column.

Instances don’t need to know about each other:

  • Instance A and B each read from DB and see the version.

  • On update, DB enforces that only one can successfully commit with that version.

  • The other instance gets an exception.

So optimistic locking:

  • Works fine in multi-instance microservice deployments

  • Is scalable, because you don’t block rows

  • Is great for read-heavy, low-contention scenarios


4. Pessimistic vs Optimistic Locking — When to Use What?

๐Ÿงช Quick Comparison

AspectPessimistic LockingOptimistic Locking
StrategyLock row immediatelyAllow concurrency, detect conflicts at commit
DB behaviorSELECT ... FOR UPDATE row locksUPDATE ... WHERE version = ? check
Performance under high contentionCan cause blocking, deadlocksMany retries / failures but no blocking
Good forHigh-conflict updates, critical writesRead-heavy workloads, rare conflicts
Multi-instance supportYes (DB-level lock)Yes (DB-level version check)
Failure modeLock timeout / deadlockOptimisticLockException

General guidelines:

  • Use pessimistic locking when:

    • Conflicts are very frequent

    • You’d rather block than retry

    • Operation is critical and you want strict serialization (e.g., bank transfers, inventory with tiny stock)

  • Use optimistic locking when:

    • Most of the time, conflicts don’t happen

    • You need high throughput

    • Occasional retries are acceptable


5. Spring Transaction & Locking Tips (Interview Friendly)

  • Locking works inside a transaction, so @Transactional is important.

  • Default isolation in many databases with Spring is READ_COMMITTED.

  • Pessimistic locking relies on DB support for lock modes (e.g., MySQL InnoDB, Postgres).

  • Multiple instances do not break these strategies, because:

    • They share the same DB

    • Locks/versions are enforced in the DB, not in JVM memory.


6. Summary

  • Pessimistic locking:

    “I will lock the row now so nobody else can change it while I’m working.”
    ✅ Safe, but can block and cause deadlocks.

  • Optimistic locking:

    “I assume no one else will change it; if they do, I’ll detect it and retry.”
    ✅ High throughput, but needs retry logic.

In distributed, multi-instance Spring Boot microservices:

  • Both work, as long as you rely on database-level concurrency control.

  • The database is your global lock manager.

No comments:

Post a Comment

Optimistic vs Pessimistic Locking in Spring (with Multi-Instance Microservices)

  Optimistic vs Pessimistic Locking in Spring (with Multi-Instance Microservices) When multiple users (or services) try to update the same ...

Featured Posts