← System design

Infrastructure

Design Distributed Locking

Two copies of a payout worker both decide they are the one allowed to settle the same invoice. Each wrote correct code, and the customer still got paid twice. A lock service exists to stop that, and the interesting part is that holding a lock is never proof that you still hold it. This design takes a lock from a Redis one-liner to a replicated service whose guarantees survive pauses, partitions and crashed clients.
Core: consensus-backed leases + fencing tokens18K lock operations a secondCP over APAcquire p99 under 15 ms

01

The problem with locks across machines

01

The problem with locks across machines

Inside one process, mutual exclusion is a solved problem. The operating system and the CPU give you atomic instructions over shared memory, and if a thread dies holding a mutex, the whole process usually dies with it. Spread the same workers across thirty machines and every one of those comforts disappears. There is no shared memory, only messages over a network that can delay, reorder or drop them. Clocks on different hosts disagree. A process can stop for half a minute in a garbage collection or a VM migration and then resume as if nothing happened.

So the lock state has to live in a separate service that every client can reach, and that service has to answer one question correctly under all of those conditions: who is allowed to act on this resource right now? Teams use the answer for very different things. Some want to avoid wasted work, such as two nodes rebuilding the same cache. Others protect correctness, such as two nodes debiting the same wallet. Those two uses tolerate failure very differently, and a good design treats them separately.

Efficiency locks

Duplicate work is wasteful but harmless. A rare double holder costs some CPU. These callers want the lowest latency possible and can accept a weaker guarantee.

Correctness locks

Two holders means corrupted data or lost money. These callers need a guarantee that survives any single failure, and they will pay a few extra milliseconds for it.

The catch

Even a perfect lock service cannot see a client that is frozen. The protected resource has to help, by rejecting writes from a holder whose turn is already over.

The sentence to say early

A lease tells a client it was the owner when the service last spoke to it. Only a fencing token checked by the storage being protected turns that into mutual exclusion for the writes that actually matter.

02

What callers need

02

What callers need

Functional
  • Acquire a named lock, either try-once or wait up to a deadline, and receive a fencing token that only ever increases for that name.
  • Release only with the matching token, so a stale client cannot free a lock someone else now holds.
  • Locks are tied to a client session that heartbeats; when the session dies, every lock it held is freed together.
  • Waiters are served in arrival order, and a waiter is notified when the lock frees instead of polling.
  • Read the current holder and token of a lock for debugging, and list locks by name prefix.
  • Optional shared mode for readers and a counted mode that allows up to N holders.
Non-Functional
  • Safety first: never two exclusive holders granted at once, including during network partitions and leader changes.
  • Uncontended acquire p99 under 15 ms within a region for correctness locks, under 3 ms for efficiency locks.
  • A crashed holder's locks become available within about 10 seconds.
  • Keeps working if any two of five replicas fail.
  • Linearizable: once an acquire returns, every later read sees that holder.
Clarify before designing

Ask which callers can live with a rare double holder and which cannot. Ask whether the protected resources can check a token, because a third-party API you call over HTTP often cannot. Ask how long locks are held: milliseconds for a row update and minutes for a batch job lead to very different lease lengths.

03

Sizing the service

03

Sizing the service

Assume 1,200 service instances across 150 teams use the service in one region. A typical instance performs about 15 lock operations per second, counting acquires, releases and renewals. Lock names average 60 bytes.

QuantityWorkingResult
Lock operations1,200 instances x 15 / s18,000 / s
Correctness-tier shareabout 30% of operationsabout 5,400 / s through consensus
Efficiency-tier sharethe remaining 70%about 12,600 / s on the fast path
Locks held at any momentmany long-lived per-entity locksabout 2M
Bytes per held lockname, holder session, token, expiry, waiter list headabout 160 B
Lock state in memory2M x 160 Babout 320 MB
Session heartbeats1,200 sessions, one every 3 s400 / s, not replicated individually
Raft log bytes5,400 / s x about 200 B per entryabout 1.1 MB / s
Consensus latencyone round trip to a quorum plus an fsyncabout 4 to 8 ms in-region
Bound by agreement, not by bytes

320 MB of state fits in memory on any server. What limits the service is how many writes a single Raft group can commit per second, typically somewhere between 10,000 and 30,000 with batching. 5,400 a second fits in one group with headroom, which is why the efficiency tier is kept off the consensus path.

