Vibe Engines
YouTube
System Design

Design a Distributed Lock

Step 1 / 9

Learn system design by building a distributed lock (mutual exclusion across machines) like Redis locks, Redlock, or etcd/ZooKeeper locks step by step.

The numbers to beatSET NXset-if-absent1 winnerper raceatomicno check-then-set gap

The whole design, in writing

Learn system design by building a distributed lock (mutual exclusion across machines) like Redis locks, Redlock, or etcd/ZooKeeper locks step by step. An interactive guide covering why a DB flag gets stuck, atomic acquire, TTL leases that auto-release a dead holder, ownership tokens and safe release, the fencing-token problem, consensus-based locks for high availability, lease renewal, and the unhappy paths (clock skew, GC pauses, split brain).

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

The big idea

What is a distributed lock?

On one machine, "only one thread at a time" is a mutex — trivial. Now the workers live on different machines with no shared memory: two servers both want to run a nightly job once, or edit the same record. They can't see each other. How do you guarantee that exactly one proceeds, even when a holder crashes mid-task?

Worker Awants the lock
New in this step: Worker A.

A distributed lock provides mutual exclusion across machines. It's deceptively hard: unlike a local mutex, the holder can die (leaving the lock stuck), the network can lie, and clocks and pauses can betray you. The design adds TTL leases, ownership tokens, fencing, and consensus to make it safe.

What the new pieces do

Worker Aclient
A process on one machine that needs exclusive access to a resource — run this cron once, edit this record alone.

Step 1 · The stuck flag

A "locked = true" row

The naive version: a row lock: locked=true. To acquire, set it true; to release, set it false. What breaks the moment a holder crashes — or two workers race?

Worker Balso wants it
New in this step: Worker B.

A DB flag "locked=true" as a lock. What are the two fatal flaws?

  1. One boolean is nothing. The fatal flaws are that a crashed holder never sets it back to false (the lock is stuck forever), and a non-atomic check-then-set lets two workers both see "false" and both acquire.

  2. Speed isn't the issue. The real problems are a stuck lock after a crash (no auto-release) and a race where check-then-set isn't atomic, so two workers both acquire.

  3. If the holder dies before setting locked=false, the lock is held forever and no one can proceed. And "if not locked, lock it" as two steps races — both workers read false and both set true. You need atomic acquire AND automatic release on death.

Two fatal flaws. No auto-release: if the holder crashes before unlocking, the flag stays true forever and the resource is deadlocked. Not atomic: "check if free, then set" is two steps, so two workers can both read "free" and both acquire. A real lock needs an atomic acquire and a way to release a dead holder's lock automatically.

Why this piece earns its place

The reflex patch is worth naming, because it is what most teams reach for next: keep the flag, add a locked_at column, and run a sweeper that clears any row older than some age. That is a TTL built out of the wrong materials. The sweeper is itself a process that can crash, so the thing guaranteeing liveness now needs its own liveness guarantee — and underneath it the acquire is still a read followed by a write, so the original race survives the patch completely untouched. What the sweeper is not wrong about is expiry itself: no age-based rule can tell a dead holder from a slow one, and neither can the store-side lease this design goes on to ship. That ambiguity is inherent to expiry, which is why the answer to it arrives at step 5 as a check the resource performs, rather than as a better-tuned timer. The other assumption hiding here is that the lock belongs in the application database because the database is already there. That’s convenient right up to the point where every worker on every machine depends on it for permission to start work: a table nobody thought of as infrastructure becomes the busiest coordination point you own, contended by exactly the workers you were trying to spread out.

What the new pieces do

Worker Bclient
A competing process, on another machine. The whole point of the lock is that only one of A and B proceeds at a time.

Step 2 · Grant to exactly one

Atomic acquire

First fix the race: two workers ask for the lock at the same instant. Only one can win, decided in a single indivisible step. How?

Worker Awants the lockWorker Balso wants itLock Serviceacquire · release
New in this step: Lock Service.

