System Design
T5

Concurrency control strategies

Seven ways to change one row safely while many clients race for it. Each one is correct. Contention decides which one is fast.

Not startedSaved in this browser only.
  1. 1Contention decides the strategy. First find how many clients write the same row at once.
  2. 2Put the check and the write in one statement when you can, and read the row count.
  3. 3Optimistic CAS and SERIALIZABLE abort and retry. On a hot key, most of their work is thrown away.
  4. 4For one hot key, queue the writes: row locks, or one writer per key that batches.
T5
    A

    The lost update

    read, then write
    Buyer Astock rowitem 1 · qty 1Buyer Bread qtyt1qty = 1read qtyt2qty = 1write qty = 0t3write qty = 0t4✕ 2 units sold from 1: A's write is lost
    • Each buyer computes the new value from an old read.
    • No error is raised. The stock count is simply wrong.
    • Every strategy on this sheet closes the gap between the read and the write.

    If two clients read, decide and then write, the second write silently replaces the first.

    B

    Where the loser goes

    four paths
    the second writer on a busy rowWaitqueue on the row lock, then check againFOR UPDATE · conditional UPDATE · constraintsAbort, retrythrow the work away and run it againversion CAS · SERIALIZABLESkiptake a different row that nobody holdsSKIP LOCKEDQueue in the servicewait in memory; one writer applies a batchsingle writer per key
    • Waiting costs latency, but no work is lost.
    • Retrying wastes work, and the waste grows with contention.
    • Skipping needs many rows that are equal, such as jobs or units.

    Every strategy decides one thing: does the losing writer wait, retry, skip, or queue?

    C

    Which strategy, at which contention

    from the measurements in D
    strategywhereone hot key10 to 100 keys1,000+ keyswhy
    Read, then writeServiceNot approvedNot approvedNot approvedLoses updates at any contention.
    Conditional UPDATE, count rowsPostgresApprovedApprovedApprovedOne statement, no retry. The default when the rule fits in a WHERE clause.
    CHECK, unique or exclusion constraintPostgresApprovedApprovedApprovedThe database refuses the bad row. Use it as the check, or as a backstop.
    FOR UPDATE, then writePostgresShort transactionsApprovedApprovedCorrect for any read, decide, write. On a hot row, buyers wait in line.
    Version column, compare-and-setPostgresNot approvedFew writersApprovedNo locks held while the user thinks. Aborts grow with contention.
    SERIALIZABLE, retry on 40001PostgresNot approvedLarge tableApprovedProtects rules that span many rows. Retries the whole transaction.
    SKIP LOCKED over unit rowsPostgresVacuum keeps upApprovedApprovedNobody waits. Best for work queues and pools of equal items.
    Single writer per key, batchedServiceApprovedApprovedApprovedNo lock and one commit per batch. Costs a queue and a router.

    On a hot key I use a conditional update or a single writer. Optimistic locking and SERIALIZABLE are for keys that rarely collide.

    D

    Contention changes the winner

    measured in the lab; pick a level
    32 clients over
    takes per secondabortedp99010k20k30k40kConditional UPDATE: 4,905 takes/s, 0% of attempts aborted, p50 4.49 ms, p99 29.72 msConditional UPDATE4,9050%29.72 msCHECK constraint: 5,291 takes/s, 0% of attempts aborted, p50 4.27 ms, p99 26.45 msCHECK constraint5,2910%26.45 msFOR UPDATE: 1,638 takes/s, 0% of attempts aborted, p50 13.21 ms, p99 88.36 msFOR UPDATE1,6380%88.36 msVersion CAS: 497 takes/s, 90.7% of attempts aborted, p50 13.29 ms, p99 499.22 msVersion CAS49790.7%499.22 msSERIALIZABLE + retry: 865 takes/s, 94% of attempts aborted, p50 23.23 ms, p99 186.35 msSERIALIZABLE + retry86594%186.35 msSKIP LOCKED, unit rows: 1,804 takes/s, 0% of attempts aborted, p50 10.16 ms, p99 135.85 msSKIP LOCKED, unit rows1,8040%135.85 msSingle writer, batched: 44,788 takes/s, 0% of attempts aborted, p50 0.69 ms, p99 1.32 msSingle writer, batched44,7880%1.32 ms

    With one hot key, the fastest is Single writer, batched at 44,788 takes a second. That is 90 times the slowest, Version CAS. SERIALIZABLE + retry aborts 94% of its attempts and runs them again.

    • Postgres decides in the database
    • Service queues in the service, then writes
    • aborted share of attempts thrown away and run again

    32 clients, 3 s per run, Postgres 16.14, 16 CPU threads. Each take decrements a counter that must stay at or above zero. Repeated runs differ by up to 2 times, so read these as orders of magnitude.

    With one hot key the strategies differ by almost 100 times. With 1,000 keys they are all within a few times of each other.

    E

    Two buyers, the last unit

    recorded from Postgres
    1. 1read qtyqty = 1
    2. 2read qtyqty = 1
    3. 3write qty = 01 row changed
    4. 4write qty = 01 row changed: A's write is lost
    ✕ 2 units sold from 1. Final qty = 0, so the stock count is wrong.

    With a conditional update, the second buyer waits for the first, then sees qty 0 and gets sold out.

    F

    200 buyers, 50 units

    recorded, every strategy
    strategysoldsold outleft
    FOR UPDATE501500
    Version CAS501500
    Conditional UPDATE501500
    CHECK constraint501500
    SKIP LOCKED, unit rows501500
    SERIALIZABLE + retry501500
    Single writer501500
    • All 200 buyers start at once.
    • Every strategy sells exactly 50 units.
    • The CHECK constraint never fires in the other strategies. It stays as a backstop.

    I prove a strategy under load: the units sold must equal the stock, with no errors.

    G

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    PostgresRow locks: SELECT ... FOR UPDATEBuyers of one row wait in line. Buyers of other rows never wait.Transfers, booking nights
    PostgresRe-check after a lock wait (READ COMMITTED)A waiting UPDATE reads the new row and tests its WHERE clause again.Any conditional write
    PostgresRow count of an UPDATEThe service learns if the condition held, with no second query.State changes: pending to paid
    PostgresCHECK constraintRefuses qty below zero, whatever the code does.Balances, capacity limits
    PostgresUnique indexThe first insert wins. A concurrent second insert waits, then fails with 23505.Idempotency keys, seat claims
    PostgresExclusion constraintNo two overlapping ranges for one resource.Calendars, desk booking
    PostgresFOR UPDATE SKIP LOCKEDEach worker takes a different row, and nobody waits.Job queues, pools of codes
    PostgresSERIALIZABLE isolation (SSI)Finds read and write conflicts and aborts one transaction with 40001.Rules over many rows, write skew
    PostgresSIRead locks: row, page or whole tableLimit A full table scan marks the whole table as read, so any write conflicts.
    PostgresOne commit for many changesThe single writer pays one durable flush per batch, not one per take.Bulk loads, outbox relays
    ServiceOne writer per partition (a goroutine or an actor)Only one writer touches a key, so the write needs no lock.Actor systems, game state
    KafkaPartition by key, one consumer per partitionThe same single writer across machines, with a durable queue in front.Event sourcing, ledgers

    Postgres gives me row locks, a re-check after a lock wait, constraints, SKIP LOCKED and serializable isolation. The service adds a single writer when one key is hot.

    H

    Wait: lock, condition, constraint

    pseudo code
    three strategies that waitpseudo code
    pessimistic(item):
      BEGIN
        qty = read qty FOR UPDATE           // others wait here1
        IF qty == 0: ROLLBACK; RETURN sold out
        qty = qty - 1
      COMMIT
    
    conditional(item):
      rows = set qty = qty - 1 WHERE qty > 02  // check and write, one statement
      RETURN rows == 13 ? taken : sold out
    
    constraint(item):
      set qty = qty - 1                     // no check in the code4
      ON error 23514: RETURN sold out
    1. 1The row lock lasts until COMMIT. Keep the transaction short: no network calls inside it.
    2. 2After a wait, Postgres tests this condition on the new row, not on the row it first saw.
    3. 3The row count is the answer. 0 rows means sold out.
    4. 4The CHECK (qty >= 0) constraint is the check. The failed row is never written.
    Tested source SQL: lock, decrement, conditional · SQL: the stock table
    SQL: lock, decrement, conditionalsql
    -- Pessimistic: lock the row. Other buyers of this item wait here.
    SELECT qty FROM stock WHERE item_id = $1 FOR UPDATE;
    
    UPDATE stock SET qty = qty - 1 WHERE item_id = $1;
    
    -- Check and write in one statement. The row count says if it worked.
    UPDATE stock SET qty = qty - 1
    WHERE item_id = $1 AND qty > 0;
    SQL: the stock tablesql
    -- One row per item. The CHECK refuses stock below zero, whatever the code does.
    CREATE TABLE stock (
      item_id bigint PRIMARY KEY,
      qty     int    NOT NULL CHECK (qty >= 0),
      version bigint NOT NULL DEFAULT 0
    );

    A conditional update is the check and the write in one statement. The row count tells me if it worked.

    I

    Optimistic: version and CAS

    pseudo code
    compare-and-setpseudo code
    take(item):
      LOOP:
        read qty, version                   // no lock1
        IF qty == 0: RETURN sold out
        rows = set qty = qty - 1, version = version + 1
               WHERE version = the version you read2
        IF rows == 1: RETURN taken
        // 0 rows: another buyer wrote first; read again3
    1. 1Nothing is held between the read and the write. The user can take minutes, as in an edit form.
    2. 2The compare in compare-and-set. Any write in between changed the version.
    3. 3The retry. On a hot key, most attempts end here.
    Tested source Go: the retry loop · SQL: read, compare-and-set
    Go: the retry loopgo
    var qty int
    var version int64
    if err := db.QueryRow(ctx, q["read"], item).Scan(&qty, &version); err != nil {
      return Result{}, fmt.Errorf("optimistic read: %w", err)
    }
    if qty == 0 {
      return res, nil // sold out
    }
    tag, err := db.Exec(ctx, q["cas"], item, qty-1, version)
    if err != nil {
      return Result{}, fmt.Errorf("optimistic write: %w", err)
    }
    if tag.RowsAffected() == 1 {
      res.Taken = true
      return res, nil
    }
    res.Retries++ // another buyer changed the row first: read again
    SQL: read, compare-and-setsql
    SELECT qty, version FROM stock WHERE item_id = $1;
    
    -- Optimistic: write only if nobody changed the row since we read it.
    UPDATE stock SET qty = $2, version = version + 1
    WHERE item_id = $1 AND version = $3;
    • Use it for edits that a person makes, where a lock would be held too long.
    • An HTTP ETag with If-Match is the same idea at the API.

    I read the version, then write only if the version has not changed. A miss means someone else won, so I read again.

    J

    SERIALIZABLE with retry

    pseudo code
    serializablepseudo code
    take(item):
      LOOP:
        BEGIN ISOLATION LEVEL SERIALIZABLE1
          qty = read qty
          IF qty == 0: COMMIT; RETURN sold out
          write qty - 1
        COMMIT
        ON error 400012: ROLLBACK, run the whole transaction again3
        RETURN taken
    1. 1Postgres tracks what each transaction read (SIRead locks) and wrote.
    2. 2serialization_failure. The transaction is rolled back. Nothing it wrote remains.
    3. 3Read again too. A retry that reuses the old read repeats the lost update.
    Tested source Go: the retry loop
    Go: the retry loopgo
    taken := false
    err := pgx.BeginTxFunc(ctx, db, pgx.TxOptions{IsoLevel: pgx.Serializable}, func(tx pgx.Tx) error {
      var qty int
      var version int64
      if err := tx.QueryRow(ctx, q["read"], item).Scan(&qty, &version); err != nil {
        return fmt.Errorf("read: %w", err)
      }
      if qty == 0 {
        return nil
      }
      if _, err := tx.Exec(ctx, q["write_qty"], item, qty-1); err != nil {
        return fmt.Errorf("write: %w", err)
      }
      taken = true
      return nil
    })
    if IsCode(err, CodeSerialization) || IsCode(err, CodeDeadlock) {
      res.Retries++ // Postgres found a conflict: run the whole transaction again
      continue
    }
    • It also stops write skew, which row locks on one row cannot see.
    • A read by full table scan marks the whole table. On small tables, almost every pair of transactions conflicts.

    SERIALIZABLE lets me write plain reads and writes, but I must retry the whole transaction on error 40001.

    K

    Skip: a SKIP LOCKED work queue

    figure, then pseudo code
    workersjobs table, status = queuedW1W2W3job 101locked by W1job 102locked by W2job 103locked by W3job 104freejob 105freedashed line: W3 looked at a locked row and skipped itNo worker waits. Each job goes to exactly one worker.
    claim a jobpseudo code
    claim(worker):                     // one statement
      job = oldest queued job
            that no other transaction holds1   // FOR UPDATE SKIP LOCKED
      IF none: RETURN nothing to do
      mark job running2 by worker; COMMIT
      RETURN job
    1. 1Locked rows are skipped, not waited for. Each worker gets a different job.
    2. 2A crash leaves the job running. A reaper puts old running jobs back in the queue.
    Tested source SQL: claim a job
    SQL: claim a jobsql
    -- A worker takes the oldest queued job that no other worker holds.
    UPDATE jobs SET status = 'running', worker = $1
    WHERE id = (
      SELECT id FROM jobs
      WHERE status = 'queued'
      ORDER BY id
      LIMIT 1
      FOR UPDATE SKIP LOCKED
    )
    RETURNING id;

    Workers claim jobs with FOR UPDATE SKIP LOCKED, so each job goes to one worker and no worker waits.

    L

    Queue: a single writer per key

    figure, then pseudo code
    buyerstake(1)take(8)take(1)take(7)Routeritem mod 8no lockpartition 0items 8, 16, …1 writerpartition 1items 1, 9, …1 writerpartition 7items 7, 15, …1 writerPostgres1 commitper batchA batch of 7 takes for item 1 is one UPDATE and one commit.
    router and writerpseudo code
    route(take(item)):
      queue[item mod 81].push(request)        // no lock anywhere
    
    writer(queue):                           // ONE writer per queue2
      LOOP:
        batch = wait for 1 request, then take up to 256 more
        BEGIN
          FOR EACH item IN batch:
            n = requests for item
            granted = set qty = qty - min(qty, n)3, RETURN min(qty, n)
        COMMIT                               // one commit for the batch4
        reply taken to the first granted requests, sold out to the rest
    1. 1The router. Every request for an item goes to the same queue.
    2. 2Only this writer changes these items. The write needs no lock.
    3. 3Grant as many as the stock allows, in one statement.
    4. 4One durable flush for many takes. Replies go out only after the commit.
    Tested source Go: drain, group, commit, reply · SQL: grant a batch
    Go: drain, group, commit, replygo
      batch := []request{first}
    drain:
      for len(batch) < MaxBatch {
        select {
        case r := <-ch:
          batch = append(batch, r)
        default:
          break drain
        }
      }
      byItem := map[int64][]request{}
      var order []int64
      for _, r := range batch {
        if _, ok := byItem[r.item]; !ok {
          order = append(order, r.item)
        }
        byItem[r.item] = append(byItem[r.item], r)
      }
      granted := map[int64]int{}
      err := pgx.BeginFunc(ctx, w.db, func(tx pgx.Tx) error { // one commit for the whole batch
        for _, item := range order {
          var n int
          if err := tx.QueryRow(ctx, q["take_batch"], item, len(byItem[item])).Scan(&n); err != nil {
            return fmt.Errorf("take item %d: %w", item, err)
          }
          granted[item] = n
        }
        return nil
      })
      for _, item := range order {
        for i, r := range byItem[item] { // reply only after the commit
          r.reply <- Reply{Taken: err == nil && i < granted[item], Err: err}
        }
      }
    SQL: grant a batchsql
    -- Single writer: grant up to $2 requests in one statement. No other writer touches this item.
    WITH cur AS (SELECT qty FROM stock WHERE item_id = $1)
    UPDATE stock SET qty = stock.qty - LEAST(cur.qty, $2)
    FROM cur
    WHERE stock.item_id = $1
    RETURNING LEAST(cur.qty, $2);

    For one very hot key I route every request for that key to one writer. It applies a batch in one transaction, so there is no lock and one commit.

    M

    Failure cases

    what breaks, and why it stays correct
    eventresultwhy it is safesaved by
    Two buyers take the last unit at onceThe second waits on the row lock.It tests qty > 0 on the new row and changes 0 rows.Row lock
    A compare-and-set loses the race0 rows changed.Nothing was written. The service reads again and retries.Version
    SERIALIZABLE finds a conflictError 40001.Postgres rolls back all of it. The service runs the transaction again.SSI
    A code path decrements without a checkError 23514.The CHECK constraint refuses qty below zero.CHECK
    A worker crashes after it claims a jobThe job stays running.A reaper puts it back after a timeout. The job must be safe to run twice.Reaper
    Every free unit is locked by claims in flightSKIP LOCKED finds no row.Answer "try again", not "sold out", if those claims can roll back.Retry
    The single writer crashes in a batchThe transaction rolls back.Nothing was granted. Callers time out and retry with an idempotency key.Transaction
    A hot key under CAS or SERIALIZABLEAborts grow with clients.Cap the retries and add jitter, or move to a strategy that waits.Backoff
    N

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1One UPDATE2+ row locks3+ split hot rows4+ single writer5+ shardsmore load →
    One hot key: demand against each strategy1001k10k100kDemand, flash sale: 1,667 takes per secondDemand, flash sale1,667Version CAS: 497 takes per secondVersion CAS497SERIALIZABLE: 865 takes per secondSERIALIZABLE865FOR UPDATE: 1,638 takes per secondFOR UPDATE1,638Conditional UPDATE: 4,905 takes per secondConditional UPDATE4,905Single writer, batched: 44,788 takes per secondSingle writer, batched44,788takes per second, log scale
    stepaddit handlesmove up when you see
    1A conditional UPDATE and constraints. One statement checks and writes.About 4,900 takes a second on one hot row, and about 48,000 over 1,000 rows.The rule needs a read and a decision in code.
    2Row locks around read, decide, write. Keep the transaction short.About 1,600 a second on one hot row. Other rows do not wait.Lock waits on one row set the p99.
    3Split the hot row: N counter rows per item, or one row per unit with SKIP LOCKED.10 rows took the conditional UPDATE from about 4,900 to 20,000 a second.One item needs more than its rows can commit.
    4A single writer per key that batches.About 45,000 takes a second on one key, with one commit per batch.One primary reaches its write limit.
    5Shard by key.Each shard adds its own write rate. A key still lives on one shard.Top of the ladder.

    Demand example: 100,000 buyers want one item within 60 seconds, so 100,000 / 60 = 1,667 takes a second. The single writer bar is drawn in the Postgres colour because its writes land in Postgres.

    I start with a conditional update. I split a hot row or add a single writer only when one key needs more than its row can commit.

    O

    Drill

    predict, then reveal

    0 of 9 known

    1. Two buyers read qty = 1 and both write qty = 0. What went wrong, and what is the smallest fix?

    2. A conditional UPDATE waits on a row lock. When it continues, which qty does it test?

    3. Why does optimistic locking collapse on one hot key?

    4. SERIALIZABLE aborted 92.6% of attempts with 100 keys but 2.1% with 1,000. Why?

    5. When is SKIP LOCKED the wrong tool?

    6. How can a single writer beat row locks on one hot key?

    7. What does a CHECK constraint add when the code already checks qty > 0?

    8. How does a unique index act as a lock?

    9. What must be true before you retry a transaction automatically?

    P

    Numbers to say

    measured in the lab
    hot row
    About 4,900 conditional UPDATEs a second on one row; about 1,600 with FOR UPDATE.
    spread
    About 48,000 conditional UPDATEs a second over 1,000 rows.
    CAS
    90.7% of attempts abort on one hot key; 2.2% over 1,000 keys.
    SSI
    92.6% abort on a 100-row table; 2.1% on 1,000 rows.
    batching
    About 45,000 takes a second on one key with a single writer.
    p99
    88.36 ms with FOR UPDATE on one hot row; 1.32 ms with the single writer.

    Postgres 16.14 on an 8-core laptop, 32 clients, shared with other workloads. A server that flushes every commit to durable storage commits slower. Use these as orders of magnitude.