Optimistic and Pessimistic Locking: Handling Concurrent Updates

The Lost Update Problem

Two users edit the same product simultaneously:

Time    User A                          User B
T1      Load product (price=999)        Load product (price=999)
T2      Change price to 899
T3                                      Change price to 799
T4      Save → UPDATE price=899
T5                                      Save → UPDATE price=799

User A’s change is silently overwritten. This is the lost update problem. Both JPA locking strategies prevent it — in different ways.


Optimistic Locking with @Version

Optimistic locking assumes conflicts are rare. It uses a version counter to detect concurrent modifications:

@Entity
@Table(name = "products")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private BigDecimal price;

    @Version
    private Long version;  // Hibernate manages this automatically
}

How It Works

When Hibernate generates an UPDATE, it includes the version in the WHERE clause:

UPDATE products
SET name=?, price=?, version=2
WHERE id=? AND version=1  -- checks the version hasn't changed

If the row’s version in the database no longer matches (another transaction updated it), the UPDATE affects 0 rows. Hibernate detects this and throws OptimisticLockException.

User A loads product (version=1)
User B loads product (version=1)
User A saves → UPDATE ... WHERE version=1 → affects 1 row → success, version becomes 2
User B saves → UPDATE ... WHERE version=1 → affects 0 rows → OptimisticLockException

User B gets an exception instead of silently overwriting User A’s change.

@Version Field Types

@Version
private Long version;     // long counter — most common

@Version
private Integer version;  // int counter

@Version
private Timestamp version; // timestamp — less reliable (millisecond collisions)

Use Long or Integer. Avoid Timestamp — two transactions completing within the same millisecond would not be detected.

Handling OptimisticLockException

@Service
public class ProductService {

    @Retryable(
        retryFor = OptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100, multiplier = 2)
    )
    @Transactional
    public Product updatePrice(Long productId, BigDecimal newPrice) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));
        product.setPrice(newPrice);
        return productRepository.save(product);
    }
}

@Retryable (from Spring Retry) automatically retries on OptimisticLockingFailureException (Spring’s wrapper around JPA’s OptimisticLockException). Each retry re-reads the entity with the latest version.

Enable Spring Retry:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
@Configuration
@EnableRetry
public class RetryConfig {}

REST API Pattern with Optimistic Locking

Return the version field in API responses and require it on update requests:

public record ProductUpdateRequest(
    String name,
    BigDecimal price,
    Long version  // client sends the version it read
) {}
@PutMapping("/{id}")
public ProductDto updateProduct(
    @PathVariable Long id,
    @RequestBody ProductUpdateRequest request
) {
    try {
        return productService.update(id, request);
    } catch (OptimisticLockingFailureException e) {
        throw new ResponseStatusException(
            HttpStatus.CONFLICT,
            "Product was modified by another user. Please reload and try again."
        );
    }
}

The client receives a 409 Conflict and can show a “reload and try again” message.


Pessimistic Locking

Pessimistic locking acquires a database-level lock when reading, preventing other transactions from modifying the row until the lock is released:

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Product> findById(Long id);

    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForRead(@Param("id") Long id);
}
-- PESSIMISTIC_WRITE generates:
SELECT * FROM products WHERE id = ? FOR UPDATE

-- PESSIMISTIC_READ generates:
SELECT * FROM products WHERE id = ? FOR SHARE  -- (MySQL) or LOCK IN SHARE MODE

Lock Mode Types

LockModeTypeSQLBehavior
PESSIMISTIC_WRITEFOR UPDATEExclusive lock — no other reads or writes
PESSIMISTIC_READFOR SHAREShared lock — other reads allowed, writes blocked
PESSIMISTIC_FORCE_INCREMENTFOR UPDATEExclusive lock + increments @Version

PESSIMISTIC_WRITE is the most common — use it when you’re about to modify the row and want no concurrent modifications.

Using Pessimistic Locking in a Service

@Service
public class InventoryService {

    @Transactional
    public void reserveStock(Long productId, int quantity) {
        // Lock the row before reading stock
        Product product = productRepository.findById(productId) // uses @Lock(PESSIMISTIC_WRITE)
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));

        if (product.getStockQuantity() < quantity) {
            throw new InsufficientStockException("Not enough stock");
        }

        product.setStockQuantity(product.getStockQuantity() - quantity);
        // Lock released when transaction commits
    }
}

Only one transaction can hold the FOR UPDATE lock at a time. Concurrent calls to reserveStock for the same product are serialized — no race condition.

Lock Timeout

By default, a FOR UPDATE blocks indefinitely if another transaction holds the lock. Set a timeout:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) // ms
Optional<Product> findById(Long id);

Or globally:

spring:
  jpa:
    properties:
      jakarta:
        persistence:
          lock:
            timeout: 3000

