Definition
- AB-BA deadlock -- Thread-1 locks row A then waits for row B. Thread-2 locks row B then waits for row A. Each holds what the other needs, creating a circular wait.
- Database detection -- the database detects the cycle and kills one transaction with an error like PostgreSQL's
ERROR: deadlock detected. - Consistent lock ordering -- the fix is to always lock rows in the same deterministic order across all threads. If every thread locks the row with the smaller ID first, no cycle can form.
- hibernate.order_inserts -- when set to
true, Hibernate sorts INSERT statements by entity type and primary key before flushing them to the database. - hibernate.order_updates -- when set to
true, Hibernate sorts UPDATE statements by entity type and primary key before flushing. - JDBC batching bonus -- ordered statements enable JDBC batching because the database driver can group consecutive statements of the same type into a single batch.
Two tellers at the National Bank of India try to update Rajesh's and Priya's accounts simultaneously. Teller 1 locks Rajesh's file (SAV2001) then tries to lock Priya's file (CUR3001). Teller 2 locks Priya's file first then tries to lock Rajesh's file. Both wait forever. The fix: ALL tellers ALWAYS lock files in alphabetical order (CUR3001 before SAV2001). No cycle is possible because everyone follows the same order.
Understanding the Concepts
What is an AB-BA row-level deadlock?
An AB-BA deadlock is a circular wait at the database row level. Two transactions each hold a lock on one row and wait for a lock on the other row. Neither can proceed because each holds what the other needs.
The database detects this cycle after a timeout (typically 1 second in PostgreSQL) and kills one of the transactions with an error. The killed transaction must be retried by the application.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class UnsafeTransferService {
private final AccountRepository accountRepository;
public UnsafeTransferService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// WRONG: locks in caller-specified order -- deadlock risk
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
// Thread-1: transfer(SAV2001, CUR3001) -> locks SAV2001 first
// Thread-2: transfer(CUR3001, SAV2001) -> locks CUR3001 first
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
}
}
Deadlock scenario:
Thread-1: transfer(SAV2001, CUR3001, 10000)
1. SELECT ... FOR UPDATE WHERE id = 'SAV2001' -> locked SAV2001
2. SELECT ... FOR UPDATE WHERE id = 'CUR3001' -> WAITING (CUR3001 locked by Thread-2)
Thread-2: transfer(CUR3001, SAV2001, 5000)
1. SELECT ... FOR UPDATE WHERE id = 'CUR3001' -> locked CUR3001
2. SELECT ... FOR UPDATE WHERE id = 'SAV2001' -> WAITING (SAV2001 locked by Thread-1)
Circular wait detected. PostgreSQL kills Thread-2:
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678;
blocked by process 9012.
Why does inconsistent lock ordering cause deadlocks?
The root cause is not the number of rows or the load. It is the order in which locks are acquired. If Thread-1 locks A then B, and Thread-2 locks B then A, the cycle is guaranteed when both threads are active at the same time.
If both threads always lock A first, then B, no cycle can form. Thread-2 waits for A (held by Thread-1), but Thread-2 does not hold B yet, so Thread-1 can proceed to lock B and finish. Then Thread-2 gets A and proceeds.
Inconsistent order (deadlock possible):
Thread-1: lock A -> lock B
Thread-2: lock B -> lock A
Circular: A->B->A
Consistent order (no deadlock):
Thread-1: lock A -> lock B
Thread-2: lock A -> lock B
Thread-2 waits for A (no cycle), Thread-1 finishes, Thread-2 proceeds
How do you fix AB-BA deadlocks with consistent lock ordering?
The application-level fix is to sort the row identifiers before locking. For a transfer between two accounts, sort the account IDs and always lock the smaller ID first.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class SafeTransferService {
private final AccountRepository accountRepository;
public SafeTransferService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// CORRECT: always lock in sorted order
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
// Sort IDs to guarantee consistent lock order
String firstId = fromId.compareTo(toId) < 0 ? fromId : toId;
String secondId = fromId.compareTo(toId) < 0 ? toId : fromId;
var first = accountRepository.findByIdForUpdate(firstId).orElseThrow();
var second = accountRepository.findByIdForUpdate(secondId).orElseThrow();
// Determine which is "from" and which is "to"
var from = first.getId().equals(fromId) ? first : second;
var to = first.getId().equals(toId) ? first : second;
if (from.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(fromId, amount);
}
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
}
}
With consistent ordering:
Thread-1: transfer(SAV2001, CUR3001, 10000)
sorted: CUR3001, SAV2001 (C < S alphabetically)
1. lock CUR3001
2. lock SAV2001
Thread-2: transfer(CUR3001, SAV2001, 5000)
sorted: CUR3001, SAV2001 (same order!)
1. lock CUR3001 -> WAITS (held by Thread-1)
2. Thread-1 finishes, releases both locks
3. Thread-2 locks CUR3001, then SAV2001
No cycle possible. No deadlock.
How does hibernate.order_updates prevent deadlocks?
When Hibernate flushes the persistence context, it converts in-memory changes to SQL statements. By default, the order of these statements depends on the order you called setters or save(), which can vary between threads. Setting hibernate.order_updates=true makes Hibernate sort UPDATE statements by entity type and primary key before sending them to the database.
This means all threads send their updates in the same deterministic order, which prevents AB-BA deadlocks on implicit writes.
# application.yml
spring:
jpa:
properties:
hibernate:
order_inserts: true
order_updates: true
jdbc:
batch_size: 50
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class BulkInterestService {
private final AccountRepository accountRepository;
public BulkInterestService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
@Transactional
public void applyInterest(List<String> accountIds, BigDecimal rate) {
for (String id : accountIds) {
var account = accountRepository.findById(id).orElseThrow();
BigDecimal interest = account.getBalance()
.multiply(rate)
.setScale(2, java.math.RoundingMode.HALF_UP);
account.setBalance(account.getBalance().add(interest));
}
// Without order_updates: Hibernate flushes in traversal order
// Thread-1: UPDATE SAV2001, UPDATE CUR3001
// Thread-2: UPDATE CUR3001, UPDATE SAV2001
// -> AB-BA deadlock possible
// With order_updates=true: Hibernate sorts by PK
// Thread-1: UPDATE CUR3001, UPDATE SAV2001
// Thread-2: UPDATE CUR3001, UPDATE SAV2001
// -> same order, no deadlock
}
}
Without order_updates (Hibernate flushes in entity traversal order):
Thread-1: UPDATE accounts SET balance=... WHERE id='SAV2001'
Thread-1: UPDATE accounts SET balance=... WHERE id='CUR3001'
Thread-2: UPDATE accounts SET balance=... WHERE id='CUR3001'
Thread-2: UPDATE accounts SET balance=... WHERE id='SAV2001'
-> AB-BA deadlock risk
With order_updates=true (Hibernate sorts by entity type + PK):
Thread-1: UPDATE accounts SET balance=... WHERE id='CUR3001'
Thread-1: UPDATE accounts SET balance=... WHERE id='SAV2001'
Thread-2: UPDATE accounts SET balance=... WHERE id='CUR3001'
Thread-2: UPDATE accounts SET balance=... WHERE id='SAV2001'
-> same order, no deadlock
How does order_inserts enable JDBC batching?
JDBC batching groups consecutive statements of the same type into a single batch call, reducing network round trips. For batching to work, the driver needs consecutive INSERT statements for the same table. Without ordering, Hibernate may interleave inserts for different entities.
Setting order_inserts=true sorts inserts by entity type and primary key, which groups them naturally for batching.
package com.bank.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class AccountOpeningService {
private final AccountRepository accountRepository;
private final LedgerRepository ledgerRepository;
public AccountOpeningService(AccountRepository accountRepository,
LedgerRepository ledgerRepository) {
this.accountRepository = accountRepository;
this.ledgerRepository = ledgerRepository;
}
@Transactional
public void openBulkAccounts(List<AccountRequest> requests) {
for (AccountRequest req : requests) {
Account account = new Account();
account.setId(req.id());
account.setHolderName(req.holderName());
account.setBalance(req.initialDeposit());
accountRepository.save(account);
LedgerEntry entry = new LedgerEntry();
entry.setAccountId(req.id());
entry.setAmount(req.initialDeposit());
entry.setType("OPENING_DEPOSIT");
ledgerRepository.save(entry);
}
}
}
Without order_inserts (interleaved): INSERT INTO accounts ... (SAV2001) INSERT INTO ledger ... (SAV2001) INSERT INTO accounts ... (SAV2002) INSERT INTO ledger ... (SAV2002) 4 individual statements, 4 round trips With order_inserts=true (grouped): INSERT INTO accounts ... (SAV2001) ┐ INSERT INTO accounts ... (SAV2002) ┘ batch of 2 INSERT INTO ledger ... (SAV2001) ┐ INSERT INTO ledger ... (SAV2002) ┘ batch of 2 2 batch calls, 2 round trips (50% fewer)
What are common mistakes with deadlock prevention?
The most common mistake is catching the deadlock exception and not retrying. The database killed the transaction, so the work was not done. The second mistake is assuming Hibernate's order_updates covers explicit FOR UPDATE queries -- it does not. Explicit lock ordering must be done in application code.
package com.bank.service;
import org.springframework.dao.CannotAcquireLockException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class RetryableTransferService {
private final AccountRepository accountRepository;
public RetryableTransferService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// WRONG: catch deadlock and ignore it
@Transactional
public void transferWrong(String fromId, String toId, BigDecimal amount) {
try {
var from = accountRepository.findByIdForUpdate(fromId).orElseThrow();
var to = accountRepository.findByIdForUpdate(toId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
} catch (CannotAcquireLockException e) {
// WRONG: swallowing the exception -- transfer never happened!
System.out.println("Deadlock occurred, skipping");
}
}
// CORRECT: retry with backoff
@Retryable(
retryFor = CannotAcquireLockException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void transferCorrect(String fromId, String toId, BigDecimal amount) {
String firstId = fromId.compareTo(toId) < 0 ? fromId : toId;
String secondId = fromId.compareTo(toId) < 0 ? toId : fromId;
var first = accountRepository.findByIdForUpdate(firstId).orElseThrow();
var second = accountRepository.findByIdForUpdate(secondId).orElseThrow();
var from = first.getId().equals(fromId) ? first : second;
var to = first.getId().equals(toId) ? first : second;
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
}
}
WRONG (swallow deadlock):
Deadlock detected -> catch -> "skipping"
Transfer never happened! Customer thinks money moved.
CORRECT (retry with backoff):
Attempt 1: deadlock detected -> rollback -> wait 100ms
Attempt 2: deadlock detected -> rollback -> wait 200ms
Attempt 3: success -> transfer committed
@Retryable must be on a DIFFERENT bean than @Transactional
or use a new transaction for each retry.
What should a senior engineer know?
- Gap locks in MySQL InnoDB -- under REPEATABLE_READ, InnoDB acquires gap locks on index ranges, which can cause deadlocks even when threads update different rows that fall in the same index gap.
- PostgreSQL deadlock_timeout -- PostgreSQL waits
deadlock_timeout(default 1 second) before checking for cycles. Setting it lower detects deadlocks faster but adds CPU overhead from more frequent checks. - Hibernate order settings and @Version --
order_updateshelps with pessimistic locking order, but with optimistic locking (@Version), deadlocks are replaced byOptimisticLockExceptionbecause no row locks are held. - Bulk operations -- for bulk updates on thousands of rows, use
@Modifying @Query("UPDATE ...")with explicit ORDER BY in the WHERE clause to control the lock acquisition order at the SQL level. - Monitoring -- track
pg_stat_activityforwait_event_type = Lockand count deadlock occurrences frompg_stat_database.deadlocks.
-- PostgreSQL: check for current lock waits
SELECT pid, wait_event_type, wait_event, state, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';
-- PostgreSQL: count deadlocks since server start
SELECT datname, deadlocks
FROM pg_stat_database
WHERE datname = 'national_bank';
Monitoring output:
pid | wait_event_type | state | query
1234 | Lock | active | UPDATE accounts SET balance=... WHERE id='SAV2001'
datname | deadlocks
national_bank | 47 (47 deadlocks since server start)
What are common interview follow-up questions?
- How does the database detect a deadlock? The database maintains a wait-for graph. Periodically (or when a timeout fires), it checks the graph for cycles. If a cycle is found, it picks a victim transaction and kills it.
- Does
hibernate.order_updatesguarantee no deadlocks? No. It covers implicit updates from dirty checking during flush, but explicitSELECT FOR UPDATEqueries must be ordered by application code. - Can you combine consistent ordering with @Retryable? Yes, and you should. Consistent ordering prevents most deadlocks, but retryable is a safety net for edge cases like gap-lock conflicts in MySQL.
- What about multi-table deadlocks? The same principle applies. If you update accounts and then ledger entries, always update them in the same table order (accounts first, then ledger) across all transactions.
Visual Overview
┌─────────────────────────────────────────────────────┐ │ AB-BA Deadlock (inconsistent lock order) │ │ │ │ Thread-1 Thread-2 │ │ ──────── ──────── │ │ LOCK row A LOCK row B │ │ │ │ │ │ ▼ ▼ │ │ LOCK row B ──WAIT──► LOCK row A ──WAIT──► │ │ │ │ │ │ ◄────┴─────CIRCULAR────────┘ │ │ │ │ DB detects cycle -> kills one transaction │ └─────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────┐ │ Consistent Lock Order (no deadlock) │ │ │ │ Thread-1 Thread-2 │ │ ──────── ──────── │ │ LOCK row A LOCK row A ── WAIT │ │ │ │ │ │ ▼ │ (no cycle: │ │ LOCK row B │ Thread-2 has no lock │ │ │ │ to create a cycle) │ │ COMMIT │ │ │ (release A, B) ▼ │ │ LOCK row A (now free) │ │ LOCK row B │ │ COMMIT │ └─────────────────────────────────────────────────────┘
Hibernate Flush Order:
Without order_updates:
Thread-1: UPDATE SAV2001 -> UPDATE CUR3001
Thread-2: UPDATE CUR3001 -> UPDATE SAV2001
-> AB-BA deadlock risk
With order_updates=true:
Thread-1: UPDATE CUR3001 -> UPDATE SAV2001
Thread-2: UPDATE CUR3001 -> UPDATE SAV2001
-> Same order, no deadlock
Key Takeaway
AB-BA row-level deadlocks occur when two transactions lock the same rows in opposite order. Thread-1 locks row A and waits for row B, while Thread-2 locks row B and waits for row A. The database detects the circular wait and kills one transaction. The root cause is inconsistent lock ordering, not the number of rows or the load.
In Rajesh's bulk transfer service at the National Bank of India, both the "Rajesh to Priya" transfer and the "Priya to Rajesh" transfer sort account IDs alphabetically and lock CUR3001 before SAV2001. This eliminates the cycle because both threads always acquire locks in the same deterministic order. For implicit writes during Hibernate flush, enabling order_inserts and order_updates in application.yml makes Hibernate sort SQL statements by entity type and primary key before sending them to the database, ensuring all threads acquire row locks in the same order.
The bonus of ordered statements is that they enable JDBC batching. Hibernate groups INSERT and UPDATE statements by entity type, sending them in batches instead of one at a time. Combined with hibernate.jdbc.batch_size, this significantly improves bulk operation performance. The three-layer defense is: explicit lock ordering in application code for SELECT FOR UPDATE queries, Hibernate ordering for implicit dirty-check writes, and @Retryable as a safety net for edge cases that slip through.
Remember one sentence: always lock rows in a consistent deterministic order, and let hibernate.order_updates enforce that order for implicit writes.
---