Spring Transaction Locks: Optimistic vs Pessimistic Locking
Master transaction locking in Spring Data JPA.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Basic knowledge of Spring Boot and Spring Data JPA
- ✓Understanding of database transactions and ACID properties
- ✓Familiarity with Java annotations and dependency injection
- Use optimistic locking for read-heavy, low-conflict systems; it's non-blocking and uses version fields.
- Use pessimistic locking for write-heavy, high-conflict scenarios; it prevents concurrent modifications with database locks.
- Always define a clear retry strategy for optimistic locking failures to avoid silent data loss.
- Pessimistic locks can cause deadlocks; keep lock duration minimal and order lock acquisitions consistently.
- Test locking behavior under realistic concurrency; integration tests with multiple threads are mandatory.
Imagine two people editing the same document. Optimistic locking is like each person taking a copy, editing, and then checking if the other changed it before saving—if so, they redo their work. Pessimistic locking is like locking the document so only one person can edit at a time, preventing conflicts but slowing things down.
If you've ever dealt with concurrent transactions in a Spring Boot application, you've probably encountered the dreaded OptimisticLockException or a deadlock that brings your database to its knees. Locking is not just an academic concept—it's a critical tool for maintaining data integrity under concurrent access. I've seen production outages caused by a missing @Version annotation and a deadlock cascade that took down a payment processing system for 15 minutes. In this article, I'll walk you through the two primary locking strategies in Spring Data JPA: optimistic and pessimistic locking. You'll learn when to use each, how to implement them correctly, and—more importantly—what the official documentation glosses over. By the end, you'll have a battle-tested approach to handling concurrent data access that won't leave you debugging at 2 AM.
What Is Transaction Locking in JPA?
Transaction locking is the mechanism that prevents concurrent transactions from interfering with each other when accessing the same data. In JPA, you have two main strategies: optimistic and pessimistic locking. Optimistic locking assumes conflicts are rare; it checks at commit time whether the data has changed since it was read. Pessimistic locking assumes conflicts are frequent; it locks the data when it's read, preventing others from modifying it until the lock is released.
Spring Data JPA provides annotations like @Version for optimistic locking and @Lock for pessimistic locking. You can also use LockModeType enums in repository methods. The choice between them depends on your application's concurrency profile. I've seen teams default to pessimistic locking because it 'feels safer,' only to run into deadlocks and performance bottlenecks. The truth is, optimistic locking is often the better choice for most web applications because it scales better and avoids long-held locks.
Pessimistic Locking in Spring Data JPA
Pessimistic locking acquires a database lock on the selected rows, preventing other transactions from modifying or sometimes even reading them until the lock is released. In Spring Data JPA, you can use the @Lock annotation on repository methods to specify the lock mode: PESSIMISTIC_READ (shared lock) or PESSIMISTIC_WRITE (exclusive lock).
Here's the hard truth: pessimistic locking can be a performance killer if misused. Every PESSIMISTIC_WRITE lock blocks other write transactions and, depending on your database and isolation level, even read transactions. In MySQL with InnoDB, PESSIMISTIC_WRITE uses SELECT ... FOR UPDATE, which locks the rows until the transaction commits or rolls back. If your transaction takes a long time—say, because of a slow external API call—you'll have a queue of blocked transactions piling up.
I once debugged a system where a developer wrapped an entire REST endpoint in a @Transactional with PESSIMISTIC_WRITE. The endpoint made an HTTP call to a third-party service that sometimes timed out after 30 seconds. The result? A deadlock cascade that took down the application. The fix was to move the lock acquisition as late as possible and keep the transaction scope minimal.
lock_timeout; in MySQL, use innodb_lock_wait_timeout. Defaults are often too high (50 seconds in MySQL).Choosing Between Optimistic and Pessimistic Locking
So when should you use which? Here's my rule of thumb:
- Optimistic locking is the default choice. Use it when contention is low (i.e., conflicts are rare) and you can afford to retry failed transactions. Most web applications fall into this category: many reads, few writes, and conflicts are exceptions.
- Pessimistic locking is for high-contention scenarios where the cost of retries is unacceptable. For example, a seat reservation system where two users might try to book the same seat simultaneously. In such cases, it's better to lock the seat immediately and let the second user wait or see it as unavailable.
But there's a middle ground: you can combine both. Use optimistic locking as the primary strategy, but fall back to pessimistic locking for specific high-contention operations. Spring Data JPA allows you to specify lock modes on a per-query basis, so you can have a default optimistic approach and override for critical paths.
I've also seen teams use database-specific hints to fine-tune lock behavior. For example, PostgreSQL's SKIP LOCKED is useful for queue-like workloads where you want to skip locked rows instead of waiting. Spring Data JPA doesn't support this natively, but you can use native queries or Hibernate's @QueryHints.
SHOW ENGINE INNODB STATUS gives you lock waits. In PostgreSQL, pg_locks and pg_stat_activity are your friends.What the Official Docs Won't Tell You
I've spent years debugging locking issues, and here are the gotchas that the Spring Data JPA documentation doesn't emphasize enough:
- Optimistic locking works only if you use
@Versioncorrectly.@Versionmust be on a numeric,Timestamp, orInstantfield. If you put it on aString, it won't work. Also, if you update an entity without reading it first (e.g., using a custom@Modifyingquery), the version check is bypassed. You must manually include version in the WHERE clause. - Pessimistic locking in Spring Data JPA is not always honored. If you call a repository method with
@Lock(PESSIMISTIC_WRITE)but the method is not inside a transaction, no lock is acquired. The lock is only active during the transaction. Also, if you usefindByIdwith@Lock, Hibernate might ignore the lock hint because it uses the persistence context first. Always use@Queryto force a database query. - Lock escalation can happen. In some databases, row-level locks can escalate to table-level locks if too many rows are locked, causing unexpected contention. Monitor your lock granularity.
- Timeouts are critical. Both optimistic and pessimistic locking can lead to long waits if not configured properly. Set transaction timeouts with
@Transactional(timeout = 5)(seconds) to avoid hanging threads. - Distributed transactions complicate locking. If you use multiple databases or a message broker, JPA locks won't protect you across systems. Consider distributed locking with Redis or ZooKeeper for cross-service consistency.
ExecutorService to simulate race conditions. It's saved me from many production bugs.Implementing Retry Logic for Optimistic Locking
Optimistic locking fails with OptimisticLockException when a concurrent update is detected. Without a retry mechanism, the user gets an error and the operation fails. In a typical web application, you should automatically retry the transaction a few times. Spring Retry provides a declarative way to do this.
Add spring-retry and spring-boot-starter-aop to your dependencies. Then annotate your service method with @Retryable. You can configure backoff policies to avoid hammering the database.
I've seen teams set retries to 3 with an exponential backoff (e.g., 1 second, 2 seconds, 4 seconds). But don't set it too high—if contention is persistent, retrying 10 times will just waste resources. In that case, switch to pessimistic locking for that operation.
One more thing: make sure your retry logic is idempotent. If the operation has side effects (e.g., sending an email), you might duplicate them. Use the @Retryable annotation on the transactional method, but ensure that any non-idempotent actions are either moved outside the transaction or compensated.
Testing Locking Behavior
Testing concurrent behavior is tricky but essential. Unit tests won't catch race conditions; you need integration tests that simulate multiple threads accessing the same data. I use ExecutorService to launch several threads that concurrently invoke the same service method. Then I assert the final state of the database.
Spring Boot's @SpringBootTest with an embedded database works well for this. Use CountDownLatch to synchronize thread start times. Also, set the database isolation level to READ_COMMITTED (default) to match production.
One gotcha: embedded databases like H2 have different locking behaviors than MySQL or PostgreSQL. Always run your concurrency tests against the same database you use in production. I've seen tests pass on H2 but fail on PostgreSQL because of different lock implementations.
The Case of the Vanishing Inventory
synchronized block on a single server was sufficient.synchronized block only worked within one instance; with multiple instances, it was useless.@Version column to the Inventory entity and enabled optimistic locking. Also implemented a retry mechanism with Spring Retry for OptimisticLockException.- Always use
@Versionfor entities that are updated concurrently. - Never rely on application-level locking alone in distributed systems.
- Implement retry logic for optimistic lock failures.
- Monitor lock contention metrics in production.
- Use pessimistic locking only when conflicts are frequent and retries are too costly.
@Version field. If not, add one. If yes, increase retry count or switch to pessimistic locking if contention is high.PESSIMISTIC_READ instead of PESSIMISTIC_WRITE if possible).@Retryable(value = OptimisticLockException.class, maxAttempts = 3)Check @Version annotation presence| File | Command / Code | Purpose |
|---|---|---|
| OptimisticLockingExample.java | @Entity | What Is Transaction Locking in JPA? |
| PessimisticLockingExample.java | public interface AccountRepository extends JpaRepository | Pessimistic Locking in Spring Data JPA |
| CombinedLockingStrategy.java | @Entity | Choosing Between Optimistic and Pessimistic Locking |
| VersionCheckBypass.java | @Modifying | What the Official Docs Won't Tell You |
| RetryLogicExample.java | @Service | Implementing Retry Logic for Optimistic Locking |
| ConcurrencyTest.java | @SpringBootTest | Testing Locking Behavior |
Key takeaways
Interview Questions on This Topic
Explain optimistic locking in JPA. How does it work under the hood?
@Version. When an entity is read, the version is captured. On update, JPA includes a WHERE clause like WHERE id = ? AND version = ?. If the version has changed, the update affects zero rows and Hibernate throws an OptimisticLockException. It's a lightweight mechanism that avoids database locks.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Hibernate & JPA. Mark it forged?
4 min read · try the examples if you haven't