If the lock is not acquired within the timeout, LockTimeoutException is thrown.


Optimistic vs Pessimistic: When to Use Each

OptimisticPessimistic
AssumptionConflicts are rareConflicts are common
HowVersion check at commitLock at read time
ConcurrencyHigh — no locking overheadLower — blocks concurrent reads/writes
DeadlocksNot possiblePossible
Transaction lengthWorks for long transactionsBad for long transactions (holds lock)
Use whenRead-heavy, occasional updatesInventory, seat booking, financial transfers

Decision Guide

Use optimistic locking when:

  • Users edit records in a UI (long think time between load and save)
  • Conflicts are expected to be rare (most edits don’t collide)
  • You want maximum concurrency

Use pessimistic locking when:

  • Race conditions cannot be tolerated (stock reservation, seat booking, bank transfer)
  • Conflicts are common (many workers competing for the same rows)
  • Transaction is short (lock held only briefly)

Deadlock Prevention with Pessimistic Locking

When multiple resources are locked in different orders, deadlocks occur:

Transaction A locks Product 1, then tries to lock Product 2
Transaction B locks Product 2, then tries to lock Product 1
→ Deadlock

Fix: always acquire locks in a consistent order:

@Transactional
public void transfer(Long fromProductId, Long toProductId, int quantity) {
    // Always lock lower ID first — prevents deadlock
    Long firstId  = Math.min(fromProductId, toProductId);
    Long secondId = Math.max(fromProductId, toProductId);

    Product first  = productRepository.findById(firstId).orElseThrow();  // locks
    Product second = productRepository.findById(secondId).orElseThrow(); // locks

    Product from = fromProductId.equals(firstId) ? first : second;
    Product to   = fromProductId.equals(firstId) ? second : first;

    from.setStockQuantity(from.getStockQuantity() - quantity);
    to.setStockQuantity(to.getStockQuantity() + quantity);
}

Combining @Version with Explicit Locking

Use both together when you want optimistic locking as the default but need explicit locking for high-contention operations:

@Entity
public class Product {
    @Version
    private Long version; // optimistic locking for normal operations
}
// Normal update — optimistic locking (no SQL lock)
@Transactional
public Product updateDescription(Long id, String description) {
    Product p = productRepository.findById(id).orElseThrow();
    p.setDescription(description);
    return p; // version increment on commit
}

// Stock operation — pessimistic locking (SQL FOR UPDATE)
@Transactional
public void deductStock(Long id, int qty) {
    Product p = productRepository.findById(id) // @Lock(PESSIMISTIC_WRITE)
        .orElseThrow();
    if (p.getStockQuantity() < qty) throw new InsufficientStockException();
    p.setStockQuantity(p.getStockQuantity() - qty);
}

PESSIMISTIC_FORCE_INCREMENT is useful here too — it acquires a pessimistic lock AND increments the version, so that any concurrent optimistic-locking transaction on the same entity will also detect the conflict.


Testing Concurrent Locking Behaviour

@SpringBootTest
class ConcurrencyTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void optimisticLock_shouldPreventLostUpdate() throws Exception {
        Product product = productRepository.save(createProduct("Laptop", new BigDecimal("999")));
        Long id = product.getId();

        // Simulate two concurrent loads
        Product userA = productRepository.findById(id).orElseThrow();
        Product userB = productRepository.findById(id).orElseThrow();

        // Both read version=0
        assertThat(userA.getVersion()).isEqualTo(userB.getVersion());

        // User A saves first
        userA.setPrice(new BigDecimal("899"));
        productRepository.save(userA); // version becomes 1

        // User B tries to save with stale version=0
        userB.setPrice(new BigDecimal("799"));
        assertThatThrownBy(() -> productRepository.saveAndFlush(userB))
            .isInstanceOf(ObjectOptimisticLockingFailureException.class);
    }
}

Summary

  • Optimistic locking (@Version): Hibernate adds AND version=? to UPDATE statements. Throws OptimisticLockException on conflict. Use for user-facing edits where conflicts are rare. Retry on conflict.
  • Pessimistic locking (@Lock(PESSIMISTIC_WRITE)): Acquires FOR UPDATE at read time. Concurrent transactions block. Use for inventory, bookings, financial operations where conflicts cannot be tolerated.
  • Set a lock timeout to prevent indefinite blocking in pessimistic scenarios.
  • Prevent deadlocks by always acquiring locks in a consistent order.
  • Return the version field in REST APIs so clients can detect and report conflicts cleanly.
  • Combine both: use @Version as the default and pessimistic locking for high-contention code paths.

Next: Article 30 — the final article — covers testing Spring Data JPA with @DataJpaTest, Testcontainers, and test strategies for repositories, services, and transactional behaviour.