04

Client API

04

Client API

Locks are connection-oriented, so the interface is gRPC with a long-lived session stream rather than stateless REST. Most callers use a thin SDK that hides renewals and reconnects. The calls below show the wire shape.

POSTLockService/OpenSessionStart a session
{ "client": "payouts-worker-17", "ttl_ms": 10000 } // returns session_id; the SDK sends KeepAlive every ttl/3 on the same stream
POSTLockService/AcquireTake a lock
{ "session_id": "s_7f2c91", "name": "invoice/INV-55102", "mode": "exclusive", "tier": "strict", // or "fast" for best-effort locks "wait_ms": 2000 // 0 means try once } // granted { "granted": true, "token": 90412, "lease_until_ms": 1790000010000 } // timed out: granted=false with the current holder's client name for debugging
POSTLockService/ReleaseGive it back
{ "name": "invoice/INV-55102", "token": 90412 } // FAILED_PRECONDITION if the token is not the current one: you already lost it
GETLockService/Describe?name=invoice/INV-55102Inspect a lock
{ "holder": "payouts-worker-17", "token": 90412, "waiters": 3, "held_for_ms": 1840 }
Where the token goes next

The token is not decoration. The SDK makes callers pass it into every write against the protected resource, for example UPDATE invoices SET ..., fence = 90412 WHERE id = ... AND fence < 90412. A write with an older token affects zero rows.

05

Service layout

05

Service layout

Two tiers behind one SDKDiagram
flowchart LR
	APP["Service instance"] --> SDK["Lock SDK, session and renewals"]
	SDK -->|fast tier| RD[("Redis primary per shard")]
	SDK -->|strict tier| GW["Lock gateway"]
	GW --> L["Raft leader"]
	L --> F1["Follower"]
	L --> F2["Follower"]
	L --> F3["Follower"]
	L --> F4["Follower"]
	L --> SM[("Lock state machine in memory")]
	SM -. "grant events" .-> GW
	APP --> RES[("Protected store checks fence")]

The heart of the strict tier is a five-node Raft group running a small lock state machine. Every acquire, release and session expiry is a log entry. The leader appends it, waits for two followers to persist it, applies it to the in-memory map of locks, and answers. Because every node applies the same entries in the same order, any node that becomes leader has exactly the same view of who holds what. Snapshots of the map are taken every few minutes so a restarted node replays only a short tail of the log.

The fencing token comes for free from the log. Each lock name keeps a counter that the state machine increments on every grant, and since grants are serialized through Raft the counter is strictly increasing across leader changes. etcd exposes the same idea as a key's revision number, and ZooKeeper as the zxid or a sequential znode number.

Sessions are what make crashed clients go away. Rather than writing a heartbeat into the log 400 times a second, the leader tracks the last heartbeat time per session in memory. When a session misses its deadline, the leader commits a single ExpireSession entry that frees all of that session's locks at once and promotes the next waiter on each. A newly elected leader gives every session a fresh grace period before expiring anything, since it cannot know when old heartbeats arrived.

The fast tier is a set of independent Redis primaries, sharded by lock name. An acquire is SET name token NX PX 8000 and a release is a Lua script that deletes the key only if the value still matches. There is no replication wait and no quorum. If a Redis primary fails over before its replica copied the key, two clients may briefly hold the same lock. For a cache rebuild, that is an acceptable price for a one-millisecond acquire.

Lock gateway

Stateless front door that holds client streams, forwards writes to the current leader, and fans grant notifications back to waiters. It lets clients survive leader changes without reconnecting.

Wait queues

Each contended lock keeps a FIFO of waiting sessions inside the state machine. Releasing the lock grants it to the head of the queue in the same log entry, so there is no thundering herd of retrying clients.

Placement

Spread the five replicas across three availability zones as 2, 2 and 1. Losing a whole zone leaves three nodes, still a majority.

06

Deep dive: why leases fail and how fencing saves you

06

Deep dive: why leases fail and how fencing saves you

The failure that matters

Every lock design is safe when clients are fast and the network is healthy. Interviewers care about one scenario: the holder stops, its lease runs out, someone else takes over, and then the original holder wakes up and writes.

Ranking the options

ApproachSurvives a node failureSafe against a paused holderTypical acquire
One Redis key with an expiryNo, async replica can lose the keyNoabout 1 ms
Redlock across five Redis nodesMostly, but relies on bounded clock driftNoabout 3 ms
Database row or advisory lockOnly as well as the database fails overNo, unless writes check a versionabout 2 ms
Raft-based lock serviceYes, with a majority aliveNo on its ownabout 6 ms
Raft-based service plus fenced writesYesYesabout 6 ms
A paused holder meets a fenced storeDiagram
flowchart TD
	A["Worker A acquires, token 90412"] --> B["A pauses 25 s in GC"]
	B --> C["Session misses heartbeats, lease expires"]
	C --> D["Worker B acquires, token 90413"]
	D --> E["B writes with fence 90413, store records 90413"]
	E --> F["A wakes and writes with fence 90412"]
	F --> G{"Store: 90412 greater than 90413?"}
	G -->|no| H["Write rejected, A learns it lost the lock"]

Why the lock service alone is not enough

Follow worker A carefully. It acquires the lock with a 10-second lease and starts preparing a write. Then its JVM stops the world for 25 seconds. During that pause A sends no heartbeats, so the service correctly concludes A is gone, expires the session and grants the lock to B. Nothing in the lock service misbehaved. When A resumes, its code continues from the line after "lock acquired", and nothing forces it to check again before the write. Even if it did check, the check and the write are two steps, and a pause can land between them.

The fix moves the final decision to the place where the damage would happen. Each grant carries a token, and the protected store remembers the highest token it has accepted for that resource. A write carrying a lower token is refused. B's write raised the stored value to 90413, so A's write with 90412 fails, and A's SDK surfaces that as "lock lost". This is optimistic concurrency control layered under a pessimistic lock: the lock keeps contention orderly, the fence makes the rare overlap harmless.

When the resource cannot check a token, for example an external payment provider, the next best option is to send an idempotency key derived from the work item. The provider then deduplicates for you, and the lock becomes an efficiency measure rather than the only line of defence.

Why Redlock does not close the gap

Redlock acquires the same key on a majority of five independent Redis nodes and treats the lock as held until the shortest expiry passes. It improves availability over a single Redis key, but it still decides expiry from wall-clock time on each node, so a clock jump on two nodes can produce two holders. More importantly, it hands out a random value rather than an increasing number, so the store has nothing to compare. Martin Kleppmann's 2016 critique makes this argument in detail and is worth naming in an interview.

Lease length is a trade

Short leases free a dead client's locks quickly but expire healthy clients that hit a brief network blip. Long leases do the opposite. A 10-second session TTL with heartbeats every 3 seconds tolerates two lost heartbeats and recovers a crashed holder in about 10 seconds. Clients should also stop acting on their own once their local timer says the lease is about to run out, measured with a monotonic clock, even if they have not heard back from the service.

07

Decisions and what they cost

07

Decisions and what they cost

Chosen
Consistent and partition-intolerant for strict locks

A minority partition refuses all lock requests. Callers on that side see errors, but nobody ever gets a second grant. For a correctness lock, unavailability is the only safe failure.

Safety over uptime
Rejected
Keep serving on both sides of a partition

Higher availability, and exactly the wrong property for mutual exclusion. Two partitions granting the same lock is the bug the service exists to prevent.

Defeats the purpose
Chosen
Sessions with heartbeats

One heartbeat per client keeps every lock that client holds alive, and one log entry frees them all on expiry. Clients need a long-lived connection and an SDK that renews in the background.

Fewer writes, atomic cleanup
Alternative
Per-lock TTL with no sessions

Simpler and stateless for the client. But each held lock needs its own renewal write, which at 2M held locks is far more consensus traffic, and a dead client's locks expire one by one.

Fine for the fast tier
Chosen
Two tiers behind one SDK

Most lock traffic is for efficiency and gets millisecond latency from Redis. The few callers that need safety pay for consensus. The cost is two systems and the risk of a team picking the wrong tier, which code review and naming conventions must catch.

Right tool per caller
Alternative
Everything through consensus

One system, one set of guarantees, no wrong choices. You need more Raft groups to carry 18,000 operations a second and every caller pays 6 ms instead of 1 ms.

Reasonable if load is low
Chosen
Server-side FIFO wait queues

Fair ordering and one notification per release. The state machine has to store waiters, and a waiter whose session dies must be removed from every queue it joined.

No herd on release
Rejected
Clients poll and retry

No server state for waiters, but a popular lock gets hammered by every waiting client on every release, and the fastest poller wins instead of the longest waiter.

Unfair and noisy

08

How it breaks

08

How it breaks

💤
The holder pauses longer than its lease

A GC pause, a VM live migration or a laptop-lid style host suspend freezes the holder past its session TTL. The lock passes to another client, and the original wakes up mid-operation still believing it is in charge.

Fence every write with the token, have the SDK track remaining lease time on a monotonic clock and abort work when it runs low, and prefer idempotent operations for resources that cannot check a token.
✂️
Network partition splits the cluster

Two replicas end up cut off from the other three. The two can no longer reach a majority, and clients routed to them start timing out.

The minority side steps down and serves nothing, which is correct. The gateway retries against the majority side. Placing replicas across three zones keeps a single zone's isolation from ever leaving a minority in charge.
👑
Leader fails while sessions are open

The leader crashes. A new leader is elected in about a second but has no record of recent heartbeats. If it applied the old deadlines directly, it would expire hundreds of healthy sessions at once and trigger a wave of lock churn.

On election, the new leader extends every session by one full TTL before checking deadlines. Clients reconnect through the gateway and resume heartbeats within that grace period.
🔁
Fast-tier Redis fails over and loses a key

A Redis primary dies before replicating a fresh lock key. The replica takes over without it, and a second client acquires the same name.

Accepted by design for the fast tier, since its callers only lose efficiency. Document it in the SDK, and block correctness-critical lock names from using the fast tier by naming convention.
🔥
One hot lock dominates the log

A bug makes 300 instances fight over one global lock thousands of times a second. The Raft group spends its write budget on a single name, and every other team's acquires slow down.

Per-client and per-name rate limits at the gateway, metrics on the busiest names, and guidance to shard coarse locks into finer ones such as one lock per account instead of one per table.

09

Interview notes

09

Interview notes

1

Open with the paused-holder story. Explain in thirty seconds why any lease-based lock can have two active writers, and that the store must reject the stale one. Everything after that is detail.

2

Ask what the lock protects. Split efficiency from correctness out loud. It justifies a fast tier and a strict tier and shows judgement rather than a single default.

3

Build up the options in order. Process mutex, database lock, single Redis key, Redlock, consensus, consensus with fencing. Say what breaks at each step so the final design feels earned.

4

Know where the token comes from. It falls out of the replicated log for free. Mention the etcd revision or ZooKeeper's sequential nodes as concrete examples.

5

Pick CP and defend it. A lock that keeps granting during a partition is not a lock. Say what callers see when the service is unavailable and how they back off.

6

Mention sessions and the new-leader grace period. It shows you have thought about failover beyond "Raft elects a new leader".

7

Offer idempotency as the fallback. When a resource cannot check tokens, an idempotency key keeps a rare double holder from causing a double effect.

10

How the service grows

11

How the service grows

One thing to do on day one

Most stages can wait for a real problem. Fencing tokens cannot. Adding them later means changing every write path in every caller, so hand them out from the very first version even if nothing checks them yet.

Stage 1: one service, a few instances
Database locks

Use pg_try_advisory_lock or a row with a unique key, plus a version column for fencing. The database you already run is the cheapest correct lock you can get.

Stage 2: many services, mostly efficiency locks
Shared Redis with a small SDK

A Redis cluster with SET NX PX and a compare-and-delete release script. Fast, cheap, and honest about its weak guarantee.

Stage 3: correctness locks appear
Adopt etcd or ZooKeeper

Run a managed three or five node consensus cluster for strict locks, with sessions, revision-based tokens and watches. Keep Redis for the fast tier.

Stage 4: thousands of instances
Dedicated lock service

A purpose-built state machine with server-side wait queues, a gateway that hides leader changes, per-name quotas and lock dashboards. Split names across several Raft groups if one group's write budget runs out.

Stage 5: several regions
Region-homed lock names

Each lock name belongs to one region's cluster, since a quorum spanning continents adds 100 ms or more to every acquire. Callers in other regions accept the higher latency for those names.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.