On this page
When two or more processes reach for the same shared resource at the same moment, something has to give. In a single booking system, that resource might be a seat, a bank balance, or an inventory count — and the failure mode, a race condition, is the same shape regardless of the domain.
Two users racing for the same seat
The goal of concurrency control is to keep data correct — one seat, one occupant — without slowing every request down to a crawl. The challenge is that in a distributed, microservices world, the tools that solved this problem on a single machine no longer apply.
02Why local locking fails
The instinct, coming from single-process programming, is to reach for a language-level lock:
// This does NOT protect you in a distributed system
public synchronized void bookSeat(int seatId) {
// only safe within ONE JVM instance
}
synchronized protects one process's memory. It says nothing about a second instance of the same service running on a different machine, which is the normal case the moment you scale past one replica.
Local locks are invisible across instances
03Database fundamentals
Transactions and ACID
A transaction bundles several operations into one unit: either all of them apply, or none do. The classic example is moving money between two accounts:
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 500 WHERE id = 1; -- debit
UPDATE accounts SET balance = balance + 500 WHERE id = 2; -- credit
-- if anything fails, ROLLBACK returns the DB to its prior state
COMMIT;
If the credit fails partway through, the debit is rolled back with it — the account holder never simply loses $500 to a crashed process.
| Property | What it guarantees |
|---|---|
| Atomicity | All operations in the transaction succeed, or none do. |
| Consistency | The database moves from one valid state to another — constraints hold before and after. |
| Isolation | Concurrent transactions don't see each other's half-finished work. |
| Durability | Once committed, the result survives a crash. |
Two kinds of locks
Underneath isolation, a database is choosing between two lock flavors on every row it touches:
Shared lock (read)
- Any number of readers can hold it at once.
- No writer may proceed while it's held.
Exclusive lock (write)
- Exactly one transaction may hold it.
- Blocks every other reader and writer until released.
| S-Lock | X-Lock | |
|---|---|---|
| S-Lock | compatible | conflict |
| X-Lock | conflict | conflict |
04Isolation levels
Isolation levels are a dial between correctness and throughput. Turning it up prevents more anomalies; turning it down lets more transactions run at once.
Three ways a read can go wrong
Dirty read
A reads a value B wrote but hasn't committed. If B rolls back, A acted on data that never really existed.
Non-repeatable read
A reads the same row twice and gets two different answers because B committed a change in between.
Phantom read
A re-runs the same query and new rows appear, inserted by B after A's transaction began.
(Media convention above: red marks a fully unsafe read; amber marks an anomaly that's narrower but still real.)
| Isolation level | Dirty read | Non-repeatable | Phantom | Typical use |
|---|---|---|---|---|
| Read Uncommitted | possible | possible | possible | Analytics, approximate counts |
| Read Committed | prevented | possible | possible | Most OLTP apps · PostgreSQL default |
| Repeatable Read | prevented | prevented | possible | Financial reads · MySQL InnoDB default |
| Serializable | prevented | prevented | prevented | Critical financial ops · seat booking |
Higher isolation isn't free — Serializable typically means range locks and lower throughput. Pick the lowest level that still keeps your invariants true.
05Distributed concurrency techniques
With the primitives in place, there are two coherent philosophies for handling a write in a distributed system: assume conflicts are rare, or assume they're common.
A. Optimistic concurrency control (OCC)
Philosophy: don't lock — do the work, then check nothing changed before committing.
Optimistic concurrency control flow
CREATE TABLE seats (
id INT PRIMARY KEY,
status VARCHAR(20),
version INT DEFAULT 0 -- the key column
);
@Entity
public class Seat {
@Id
private Long id;
private String status;
@Version
private Integer version;
}
@Transactional
public boolean bookSeat(Long seatId) {
try {
Seat seat = seatRepository.findById(seatId)
.orElseThrow(() -> new RuntimeException("Seat not found"));
if (!"available".equals(seat.getStatus())) return false;
seat.setStatus("booked");
seatRepository.save(seat); // JPA checks version automatically
return true;
} catch (OptimisticLockingFailureException e) {
return false; // caller decides whether to retry
}
}
String sql = """
UPDATE seats
SET status = 'booked', version = version + 1
WHERE id = ? AND version = ? AND status = 'available'
""";
int rowsUpdated = jdbcTemplate.update(sql, seatId, expectedVersion);
return rowsUpdated == 1; // 0 means someone beat you to it
B. Pessimistic concurrency control (PCC)
Philosophy: conflicts are likely — take the lock first, then do the work with nobody else able to interfere.
Pessimistic concurrency control flow
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Seat s WHERE s.id = :id")
Optional<Seat> findByIdWithLock(@Param("id") Long id);
@Transactional
public boolean bookSeat(Long seatId) {
Seat seat = seatRepository.findByIdWithLock(seatId)
.orElseThrow(() -> new RuntimeException("Seat not found"));
if (!"available".equals(seat.getStatus())) return false;
seat.setStatus("booked");
seatRepository.save(seat);
return true; // lock released on commit
}
try {
bookSeat(seatId);
} catch (DeadlockLoserDataAccessException e) {
retryBookSeat(seatId);
}
06Choosing a strategy
Decision: is contention high?
| Factor | Optimistic | Pessimistic |
|---|---|---|
| Conflict frequency | Low | High |
| Read / write ratio | Read-heavy | Write-heavy |
| Throughput | Higher | Lower |
| Deadlock risk | None | Yes — must be handled |
| Retry complexity | Yes | No |
| DB load | Lower | Higher (locks held) |
07Quick reference
Local synchronized | Useless across processes — protects one JVM's memory only. |
|---|---|
| Shared lock (S) | Many readers, zero writers, while held. |
| Exclusive lock (X) | One writer, nobody else reads or writes. |
| Read Uncommitted | Dirty, non-repeatable, and phantom reads all possible. |
| Read Committed | No dirty reads · PostgreSQL default. |
| Repeatable Read | No dirty or non-repeatable reads · MySQL default. |
| Serializable | No read anomalies at all · slowest. |
| Optimistic lock | @Version, check-then-write, caller retries on conflict. |
| Pessimistic lock | SELECT ... FOR UPDATE, holds the lock through commit. |