Blueprint
Infrastructure advanced

Design a distributed lock

The interview question that sounds trivial and then eats candidates alive — process pauses, clock drift, and 'is this fencing token high enough?'.

asked at Google · Cloudflare · Databricks Rarely asked ~35 min walkthrough
01

Scope the requirements

Everyone reaches for Redis SETNX first. That's the trap — it works until it doesn't, and "doesn't" is a data corruption incident. The interview is asking you to know precisely when Redis is enough and when you need Zookeeper.

Functional — what it must do

  • acquire(key, holderId, ttl) → boolean, non-blocking.
  • release(key, holderId): only the holder can release.
  • extend(key, holderId, newTtl): a live holder can prolong its lease.
  • Fencing token: a monotonically increasing number handed out on acquire.
  • Explicitly cut: waiting queues, priority acquisition — leave callers to retry.

Non-functional — the numbers that shape the design

  • Correctness under network partitions: at most one holder per key at any time.
  • acquire latency under 10 ms p95.
  • 10k acquire/release ops per second on the cluster.
  • Auto-release on holder death — lease expiry within 2× the TTL worst-case.
  • 99.9% availability on acquire; caller retries handle brief outages.
Say this out loud

The whole question is one word: correctness. "Fast" is easy — Redis single-node does that. The hard part is what "one holder at a time" means when clocks drift and processes pause.

02

Back-of-envelope estimates

The load is small compared to a normal database — a lock service handles thousands of ops per second, not millions. The design constraint is correctness, not throughput.

QuantityEstimateHow you got there
Ops/s~10kacquire + release, dominated by short-lived locks (job leases, leader election).
Live locks~1Min-memory hash map; 100 bytes each = 100 MB, trivially fits on any node.
TTL median~30 slong enough to survive a GC pause, short enough to auto-release on crash.
Nodes in quorum3 or 5an odd number for majority; 5 tolerates 2 failures at the cost of write latency.
Consensus latency~5-10 msRaft round-trip within a region; the reason a global lock service is a bad idea.
Fencing tokensmonotonic 64-bithanded out per lock; never reused, even after lock is released.
Say this out loud

I want three or five nodes running Raft, not ten. Consensus latency scales with the majority — bigger clusters are slower, not more reliable.

03

Sketch the API

A tiny surface. The interesting field is the fencing token — most naive designs forget it and pay for it in a corruption incident.

POST/lock/acquirebody: { key, holderId, ttlMs } → { acquired, fencingToken? }. Non-blocking.
POST/lock/releasebody: { key, holderId }. No-op if holder mismatch (returns success).
POST/lock/extendbody: { key, holderId, newTtlMs } → { extended: bool }.
GET/lock/status/{key}→ { holder, expiresAt, token }. For diagnostics; do not use for correctness.
  • Every acquire returns a fencing token that the client must include with every I/O to the resource being protected. Storage systems reject writes with tokens lower than the last-seen token. This is the correctness backbone.
  • release is a no-op on mismatch — a client whose lease expired and got taken by someone else should not be able to release the successor's lock.
04

Data model

One in-memory table replicated by Raft. Persistence is the Raft log itself, not a separate database.

locks (in-memory)
key PK · holder_id · expires_at · fencing_token
one entry per active lock. Deleted on release or expiry.
raft_log
index · term · command (acquire/release/extend)
the durable record; replicated to every node before commit.
token_counter
current_token BIGINT
single global counter, incremented on every acquire, replicated via Raft.
The database call

The whole state fits in RAM on every node. Postgres is the wrong tool because we're doing this to avoid Postgres — the point is to make coordination decisions fast and outside the transactional store. Raft's log is our durable store, and it's designed for exactly this.

05

High-level design

A cluster of odd-numbered nodes running Raft (etcd, Zookeeper or a custom implementation). All writes go through the leader; reads can be linearised via readIndex or served from local state with a small lag.

clients lock service consensus resource Client A Client B Client C Router finds leader Leader term T Follower Follower Raft log replicated Protected resource checks fencing token replicate leader acquire write + token
acquire commits through the leader after replication to a majority; the client then writes to the protected resource carrying the fencing token.
  1. Client A sends acquire(K, ttl=30s) to any node; router forwards to the current leader.
  2. Leader assigns a monotonic fencing token, appends an acquire command to its Raft log, replicates to followers, waits for a majority to ack.
  3. On quorum ack, the leader applies to state machine: locks[K] = (A, now+30s, token=42). Returns success and token=42 to A.
  4. A now writes to the protected resource including token=42. The resource stores the last-seen token per key; a write with a token lower than the stored one is rejected.
  5. If A crashes without releasing, the lease expires at expires_at. Next acquirer B gets a higher token (43), and any late writes from A still carrying token 42 are refused by the resource.
06

Deep dives — where the interview is won

Phase 01

Fencing tokens: why Redis SET NX PX alone isn't enough

The failure mode looks like this: client A acquires the lock, then its process pauses for 40 seconds (GC, stop-the-world, virtualisation freeze). The 30-second lease expires. Client B acquires the lock and starts writing to the protected resource. Then A wakes up, still thinks it holds the lock, and writes too. Two writers. Corruption. No amount of clever Redis scripting fixes this — the fundamental issue is that A cannot know it has been paused past its lease.

