Distributed locks, leases and fencing tokens
A lock in a shared store is a lease; only the resource itself can refuse a holder whose lease ran out.
Predict first
Before you read: predict
ExamHall's 6 API pods each try the 11:00 "close exam and auto-submit" job. A pod runs it only after SET lock:close-exam-42 <pod> NX PX 30000 answers OK, and right before each submit it checks that GET lock:close-exam-42 still returns its own name. At 11:00:00 pod A takes the lock and loads attempt 9 (in progress). Its GET check passes at 11:00:05; then pod A stops in a 40-second garbage-collection pause, before its submit. At 11:00:31 pod B takes the lock, loads attempt 9, passes its check and submits it. Pod A wakes at 11:00:45 and runs its submit. The submit is UPDATE attempts SET status = 'submitted', submits = submits + 1 WHERE id = 9.
How many times is attempt 9 submitted?
The 30-second version
A Redis lock with an expiry is a lease: it ends at its time, finished or not. A paused holder wakes and acts as if it still held it, and no check beforehand can see the pause coming. So the resource must refuse the stale holder: a fencing token that only grows, or a guarded write in the database.
A lock in a shared store is a lease; only the resource itself can refuse a holder whose lease ran out.
How it works
Step 1 of 15
SET key id NX PX 30000 creates it only if nobody holds it (nosql-redis-data-structures).In the kitchen
- one live permit per valve, valid until 14:00 →
SET … NX PX(steps 1–2)
Analogy: An oil platform's permit-to-work office
The control room issues one live permit per valve, each valid until a stated time. Crews take a permit before they touch the valve.
| On the platform | In the lock |
|---|---|
| one live permit per valve, valid until 14:00 | SET … NX PX (steps 1–2) |
| a permit lapses at its time even if the crew never returns | the lease (step 2) |
| the foreman, held at a muster drill with no watch, comes back at 14:30 sure his permit stands | a paused holder (steps 3–4) |
| reading the permit at the tool store proves nothing about the walk to the valve | a check before the write (step 5) |
| a crew's permit hand-back can reach the control room after the permit lapsed | a release arriving late (step 6) |
| each permit carries a serial from the control room's book, always higher | a fencing token (step 8) |
| the valve's lockout box keeps the highest serial it has accepted and turns away a lower one | the resource checking tokens (step 9) |
Breaks down when: a foreman who comes back late knows he was away. A paused process does not know it stopped (step 3).
Contrasting case: a valve with a mechanical interlock that only one key turns. The valve itself decides, as a guarded UPDATE does (step 12).
In real terms: treat every distributed lock as a lease, and make the thing it protects reject the holder whose lease ran out.
Examples, run for real
4.1 ExamHall EXH-5070: an attempt auto-submitted twice
ExamHall's 11:00 job ran under a Redis lease, and each pod checked the lock before its submit. Pod A paused after its check; pod B took the lock and submitted; pod A woke and submitted too.
4.1 EXH-5070 Redis 7.4.11, PostgreSQL 17.11 · lease only: pod A's check passed 11:00:05 · its lease expired 11:00:30 · pod B UPDATE 1 at 11:00:31 · pod A UPDATE 1 at 11:00:45 · attempt 9 submitted 2 times
4.1 MR !5370 same timeline, guarded UPDATE (AND status = 'in_progress'): pod B UPDATE 1 · pod A UPDATE 0 · attempt 9 submitted 1 time
MR !5370 keeps the lease to pick a runner, and adds the state check to the UPDATE itself: the second submit changes no row (steps 4–5, 12).
4.2 A token at the resource
Two chat workers pin a channel's notice. W1 gets token 33 and pauses past its lease; W2 gets 34 and writes; W1 wakes and writes with 33.
4.2 tokens from INCR: W1 33, W2 34 · W2 writes with 34: UPDATE 1 · W1 wakes, writes with 33: UPDATE 0 · pinned "W2 notice", fence 34
The table keeps the highest token it accepted, so 33 changes nothing once 34 has written (steps 8–9).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 6
A chat service's 4 workers run the hourly digest job. A worker runs it only after SET lock:digest <worker> NX PX 30000 answers OK, and its finally block runs DEL lock:digest. At 09:00:00 worker W1 takes the lock and at once freezes for 50 s; its lease runs out at 09:00:30. At 09:00:31 W2 tries the lock and, today, gets it. W1 wakes at 09:00:50, sends its digest and runs its finally. At 09:00:55 W3 tries the lock. The team wants W3 refused at 09:00:55, while W2 is still working.
On this lesson's virtual-clock model, which change does that?
Question 2 of 6
A chat service's settings job writes a channel's message retention. A worker takes SET lock:retention <worker> NX PX 30000, then INCR lock:retention:fence for its token, and writes with UPDATE channels SET retention_days = $1, fence = $2 WHERE id = 7 AND fence <= $2, where $2 is its token. The row's fence is 32 and the counter is at 32. At 10:00:00 W1 takes the lock (token 33) and pauses; its lease runs out at 10:00:30. At 10:00:31 W2 takes the lock (token 34). Then W1 wakes and writes 30 days at 10:00:40, W2 writes 90 days at 10:00:45, and W1 writes 30 days again at 10:00:50.
On this lesson's virtual-clock model, which writes change the row?
Question 3 of 6
You proposed a Redis lock for a chat service's channel-export job. The interviewer: "The job reads channel 7's export row in Postgres, sends the export, then marks the row done. Any worker can pause for 6 minutes. What stops a second export?" In the test run, W1 starts at 12:00:00 and pauses for 6 minutes just before it sends; W2 starts at 12:00:10 and, when refused, retries each minute. An answer that uses the lock takes SET lock:export-7 <worker> NX PX 30000 and a token from INCR lock:export-7:fence; the row's fence and the counter start at 32.
On this lesson's virtual-clock model, which answer has channel 7 exported exactly once?
Question 4 of 6
A chat service sends each channel's weekly digest from a job that any worker may run on PostgreSQL. The job runs BEGIN; SELECT pg_try_advisory_xact_lock(9), stopping with ROLLBACK if it returns false; SELECT status FROM digests WHERE channel_id = 9, stopping with COMMIT unless it is 'pending'; COMMIT; then it sends the digest and runs UPDATE digests SET status = 'sent' WHERE channel_id = 9. Channel 9's row is 'pending'. At 08:00:00 W1 starts the job and pauses for 2 minutes right after its COMMIT. At 08:00:30 W2 starts it and pauses for 2 minutes right after its status read, before its COMMIT. At 08:01:00 W3 starts it and does not pause.
On this lesson's forced-order model, which workers send channel 9's digest?
Question 5 of 6
A streaming service's transcode scheduler runs SELECT pg_try_advisory_lock(42) on a session it keeps open, and schedules only while it holds the lock. Scheduler A runs BEGIN and that call, which returns true at 12:00; then its process freezes with its connection still open. The server runs with default settings and no connection pooler. Scheduler B runs the same call on its own session at 12:10, 12:20 and 12:30. At 12:15 A's process wakes just long enough to run COMMIT, then freezes again. At 12:25 an operator ends A's session with pg_terminate_backend.
On this lesson's forced-order model, which of B's calls is the first to return true?
Question 6 of 6
A ticketing site's nightly job rebuilds event 12's seat-map snapshot. A worker takes SET lock:seatmap <worker> NX PX 30000 and a token from INCR lock:seatmap:fence, reads the row, builds, and writes with UPDATE seatmaps SET status = 'ready', snapshot = $1 WHERE id = 12. The row's fence is 32 and so is the counter. W1 takes the lock at 02:00:00 (token 33) and reads status 'building'; after its last read of Redis or Postgres at 02:00:05 it freezes for 60 s before its write arrives. W2 tries the lock at 02:00:31 and, today, gets it (token 34), reads 'building' and writes at 02:00:40. At 02:00:50 an admin starts a manual rebuild, which sets the status back to 'building'. W1's write arrives at 02:01:05. The team wants W2's write to change the row and W1's not to.
On this lesson's virtual-clock model, which change does that?
Go deeper
Pro tips
- When the protected thing is a database row, put the check in the
UPDATEand act only on a row count of 1. When: a job that one pod should run, such as closing an exam (steps 12–13). Status: RAN.
Sources
quote-check.mjs finds every passage below verbatim in its saved text. The row names the first three; the row's Redis URL (develop/use/patterns/distributed-locks/) now only redirects to the page below. Valid as of 2026-10-18.
- Martin Kleppmann, How to do distributed locking (2016): https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html (
kleppmann-distributed-locking.txt). Quoted: "The lock has a timeout (i.e. it is a lease), which is always a good idea (otherwise a crashed client could end up holding a lock forever and never releasing it)."; "You cannot fix this problem by inserting a check on the lock expiry just before writing back to storage."; "Remember that GC can pause a running thread at any point , including the point that is maximally inconvenient for you (between the last check and the write operation)."; "a fencing token is simply a number that increases (e.g. incremented by the lock service) every time a client acquires the lock."; "the storage server remembers that it has already processed a write with a higher token number (34), and so it rejects the request with token 33."; "Efficiency: Taking a lock saves you from unnecessarily doing the same work twice"; "Correctness: Taking a lock prevents concurrent processes from stepping on each others’ toes and messing up the state of your system." Supports steps 2–5, 8–9 and 15. - Redis, Distributed Locks with Redis: https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/ (
redis-distributed-locks.txt). Quoted: "SET resource_name my_random_value NX PX 30000"; "The command will set the key only if it does not already exist ( NX option), with an expire of 30000 milliseconds ( PX option)."; "You should implement fencing tokens." Supports steps 1, 6 and 8. The page'sDELEXcommand needs Redis 8.4; on Redis 7 this lesson runs the page's Lua script. - PostgreSQL 17 documentation, 13.3.5. Advisory Locks: https://www.postgresql.org/docs/17/explicit-locking.html (
pg17-explicit-locking.txt; the same passages are verbatim in the version 18 page,pg18-explicit-locking.txt). Supports steps 13–14.
Steps 7, 10, 11 and 12 are what the scripts run, not quoted rules.
Related lessons
Checked against the tools and docs as of 2026-10-18.