Locking and Deadlocks in Practice
Locking and Deadlocks in Practice
Hibernate generates SQL and flushes it to the database. The order in which Hibernate flushes statements determines the order in which the database acquires row locks. If two transactions flush in different orders, deadlock is possible.
The Lie
Hibernate prevents deadlocks by managing transactions for you. If you use @Transactional, locking is handled correctly.
The Reality
Hibernate flushes SQL in a defined order per entity action type: INSERTs first, then UPDATEs, then DELETEs. Within each action type, the ordering depends on the persistence context iteration order, which is not guaranteed.
When two transactions update the same set of rows in different orders, the database detects a deadlock and kills one transaction. Hibernate does not control the lock acquisition order at the row level.
// Transaction 1: Updates Order A, then Order B
@Transactional
public void processOrders1() {
Order orderA = orderRepository.findById(1L).orElseThrow();
Order orderB = orderRepository.findById(2L).orElseThrow();
orderA.setStatus(OrderStatus.PROCESSING);
orderB.setStatus(OrderStatus.PROCESSING);
// Flush: UPDATE order A (locks row 1), UPDATE order B (locks row 2)
}
// Transaction 2: Updates Order B, then Order A
@Transactional
public void processOrders2() {
Order orderB = orderRepository.findById(2L).orElseThrow();
Order orderA = orderRepository.findById(1L).orElseThrow();
orderB.setStatus(OrderStatus.SHIPPED);
orderA.setStatus(OrderStatus.SHIPPED);
// Flush: UPDATE order B (locks row 2), UPDATE order A (locks row 1)
}
// Deadlock scenario:
// T1 locks row 1, waits for row 2
// T2 locks row 2, waits for row 1
// Database detects deadlock, kills one transaction
// Hibernate throws: org.hibernate.exception.LockAcquisitionException
The Evidence
The deadlock is not guaranteed on every execution. It depends on timing. When both transactions flush at the same time and the database interleaves the lock acquisitions, deadlock occurs. In testing with a single user, this never happens. In production with concurrent requests, it happens unpredictably.
// BAD: Batch processing with no lock ordering
@Transactional
public void updateOrderStatuses(List<Long> orderIds,
OrderStatus newStatus) {
for (Long id : orderIds) {
Order order = orderRepository.findById(id).orElseThrow();
order.setStatus(newStatus);
}
// Flush generates UPDATEs in persistence context iteration order.
// If two calls process overlapping but differently-ordered lists:
// Call 1: [1, 3, 5, 7]
// Call 2: [7, 5, 3, 1]
// Deadlock is possible.
}
PostgreSQL deadlock log:
ERROR: deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890;
blocked by process 12346.
Process 12346 waits for ShareLock on transaction 67891;
blocked by process 12345.
HINT: See server log for query details.
The Fix
1. Consistent Lock Ordering
Sort entities before processing them. This ensures all transactions acquire locks in the same order.
// BETTER: Sort by ID to enforce consistent lock ordering
@Transactional
public void updateOrderStatuses(List<Long> orderIds,
OrderStatus newStatus) {
List<Long> sorted = orderIds.stream().sorted().toList();
for (Long id : sorted) {
Order order = orderRepository.findById(id).orElseThrow();
order.setStatus(newStatus);
}
// Flush order for UPDATEs follows persistence context iteration,
// which is not guaranteed to match our sort order.
// Force flush after each update to control lock acquisition order:
}
// BETTER: Explicit flush ordering
@Transactional
public void updateOrderStatuses(List<Long> orderIds,
OrderStatus newStatus) {
List<Long> sorted = orderIds.stream().sorted().toList();
for (Long id : sorted) {
Order order = orderRepository.findById(id).orElseThrow();
order.setStatus(newStatus);
entityManager.flush(); // Lock this row NOW, in sorted order
}
}
Flushing after each update is less efficient (one round trip per update instead of batched), but it guarantees lock acquisition order. The trade-off is explicit.
2. Pessimistic Locking with Lock Ordering
// BETTER: SELECT FOR UPDATE with consistent ordering
@Transactional
public void updateOrderStatuses(List<Long> orderIds,
OrderStatus newStatus) {
List<Long> sorted = orderIds.stream().sorted().toList();
// Lock all rows upfront in sorted order
List<Order> orders = entityManager.createQuery(
"SELECT o FROM Order o WHERE o.id IN :ids ORDER BY o.id",
Order.class)
.setParameter("ids", sorted)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.getResultList();
// All rows locked in ID order. No deadlock possible.
for (Order order : orders) {
order.setStatus(newStatus);
}
}
// Generated SQL:
// select ... from orders where id in (?, ?, ?, ?)
// order by id for update
The ORDER BY in the SELECT ... FOR UPDATE is critical. Without it, the database may return and lock rows in any order, reintroducing deadlock potential.
3. Lock Timeout
Configure a lock timeout so deadlocks resolve quickly instead of blocking indefinitely:
spring:
jpa:
properties:
jakarta:
persistence:
lock:
timeout: 5000 # 5 seconds
// Or per-query:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(
name = "jakarta.persistence.lock.timeout",
value = "3000"))
@Query("SELECT o FROM Order o WHERE o.id = :id")
Optional<Order> findByIdForUpdate(@Param("id") Long id);
With a lock timeout, a deadlock that would block for the database’s default detection interval (PostgreSQL: 1 second, MySQL: 50 seconds by default) resolves within your configured timeout.
The Cost Model
| Strategy | Deadlock Risk | Performance | Complexity |
|---|---|---|---|
| No ordering (default) | High with concurrent updates | Best (batched flush) | None |
| Sort + flush per entity | None | Lower (one flush per entity) | Low |
| SELECT FOR UPDATE + ORDER BY | None | Medium (single lock query) | Low |
| Lock timeout | Deadlock resolves quickly | Unchanged | Configuration only |
Deadlocks are rare in applications that update independent rows. They become common in batch processing, queue consumers processing overlapping work, and any pattern where multiple transactions update the same set of rows concurrently.
If you have never seen a deadlock in your application, you may not need lock ordering. If you see deadlocks in production logs, sort your updates by primary key. The fix is mechanical and the cost is modest.