The fencing token flips the problem: the resource itself validates every write against a monotonically-increasing token. A carries token 42, B carries token 43. When A finally writes, the resource sees 42 < 43 and rejects. The lock service doesn't need to prevent A from thinking it's still the holder — it just needs to make sure A's writes are stale and refused. This is Martin Kleppmann's canonical argument for why 'Redlock' is not safe by itself.

clients lock svc resource A (paused) B Lock svc Resource last token=43 token 42 token 43 write ok 42<43 reject
A holds a stale token. B holds the current one. The resource, not the lock service, is the enforcement point.
Trade-offFencing pushes correctness downstream — every resource that participates has to know how to validate tokens. Systems that can't (a legacy API you don't own) can't be safely protected by a distributed lock at all; you need application-level idempotency instead.
Phase 02

Consensus vs Redis: pick your correctness bar

Redis single-node with SET NX PX is fast and correct as long as the Redis node doesn't fail during a critical window. Failover to a replica can silently promote a node that hasn't seen the latest SET — for a brief window, two clients can both hold the 'same' lock. This is fine for cache coordination, cron deduplication, feature-flag guards. It is not fine for anything that touches money or user data.

Raft-backed services (etcd, Zookeeper) commit acquire only after a majority of nodes have persisted the write. A failover is safe because the new leader has the log. The cost is 5-10 ms per acquire versus <1 ms on Redis, and the operational overhead of running a quorum service. The interview move: name the axis. Redis for cheap coordination; Zookeeper/etcd for anything you'd be woken up for at 3 am.

client options acquire Redis SETNX 1ms, unsafe on failover etcd Raft 5-10ms, safe fast correct
the two ends of the spectrum. name the choice explicitly.
Trade-offRedlock (multiple independent Redis nodes) does not close the gap in a principled way — it swaps one set of failure modes for another. If safety matters, use a real consensus system.
Phase 03

Lease extension without a thundering herd of renewals

Long-running jobs need to hold locks for minutes or hours, but the TTL should stay short (30-60 s) so a dead holder releases fast. The solution is periodic extension: the holder sends extend(key, holderId, newTtl) well before expiry — say at 1/3 of the lease elapsed. If the extend fails (leader unreachable, holder no longer matches), the job self-cancels rather than continuing under a lock it may have lost.

The design pitfall: a naive extender fires exactly at expiry-minus-epsilon. If 10k jobs share a schedule, they all extend at the same instant and DDoS the lock service. Jitter the extension time (±25%) and stagger acquire times when many jobs start together. This isn't unique to locks — it's the same pattern as cache TTL and cron randomisation.

holder lock svc Job holder Lock svc acquire 30s extend @10s extend @20s release
extend at ⅓ of TTL, three times. if any extend fails, the job aborts — you never keep working past a lost lease.
Trade-offAggressive extension increases traffic on the lock service. Aggressive-TTL means slower failover. Pick the TTL from the tolerable stale-holder window on the resource, not from the job's expected runtime.
07

Follow-ups you should expect

Why not use a Postgres row with SELECT ... FOR UPDATE as a lock?
It works for coarse-grained coordination but ties every lock operation to database performance and locks live inside a transaction — releasing means committing. Long-held locks tie up connections. It's fine for occasional job leases; a dedicated lock service scales and decouples from your OLTP database.
How do you deal with clock drift?
You don't rely on client clocks at all. TTLs are measured from the moment the leader applies the acquire — the holder gets back an expiry time authoritative to the lock service. Fencing tokens make client-clock-drift irrelevant for correctness because the resource orders operations, not time.
What breaks under a network partition?
The minority-side partition can't acquire new locks (no quorum), so no new holders are minted. Existing holders in the minority side keep believing they hold the lock — that's why the fencing check is at the resource. As leases expire, the majority side hands out new leases with higher tokens, and the minority's late writes are rejected.
How would you support waiting on a lock?
Add a queue per key: contended acquires get enqueued and served in order when the holder releases (or the lease expires). Adds complexity (deadlock detection, priority inversion) and is often solved better client-side by exponential-backoff retry against a non-blocking service. Say the trade-off; don't build the queue by default.
Redlock — is it safe?
It's controversial. It adds availability (survives a single Redis node failure) but doesn't solve the process-pause fencing problem, and its safety argument depends on assumptions about clock monotonicity that aren't reliably true. If you need safety, use Raft. If you need Redis performance, use Redis and add fencing at the resource — Redlock isn't a middle path, it's the same failure modes with more nodes.

Where candidates lose the room

  • Believing Redis SETNX is a lock — it's a mutex-in-happy-path, not a lock in the distributed-systems sense.
  • Not handing out a fencing token, or handing one out but not requiring the resource to check it.
  • Building a global lock service — a single cluster in one region is fine; cross-region locks pay a WAN round trip per acquire.
  • Long TTLs to "cover the job" — you can't shorten the failover window later; short TTL with extension is the pattern.
  • Firing all extensions on a fixed schedule — thundering herd against the leader every N seconds.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.