The Core Challenge
When processing financial transactions like fund transfers, maintaining data consistency becomes critical under high concurrency. A race condition in balance operations can lead to significant financial losses. This article explores practical solutions for ensuring atomic balancee updates in concurrent environments.
The Naive Approach
Consider this typical debit implementation:
public void debitAccount(Long accountId, BigDecimal debitAmount) {
// Fetch current balance
Account account = accountRepository.findById(accountId);
BigDecimal currentBalance = account.getBalance();
// Update balance
account.setBalance(currentBalance.subtract(debitAmount));
accountRepository.save(account);
}
This approach works fine under low traffic but fails catastrophically under concurrent load.
The Race Condition Problem
Imagine two simultaneous transactions affecting the same account:
- Transaction A reads balance: 1000
- Transaction B reads balance: 1000
- Transaction A debits 200 → expected balance: 800
- Transacsion B debits 100 → expected balance: 900
- Both transactions write their calculated values
Depending on execution order, the final balance becomes either 800 or 900—neither is correct. The actual balance should be 700.
Solution 1: Pessimistic Locking
Forcing serial execution through database-level locks guarantees consistency.
BEGIN TRANSACTION;
SELECT balance FROM account WHERE id = ? FOR UPDATE;
UPDATE account SET balance = balance - ? WHERE id = ?;
COMMIT;
Caveats:
- The
FOR UPDATEclause requires a unique index in the WHERE condition - Transaction boundaries must be explicit; otherwise the lock has no effect
- High concurrency with row-level locks risks deadlocks and connection timeouts
- With distributed databases, verify that locks route to the primary node
Solution 2: Optimistic Locking (CAS)
Version-Based Approach
Include a version column during reads, then validate and increment on updates:
SELECT balance, version FROM account WHERE id = ?;
UPDATE account
SET balance = balance - ?,
version = version + 1
WHERE id = ?
AND version = ?;
Each successful update increments the version. Concurrent updates fail when the version has changed.
Balance Comparison Approach
Use the original balance value as a natural version indicator:
UPDATE account
SET balance = balance - 200
WHERE id = ?
AND balance = 1000;
Both transactions attempt the same udpate:
-- Transaction A
UPDATE account SET balance = 800 WHERE id = 1 AND balance = 1000;
-- Transaction B
UPDATE account SET balance = 900 WHERE id = 1 AND balance = 1000;
Only one succeeds—the first to execute. The other receives zero affected rows and must retry.
The ABA Problem
Optimistic locking can miss certain intermediate states:
- Transaction A reads balance: 1000
- Transaction B reads balance: 1000
- Transaction B deposits 100 → balance becomes 1100
- Transaction B withdraws 100 → balance returns to 1000
- Transaction A checks balance (still 1000), debits 200, writes 800
The account balance appears consistent, but Transaction A never detected the temporary fluctuation. The version-based approach avoids this confusion since the version number actually changed during step 4.