Two workers acquire at the same instant. How do you grant to exactly one?

  1. A single atomic operation — set the lock key only if absent — is decided by the store indivisibly, so exactly one of the racing workers succeeds and the other sees "already set". No check-then-set gap for a race to slip through.

  2. Timestamps race and clocks disagree across machines; deciding by arrival time isn't atomic and can grant to two. The store must decide with one indivisible conditional operation.

  3. The workers can't see each other (that's the whole problem) and can't be trusted to coordinate. A single authority (the store) must grant atomically.

Use an atomic conditional write: "set the lock key only if it doesn't exist" (Redis SET key token NX, or a unique-constraint insert). The store decides this in one indivisible step, so exactly one racing worker succeeds and every other gets "already held". No check-then-set window for a race to slip through.

Why this piece earns its place

Atomicity settles who wins; it says nothing about what the losers do, and that’s where this step bites under load. Every worker told already held retries, and if they all retry on the same interval they arrive together on the next tick and keep arriving together — a herd that pounds the store hardest exactly when contention is worst. Randomized backoff between attempts breaks the lockstep, and a bounded wait, after which the caller gives up and reports rather than spinning forever, keeps a pile of blocked jobs from becoming an outage. The sharper problem is that SET NX can succeed and its reply can be lost. The worker sees a timeout and has no idea whether it holds the lock. Retry blindly and it fails — the key exists, its own key — so it walks away from a lock it actually owns, which now sits held with nobody doing the work. So generate the identifier for this attempt on the client before the call, and a retry can ask whether the current holder is already me instead of guessing.

  • SET NXset-if-absent
  • 1 winnerper race
  • atomicno check-then-set gap

What the new pieces do

Lock Servicegateway
Grants the lock to exactly one caller via an atomic acquire, hands back an ownership token, and releases safely.

Step 3 · Don't deadlock on death

TTL leases

Atomic acquire still leaves the second flaw: if the holder crashes while holding the lock, it never releases, and the lock is stuck forever. You can't rely on a dead process to clean up. How does the lock free itself?

Lock Serviceacquire · releaseLock Storekey + TTL + owner
New in this step: Lock Store.

The holder crashes while holding the lock. How does it ever get released?

  1. Manual cleanup is far too slow and error-prone for something that can happen constantly. The lock must auto-release without any live process or human.

  2. Waiting doesn't help — a dead holder never releases, so the wait is infinite. The lock itself must expire.

  3. The lock is granted for a bounded lease (TTL). If the holder dies, the store expires the key automatically and another worker can acquire. The holder must finish (or renew) within the lease, turning "held forever" into "held for at most T".

Make the lock a lease with a TTL: it's granted for a bounded time and auto-expires. If the holder dies, the store removes the key when the TTL elapses, and another worker can acquire — no dead process or human needed. The holder must complete its work (or renew, step 7) within the lease. "Held forever" becomes "held for at most T".

Why this piece earns its place

The TTL is the one number you have to defend, and it’s pulled in two directions at once. Make it short and you get false expiries: a holder that was alive and working loses the lock, a second worker starts the same job, and overlap becomes routine rather than rare. Make it long and it becomes your recovery time — when a holder really does die, everyone else waits out the whole lease before anything moves, so the TTL is the worst-case stall you sign up for on every crash. That means it should come from the tail of how long the protected work actually takes, not the average; sizing to the average guarantees that the slowest runs, which are usually the ones under load, are the runs that get cut off. Two things the lease is not: it isn’t timed by the holder — expiry is decided by the store, so a holder trusting its own stopwatch is trusting a clock nobody else agrees with — and watching your own remaining lease makes an overrun rarer, never impossible.

  • TTLbounded lease
  • auto-expirefrees a dead holder
  • ≤ Tmax hold time

What the new pieces do

Lock Storedata
Holds the lock: a key, the owner's token, and a TTL so a dead holder's lock auto-expires. The contended cell.

Step 4 · Only the owner unlocks

Ownership tokens

With TTLs, here's a nasty bug: Worker A acquires, runs slow, its lease expires; Worker B acquires. Then A finishes and calls "release" — and deletes B's lock, which A no longer owns. How do you make release safe?

