Distributed Systems / Data Layer

Concurrency Control in Distributed Systems

Two requests reach for the same row at the same instant. One of them has to wait, lose, or lie — the whole discipline of concurrency control is deciding which, and how the system finds out. This is a field guide to that decision, from a single JVM lock up through optimistic and pessimistic control in a distributed fleet.

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.

Fig. 1

Two users racing for the same seat

Two users racing for the same seatUser A and User B each read seat 12A, both see it as free, and both writes succeed, producing a double booking.read: freeread: freewrite × 2User Abooks seat 12AUser Bbooks seat 12ASeat 12Athe shared resourceDouble bookingboth writes succeedno one checks what changed in between
The classic race: two independent reads see the seat as free, both writes succeed, nobody notices until a passenger shows up twice.

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:

Java — looks safe, isn't
// 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.

Fig. 2

Local locks are invisible across instances

Local locks are invisible across instancesUser A reaches Instance 1 and User B reaches Instance 2. Each takes a synchronized() lock in its own JVM, so neither lock is visible to the other, and both write the same database row, causing a conflict.Instance 1 · own memoryInstance 2 · own memory✕ locks can’t see each otherUser AUser BInstance 1synchronized()Instance 2synchronized()Same DB rowthe only shared stateConflict
Each instance has its own memory. A lock held in one process is simply invisible to the other — the row is the only thing they truly share.
Bottom line
Once a service runs as more than one replica, correctness has to live at the database layer — the one thing every instance actually shares — not in process memory.

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:

SQL — transfer as a single unit
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.

PropertyWhat it guarantees
AtomicityAll operations in the transaction succeed, or none do.
ConsistencyThe database moves from one valid state to another — constraints hold before and after.
IsolationConcurrent transactions don't see each other's half-finished work.
DurabilityOnce 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)

S-LOCK
  • Any number of readers can hold it at once.
  • No writer may proceed while it's held.

Exclusive lock (write)

X-LOCK
  • Exactly one transaction may hold it.
  • Blocks every other reader and writer until released.
S-LockX-Lock
S-Lockcompatibleconflict
X-Lockconflictconflict

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

reads uncommitted data

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

value changes mid-transaction

A reads the same row twice and gets two different answers because B committed a change in between.

Phantom read

row set changes mid-transaction

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 levelDirty readNon-repeatablePhantomTypical 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.

Fig. 3

Optimistic concurrency control flow

Optimistic concurrency control flowRead the row with version 1, do the work, then UPDATE set version 2 where version 1. If one row is updated the write succeeds; if zero rows are updated the version changed and the transaction retries.rows = 1rows = 0retryRead rowversion = 11Do the workno lock held2UPDATE ... SET version=2WHERE version = 13rows updated?SuccessVersion changedsomeone else wrote first
The version column is the whole mechanism: the update only lands if nobody else touched the row first.
Table shape
CREATE TABLE seats (
    id INT PRIMARY KEY,
    status VARCHAR(20),
    version INT DEFAULT 0    -- the key column
);
Spring / JPA — @Version handles it for you
@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
    }
}
Raw JDBC equivalent
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
Best for
Read-heavy, low-contention data — product catalogs, user profiles, anything where two writers colliding is the exception, not the rule.
Watch out for
Retry storms: under real contention, many threads fail their version check at once and all retry simultaneously, which can make things worse before they get better.

B. Pessimistic concurrency control (PCC)

Philosophy: conflicts are likely — take the lock first, then do the work with nobody else able to interfere.

Fig. 4

Pessimistic concurrency control flow

Pessimistic concurrency control flowSELECT FOR UPDATE acquires an exclusive lock, the work is done safely and alone, then COMMIT releases the lock. Other transactions wait while the row is locked; the next waiter proceeds once it is released.Transaction A holds the X-lockblockednext waiter proceedsSELECT ... FOR UPDATEX-lock acquired1Do the worksafely, alone2COMMITlock released3Others waitrow is locked — no version check, no retryTx BTx C
The lock is the queue: everyone else genuinely waits, so there is no version to check and nothing to retry.
Spring / JPA — SELECT ... FOR UPDATE
@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
}
The real risk: deadlock
T1 locks Seat A and wants Seat B. T2 locks Seat B and wants Seat A. Neither can proceed — the database detects the cycle and aborts one transaction so the other can continue. Always handle that abort and retry.
Handling the abort
try {
    bookSeat(seatId);
} catch (DeadlockLoserDataAccessException e) {
    retryBookSeat(seatId);
}
Best for
Write-heavy, high-contention resources — seat booking, flash sales, inventory decrements, bank transfers.

06Choosing a strategy

Fig. 5

Decision: is contention high?

Decision: is contention high?If contention is high, use pessimistic locking with SELECT FOR UPDATE. If not, use optimistic locking with a @Version column.yesnoIs contentionhigh?PessimisticSELECT ... FOR UPDATEOptimistic@Version columnwriters often collideconflicts are rare
One question decides most of it: how often do two writers actually reach for the same row?
FactorOptimisticPessimistic
Conflict frequencyLowHigh
Read / write ratioRead-heavyWrite-heavy
ThroughputHigherLower
Deadlock riskNoneYes — must be handled
Retry complexityYesNo
DB loadLowerHigher (locks held)

07Quick reference

Local synchronizedUseless 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 UncommittedDirty, non-repeatable, and phantom reads all possible.
Read CommittedNo dirty reads · PostgreSQL default.
Repeatable ReadNo dirty or non-repeatable reads · MySQL default.
SerializableNo read anomalies at all · slowest.
Optimistic lock@Version, check-then-write, caller retries on conflict.
Pessimistic lockSELECT ... FOR UPDATE, holds the lock through commit.