A Redis SET NX PX lock is a lease. It is not mutual exclusion. If the holder pauses longer than the TTL, a second client acquires the same key, writes, and the first client still writes when it wakes up. The fix is a fencing token: a number that only goes up, checked by storage, not by lease.isHeld().
Google autocomplete for fencing token currently suggests pattern, redis, lock, zookeeper, etcd, and redlock. redis distributed lock fills in with Java, Python, Go, C#, Spring Boot. People are searching the primitive by name. The interview is whether you know the lease is not the safety boundary.
What is the interviewer actually asking?
Most whiteboard answers stop at Redis:
SET lock:acct:1 <random> NX PX 30000
That is the pattern Redis itself documents on the SET command page: create the key only if it is missing, auto-release with an expiry, unlock with a Lua compare-and-delete so you do not DEL someone else's lease. Fine for efficiency. Martin Kleppmann's 2016 note on distributed locking splits the use cases in one paragraph. If two nodes do the same work and you pay AWS five extra cents, a best-effort lock is enough. If two nodes debit the same account, you need correctness. SET NX does not give you that.
The failure is not exotic. Kleppmann's diagram is the whole interview: client A gets token 33, pauses (GC, a page fault, a SIGSTOP, a delayed packet), the lease expires, client B gets token 34 and writes. A resumes and writes with 33. Storage that only checks "did you once hold a lock?" accepts both.
I ran that sequence in 70 lines of Node with an injected clock. No Redis process. The clock is the point.
Why doesn't checking the lease just before the write save you?
Because the pause can happen after the check. Kleppmann is blunt about this: you cannot insert if (!lock.expired) write() and call it fixed. GC can stop the thread between the last check and the write. A packet can sit in the network for a long time after you sent it. He cites a GitHub incident where packets were delayed about 90 seconds. Your isHeld() returned true. The write arrived after B already committed.
So the resource has to be the one that says no. The lock service only issues a strictly increasing token. Storage remembers the highest token it accepted and rejects anything that went backwards.
ZooKeeper people already have this: zxid or the znode version. etcd has revision. Redis SET NX returns OK or nil. It does not return a monotonic fence. That is Kleppmann's actual objection to Redlock as a correctness lock, not a dunk on Redis.
How small is the pad version?
Two objects. A lease map that mints a fence on every successful acquire. An account that optionally enforces token > lastFence on every debit. Clock and TTL are injected so the tests do not sleep.
const STALE = "stale_fence";
const HELD = "lock_held";
function createLeaseLock({ now, ttlMs = 30 } = {}) {
const locks = new Map();
let nextFence = 33; // Kleppmann's diagram
function acquire(key, owner) {
const current = locks.get(key);
if (current && current.expiresAt > now()) {
return { ok: false, error: HELD, holder: current.owner };
}
const fence = nextFence++;
locks.set(key, { owner, fence, expiresAt: now() + ttlMs });
return { ok: true, fence };
}
function isHeld(key, owner) {
const current = locks.get(key);
return Boolean(current && current.owner === owner && current.expiresAt > now());
}
return { acquire, isHeld };
}
function createAccount({ fence = true } = {}) {
let balance = 100;
let lastFence = 0;
const writes = [];
function debit(amount, token) {
if (fence && !(token > lastFence)) {
return { ok: false, error: STALE, lastFence, token };
}
if (fence) lastFence = token;
balance -= amount;
writes.push({ amount, token, balance });
return { ok: true, balance };
}
return { debit, getBalance: () => balance, getLastFence: () => lastFence, writes };
}
The starting fence is 33 so the narration matches the blog post you should actually cite. Production would start at 1. I do not care.
Which six contracts do you have to show?
Contract 1: while the lease is live, B cannot acquire. This is the NX part. If you cannot show this, you do not have a lock at all.
const clock = { t: 0 };
const lock = createLeaseLock({ now: () => clock.t, ttlMs: 30 });
const a = lock.acquire("acct:1", "A");
const b = lock.acquire("acct:1", "B");
assert.equal(a.ok, true);
assert.equal(a.fence, 33);
assert.equal(b.ok, false);
Contract 2: TTL is a liveness feature. After 31ms, A is not the holder. B acquires. The new fence is 34, strictly greater.
clock.t = 31;
const b2 = lock.acquire("acct:1", "B");
assert.equal(lock.isHeld("acct:1", "A"), false);
assert.equal(b2.fence, 34);
Redis documents this as a liveness property: even if the client crashes, the key expires. That is the part people remember. The next contract is the part they skip.
Contract 3: turn fencing off. Same pause. B debits 10 with token 34. A wakes up and debits 10 with token 33. Both succeed. Balance is 80. Last write is token 33. You just lost B's update, or you double-charged the account, depending how you read the ledger.
const unfenced = createAccount({ fence: false });
unfenced.debit(10, 34);
assert.equal(unfenced.debit(10, 33).ok, true);
assert.equal(unfenced.getBalance(), 80);
That is the broken writeData in Kleppmann's post, executable.
Contract 4: same sequence, fencing on. B's debit is accepted. A's debit returns stale_fence. Balance stays 90. lastFence is 34.
const fenced = createAccount({ fence: true });
const lock2 = createLeaseLock({ now: () => clock.t, ttlMs: 30 });
clock.t = 0;
const aLease = lock2.acquire("acct:1", "A");
clock.t = 31;
const bLease = lock2.acquire("acct:1", "B");
assert.equal(fenced.debit(10, bLease.fence).ok, true);
assert.equal(fenced.debit(10, aLease.fence).error, STALE);
assert.equal(fenced.getBalance(), 90);
Contract 5: equal token is stale. > not >=. A retry of the same successful write should be idempotent at the business layer (same debit id), not by sending the same fence again as a second debit.
assert.equal(fenced.debit(5, 40).ok, true);
assert.equal(fenced.debit(5, 40).error, STALE);
Contract 6: the TOCTOU the interviewer will poke. A checks isHeld at t=0. Clock jumps to 31. B acquires and writes. A then writes with the old fence. Fencing still rejects it. The check was theater.
assert.equal(lock.isHeld("acct:1", "A"), true);
clock.t = 31;
// B writes, A writes. Only B's token survives.
I ran those six with node fencing-token.mjs. All green.
What do you say out loud in the system-design round?
Something like:
I would ask whether the lock is for efficiency or correctness. For a cache fill, SET NX PX on one Redis is enough and I would say so. For an account debit I would not trust the lease. The lock service returns a monotonically increasing fencing token. Every write to the resource carries that token. The resource rejects anything less than or equal to the highest token it has already accepted. The check lives in the UPDATE ... WHERE last_fence < $token (or the equivalent compare-and-set). A client-side if (lease.isValid()) is not the check.
If they say "we use Redlock," I would not pick a fight with Redis's page. I would say: Redlock still does not hand me a fence. Without a fence, a paused holder can still write after a new holder has written. For correctness I want ZooKeeper/etcd (zxid / revision) or a database unique constraint plus a generation column. Redis is fine as an efficiency lock.
The backend round that asks "how do you lock this?" is the same round as outbox, idempotency keys, and the 3 AM CPU spike. The AceRound backend-developer interview guide puts those in the production-scenario bucket. aceround.app — AI interview assistant is useful when the next hour is talking through the pause while someone interrupts you. It does not replace running the six contracts above.
A few follow-ups I would expect:
| Follow-up | What I would not say |
|---|---|
| Can't we just heartbeat and extend the TTL? | "Then the pause cannot happen." Heartbeats help a healthy holder. They do not fence a write that is already in flight after the lease died. |
Redis GET the lock just before SET the account? |
That is two round trips and still a race. The account write has to be conditional on the fence in one atomic step. |
| UUID as the token? | UUIDs do not order. Storage cannot tell 33 from 34. Monotonic is the whole trick. |
| What about Redlock's five nodes? | Majority voting is about the lock service surviving a Redis crash. It still does not produce a fence. Kleppmann's critique starts there. |
I would not ship this Map. Production needs the fence on the resource that can actually lose money, persisted, compared atomically. The pad exists so you can fail the stale debit in 20 seconds instead of drawing a mutex on a whiteboard.
FAQ
Is a fencing token the same as an idempotency key?
No. An idempotency key says "this client intent runs once." A fencing token says "this generation of the lock is allowed to write, older generations are not." You usually want both. The debit should have an idempotency key so B's retry does not debit twice, and a fence so A's delayed packet cannot debit after B.
Does every lock need this?
No. Kleppmann's efficiency case is real. Singleflight for a cache stampede, a rate-limit counter, a "don't send the same email twice if we can help it" lock: lose one and you are annoyed. Do not pay ZooKeeper for that.
Why start the demo at 33?
So the story matches the diagram in the 2016 post. Interviewers who have read it will nod. If they have not, 1 and 2 work the same.
Can the storage service skip the check if the client promises it still holds the lock?
That is the broken code sample. The client is the one that paused. Storage is the only place the side effect becomes true.
If you already use Redis locks in production, does the resource you protect compare a fence, or does it trust SET NX?
Drafted with AI assistance, then edited. The Node contracts were run locally before publishing. Lease vs mutex, token 33 vs 34, and the "check before write" hole: Kleppmann, 8 Feb 2016. SET key value NX EX|PX: Redis SET. Redlock's safety/liveness list: Distributed locks with Redis.
Top comments (0)