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.
- 1Contention decides the strategy. First find how many clients write the same row at once.
- 2Put the check and the write in one statement when you can, and read the row count.
- 3Optimistic CAS and SERIALIZABLE abort and retry. On a hot key, most of their work is thrown away.
- 4For one hot key, queue the writes: row locks, or one writer per key that batches.
- 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.
- 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?
| strategy | where | one hot key | 10 to 100 keys | 1,000+ keys | why |
|---|---|---|---|---|---|
| Read, then write | Service | Not approved | Not approved | Not approved | Loses updates at any contention. |
| Conditional UPDATE, count rows | Postgres | Approved | Approved | Approved | One statement, no retry. The default when the rule fits in a WHERE clause. |
| CHECK, unique or exclusion constraint | Postgres | Approved | Approved | Approved | The database refuses the bad row. Use it as the check, or as a backstop. |
| FOR UPDATE, then write | Postgres | Short transactions | Approved | Approved | Correct for any read, decide, write. On a hot row, buyers wait in line. |
| Version column, compare-and-set | Postgres | Not approved | Few writers | Approved | No locks held while the user thinks. Aborts grow with contention. |
| SERIALIZABLE, retry on 40001 | Postgres | Not approved | Large table | Approved | Protects rules that span many rows. Retries the whole transaction. |
| SKIP LOCKED over unit rows | Postgres | Vacuum keeps up | Approved | Approved | Nobody waits. Best for work queues and pools of equal items. |
| Single writer per key, batched | Service | Approved | Approved | Approved | No 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.
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.
- 1read qtyqty = 1
- 2read qtyqty = 1
- 3write qty = 01 row changed
- 4write qty = 01 row changed: A's write is lost
With a conditional update, the second buyer waits for the first, then sees qty 0 and gets sold out.
| strategy | sold | sold out | left |
|---|---|---|---|
| FOR UPDATE | 50 | 150 | 0 |
| Version CAS | 50 | 150 | 0 |
| Conditional UPDATE | 50 | 150 | 0 |
| CHECK constraint | 50 | 150 | 0 |
| SKIP LOCKED, unit rows | 50 | 150 | 0 |
| SERIALIZABLE + retry | 50 | 150 | 0 |
| Single writer | 50 | 150 | 0 |
- 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.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | Row locks: SELECT ... FOR UPDATE | Buyers of one row wait in line. Buyers of other rows never wait. | Transfers, booking nights |
| Postgres | Re-check after a lock wait (READ COMMITTED) | A waiting UPDATE reads the new row and tests its WHERE clause again. | Any conditional write |
| Postgres | Row count of an UPDATE | The service learns if the condition held, with no second query. | State changes: pending to paid |
| Postgres | CHECK constraint | Refuses qty below zero, whatever the code does. | Balances, capacity limits |
| Postgres | Unique index | The first insert wins. A concurrent second insert waits, then fails with 23505. | Idempotency keys, seat claims |
| Postgres | Exclusion constraint | No two overlapping ranges for one resource. | Calendars, desk booking |
| Postgres | FOR UPDATE SKIP LOCKED | Each worker takes a different row, and nobody waits. | Job queues, pools of codes |
| Postgres | SERIALIZABLE isolation (SSI) | Finds read and write conflicts and aborts one transaction with 40001. | Rules over many rows, write skew |
| Postgres | SIRead locks: row, page or whole table | Limit A full table scan marks the whole table as read, so any write conflicts. | |
| Postgres | One commit for many changes | The single writer pays one durable flush per batch, not one per take. | Bulk loads, outbox relays |
| Service | One writer per partition (a goroutine or an actor) | Only one writer touches a key, so the write needs no lock. | Actor systems, game state |
| Kafka | Partition by key, one consumer per partition | The 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.
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- 1The row lock lasts until COMMIT. Keep the transaction short: no network calls inside it.
- 2After a wait, Postgres tests this condition on the new row, not on the row it first saw.
- 3The row count is the answer. 0 rows means sold out.
- 4The CHECK (qty >= 0) constraint is the check. The failed row is never written.
Tested source SQL: lock, decrement, conditional · SQL: the stock table
-- 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;-- 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.
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- 1Nothing is held between the read and the write. The user can take minutes, as in an edit form.
- 2The compare in compare-and-set. Any write in between changed the version.
- 3The retry. On a hot key, most attempts end here.
Tested source Go: the retry loop · SQL: read, compare-and-set
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 againSELECT 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.
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- 1Postgres tracks what each transaction read (SIRead locks) and wrote.
- 2serialization_failure. The transaction is rolled back. Nothing it wrote remains.
- 3Read again too. A retry that reuses the old read repeats the lost update.
Tested source Go: the retry loop
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.
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- 1Locked rows are skipped, not waited for. Each worker gets a different job.
- 2A crash leaves the job running. A reaper puts old running jobs back in the queue.
Tested source SQL: claim a job
-- 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.
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- 1The router. Every request for an item goes to the same queue.
- 2Only this writer changes these items. The write needs no lock.
- 3Grant as many as the stock allows, in one statement.
- 4One durable flush for many takes. Replies go out only after the commit.
Tested source Go: drain, group, commit, reply · SQL: grant a batch
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}
}
}-- 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.
| event | result | why it is safe | saved by |
|---|---|---|---|
| Two buyers take the last unit at once | The 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 race | 0 rows changed. | Nothing was written. The service reads again and retries. | Version |
| SERIALIZABLE finds a conflict | Error 40001. | Postgres rolls back all of it. The service runs the transaction again. | SSI |
| A code path decrements without a check | Error 23514. | The CHECK constraint refuses qty below zero. | CHECK |
| A worker crashes after it claims a job | The 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 flight | SKIP LOCKED finds no row. | Answer "try again", not "sold out", if those claims can roll back. | Retry |
| The single writer crashes in a batch | The transaction rolls back. | Nothing was granted. Callers time out and retry with an idempotency key. | Transaction |
| A hot key under CAS or SERIALIZABLE | Aborts grow with clients. | Cap the retries and add jitter, or move to a strategy that waits. | Backoff |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | A 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. |
| 2 | Row 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. |
| 3 | Split 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. |
| 4 | A 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. |
| 5 | Shard 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.
0 of 9 known
Two buyers read qty = 1 and both write qty = 0. What went wrong, and what is the smallest fix?
A conditional UPDATE waits on a row lock. When it continues, which qty does it test?
Why does optimistic locking collapse on one hot key?
SERIALIZABLE aborted 92.6% of attempts with 100 keys but 2.1% with 1,000. Why?
When is SKIP LOCKED the wrong tool?
How can a single writer beat row locks on one hot key?
What does a CHECK constraint add when the code already checks qty > 0?
How does a unique index act as a lock?
What must be true before you retry a transaction automatically?
- 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.