Worker AWorker BLock ServiceLock Store
The system as it stands at this step. · swipe to pan the diagram

A's lease expired and B now holds the lock. A finishes and calls release. What must happen?

  1. On acquire, the holder is given a unique token stored with the lock. Release checks "is the current token still mine?" and deletes only if so — done atomically — so A's late release can't delete B's lock. A just learns it no longer holds it.

  2. Blind delete is exactly the bug: A's stale release wipes B's valid lock, and now two workers think they can proceed. Release must verify ownership first, atomically.

  3. A genuinely believes it still holds the lock (it doesn't know its lease expired), so trust isn't enough. The store must verify the token so a stale owner can't release the current one.

Give each acquisition a unique ownership token stored with the lock. Release is conditional: "delete the lock only if its token still equals mine" — executed atomically (a small Lua script / compare-and-delete). So A's late release finds the token now belongs to B and does nothing; A simply learns it no longer holds the lock. Never a blind delete.

Why this piece earns its place

Two details decide whether this actually holds. First, the token has to be genuinely unique per acquisition, and the convenient choices aren’t: hostname plus process id repeats the moment a container is rescheduled onto the same host or a supervisor restarts a crashed worker into the same slot — and then a stale release matches a live lock and deletes it, the exact bug the token was added to prevent, only harder to see. A random identifier minted per attempt is the cheap correct answer. Second, the check and the delete have to be one operation on the store. Reading the token, comparing it in the client, then issuing the delete is two round trips with a gap in the middle, and that gap is precisely long enough for the lease to expire and someone else to acquire. It’s also worth saying that release is an optimization, not a requirement: if it fails, nothing is broken, because the lease still expires. A holder that can’t release should log it and move on rather than block on retries.

  • unique tokenper acquire
  • release if token==mineatomic
  • no blind deletestale owner is safe

Step 5 · The safety hole

The fencing-token problem

Now the deepest issue. Worker A holds the lock, then pauses — a long GC, a VM stall — past its lease. The lock expires; Worker B legitimately acquires. Then A wakes up, still believing it holds the lock, and writes to the resource. TTLs plus tokens didn't stop this: for a moment, two workers act as holder. How do you make the resource reject the stale one?

Fencingmonotonic tokenResourceprotected
New in this step: Fencing, Resource.

A paused past its lease; B acquired; A wakes and writes. How does the resource reject A?

  1. A pause can exceed ANY fixed TTL, and shorter TTLs cause more false expiries. You can't eliminate the race with timing — you need the resource itself to reject stale writers.

  2. A can check and be told "you hold it" and then pause again before the write — the check-to-write gap reopens the hole. The resource, not the client, must enforce ordering.

  3. Each lock grant carries an ever-increasing number (a fencing token). B's token is higher than A's. The resource remembers the highest token it has accepted, so when stale A writes with its old, lower token, the resource rejects it. Safety is enforced at the resource, not by hoping the lock never overlaps.

Add a fencing token: every grant carries a monotonically increasing number, and the protected resource records the highest token it has accepted and rejects any write with a lower one. B's grant has a higher token than A's; when paused-A wakes and writes with its old, lower token, the resource refuses it. Correctness is enforced at the resource, not by praying the lease never overlaps a pause.

Why this piece earns its place

The uncomfortable part of fencing is that the lock can’t do it for you — it needs the resource to participate. A relational database can, cheaply: keep the highest accepted token in a column and make every write a conditional update that also asserts the incoming token is greater, in the same transaction as the write itself. Object stores and some queues expose a conditional-write primitive you can hang it on. But plenty of things you’d like to protect can’t: a third-party API, a shell script writing to a file share, a legacy service with no version column and no notion of an owner. For those, be honest about what you have — a lock that is usually exclusive — and move the exclusivity to a layer that can actually enforce it. The token also has to come from somewhere monotonic across failures. A counter incremented in a store that can restart from an older snapshot will hand out a number it already gave away, which silently destroys the ordering the resource is relying on.

  • token↑monotonic per grant
  • resourcerejects lower tokens
  • safeeven across a pause

What the new pieces do

Fencingservice
Issues an ever-increasing fencing token with each grant, so the protected resource can reject a stale holder's writes.
Resourcedata
The thing the lock guards — a record, a file, a job. It checks the fencing token and rejects anything stale.

Step 6 · Don't trust one node

Consensus for high availability

So far the lock lives in one store — which is a single point of failure for all coordination, and a naive replicated Redis can even grant the lock twice during failover (the primary dies after granting, a replica that never saw the grant is promoted). How do you make the lock both highly available and correct?

Lock Servicequorum-basedFencingmonotonic tokenQuorumconsensus nodes
New in this step: Quorum.

Base correctness-critical locks on a consensus system — etcd or ZooKeeper (Raft/ZAB) — where a majority quorum must agree on the lock holder, so a single node failure never loses or duplicates the lock, and a promoted node already knows the current state. For Redis specifically, Redlock acquires on a majority of independent Redis nodes to reduce single-node risk (though it's debated). The rule: if the lock guards something whose correctness matters, put it on a quorum, and still use fencing.

Why this piece earns its place

A quorum changes the operational shape of the lock, and two decisions get made badly by default. First, membership: keep the cluster small and odd-numbered, because a majority of an even set tolerates no more failures than the odd size below it while costing another participant on every commit. Every grant has to reach a majority and be durable there, so adding members buys failure tolerance and pays for it in acquire latency — which becomes very visible in a system that takes a lock per small task instead of per batch of work. Second, placement and reads. A majority that all lives in one rack or one availability zone is a majority that disappears together, which is the failure you bought the quorum to survive. And it’s tempting to read lock state from whichever member is nearest, since reads are cheap there — but a follower can be behind, so it will happily report a holder that has already changed. Anything that decides who holds the lock has to go through the leader or a linearizable read.

  • quorummajority agrees
  • etcd / ZooKeeperRaft / ZAB
  • no double-granton failover

What the new pieces do

Quorumdata
A replicated majority (etcd/ZooKeeper/Redlock) that agrees on the lock holder, so no single node failure loses it.

Step 7 · Living with the lock

Renewal, reentrancy & granularity

Practical questions remain: a legitimate task that runs longer than one TTL, a holder that needs the lock again recursively, choosing what the lock actually protects, and whether waiters should queue fairly.

Worker AWorker BLock ServiceLock StoreFencingQuorumResource
The system as it stands at this step. · swipe to pan the diagram

Support lease renewal (a heartbeat/watchdog that extends the TTL while the holder is alive and working, so long tasks don't lose the lock — but stop renewing if the work stalls). Allow reentrancy if the same holder may re-acquire (track a count). Choose granularity: lock the narrowest resource (one record, not the whole table) to maximize concurrency. Offer fairness where needed via a queue (ZooKeeper's sequential ephemeral nodes give ordered, fair locks) instead of a thundering-herd retry.

Why this piece earns its place

Renewal quietly reintroduces the failure from the very first step. A watchdog on its own thread keeps extending the lease while the worker thread is wedged on a hung connection or an infinite retry, and now the lock is held indefinitely by a process that is technically alive and doing nothing — the stuck flag again, only with a pulse. So the watchdog must not renew on a timer alone: have the work publish progress, a counter it bumps as it finishes units, renew only while that counter moves, and put a ceiling on total extensions so even a plausible-looking stall eventually gives the lock back. The renewal interval has its own trap. Renewing once near the end of the lease means a single lost packet costs you the lock, so renew at a fraction of the TTL and treat a failed renewal as serious rather than routine. And the rule that matters most: when renewal fails, the holder stops working immediately. Carrying on while retrying is the choice that produces two workers.

  • heartbeatextend while alive
  • reentrantsame holder re-acquires
  • narrowlock least, concur more

Step 8 · The sharp edges

Clocks, pauses & "do you even need it?"

Distributed locks live on a bed of dangerous assumptions: clocks drift, processes pause unpredictably (GC, VM migration), networks partition (split brain), and sometimes a lock is the wrong tool entirely.

Worker AWorker BLock ServiceLock StoreFencingQuorumResource
The system as it stands at this step. · swipe to pan the diagram

Don't trust wall clocks for lease timing across machines (skew) — rely on the store's expiry and monotonic tokens, not client clocks. Accept that pauses can exceed any TTL, which is exactly why fencing is mandatory for correctness. Survive partitions with a quorum (a minority side can't grant). And first ask whether you need a lock at all: often idempotency (make the operation safe to run twice), optimistic concurrency (compare-and-swap on a version), or a single-writer partition is simpler and safer than mutual exclusion. Use a lock only when you truly need one-at-a-time.

Why this piece earns its place

The decision this step really asks for is one sentence long and almost never written down: what happens if two run at once? If the answer is duplicate work — a report generated twice, a cache warmed twice — the lock is an efficiency measure and is allowed to fail. If the answer is a double payment or a corrupted record, it’s a correctness requirement, and a lock alone can’t deliver it, because everything above only narrows the window. In that case the resource has to reject the duplicate on its own terms — and once it does, the lock has become an optimization on top of a guarantee that already holds without it. That same classification answers the question you’ll be asked during an incident: what do we do when the coordination store is unreachable? An efficiency lock should fail open and run anyway, accepting some duplicate work. A correctness lock has to fail closed and stall. Teams that never made the call find out which one they built at the worst possible moment, because the default is whatever the client library happens to do on a timeout.

You did it

You just designed a distributed lock.

Worker AWorker BLock ServiceLock StoreFencingQuorumResource
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A "locked=true" flag fails twice: it deadlocks when a holder dies, and its check-then-set races.
  • Atomic acquire (set-if-absent / SET NX) grants to exactly one racing worker.
  • A TTL lease auto-expires a dead holder's lock, so it never deadlocks forever.
  • A unique ownership token makes release safe — delete only if the token is still mine.
  • A pause can overrun any lease, so fencing tokens let the resource reject stale writers — real safety.
  • Correctness-critical locks live on a consensus quorum (etcd/ZooKeeper), never one node.
  • Lease renewal, reentrancy, narrow granularity and fairness make it usable; often idempotency/CAS is better.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why isn't a simple "locked = true" database flag a working distributed lock?

    It fails on the two fundamental requirements at once. First, liveness: if the process holding the lock crashes before it can set the flag back to false, the flag stays true forever and the resource is deadlocked — no live process or human is guaranteed to clean it up, so the lock never frees. Second, atomicity: acquiring by "read the flag; if it's false, set it true" is two separate steps, so two workers can both read false at the same instant and both set it true, and now two of them believe they hold the lock. A real distributed lock has to solve both: grant to exactly one caller even under a simultaneous race (via an atomic set-if-absent primitive from the store), and guarantee the lock is eventually released even if the holder dies (via a TTL lease that auto-expires). The flag has neither, which is why it's a classic footgun.

  2. Why is a distributed lock really a time-bounded lease, and what problem does that create?

    Because in a distributed setting the holder can crash at any moment, and you can't depend on a dead process to release anything. To guarantee the lock always frees eventually, you grant it with a TTL — a lease — so if the holder dies the store simply expires the key and someone else can acquire. That solves liveness, but it introduces a subtle and dangerous new problem: a lease is a bet that the holder finishes within the time limit, and that bet can lose even when the holder is alive. If the holder experiences a long stop-the-world garbage-collection pause, a VM migration, or gets descheduled past its lease, the lock expires and a second worker legitimately acquires it — and then the first worker wakes up still believing it holds the lock. For a window, two workers act as the holder. So the TTL trades "held forever on crash" for "possibly overlapping on a pause," which is exactly why a lease alone is not safe and you need fencing.

  3. What is a fencing token and why is it the thing that actually makes a lock safe?

    A fencing token is a monotonically increasing number handed out with every lock grant, combined with the rule that the protected resource records the highest token it has ever accepted and rejects any write carrying a lower token. It exists to close the pause-overrun hole that TTLs and ownership tokens can't: when worker A holds the lock, pauses past its lease, and worker B acquires, B's grant carries a strictly higher fencing token than A's. If paused-A wakes up and tries to write with its old, lower token, the resource sees a token below the highest it has already accepted (B's) and refuses the write. The crucial shift is that safety is enforced at the resource by ordering writes, not by hoping the lock never overlaps — because a pause can exceed any TTL you pick, no amount of clever timing or re-checking on the client side can prevent two holders, but fencing makes the stale holder's actions harmless. This is why a lock without fencing is only "usually exclusive," while a lock with fencing is genuinely correct.

  4. Why base correctness-critical locks on consensus instead of a single fast store?

    A single lock store has an unavoidable dilemma between availability and safety. If it's one node, it's a single point of failure for all coordination — when it dies, every worker that needs the lock stalls. If you make it highly available with naive asynchronous replication (say a Redis primary with replicas), you open a correctness hole during failover: the primary can grant the lock to A and then die before the grant replicates, a replica that never saw the grant gets promoted, and it happily grants the same lock to B — two holders, silently. Consensus systems like etcd or ZooKeeper (using Raft or ZAB) resolve this by requiring a majority quorum to agree on the lock state before it's committed, so a grant isn't acknowledged until a majority has it durably; then no single node failure can lose the lock, and a newly elected leader already knows the current holder, so failover can't double-grant. The cost is latency — reaching quorum is slower than a single in-memory set — which is why you reserve consensus-backed locks for cases where correctness genuinely matters, and even then you still layer fencing on top, because consensus fixes who is told they hold the lock but not the client-pause overrun.

  5. When should you avoid a distributed lock altogether?

    Often, because mutual exclusion across machines is a sharp tool built on shaky assumptions (clock skew, unpredictable pauses, partitions) and there are frequently simpler, safer patterns that give the guarantee you actually want. If the real goal is "running this operation twice must not corrupt anything," the cleanest solution is idempotency — design the operation so that repeating it has no additional effect (via idempotency keys or naturally idempotent writes) — which removes the need to prevent concurrency at all. If you're guarding an update to a single record, optimistic concurrency control is usually better: read a version number, and on write assert the version hasn't changed (a compare-and-swap), retrying if it has, so conflicting writers are detected without anyone holding a lock. If a whole category of work must be serialized, routing it through a single-writer partition (all operations for a key handled by one owner) gives serialization structurally. You reach for an actual distributed lock only when you truly need exclusive, one-at-a-time access to something that can't be made idempotent or partitioned — and when you do, you use consensus plus fencing rather than a bare flag with a TTL.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A "locked=true" flag fails as a distributed lock because…

    You need atomic acquire AND automatic release of a dead holder's lock.

  2. A TTL lease is added so that…

    The lock frees itself after a bounded time with no live process or human needed.

  3. Release checks a unique ownership token so that…

    Delete only if the token is still mine — a stale owner's release becomes a safe no-op.

  4. Fencing tokens are the real safety mechanism because…

    The resource orders writes by a monotonic token and ignores an overrun holder — true correctness.

  5. Correctness-critical locks use a consensus quorum because…

    Majority agreement means no single failure loses the lock and a promoted node knows the holder.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Acquire: grant a lock to exactly one worker among many racing across machines.
  • Auto-release: free a dead holder’s lock automatically so nothing deadlocks forever.
  • Safe release: let only the true owner release, never a stale holder deleting someone else’s lock.
  • Fence writes: hand each grant a monotonic token so the resource rejects a stale writer.
  • Stay available: keep the lock on a consensus quorum so no single node loses or double-grants it.

The qualities that shape everything

Each one names the mechanism that buys it.

Grant to exactly one under a race
An atomic set-if-absent (SET NX / unique insert) is decided by the store in one indivisible step, so exactly one racing worker wins with no check-then-set gap.
Never deadlock when a holder dies
The lock is a bounded lease with a TTL that auto-expires, so a dead holder’s lock frees itself with no live process or human needed.
A stale owner can’t free the current lock
Each acquire stores a unique token and release is a conditional "delete only if the token is still mine", executed atomically — never a blind delete.
Correct even across a pause past the lease
Every grant carries a monotonically increasing fencing token; the resource records the highest it accepted and rejects any lower one, so a paused-then-woken holder’s write is refused.
Available and correct, no double-grant on failover
Correctness-critical locks live on a consensus system (etcd/ZooKeeper) where a majority quorum agrees the holder, so no single node loss loses or duplicates the lock.
Long jobs don’t lose the lock mid-work
A heartbeat/watchdog extends the TTL while the holder is alive and making progress, stopping if the work stalls.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

Atomic acquire + a TTL lease over a "locked=true" flag

A flag deadlocks forever when a holder crashes (no auto-release) and its check-then-set races two winners. A real lock needs an atomic grant AND automatic release of a dead holder’s lock.

An atomic set-if-absent (SET NX) over granting by arrival timestamp

Timestamps race and clocks disagree across machines, so deciding by arrival time isn’t atomic and can grant to two. One indivisible conditional write lets exactly one racing worker win.

Conditional release keyed on an ownership token over release always deletes the lock key

A blind delete lets an expired holder A wipe B’s valid lock — now two workers proceed. Release must verify ownership atomically so a stale owner’s release becomes a safe no-op.

A monotonic fencing token the resource enforces over a shorter TTL to prevent overlap

A pause (long GC, VM stall) can exceed any fixed TTL, and shorter TTLs cause more false expiries. Only the resource ordering writes by an increasing token and rejecting stale ones makes a lock actually safe, not just usually-exclusive.

A consensus quorum over a single store or naive async-replicated Redis

A single store is a SPOF for all coordination, and async failover can double-grant — the primary dies after granting, a replica that never saw it is promoted and grants again. A majority quorum loses neither availability nor safety.

The answer, out loud

What a strong answer to “Design a Distributed Lock” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    Pin down what we’re protecting

    Let me pin down what we’re protecting first, because the answer depends on it. A distributed lock is mutual exclusion across machines that share no memory. On one box that’s a mutex and it’s trivial. Across boxes, three things stop being true: the holder can die mid-hold, the network can drop a reply without telling anyone, and a process can stop for long enough that timing stops meaning anything. Most of what I build from here answers one of those three; the rest is the plain mutual exclusion a mutex already gives you, and the question of where the lock itself lives when that store can fail too.

  2. 4–9 min

    Why the obvious version fails

    The version everyone writes first is a row saying locked equals true. It fails twice. If the holder crashes before unsetting, the flag stays true forever and nothing proceeds — there’s no live process left that’s obliged to clean up. And acquiring by reading the flag and then setting it is two steps, so two workers can both read false in the same instant and both set true. So I need two properties out of this design: grant to exactly one under a race, and free the lock even when the holder never comes back.

    Built in step 1: A "locked = true" row
  3. 9–15 min

    Grant it to exactly one

    The race I solve by borrowing atomicity from the store instead of building it: set the key only if it doesn’t already exist — SET NX in Redis, or a unique-constraint insert. The store decides that in one indivisible step, so exactly one racing worker succeeds and the rest are told it’s already held. I’d resist granting by who asked first, because that compares clocks across machines that don’t agree and isn’t atomic anyway.

    Built in step 2: Atomic acquire
  4. 15–20 min

    Make it a lease, not a lock

    That still leaves the dead holder, so the lock isn’t granted indefinitely — it’s a lease with a TTL that the store expires on its own. If the holder dies, the key goes away and someone else can acquire, with no operator and no cleanup job in the loop. Held forever becomes held for at most the lease. This isn’t free: I’ve traded a permanent deadlock for the chance that a lease expires while its holder is alive and still working, and surviving that is what the rest of the design is for.

    Built in step 3: TTL leases
  5. 20–26 min

    Only the owner unlocks

    Which leads straight to a real bug. A’s lease expires, B acquires, then A finishes and calls release — deleting B’s lock. Now two workers think they’re clear to run. So release can’t be a delete. Each acquisition gets a unique token stored alongside the lock, and release means delete this key only if its token is still mine, evaluated atomically by the store. A’s late release finds the token belongs to B and does nothing. Trusting the holder isn’t enough here, because A genuinely believes it’s still the owner.

    Built in step 4: Ownership tokens
  6. 26–33 min

    The part that actually makes it safe

    Now the deep one. A holds the lock and then pauses — a long garbage collection, a stalled VM — past its lease. The lock expires, B legitimately acquires, and A wakes up still convinced it’s the holder and writes. Neither the TTL nor the ownership token stopped that. I’d explicitly reject the two tempting fixes: a shorter lease can be overrun by a long enough pause whatever number you pick, and having A re-check the lock before writing just moves the pause into the gap between the check and the write. The fix is a fencing token — every grant carries a number that only increases, and the protected resource records the highest it has accepted and refuses anything lower. B’s number is higher than A’s, so stale A’s write is rejected by the very thing it’s writing to. That is the difference between usually exclusive and actually correct.

    Built in step 5: The fencing-token problem
  7. 33–39 min

    Where the lock itself lives

    I’d then ask where the lock itself lives, because so far it’s one store, and that’s a single point of failure for all coordination. Making it available the naive way opens a correctness hole instead: an asynchronously replicated primary can grant the lock, die before the grant reaches a replica, and the promoted replica grants it again to someone else. Two holders, no error anywhere. So if the lock guards something whose correctness matters, I’d put it on a consensus system — etcd or ZooKeeper — where a majority has to agree before a grant counts and a new leader already knows the holder. Consensus is slower, which is why it’s a choice rather than a default.

    Built in step 6: Consensus for high availability
  8. 39–43 min

    Living with it day to day

    For the practical layer: long jobs renew the lease with a heartbeat instead of asking for an enormous TTL up front, and I’d stop renewing the moment the work stops making progress. I’d lock the narrowest thing that still gives the guarantee — one record, not the whole table — because granularity is where concurrency comes from. And where waiters need fairness, a queue beats a retry storm; ZooKeeper’s sequential nodes give you ordered acquisition instead of everyone stampeding the store together.

    Built in step 7: Renewal, reentrancy & granularity
  9. 43–45 min

    Close on the trade-off

    To close on the trade-off: every mechanism here buys safety with latency or liveness — the lease can cut off a live worker, consensus costs a round trip to a majority, fencing puts a check on the resource’s write path. And the honest last word is that the best distributed lock is often no distributed lock. If I can make the operation idempotent, or guard the record with a compare-and-swap on a version, or route all work for a key to a single writer, I get the guarantee structurally without any of this. With more time I’d work through which of those fits this workload, and what we do when the lock store is unreachable.

What this teaches

Learn system design by building a distributed lock (mutual exclusion across machines) like Redis locks, Redlock, or etcd/ZooKeeper locks step by step. An interactive guide covering why a DB flag gets stuck, atomic acquire, TTL leases that auto-release a dead holder, ownership tokens and safe release, the fencing-token problem, consensus-based locks for high availability, lease renewal, and the unhappy paths (clock skew, GC pauses, split brain).

Key takeaways

  • A "locked=true" flag fails twice: it deadlocks when a holder dies, and its check-then-set races.
  • Atomic acquire (set-if-absent / SET NX) grants to exactly one racing worker.
  • A TTL lease auto-expires a dead holder's lock, so it never deadlocks forever.
  • A unique ownership token makes release safe — delete only if the token is still mine.
  • A pause can overrun any lease, so fencing tokens let the resource reject stale writers — real safety.
  • Correctness-critical locks live on a consensus quorum (etcd/ZooKeeper), never one node.
  • Lease renewal, reentrancy, narrow granularity and fairness make it usable; often idempotency/CAS is better.

Concepts covered

  • What is a distributed lock?
  • A "locked = true" row
  • Atomic acquire
  • TTL leases
  • Ownership tokens
  • The fencing-token problem
  • Consensus for high availability
  • Renewal, reentrancy & granularity
  • Clocks, pauses & "do you even need it?"
built so exactly one worker holds it, even when a holder dies mid-grip — make the calls, kill the store, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs