Design a distributed lock service
The interviewer asks
“Design a lock service that many services can use to make sure only one worker at a time performs a task, for example processing a given payment, running a nightly settlement job, or being the leader of a shard. It must be safe even when processes pause or the network fails.”
What a strong answer looks like
A strong answer starts by asking what the lock protects: efficiency (avoiding duplicate work, where a rare double run is acceptable) or correctness (two holders would corrupt data or move money twice). For efficiency, a Redis SET NX PX lock is fine. For correctness, build on a consensus system (etcd, ZooKeeper, or a Raft group you run) with leases, sessions and keep-alives, and, most important, fencing tokens: a number that increases with every lock grant and that the protected resource checks, so a paused old holder cannot write after its lease expired. Cover lease expiry vs process pauses (GC, VM stalls), waiting and fairness (watch the predecessor key, not the lock key, to avoid a thundering herd), lock granularity, and why Redlock is debated. Mention that many problems are better solved without a lock: idempotency keys, conditional writes or a single-partition owner.
Concepts to mention
1 Requirements
Functional
- Acquire a named lock with a lease (TTL); return a fencing token on success
- Release a lock (only by the current holder)
- Renew the lease while work continues (keep-alive)
- Blocking acquire with timeout: wait in line and be notified when the lock is free
- Try-acquire without waiting
- Read who holds a lock (for debugging and operations)
Non-functional
- Safety: at most one holder per lock at any time according to the lock service, and stale holders are rejected via fencing tokens
- Availability: survives the loss of any one node (2 of 3) or two nodes (3 of 5)
- Acquire latency p99 under 10 ms within a region
- Throughput: 20k lock operations/s, about 1M lock names in use
- Liveness: a crashed holder's lock frees automatically within the lease time (e.g. 10-30 s)
Out of scope (say it out loud)
- Cross-region global locks with single-digit ms latency (impossible due to physics; discuss)
- Read-write locks and semaphores (mention as extensions)
- General-purpose transactions
2 Estimates
3 API
| Endpoint | What it does |
|---|---|
gRPCLock.Acquire(name, ttl_ms, wait_timeout_ms) -> {token, lease_id} | Grant the lock with a monotonically increasing fencing token, or wait in a queue up to the timeout. |
gRPCLock.Release(name, lease_id) | Release only if the caller holds it (compare lease id). |
gRPCLease.KeepAlive(lease_id) stream | Renews the session lease that all of a client's locks are attached to. |
gRPCLock.Describe(name) | Current holder, token, lease expiry and number of waiters. |
Resource-sidewrite(data, fencing_token) | The protected storage rejects writes whose token is lower than the highest token it has seen. |
4 Data model
locksReplicated state machine (etcd / Raft log + in-memory B-tree)nameholder_lease_idfencing_token (= Raft revision)acquired_atAll changes go through the consensus log, so every replica agrees on the holder. The log index/revision is a natural fencing token: it only increases.
lease/sessionReplicated state machinelease_idclient_idttlexpires_at (leader-tracked)When a lease expires, all locks attached to it are released in one log entry.
waitersReplicated state machinekeys /locks/{name}/{create_revision}Each waiter creates its own key; the lowest revision holds the lock; each waiter watches only the key just before it (fair FIFO, no herd).
fencing high-water markIn the protected resource (e.g. Postgres row, storage service)resource_idlast_tokenUPDATE ... WHERE last_token < :token; zero rows updated means a newer holder exists.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Acquire, work, release
- 1Worker A opens a session: Lease.Grant(ttl=15 s), and starts a keep-alive every 5 s.
- 2It calls Acquire('settlement-2026-10-25'); the leader appends 'create /locks/settlement-2026-10-25/<lease>' to the Raft log.
- 3When a majority (2 of 3) has persisted the entry, it is committed; the key's create revision (e.g. 41) is the lowest, so A holds the lock with token 41.
- 4A writes to the settlement table with token 41; the DB update succeeds only if 41 > last_token stored for that resource.
- 5A finishes and calls Release; the leader deletes A's key through the log.
- 6Worker B, which watches A's key, receives a delete event and now holds the lock with its own (higher) token, e.g. 57.
Holder pauses (GC) and lease expires
- 1Worker A enters a 40 s stop-the-world GC pause and cannot renew.
- 2After 15 s without keep-alive, the leader expires A's lease and deletes its lock keys in one committed entry.
- 3Worker B acquires the lock with token 57 and writes to the resource; the resource records last_token = 57.
- 4A wakes up, still believing it holds the lock, and tries to write with token 41.
- 5The resource rejects the write because 41 < 57. A's client library also sees the lease is gone and aborts.
7 Deep dives
Why leases alone are not enough: fencing tokens
Every distributed lock must have a lease, otherwise a crashed holder blocks everyone forever. But a lease creates a new problem: the holder cannot know for sure that its lease is still valid. A process can pause for longer than the lease (a long GC, a VM migration, a slow disk swap), or its packets can be delayed in the network. When it resumes, it continues acting as the holder while the lock service has already given the lock to someone else. Checking 'do I still hold it?' before writing does not help, because the pause can happen right after the check. The fix, described by Martin Kleppmann and used by Google Chubby (as sequencers), is a fencing token: a number that increases with each grant. The holder sends it with every write, and the protected resource remembers the highest token seen and rejects anything lower. In etcd, the key's revision serves as the token; in ZooKeeper, the zxid or the sequential node number. This moves the final safety check to the resource itself, which is the only place that can enforce it. If the resource cannot check tokens (e.g. an external payment API), use idempotency keys there instead.
Why consensus, and the Redlock debate
A single Redis node with SET lock value NX PX 30000 is fast and simple, and release must check the value with a small Lua script so you do not delete someone else's lock. But Redis replication is asynchronous: if the primary fails after granting a lock and before replicating it, the promoted replica does not know about it and grants it again. Redlock tries to fix this by acquiring the lock on a majority of 5 independent Redis nodes within a time limit. Critics (notably Kleppmann) pointed out it depends on bounded clock drift and process pauses and provides no fencing token, so it is not safe for correctness; Redis's author disagreed, and the debate is worth knowing. The safe recommendation: for efficiency locks (avoid running a cache rebuild twice), single-Redis locks are fine. For correctness locks, use a consensus-based store such as etcd, ZooKeeper or Consul, where a grant is only acknowledged after a majority has written it to disk, so leader failover never loses a grant, plus fencing tokens. The cost is a few milliseconds per operation and a cluster of 3 or 5 nodes that needs careful operation.
Waiting without a thundering herd
If 500 workers wait for the same lock and all watch the lock key, a release wakes all 500, they all try to acquire, 499 fail and go back to waiting. This herd effect wastes CPU and spikes load on the lock service at exactly the wrong moment. The ZooKeeper recipe avoids it: each waiter creates an ephemeral sequential node (lock-0000041, lock-0000042, ...). The one with the lowest number holds the lock. Every other waiter watches only the node immediately before its own. When the holder releases or dies, exactly one waiter is woken. This also gives fairness (FIFO order) and handles a waiter that dies in the middle of the line, since its successor gets an event and re-checks. etcd's concurrency package implements the same idea using create revisions. Add a wait timeout so callers do not wait forever, and keep lock scopes narrow (lock per payment ID, not one global 'payments' lock) to avoid long queues.
8 Trade-offs
Lease length
Short leases (5 s) free locks quickly after a crash but cause false expiries during GC pauses or network blips, and more renewal traffic. Long leases (60 s) are robust to pauses but block progress for up to a minute after a crash. A common choice is 10-15 s with renewal every third of the lease, plus fencing tokens to make false expiries safe.
Use a lock vs avoid locking
Locks add latency, a dependency and failure modes. Often a better design removes the need: idempotency keys with a unique constraint for payment processing, conditional updates (UPDATE ... WHERE version = v), or routing all work for a key to one Kafka partition so a single consumer owns it. Use a lock service mostly for leader election and coarse jobs, as Google's Chubby paper recommends (coarse-grained locks held for hours, not per-request locks).
Run your own vs use etcd/ZooKeeper
Writing your own Raft implementation is rarely worth it. etcd and ZooKeeper are battle-tested; DynamoDB conditional writes with a lease timestamp and a version number can also implement leases with fencing for teams already on AWS. Building a 'lock service' in an interview mostly means wrapping one of these with a clean API, sessions and fencing.
9 Bottlenecks
Consensus write throughput
All grants go through one Raft leader. Beyond ~30-50k writes/s, shard the lock namespace across several independent Raft groups (by hash of lock name), as each lock is independent. Also make renewals per session, not per lock.
Very hot single lock
If thousands of workers fight over one lock, the lock is the wrong tool. Split the work into finer-grained locks or partitions, or use a queue so work is assigned rather than contended.
10 Failure handling
Lock service leader fails
Followers elect a new leader within a few seconds (election timeout ~1-3 s). Committed grants are on a majority, so none are lost. Lease timers restart on the new leader, which by convention extends leases rather than expiring them early, so holders are not unfairly dropped. Acquires during the election wait or retry.
Client partitioned from the lock service
The client cannot renew. Its library must assume the lock is lost when the lease deadline (measured on its own monotonic clock, minus a safety margin) passes, and stop work. Fencing tokens protect the resource even if the client is slow to notice.
Majority of lock nodes down
No new grants or releases are possible (the system chooses consistency over availability). Existing holders keep working until their leases run out. Run 5 nodes across 3 AZs if you need to survive two node failures.
11 What impresses
- Opens by asking whether the lock is for efficiency or correctness
- Explains with a timeline how a GC pause breaks lease-only locking, then fixes it with fencing tokens
- Knows why async Redis replication can grant a lock twice
- Uses the etcd revision or ZooKeeper sequence number as the fencing token
- Describes the predecessor-watch recipe for fair, herd-free waiting
- Suggests removing the lock with idempotency or single-owner partitioning where possible
12 Mistakes
- A lock without a TTL, so a crashed client blocks everyone forever
- Releasing with plain DEL, which can delete another client's lock
- Claiming a Redis lock is safe for money movement
- Checking 'do I still hold the lock' before each write and calling it safe
- Waking all waiters on release
- Ignoring what happens when the lock service itself fails
13 Follow-ups
Lessons this design uses
Practice questions
- A cron worker takes a Redis lock with a 30-second TTL before writing a daily settlement file. A long GC pause makes it run 45 seconds, and another worker starts. How do you make this safe?
- When would you put etcd or ZooKeeper (Raft or ZAB consensus) into your design, and when is a normal database enough?
- Your primary database becomes unreachable and the replica is promoted. Ten seconds later the old primary comes back and still accepts writes. What went wrong and how should failover be designed?
- A scheduled job that settles merchant payouts uses a Redis lock with a 30-second TTL so only one instance runs. One day two instances both paid the same merchants. How could that happen, and how do you make it safe?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.