• ES

JPA - OPTIMISTIC VS PESSIMISTIC LOCKING

TWO REQUESTS, ONE BALANCE AND A LOST UPDATE WITHOUT AN EXCEPTION

JPA - OPTIMISTIC VS PESSIMISTIC LOCKING

My blog is heavy on platform stuff (Vault, Kubernetes, Docker) and light on backend, even though the About page says "backend". This is the first post in a Java and Spring Boot series I'd been meaning to write, and I'm starting with something that looks trivial in a tutorial and only shows up late in production: the lost update.

In production I've used optimistic locking on quite a few tables. Every now and then a conflict error would pop up, and we'd fix it by retrying the RabbitMQ message that triggered the process and the database write. Here I'll tell you how it works and how to reproduce the problem in a test.

Tested with Java 21, Spring Boot 4.1.1 (Hibernate 7.4.5), Testcontainers 2.0.5 and PostgreSQL 17. As of October 2026, Hibernate 8 is still in beta (8.0.0.Beta1, with Jakarta Persistence 4.0), so I'm using the stable 7.x line; when 8 is released, the details about locks and timeouts may change.

The problem

Two requests deposit money into the same account at the same time. Each one reads the balance, adds its amount and saves. If both read before the other one commits, whichever saves second overwrites the result of the first. Nobody throws an exception and the balance ends up wrong.

Before talking about solutions, let's reproduce the failure. If you can't trigger it in a test, you won't know whether you've fixed it either.

The entities

Two almost identical entities: one with no protection, to see the problem, and one with @Version.

@Entity
public class UnprotectedAccount {

    @Id
    @GeneratedValue
    private Long id;

    private long balance;

    // getters and setters
}

@Entity
public class Account {

    @Id
    @GeneratedValue
    private Long id;

    private long balance;

    @Version
    private long version;

    // getters and setters
}

Optimistic locking: @Version

The idea is to lock nothing and detect the conflict when saving. With @Version, Hibernate adds the version to the UPDATE: update account set balance=?,version=? where id=? and version=?. If another transaction has already changed the row, the where finds nothing, 0 rows are updated and an exception is thrown. According to the Hibernate 7.0 introduction guide (section 3.8, "Version attributes"), almost any entity that is updated frequently should have a version attribute, and it's Hibernate that assigns it when the entity is persisted and updates it on every modification.

@Service
public class AccountService {

    private final AccountRepository repository;

    public AccountService(AccountRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void depositOptimistic(Long id, long amount) {
        Account account = repository.findById(id).orElseThrow();
        account.setBalance(account.getBalance() + amount); // dirty checking, no save()
    }
}

In Spring that exception arrives as org.springframework.orm.ObjectOptimisticLockingFailureException, not as jakarta.persistence.OptimisticLockException: Spring translates the JPA exception. It's thrown when Hibernate flushes the changes to the database, and by default that happens when the transaction commits, that is, when the @Transactional method exits. That's why a try/catch inside the method doesn't catch it; you'd only see it in there if you force the flush (flush(), saveAndFlush() or a query that triggers it). I checked it with a test (FlushTimingTest) with three cases: with dirty checking and no forced flush, the try/catch inside doesn't catch it and it reaches the caller; with saveAndFlush() inside the try/catch, it does catch it; and with a query before the end, which triggers the automatic flush, it does too. Watch out, though: catching it inside doesn't save you. The transaction is left marked for rollback, so the outer commit fails with UnexpectedRollbackException and the change isn't saved (FlushTimingTest covers that too). You have to catch it outside, in the caller, which is also where the retry goes. If you use plain JPA, with an EntityManager and no Spring translation, what you'll get is jakarta.persistence.OptimisticLockException. And if your code can also throw the Spring Data JDBC variant, you're better off catching the superclass org.springframework.dao.OptimisticLockingFailureException, which covers both.

And what do you do when it fires? Retrying only makes sense if the operation is idempotent or you re-read everything. In my case it was a RabbitMQ message: when it was retried, the whole process read the already updated row again and saved. The way I see it: optimistic locking is ideal when conflicts are rare and retrying is cheap; if you're retrying all the time, you're paying for the work twice.

Pessimistic locking: SELECT ... FOR UPDATE

The opposite approach: you lock the row when you read it and everyone else waits. In JPA it's LockModeType.PESSIMISTIC_WRITE, and in Spring Data you set it with @Lock on the repository:

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
@Transactional
public void depositPessimistic(Long id, long amount) {
    Account account = repository.findByIdForUpdate(id).orElseThrow();
    account.setBalance(account.getBalance() + amount);
}

The lock only exists inside an active transaction: outside one there's nothing to hold. The jakarta.persistence.lock.timeout hint (in milliseconds) limits how long the second one waits. If that time passes with the row still locked, Spring throws CannotAcquireLockException. Whether the timeout is honored depends on the database, so test it with yours.

Compared to optimistic locking, what you pay for is contention: transactions waiting, risk of deadlocks if you lock several rows in different orders, and pool connections tied up while they wait.

The simplest alternative: an atomic UPDATE

Before picking a lock, ask yourself whether you need one. If all you do is add to a balance, a single statement that does it in the database is enough: UPDATE account SET balance = balance + ? WHERE id = ?. You don't read anything first, so there's nothing to overwrite, and the database serializes the writes on the row. In Spring Data, that's a method with @Modifying and @Query. Locks are needed when you read, decide in Java based on what you read and then write (for example, rejecting the deposit because of some domain rule).

The test that reproduces the problem

We launch N threads that each deposit 1 into the same row at the same time, and we release them together with a CountDownLatch to maximize the collision.

@SpringBootTest
@Testcontainers
class ConcurrentLockingTest {

