Design a Distributed Lock Service
A Chubby-style lock service for 50 k processes: leases, leader election, fencing tokens and watched config, never letting two holders act at once.
Last updated 2026-10-26. Difficulty: hard. Patterns: consensus, leases, fencing-tokens, leader-election, watches. Reported at Google, Amazon, Microsoft, Meta, Uber, Databricks.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of Google · Amazon · Microsoft · Meta, run as that company’s round is reported to go.
The short answer
How to design Distributed Lock Service in a system design interview, in five steps:
- Scope it. Acquire and release a named lock, sessions kept alive by KeepAlives, A fencing token with every grant, leader election and membership, small configuration values, watches, consistent client caching.
- Set the targets. Safety: never two holders acting; Availability: 99.99 % per cell; Acquire latency: p99 < 50 ms; Scale per region: 50 k sessions · 20 k locks.
- Do the numbers. KeepAlives per second: ~12.5 k/s; Reads reaching the cell: ~500/s; Writes through consensus: ~200/s avg · ~2 k/s peak.
- Draw the design. Protected resource, Client cache, Session manager, Proxy tier, Leader replica, Follower replicas, Cell directory, Watch table, State machine.
- Go deep where it is hard.
- A five-replica Raft cell → Five replicas across three zones (2 + 2 + 1) running Raft; lock-path reads through the leader with ReadIndex.
- Sessions, leases and the lease length → One session lease per client (12 s, KeepAlive every 4 s), a conservative local timer, jeopardy and a 45 s grace period.
- Fencing tokens → Fencing token (the Raft index of the holder’s node) checked by the resource on every write.
- Waiting without a herd → Ephemeral sequential nodes; the lowest holds; each waiter watches only its predecessor.
- Keeping load off the consensus group → Client caches invalidated by the leader, plus a proxy tier that batches KeepAlives and serves reads and watches.
- Why not a Redis lock? → Consensus-backed lock service with sessions and fencing tokens.
Functional requirements
- Acquire and release a named lock. Exclusive or shared, blocking or try-lock with a timeout. Locks are advisory: they coordinate cooperating processes, they do not stop anyone touching the data.
- Sessions kept alive by KeepAlives. Every lock and ephemeral node belongs to a client session. When the KeepAlives stop for a full lease, the session ends and everything it held is released.
- A fencing token with every grant. A number that only goes up, so the protected resource can reject a holder that lost the lock without noticing.
- Leader election and membership. Built from ephemeral sequential nodes: the lowest number leads, its node holds the leader’s address, and the set of nodes is the live membership.
- Small configuration values. Values up to 64 KB with a version, read by many and written rarely, with compare-and-set on the version.
- Watches. A one-shot notification when a node’s value changes, its children change or it is deleted, delivered on the client’s session.
- Consistent client caching. Clients cache values and the service invalidates them before a write completes, so a cached read is never stale while the session is healthy.
- Out of scope. Storing application data or large blobs, a general-purpose key-value store, locks replicated across regions beyond one small global cell, and the service-mesh side of service discovery.
Non-functional requirements
- Safety (never two holders acting). The requirement that drives the hardest trade-off: when the service cannot be sure, it waits (a lock stays held up to a lease after its holder dies, a cell without a majority stops granting) rather than ever granting twice. Fencing makes the guarantee hold even against paused clients.
- Availability (99.99 % per cell). Survives two replica failures or the loss of a zone. Every leader election in the company depends on it, so clients must also ride through short outages without dropping their locks.
- Acquire latency (p99 < 50 ms). One Raft commit is ~5 ms in a region; the budget absorbs fsync tails. Tens of milliseconds is fine for a call each client makes rarely.
- Scale per region (50 k sessions · 20 k locks). Load is dominated by 12.5 k KeepAlives a second and ~50 k reads a second before caching. Real writes are about 200 a second.
- Takeover after a holder dies (< 15 s). The 12 s lease plus a margin. Shorter would expire healthy clients during GC pauses and during the cell’s own leader failovers.
- Cell failover (< 3 s, no sessions lost). A new Raft leader in 1 to 2 s plus reconnects. It must stay well inside the lease so that clients do not notice.
- Linearizable (lock ops, writes, lock-path reads). Everything on lock paths is ordered by one log. Cached config reads are kept consistent by invalidation, not by a TTL.
- Small state (< 1 GB in memory). Values ≤ 64 KB and ~100 MB in total today. Keeping it small is what lets every replica hold it all in memory and snapshot it in a second.
Back-of-envelope estimates
- KeepAlives per second: ~12.5 k/s. 50 k sessions renewing every 4 s (a third of a 12 s lease) = 12.5 k/s. This is the biggest stream in the system, which is why renewals stay in the leader’s memory and never touch the log.
- Reads reaching the cell: ~500/s. 50 k processes reading a config value about once a second = 50 k reads/s. With a client cache that the leader invalidates and a ~99 % hit rate, ~500/s reach the cell. The cache, not the replicas, carries the read load.
- Writes through consensus: ~200/s avg · ~2 k/s peak. 5 k short-lived locks taken about once a minute = ~83 acquires and ~83 releases a second, plus ~10 config writes and ~20 session creates and expiries: ~200/s. Mass restarts reach ~2 k/s. One cell sustains ~10 k/s, so writes are not the hard part.
- Commit latency of an acquire: ~5 ms. Client to proxy to leader ~1 ms, then the leader’s own fsync (1–2 ms) in parallel with a cross-zone round trip to two followers and their fsync (~3 ms), then the reply ~1 ms: ~5 ms. fsync tails push p99 toward tens of milliseconds, still inside the 50 ms target.
- State in memory: ~100 MB. ~100 k nodes (20 k locks, 50 k ephemeral membership nodes, ~30 k config values) at ~1 KB each with metadata = ~100 MB, plus 50 k sessions at ~100 B. Every replica holds all of it in memory and snapshots it in about a second.
- Proxies needed: ~25. 50 k client connections at ~2 k per proxy = 25 proxies, spread over three zones. The leader sees 25 batched KeepAlives a second instead of 12.5 k, and a change watched by every process fans out 25 ways from the cell.
- Raft log written per day: ~3.5 GB/day. 200 writes/s × 86 400 s ≈ 17 M entries × ~200 B ≈ 3.5 GB/day. A snapshot every 100 k entries (about every 8 minutes) truncates it, so only ~20 MB of log is ever on disk.
- Herd on one release: 2 000 events vs 1. With 2 000 waiters all watching the lock node, one release sends 2 000 events and triggers up to 2 000 re-lists and creates: a fifth of a second of the cell’s entire write capacity for one handoff. Watching only the predecessor makes it 1 event.
Components
- Protected resource (rejects stale tokens): Whatever the lock protects: a database shard, a storage bucket, a partition of work. It stores the highest fencing token it has accepted for each lock and refuses any write carrying a lower one, which is the only thing that stops a paused former holder.
- Client cache (invalidated before writes): Values the library has read, held in process memory and valid only while the session lease is. The leader keeps a list of who caches each node and invalidates them before any write commits, so a cached read is never stale while the session is healthy. When the lease is in doubt, the cache is switched off.
- Session manager (leader-only lease timers): Runs on the leader and tracks every session’s lease expiry in memory, extending it on each KeepAlive without writing to the log. When a lease runs out it commits one expiry entry through Raft, which deletes the session’s ephemeral nodes and frees its locks at a log position every replica agrees on.
- Client + library (session · recipes · local lease): A service process that needs a lock, a leader or a config value. Its library does the protocol work: it holds one session, sends KeepAlives, runs the lock and election recipes, and keeps a conservative local lease clock. It tells the application to stop acting the moment that lease is in doubt.
- Proxy tier (~25 · KeepAlive fan-in): About 25 proxies, each terminating ~2 k client connections. They batch KeepAlives into one RPC a second per proxy, serve cached reads, register one watch per path on behalf of all their clients and fan events back out. Sessions belong to the cell, not the proxy, so a client whose proxy dies reconnects to another and keeps its locks.
- Leader replica (Raft leader · 1 of 5): The one replica of five that orders everything: it appends writes to the log, replicates them, commits on a majority, and serves lock-path reads after a ReadIndex check. It also owns sessions, the cacher list and watch delivery, which is why proxies and client caches exist to keep load off it.
- Follower replicas (4 of 5 · across 3 zones): The other four replicas, placed so the cell has two in each of two zones and one in a third, and any zone can be lost. They persist and acknowledge entries, apply committed ones to their own copy of the state, and stand for election if the leader’s heartbeats stop for 1 to 2 s.
- Cell directory (prefix → cell · cached): Maps each top-level prefix of the namespace (/payments, /indexer) to the cell that owns it, with each cell’s proxy addresses. A few hundred entries that change a few times a month and are cached by every library, so it is never on the lock path.
- Watch table (one-shot events · invalidations): Who is watching which node and who is caching it, kept on the leader (and, for their own clients, on each proxy). After a change commits it emits one-shot events; before a write to a cached node commits it sends invalidations and waits for the acknowledgements.
- State machine (nodes · sessions · in memory): The namespace every replica rebuilds by applying the log in order: nodes with data, versions and flags (ephemeral, sequential), the sessions that own them, and the last request id and result per session so a retried request is never applied twice. About 100 MB, all in memory.
- Raft log (WAL · fsync · group commit): Each replica’s write-ahead log of creates, deletes, value writes and session expiries, fsynced before an entry is acknowledged. Group commit puts many entries in one fsync. A p99 fsync above 10 ms is the warning sign that slow disks will start causing elections.
- Other cells (independent 5-replica groups): Other independent five-replica groups in the region, each with its own proxies and its own slice of the namespace. No operation spans two cells, so they share nothing and fail independently. A small global cell with replicas in several regions handles the rare truly global lock.
- Snapshots (every 100 k entries): A point-in-time copy of the state machine taken every 100 k entries, after which the log before it is discarded. Lagging and replacement replicas start from one, and an hourly copy in object storage in another region is the disaster-recovery backup.
User flows
- Acquire a lock and write with its token. The core write path. One Raft commit puts the client in line, the lowest node holds the lock, and the grant comes back with a fencing token that the protected resource checks on every write.
- The library maps the lock’s path to the cell that owns its prefix. The directory is tiny (a few hundred prefixes), changes a few times a month and is cached by every library for minutes, so it is never on the lock path. A prefix lives in exactly one cell, which is what keeps cells independent: no operation ever spans two consensus groups.
- It opens a session through a proxy and starts sending KeepAlives. Creating a session is one Raft write, so every replica knows it exists; renewing it is not, which is why 12.5 k KeepAlives a second cost almost nothing. The lease is 12 s and the library renews every 4 s, starting its local lease clock when it sends the request, so its idea of the expiry is always earlier than the leader’s.
- To acquire, the library creates an ephemeral sequential node under the lock’s path. Ephemeral means it belongs to the session and is deleted when the session ends. Sequential means the cell appends a counter that only goes up, so waiters are ordered by arrival. It is a write, so the proxy forwards it to the leader rather than answering it.
- The leader appends the create, replicates it, and commits once three of five replicas have it on disk. The leader fsyncs its own copy while AppendEntries is in flight to the four followers, so the commit waits for the second-fastest follower, not the slowest. A cross-zone round trip of ~1 ms plus a 1 to 2 ms fsync puts the commit at ~3 ms, and entries that arrive together share one fsync (group commit).
- The entry is applied, and the library lists the lock’s children to see whether its node is the lowest. The lowest sequence number holds the lock. The list is a read through the leader, which first confirms it still leads with a ReadIndex round (a heartbeat acknowledged by a majority), so the answer cannot come from a deposed leader’s stale memory. If its node is lowest, the library has the lock with no further write.
- The library returns the grant with a fencing token: the create index of its node. The token is 918204, the Raft index of the create. It is unique and only goes up over the cell’s life, across leader changes too, because Raft indexes do. Every later holder of this lock has a higher token, since waiters are served in creation order.
- The holder sends the token with every write, and the resource rejects any token lower than the highest it has seen. The resource stores the highest token per lock next to the data and makes each write conditional on it, in the same transaction. This is the step that makes the lock safe against a holder that paused: the lock service cannot reach into a process and stop it, but the resource can refuse it.
- On release, the library deletes its node; the delete commits and the next waiter is notified. A clean release frees the lock in one commit, about 5 ms. If the client crashes instead, its session expires after the 12 s lease and the leader deletes the node itself. That timing is the only difference between a clean and an unclean release.
- Cached config reads and an invalidating write. The read path. Most reads never leave the client, and a write is held until every cache that might hold the old value has dropped it, so cached reads stay consistent without polling or TTLs.
- A process reads a config value; on a miss, the library fetches it through its proxy. The leader answers from memory and records that this proxy and session now cache the node. The proxy keeps its own copy, so the next of its ~2 k clients to ask is answered without reaching the cell at all.
- Later reads are served from the client cache with no RPC. At 50 k processes reading about once a second, the cache turns 50 k reads a second into roughly 500 misses a second at the cell. A cached value is usable only while the session lease is; when the library’s lease is in doubt it switches the cache off, so a partitioned client cannot keep serving an old value.
- An operator writes a new value; the leader marks the node uncachable and sends invalidations to every cacher. This is Chubby’s rule: the write waits until every session that may cache the node has acknowledged the invalidation or let its lease run out. Reads in the meantime go to the leader and return the old value, which is still the committed one, so no reader can see the new value and then the old one.
- Each proxy drops its copy and passes the invalidation only to its clients that hold the node. With 20 k processes caching this flag behind 25 proxies, the cell sends 25 invalidations instead of 20 k, and each proxy sends ~800. Acknowledgements come straight back on the session stream, so a healthy fleet has acknowledged within tens of milliseconds.
- Once every cacher has acknowledged or expired, the write commits and the leader replies. The worst case is a cacher that is partitioned: the write waits up to its 12 s lease, because until then the leader cannot prove that client has stopped reading the old value. That is the price of consistent caching, and it is why values that change every second do not belong in this service.
- Watchers get a one-shot event, re-read the value and re-arm the watch in the same call. The event says only that the node changed, not what it changed to. Reading with watch=true sets the next watch atomically with the read, so no change can slip through a gap. A client may skip intermediate values, but it never misses that a newer one exists.
- 2 000 contenders and a paused leader. The scale-breaking case and the failure that matters most. Two thousand processes contend for one leadership; when the leader stalls, exactly one waiter is woken, and the old leader’s late write is refused by its token.
- Each of 2 000 workers creates an ephemeral sequential node under the election path and lists the children. 2 000 creates is 2 000 small Raft writes, a burst of a fraction of a second for a cell that sustains ~10 k writes a second. The worker with the lowest number is leader and writes its address into its node, so anyone can find the leader with one read.
- Every other worker watches only the node just below its own. Worker 42 watches 41, 43 watches 42, and so on: 1 999 watches on 1 999 different nodes. If they all watched the leader’s node, its deletion would wake 1 999 processes that would all re-list and most would re-create, thousands of operations for a handoff only one of them can win.
- The leader stalls in a 20-second garbage-collection pause, and its KeepAlives stop. From the cell’s side a paused process and a dead one look identical: no renewals. Its library cannot react either, because it is paused too, which is exactly why a lease alone cannot make a lock safe.
- When the 12-second lease runs out, the session manager commits the session’s expiry. Expiry is a replicated entry, not a timer firing separately on each replica, so all five agree on the exact log position at which the session ended. Applying it deletes every ephemeral node the session owned, here n-0000000041.
- The delete fires one watch, and only worker 42 is notified. One event instead of 1 999. Worker 42 re-lists the children rather than assuming it is now lowest, because the node it watched might have been a waiter that gave up; if so, it simply watches the next lower node.
- Worker 42 confirms it is lowest, becomes leader, and fences the resource with its token. Its token is the create index of n-0000000042, higher than 41’s. Its first act as leader is a write carrying that token, which raises the resource’s high-water mark before it reads any state, so nothing from the old leader can land after the new one has started work.
- The old leader wakes and sends a write it prepared before the pause; the resource rejects it. Its library will notice the expired lease within moments and raise "session expired", but the write was already on its way. 7703120 is below 7703188, so the resource refuses it, and the old leader learns it is no longer leader from the rejection.
- The cell’s leader replica crashes. The failure case for the service itself. A new leader is elected in about two seconds, sessions survive because their leases outlast the failover, and clients flush their caches because an invalidation may have been lost.
- The leader’s machine dies, and the followers stop receiving heartbeats. Heartbeats go out every 100 ms, and each follower waits a randomised election timeout of 1 to 2 s before standing. Long enough that a slow fsync or a short network blip does not start an election, short enough that the gap stays well inside a 12 s lease.
- A follower times out first, asks for votes, and wins a majority with an up-to-date log. A replica votes only for a candidate whose log is at least as complete as its own, so the winner holds every committed grant and expiry. It needs three votes from the four survivors, so even a second failure during the election would leave a majority.
- The new leader commits a no-op, treats every session as renewed now, and accepts KeepAlives again. It knows which sessions exist, because they are in the state machine, but not when each last renewed, since renewals were never logged. So it gives every session a full lease from the moment it took over: nobody is expired because of the failover, at the cost of a client that died during it holding its locks up to one lease longer.
- Proxies find the new leader and resend requests that had no reply; sessions are intact. A proxy asks any replica who leads and reconnects in milliseconds. A request whose outcome is unknown is resent with the same session and request id, and the state machine returns the stored result instead of applying it again, so a retried create cannot put a second waiter node in line.
- Clients receive a fail-over event and flush their caches. The list of who caches what lived in the old leader’s memory and is gone, so an invalidation could have been lost in the crash. Flushing costs a burst of re-reads, mostly absorbed by the proxies’ own caches, and keeps every cached value honest. Watches are re-registered with the last index each client saw, so any change in the gap fires at once.
- A client whose lease ran out locally while it waited enters jeopardy, stops acting, and resumes when its session is confirmed. With a 2 to 3 s failover and a KeepAlive every 4 s, most clients never get this far. Those that do pause their lock-protected work and keep retrying through a 45 s grace period, ending in either "safe" (the session survived and its locks are still held) or "expired" (start again, with new tokens).
- Snapshots, a replacement replica, a new cell. The background work that keeps a cell healthy: snapshots bound the log, a dead replica is replaced without ever allowing two majorities, and a prefix that outgrows its cell moves to a new one.
- Every 100 k entries, each replica snapshots its state machine and truncates its log. At ~200 writes a second that is about every eight minutes, and with ~100 MB of state a snapshot takes about a second in the background (copy-on-write, or a fuzzy snapshot made consistent by replaying idempotent log entries, as ZooKeeper does). An hourly copy goes to object storage in another region for disaster recovery.
- A replacement machine joins as a non-voting learner and receives the latest snapshot from the leader. 100 MB at 10 Gb/s takes a fraction of a second, and the leader then streams the log since the snapshot. As a learner it receives everything but does not count toward the majority, so a slow catch-up cannot stall commits.
- Once it has caught up, membership changes one server at a time: remove the dead replica, then promote the learner. Changing one voter per configuration means the old and new majorities always overlap, so two leaders can never be elected in two halves of a half-changed cluster. Between the two steps the cell has four voters and still tolerates one failure.
- A team’s prefix outgrows its cell, and the directory points it at a new cell. The triggers are sustained writes above ~2 k a second, more than ~100 k sessions, or simply blast radius: one team’s runaway client should not cost every other team its locks. The prefix is frozen for a few seconds, its subtree is copied, the directory flips and bumps the prefix’s epoch, and clients reconnect with a fail-over event.
- Dashboards track commit latency, fsync p99, session expiries per minute and the oldest unacknowledged invalidation. A rise in expiries without a deploy to explain it means clients are pausing or KeepAlives are being dropped, and it comes before outages in the services that depend on locks. fsync p99 above 10 ms, etcd’s published guidance, is the earliest sign a disk will start causing elections.
Deep dives
A five-replica Raft cell
Why a consensus group at all, why five replicas rather than three, and how does a read stay linearizable?
The lock state is tiny (about 100 MB) and the write rate is low (about 200 a second), so this is not a capacity problem. It is a safety problem: if the lock table can be lost or forked during a failover, two clients are granted the same lock, and nothing downstream can repair that. Primary-backup with asynchronous copying loses the last grants on failover, and a single database is one machine away from every leader election in the company stalling.
A replicated state machine gives exactly what is needed: every create, delete and session expiry is an entry in one ordered log, committed only when a majority has it on disk, and every replica applies the same entries in the same order. What is left to decide is how many replicas, where they sit, and how a read avoids being answered by a leader that has already been replaced.
- Five replicas across three zones (2 + 2 + 1) running Raft; lock-path reads through the leader with ReadIndex chosen
- Three replicas situational: test cells, or a small cell inside one zone where the lowest commit latency matters most
- Primary-backup with asynchronous replication rejected
- Seven replicas rejected
The answer: One Raft group of five replicas per cell, two in each of two zones and one in a third, so losing any zone leaves three. The leader fsyncs its own entry while replicating, commits when two followers have acknowledged, and replies; heartbeats go out every 100 ms and election timeouts are randomised between 1 and 2 s. Lock-path reads (the waiters under a lock, the current leader of a service) go to the leader, which confirms it still leads with a ReadIndex round, one heartbeat exchange with a majority batched across all reads in flight, before answering from memory. A leader lease, serving reads for a bounded time after a successful heartbeat, saves that round trip but relies on bounded clock drift, so it is an optimisation to add once clocks are monitored. Chubby ran five replicas per cell with Paxos and served reads from the master under a master lease (Burrows, 2006); etcd and ZooKeeper are usually run with three or five.
Why not four replicas?
Four needs three for a majority, the same as five, but tolerates only one failure, the same as three. You pay for an extra machine and a slower quorum and gain nothing. Odd sizes are the only ones that make sense.
A zone holding two replicas goes down while a third replica is being restarted. What happens?
That leaves two of five, which is not a majority, so the cell stops committing: no new grants, no expiries, no writes. Existing holders carry on while their libraries ride the grace period, and nobody is ever granted a lock twice. The rule that prevents getting there is operational: never take a replica down for maintenance while a zone is impaired, and alert on "cell at minimum quorum".
How does a read go wrong without ReadIndex?
A leader that has been partitioned away still believes it leads until its own timeout fires. Meanwhile the majority elects a new leader that frees the lock and grants it to someone else. A list served from the old leader’s memory would show the old holder, and a client could act on it. ReadIndex closes that: the leader notes its commit index, confirms with a heartbeat that a majority still follows it, waits until it has applied that far, and only then answers.
Could followers serve reads to take load off the leader?
Not from their own state for lock paths: a follower can be behind, and "who holds the lock" must never be stale. A follower can serve a linearizable read by asking the leader for its read index and waiting until it has applied that far, which spreads CPU but not the round trip. For config values the client cache already removes most reads, so it is rarely worth it; non-voting observers are the better way to add read capacity.
What write rate can one cell sustain, and what limits it?
The leader’s fsync and its network. Each commit needs a local fsync (1 to 2 ms on datacenter NVMe) and a cross-zone round trip of about 1 ms to two followers, and batching many entries into each AppendEntries and each fsync lifts throughput to roughly 10 k small writes a second. We use about 200 on average and 2 k at peak, so the case for more cells is blast radius long before it is throughput.
Sessions, leases and the lease length
How long should a lease be, and what must a client do when it is not sure it still holds the lock?
A lock held by a crashed process must eventually be freed, so every lock belongs to a session that the client keeps alive with KeepAlives, and the session ends when they stop. The lease length sets two things that pull in opposite directions: how long a dead holder blocks everyone (the takeover time), and how long a healthy client can go silent without losing its locks. A GC pause of a few seconds, a cross-zone network blip and the cell’s own 2 to 3 s leader failover all look like silence.
The insight is that the client and the cell need different, deliberately conservative views of the same lease. The cell must never expire a session early; the client must assume its lease ends earlier than the cell thinks and stop acting before then. Between the two sits a window in which the client is unsure, and the design has to say exactly what it does there.
- One session lease per client (12 s, KeepAlive every 4 s), a conservative local timer, jeopardy and a 45 s grace period chosen
- A TTL per lock, renewed one by one (Redis SET PX style) situational: a client holds one or two locks and has no ephemeral nodes or watches that should disappear with it
- Short leases (2 to 3 s) for fast takeover situational: a small, latency-critical election among clients without long GC pauses, on a cell inside one zone
- No lease: hold until an explicit release rejected
The answer: Each client has one session with a 12 s lease, renewed every 4 s, so two renewals can be lost before anything happens. The leader tracks expiries in memory and commits an expiry entry through Raft only when a lease actually runs out, which deletes the session’s ephemeral nodes and frees its locks at one agreed log position. The library starts its local lease clock when it sends a KeepAlive, uses a monotonic clock and subtracts a margin for clock-rate drift, so it always believes the lease ends before the cell does. When the local lease runs out it enters jeopardy: it tells the application to stop acting on its locks, switches off the cache and keeps trying other proxies for a 45 s grace period, then reports safe or expired. A new cell leader gives every session a full lease from the moment it takes over, so a 2 to 3 s failover never expires anyone. These are the defaults the Chubby paper reports: a 12 s lease and a 45 s grace period.
Why start the local clock when the KeepAlive is sent rather than when the reply arrives?
Because the leader extends the lease when it processes the request, which is after the client sent it. Measuring from the send makes the client’s estimate of the expiry earlier than the leader’s by the request’s travel time, which is the safe direction. Measuring from the reply would make it later by the reply’s travel time, and a slow reply could leave the client acting after its locks were freed.
The cell is unreachable for 30 seconds. What does a lock holder do?
Once its local lease runs out, under 12 s in, it is in jeopardy: the application gets an event, pauses work that needs the lock and stops issuing fenced writes. The library keeps retrying through the 45 s grace period. If the cell comes back and the session survived, it gets "safe" and carries on with the same locks and tokens; if not, it gets "expired" and must acquire again, with higher tokens.
Why are renewals not written to the Raft log?
At 12.5 k a second they would be by far the largest write stream in the cell, sixty times the real writes, recording something nobody needs after a failover. A new leader does not need each session’s last renewal: it assumes every session renewed at the moment it took over. That only ever makes leases longer, which is the safe direction for the lock, and costs at most one extra lease of takeover time for clients that died during the failover.
How would you choose the lease length for a new deployment?
Start from the longest silence a healthy client should survive: the cell’s failover time (2 to 3 s), the p99.9 GC pause of the client fleet, and a cross-zone network blip, and make the lease three to four times the largest. Then check that the takeover time it implies is acceptable to the services using it. Afterwards, measure false expiries, meaning sessions that expire and whose client reconnects seconds later; their rate tells you the lease is too short.
Does a client in jeopardy still hold the lock?
It does not know, and that is the point. It may still hold it (the cell is down, so nobody can take it) or the cell may already have freed it. The only safe behaviour is to act as if it does not: start no new work under the lock, and rely on the fencing token to stop anything already in flight.
Fencing tokens
A lock holder is paused for 30 seconds by garbage collection, and meanwhile the lock is given to someone else. How do you stop its next write?
No lease protocol can stop this on its own. The holder checks its lease, sees 3 seconds left, starts a write, and is frozen by a stop-the-world GC, a VM live migration or a page fault on a swapped-out heap. The lease expires at the cell, another client takes the lock and writes, and then the first process resumes and finishes its write, believing nothing happened. Checking the lock just before writing narrows the window but cannot close it, because the pause can fall between the check and the write.
So the fix has to be enforced by the thing being protected, because it is the only party that sees both writes. If every grant carries a number that only goes up, the resource can remember the highest number it has accepted and refuse anything lower. The lock service already has such a number for free: the position of the grant in its log.
- Fencing token (the Raft index of the holder’s node) checked by the resource on every write chosen
- Lock-delay: after an unclean release, nobody may take the lock for a while situational: the protected resource cannot be taught to check tokens
- The resource asks the lock service whether the token is still current situational: a low-rate resource that cannot keep state of its own but can make an RPC
- Check the lease before each write rejected
The answer: Every grant returns a token: the Raft index at which the holder’s node was created. It is unique, it only increases over the cell’s life, leader changes included, and because waiters are served in creation order, each new holder of a lock has a higher token than the last. The holder sends it with every write; the resource stores the highest token per lock next to the data and makes the write conditional on it in the same transaction, for example UPDATE … SET fence = :t WHERE fence <= :t. A new holder’s first act is a fenced write that raises the high-water mark before it reads any state, so nothing from the old holder can land afterwards. For resources that cannot check, a lock-delay of up to a minute after an unclean release is the fallback, and we say plainly that it lowers the odds rather than removing them. Chubby calls the token a sequencer and offers both mechanisms (Burrows, 2006); ZooKeeper’s zxid and etcd’s revision serve the same purpose.
The old holder’s write arrives before the new holder has written anything. Is it accepted?
Yes, if the resource has never seen the higher token, which is why the new holder must fence first. Its first operation presents its token (a claim, or a no-op write that bumps the high-water mark), and only then does it read the state it will work from. Any old write that landed before that point is ordered before the new holder’s view, which is the same as the old holder having finished in time.
Why the Raft index rather than a counter per lock?
Both work, and a per-lock generation number is what Chubby’s sequencer carries. The Raft index is free, unique across the whole cell and needs no extra state per lock. Its one caveat is that it is only comparable within one cell, so when a prefix moves to a new cell the token carries the prefix’s directory epoch in its high bits, and the new cell’s tokens compare above every old one.
The protected resource is an object store that cannot compare tokens. What do you do?
Make the write conditional on something the store can check. Write to a key that includes the token and have readers take the highest, or use the store’s conditional writes on an ETag so a stale writer’s overwrite fails. If neither fits, fall back to lock-delay plus the client stopping on jeopardy, and document that it is best effort.
How would you know fencing is actually working in production?
Count rejections at every resource that checks tokens, per lock. A trickle after failovers is fencing doing its job; zero over months on a busy lock usually means the check was never wired in. Prove it with fault injection in staging that pauses a holder past its lease and asserts that its write is refused.
Waiting without a herd
Two thousand processes are waiting for the same lock. What happens when it is released?
The obvious implementation has every waiter watch the lock node and try again when it changes. When the holder lets go, all 2 000 are notified at once; each re-reads, each tries to take the lock, one wins, and 1 999 go back to waiting. That is thousands of reads and writes through a single Raft leader for one handoff, repeated on every handoff, and it is the herd effect that the ZooKeeper paper (Hunt et al., 2010) designs its recipes to avoid.
Two primitives remove it. Ephemeral nodes tie a waiter’s place in line to its session, so a dead waiter leaves the line by itself. Sequential nodes give every waiter a number from a counter that only goes up, so the line has an order the cell defines. With an order, each waiter only needs to know when the one directly ahead of it leaves.
- Ephemeral sequential nodes; the lowest holds; each waiter watches only its predecessor chosen
- Server-side wait queue: Acquire blocks and the cell grants the next waiter directly situational: clients in many languages, where one blocking Acquire call is worth keeping the logic on the server
- Every waiter watches the lock node rejected
- Poll with backoff rejected
The answer: Acquire creates an ephemeral sequential node under the lock’s path (…/w-0000000057) and lists the children through the leader. If its node is lowest it holds the lock, and its token is the node’s create index. Otherwise it watches the node immediately below its own and waits. When that node is deleted (released, or its session expired) the waiter re-lists rather than assuming it is next, because the predecessor may have been a waiter that gave up, and then either holds the lock or watches its new predecessor. Leader election is the same recipe, with the leader writing its address into its node so others find it by reading the lowest child. Watches are one-shot: an event says only that something changed, so the client re-reads and re-arms in one call (Get with watch=true), and after reconnecting it re-registers its watches with the last index it saw so the cell fires any it missed. This is the lock recipe from the ZooKeeper paper; etcd’s lock and election helpers do the same with revisions.
A waiter in the middle of the line crashes. Does the lock break?
No. Its session expires, its node is deleted, and the one waiter that was watching it gets an event. That waiter re-lists, finds it is still not lowest, and watches the next node down instead. Nobody else is woken.
Why must a woken waiter re-list instead of assuming it now holds the lock?
Because the deleted predecessor might not have been the holder. If waiter 41 gave up while 40 still holds the lock, 42 is woken but is not first. Re-listing costs one read and keeps the only invariant the recipe relies on: the holder is always the lowest existing node.
What can a client miss between a watch firing and its re-read?
Intermediate values, never the latest one. If a value changes three times in quick succession, the client gets one event, reads the third value and re-arms. That is fine for configuration and membership, where only the current state matters. If every change matters, model the data as a sequence of nodes, or use a watch that is a stream resuming from an index, as etcd’s watches are.
Does the same recipe work for shared (read) locks?
Yes, with one change. Readers create nodes marked read and wait only for the nearest lower write node; writers wait for the nearest lower node of either kind. Many readers then hold the lock together, a writer waits for all of them, and still only the processes that can make progress are woken.
How do you spot a herd in production?
Watch events per committed write. In a healthy cell it is small; a release that produces thousands of events and a burst of creates means some team is using the naive recipe. Per-prefix metrics on watch fan-out and creates per second find them quickly, and the client library should make the correct recipe the only one on offer.
Keeping load off the consensus group
Most traffic is KeepAlives and reads from 50 k processes. How do you keep it off five replicas, and what do you do when one cell is not enough?
Writes are about 200 a second, but 50 k sessions renew every 4 s (12.5 k a second), each holds a long-lived connection, and config reads would be about 50 k a second if every one reached the leader. All of it lands on one machine, because only the leader can renew leases and serve linearizable reads. Adding voting replicas does not help: it slows every commit and leaves the leader exactly as busy.
Two observations shape the answer. Most of the load is repetitive (the same session renewing, the same flag being read), so it can be absorbed close to the clients as long as correctness stays at the leader. And the namespace has natural boundaries (teams and services rarely share a lock), so it can be split into independent cells instead of making one group bigger.
- Client caches invalidated by the leader, plus a proxy tier that batches KeepAlives and serves reads and watches chosen
- Shard the namespace into independent cells by top-level prefix chosen
- Non-voting observers that serve reads and watches situational: a ZooKeeper-style deployment, or another region that needs local reads of a cell’s data
- More voting replicas rejected
The answer: Three layers. The library caches values and the leader keeps a list of who caches what; a write is held until every cacher has dropped the old value, so cached reads stay consistent and about 99 % of reads never leave the process. About 25 proxies, each holding ~2 k client connections, send one batched KeepAlive a second covering all their live sessions (25 RPCs a second at the leader instead of 12.5 k), keep a shared cache, and register one watch per path on behalf of all their clients. A proxy that dies costs its clients a reconnect to another, well inside their lease, because sessions belong to the cell. Beyond that, the namespace is split into five-replica cells by top-level prefix through a small directory, and a new cell is added for blast radius or when one approaches ~2 k sustained writes a second or ~100 k sessions. The Chubby paper describes the same levers (longer leases, client caching, proxies, and partitioning the namespace) and reports that a cell’s RPC traffic is dominated by KeepAlives.
A proxy crashes. What do its 2 000 clients see?
Their connections drop, and each library reconnects to another proxy from the directory with its existing session id, usually within a second. Their sessions are still alive at the cell, because the lease is 12 s and the proxy had renewed them a moment ago. They treat it like a fail-over and flush their caches, since an invalidation sent to the dead proxy may have been lost.
One flag is watched by all 50 k processes and changes. How long does the write take?
The leader sends 25 invalidations to proxies, each fans out to its ~2 k clients, acknowledgements return within tens of milliseconds, and the write commits a few milliseconds later. If one client is partitioned, the write waits until that client’s lease runs out, up to 12 s. That bound is fine for configuration; for a value that changes every second it is a sign the value belongs in a different system.
A team needs 20 k writes a second for its locks. Do you scale the cell?
No: they get their own cell, or several by sub-prefix, because one leader tops out around 10 k writes a second and their load should not raise everyone else’s commit latency. It is also worth asking why a lock service needs 20 k writes a second. That usually means a lock per request, where a database row or a partitioned owner would do better.
How do tokens stay monotonic when a prefix moves to a new cell?
The new cell’s Raft index has nothing to do with the old one, so its tokens could be smaller. The fix is to put the prefix’s directory epoch in the token’s high bits and have the migration increment it. Every token issued after the move then compares above every token issued before it.
Why not one global cell across regions?
Every commit would wait for a cross-region round trip, 50 to 150 ms, and a regional network problem could stall locks everywhere. Most locks protect regional resources, so they belong in regional cells. A small global cell with replicas in five regions is reasonable for the handful of truly global decisions, such as which region is primary for a database, where 100 ms writes are fine.
Why not a Redis lock?
Everyone already runs Redis. Why not SET lock NX PX 30000 and be done?
A single Redis key set with NX and a TTL is a lock that works most of the time. Replication is asynchronous, so when the primary fails over to a replica that had not received the SET, the lock is free again and a second client takes it. There is no fencing token, so a client paused past the TTL cannot be stopped. Redlock tries to fix the first problem by taking the key on a majority of five independent Redis masters.
Whether that is enough was argued in public in 2016. Martin Kleppmann’s post "How to do distributed locking" argued that Redlock depends on bounded network delay, bounded pauses and bounded clock error, and offers no fencing token, so it cannot guarantee safety; he framed the question as locks for efficiency versus locks for correctness. Salvatore Sanfilippo, Redis’s author, replied in "Is Redlock safe?" that the timing assumptions hold in practice if clocks are not stepped, that a pause after acquiring hurts every auto-expiring lock equally, and that a resource able to check tokens could check Redlock’s unique random value instead. For an interview, the distinction is the useful part.
- Consensus-backed lock service with sessions and fencing tokens chosen
- One Redis key: SET NX PX with a random value, released by compare-and-delete situational: efficiency locks, where two holders now and then only cost duplicated work (a cache rebuild, a job that is safe to run twice)
- Redlock across five independent Redis masters situational: an efficiency lock that needs more availability than one Redis, where the team accepts its timing assumptions
- A row lock or conditional write in the resource’s own database situational: everything the lock would protect lives in that one database
The answer: Classify every lock as efficiency or correctness before choosing. Efficiency locks (do not rebuild the same cache entry twice) can stay on a single Redis key with SET NX PX and a random value, released by a compare-and-delete script; an occasional double holder costs some work. Correctness locks (one writer per ledger shard, one leader per partition) go to the consensus-backed service and use fencing tokens checked by the resource. When the protected data lives in one database, skip the lock and use that database’s own conditional write. In the interview, say that Redlock’s safety is publicly disputed and why, rather than declaring a winner: the disagreement is about timing assumptions, and fencing tokens remove the dependence on them either way.
Walk me through how a Redis failover grants the lock twice.
Client A sets the key on the primary and gets OK. Replication is asynchronous, so before the SET reaches the replica the primary dies. The replica is promoted without the key, client B sets it and also gets OK. Both now hold the lock, and Redis will never tell either of them.
Why release with a compare-and-delete script rather than DEL?
Because by the time you release, your TTL may have run out and someone else may hold the key, and a plain DEL would delete their lock. The value is a random string you chose; a Lua script deletes the key only if it still holds your value, atomically.
Can you add fencing tokens to Redis?
Partly. INCR on a counter next to the lock gives a number that goes up, but it lives in the same asynchronously replicated Redis, so a failover can hand out a number that was already issued. A token is only as monotonic as the store that issues it, which is why ours come from a consensus log.
The team says the lock only stops duplicate emails. Which design?
An efficiency lock: a single Redis key is fine, because an occasional duplicate email is a nuisance rather than corruption. Better still, make sending idempotent with a key per message in the email service, and then the lock is purely an optimisation.
The theory behind it
- Raft leader election and log replication: an interactive playground. Watch a five-server Raft cluster elect a leader, stop the leader and see a new term begin, send writes that commit on a majority, and break the quorum.
- Fencing tokens: an interactive playground. Pause a lock holder past its lease and watch two clients both believe they hold the lock, then see how fencing tokens stop the stale write.
- Distributed locks, leases and fencing tokens: When you need mutual exclusion across machines and how to get it safely: lock services, leases with expiry, why Redis locks can fail, fencing tokens, leader election, optimistic alternatives, and avoiding locks altogether.
- Consensus, leases and coordination: Leader election, Raft and Paxos at interview depth, ZooKeeper and etcd, distributed locks and why they are fencing tokens, plus how to avoid needing coordination at all.
- How Raft consensus works: Raft explained for system design interviews: replicated state machines, leader election with terms and randomised timeouts, log replication and commit rules, safety, membership changes, snapshots, read consistency and where Raft is used in practice.
- The best system design books and papers for interviews: The books worth reading for system design interviews and what each is for (Designing Data-Intensive Applications, Alex Xu's System Design Interview, Understanding Distributed Systems, Database Internals and more), the dozen papers behind the classic questions, and the order to read them in.