    private static final int THREADS = 10;

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17-alpine");

    @Autowired AccountService service;
    @Autowired AccountRepository repository;
    @Autowired UnprotectedAccountRepository unprotectedRepository;
    @Autowired TransactionTemplate transactionTemplate;

    @Test
    void withoutProtection_someUpdateIsLost() throws Exception {
        Long id = unprotectedRepository.save(new UnprotectedAccount()).getId();

        runInParallel(() -> service.depositUnprotected(id, 1));

        long balance = unprotectedRepository.findById(id).orElseThrow().getBalance();
        assertThat(balance).isLessThan(THREADS);
    }

    @Test
    void optimistic_conflictsAreDetected() throws Exception {
        Long id = repository.save(new Account()).getId();

        List<Throwable> failures = runInParallel(() -> service.depositOptimistic(id, 1));

        long balance = repository.findById(id).orElseThrow().getBalance();
        assertThat(balance).isEqualTo(THREADS - failures.size());
        assertThat(failures).allMatch(e -> e instanceof ObjectOptimisticLockingFailureException);
    }

    @Test
    void pessimistic_everyDepositIsApplied() throws Exception {
        Long id = repository.save(new Account()).getId();

        List<Throwable> failures = runInParallel(() -> service.depositPessimistic(id, 1));

        assertThat(failures).isEmpty();
        assertThat(repository.findById(id).orElseThrow().getBalance()).isEqualTo(THREADS);
    }

    @Test
    void pessimistic_whenTheLockIsNotReleased_timesOut() throws Exception {
        Long id = repository.save(new Account()).getId();
        CountDownLatch locked = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        CompletableFuture<Void> holder = CompletableFuture.runAsync(() -> transactionTemplate.executeWithoutResult(status -> {
            repository.findByIdForUpdate(id).orElseThrow();
            locked.countDown();
            await(release);
        }));
        locked.await();

        try {
            assertThatThrownBy(() -> service.depositPessimistic(id, 1))
                    .isInstanceOf(CannotAcquireLockException.class);
        } finally {
            release.countDown();
            holder.get();
        }
    }
}

The runInParallel method is at the end, in the appendix. depositUnprotected is the same method as depositOptimistic, but on UnprotectedAccount; I'm leaving it out to avoid repeating myself, and also the await helper method, which just wraps latch.await() so it doesn't propagate InterruptedException.

Watch out for three traps in this test, which are the ones I care about most:

  • Concurrency tests aren't deterministic. In theory the first one could come out as 10 some day without the code being right or wrong. In my runs with 10 threads an update was always lost, but that's not a guarantee: if you keep it in the repo, run it in a loop or raise the threads and iterations.
  • Don't use @Transactional on the test class. It would wrap everything in one transaction, and the threads, being other transactions, wouldn't see the data. The data must be committed before launching the threads.
  • The engine matters. With an in-memory database, lock behavior can differ from PostgreSQL or MySQL, and the Hibernate guide itself warns, in its testing section, to be careful when testing against an in-memory database if the code uses concurrency features such as pessimistic locks. For this kind of test the real database is worth it, with Testcontainers (I cover that in another post in this series).

To wrap up

By default I'd use optimistic locking, which is what I've used in production: conflicts were occasional and retrying the message was enough. I'd save pessimistic locking for hot rows, where I know there's going to be a conflict almost every time (a stock counter during a launch, say). And the worst thing isn't picking wrong, it's not having a test that shows you the failure.

When several actors touch the same piece of data, consistency has to be designed; it doesn't come on its own.

Appendix: the runInParallel helper

It's the test class method that launches the threads and releases them all at once:

    private List<Throwable> runInParallel(Runnable action) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(THREADS);
        CountDownLatch start = new CountDownLatch(1);
        List<Throwable> failures = new CopyOnWriteArrayList<>();
        List<Future<?>> futures = new ArrayList<>();
        for (int i = 0; i < THREADS; i++) {
            futures.add(pool.submit(() -> {
                try {
                    start.await();
                    action.run();
                } catch (Throwable e) {
                    failures.add(e);
                }
            }));
        }
        start.countDown();
        for (Future<?> f : futures) f.get();
        pool.shutdown();
        return failures;
    }

Official documentation


Tweet Send
0 Comments
Loading...