Complex Systems · Phase 3: Distributed Mechanics
Distributed Locking & Monotonic Fencing
Investigate why distributed locks fail under GC pauses and network delays, examine the Kleppmann-Sanfilippo Redlock debate, and implement fencing tokens correctly.
Primary specification: How to do distributed locking (Martin Kleppmann)
Lesson 21 of 33
1d streakThe Architectural Question
A client acquires a distributed lock, enters a 30-second garbage collection pause during which the lock lease expires, and then awakens to corrupt shared storage.
Core Engineering Objectives
- Why distributed locks are fragile: long Stop-the-World GC pauses, thread descheduling, and network delays
- The Redlock controversy: Martin Kleppmann’s critique vs Salvatore Sanfilippo’s defense
- The correct pattern: distributed lock leases paired with monotonically increasing fencing tokens validated at storage
Step 1: The Core Problem: Why Distributed Locks are Fundamentally Hazardous
In a single-process application, mutual exclusion is solved via operating system mutexes or CPU atomic instructions (CMPXCHG). The CPU hardware bus arbitration guarantees that only one thread enters the critical section. In a distributed system, there is no shared memory, no hardware bus, and no synchronized clock.
The Mirage of the Distributed Mutex
Without storage-enforced monotonic fencing tokens, a distributed lock provides only an illusion of safety, guaranteeing data corruption under ordinary production operating conditions.
Step 2: Requirements, Theoretical Bounds, and Kleppmann’s Dilemma
Martin Kleppmann (author of Designing Data-Intensive Applications) established the theoretical framework for distributed locks: you must explicitly categorize why you are locking.
Locking for Efficiency (Avoiding Redundant Work)
If the lock fails, the only consequence is duplicate computation (e.g. two workers process the same email or render the same image twice). Small race conditions do not corrupt data or lose money. A lightweight Redis lock is completely appropriate here.
Locking for Correctness (Data Integrity & Money)
If two workers execute concurrently, data is corrupted, inventory is double-allocated, or funds are double-spent. In this category, simple distributed locks are INSUFFICIENT without monotonic fencing tokens verified at the persistent storage layer.
Lease Time-to-Live (TTL)
To prevent deadlocks if a client crashes while holding a lock, distributed locks must have a finite TTL. However, TTL relies on physical time duration, which can be violated by GC pauses and VM descheduling.
Fencing Token
A monotonically increasing integer generated by the lock coordinator on every successful lock grant. Persistent storage validates this token on every write: if an incoming write carries a token smaller than the highest token committed so far, the write is rejected.
| Locking Technology | Consensus Mechanism | Failure Tolerance | Fencing Token Support | Production Evaluation |
|---|---|---|---|---|
| Redis (Single Instance) | None (single-threaded memory) | Zero node failure tolerance | None (requires manual generation) | Great for efficiency; unsafe for correctness |
| Redis Redlock (5 Instances) | Independent majority quorums | Tolerates 2 node crashes | None out-of-the-box | Highly controversial; vulnerable to clock jumps and GC pauses |
| Apache ZooKeeper | Zab atomic broadcast consensus | Strict quorum (Tolerates (N-1)/2 crashes) | Native via zxid / sequential znodes | Battle-tested gold standard for distributed coordination |
| etcd | Raft consensus algorithm | Strict quorum (Tolerates (N-1)/2 crashes) | Native via Raft revision numbers | Cloud-native gold standard; powers Kubernetes leases |
| Database Pessimistic Lock | RDBMS Row Lock (SELECT FOR UPDATE) | Tolerates replica failover via ACID WAL | Native via AUTO_INCREMENT / MVCC version | Simplest & safest if all resources reside in the same database |
Step 3: Naive Architecture: The Stop-the-World GC Annihilation
Consider the classic failure trace documented by Martin Kleppmann in his critique of the Redlock algorithm. Client 1 acquires a lock to write to an object store, but is halted by an unexpected operating system pause.
- 01Step 1: Lock Acquisition. Client 1 requests a lock on
resource_customer_100from Redis with a 10-second TTL. Redis successfully grants the lock. - 02Step 2: The Stop-the-World Pause. Client 1’s JVM triggers a major Garbage Collection pause (or the OS deschedules the container during heavy memory swapping) lasting 15 seconds.
- 03Step 3: Lease Expiration. While Client 1 is frozen in GC, 10 seconds elapse. Redis automatically expires the lock lease on
resource_customer_100and frees the key. - 04Step 4: Lock Re-grant. Client 2 requests a lock on
resource_customer_100. Redis grants the lock to Client 2 with a fresh 10-second lease. - 05Step 5: Client 2 Writes. Client 2 enters the critical section, writes version 2 of the customer record to storage, and safely releases the lock.
- 06Step 6: Client 1 Awakens & Corrupts Data. Client 1’s GC cycle completes. Client 1 resumes execution at the exact line of code following lock acquisition! Client 1 has no idea that 15 seconds have passed, believes it still owns the lock, and writes its stale version 1 data to storage, completely overwriting and destroying Client 2’s committed updates!
The Fatal Flaw: Checking `isLockHeld()` is Useless
if (lock.isStillHeld()) { storage.write(); }. This check is completely ineffective! The process can pause for 15 seconds AFTER the if (lock.isStillHeld()) check passes, but BEFORE the network packet for storage.write() hits the socket buffer. Checking lock validity inside the client process is fundamentally prone to Time-of-Check to Time-of-Use (TOCTOU) race conditions.Step 4: Evolved Production Architecture: Fencing Tokens & Storage Enforcement
To make distributed locks safe for correctness, mutual exclusion must be enforced by the storage layer using monotonically increasing Fencing Tokens.
// PRODUCTION DISTRIBUTED LOCK WITH FENCING TOKEN & STORAGE VALIDATION
import { createHash, randomBytes } from 'crypto';
export interface FencedLockGrant {
lockKey: string;
lockToken: string; // Unique random secret for safe release
fencingToken: number; // Monotonically increasing sequence number
expiresAtMs: number;
}
export interface StorageRecord<T> {
id: string;
payload: T;
lastFencingToken: number; // Highest fencing token committed
}
// 1. STORAGE LAYER: Monotonic Fencing Validator
export class FencedStorageEngine<T> {
private records: Map<string, StorageRecord<T>> = new Map();
// Storage validates fencing token on EVERY write
public write(id: string, payload: T, clientFencingToken: number): { success: boolean; error?: string } {
const existing = this.records.get(id);
if (existing) {
// INVARIANT: Reject any write carrying an older or equal fencing token!
if (clientFencingToken <= existing.lastFencingToken) {
return {
success: false,
error: `STALE_FENCING_TOKEN: Rejected token ${clientFencingToken}. Highest committed is ${existing.lastFencingToken}.`,
};
}
}
// Accept write and update highest committed fencing token
this.records.set(id, {
id,
payload,
lastFencingToken: clientFencingToken,
});
return { success: true };
}
public read(id: string): StorageRecord<T> | undefined {
return this.records.get(id);
}
}
// 2. COORDINATOR LAYER: Distributed Lock Manager with Atomic Monotonic Fencing Tokens
export class RedisDistributedLockManager {
// CRITICAL DISTRIBUTED REALITY: Fencing tokens MUST be strictly monotonic and
// generated server-side by the coordinator (e.g., Redis atomic INCR, ZooKeeper zxid,
// or etcd cluster revision). A client-side memory counter clobbers across multiple app nodes.
public async acquireLock(
redisClient: any,
resource: string,
ttlMs: number
): Promise<FencedLockGrant | null> {
const lockToken = randomBytes(16).toString('hex');
const lockKey = `lock:${resource}`;
const fencingCounterKey = `lock:fence:${resource}`;
// Atomic acquisition & monotonic token assignment via Lua script:
// 1. SET lockKey lockToken NX PX ttlMs
// 2. If acquired, INCR fencingCounterKey and return token; else return nil
const acquireLuaScript = `
if redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
local token = redis.call("incr", KEYS[2])
return { ARGV[1], token }
else
return nil
end
`;
const result = await redisClient.eval(acquireLuaScript, 2, lockKey, fencingCounterKey, lockToken, ttlMs);
if (!result) return null;
return {
lockKey,
lockToken: result[0],
fencingToken: Number(result[1]),
expiresAtMs: Date.now() + ttlMs,
};
}
// ATOMIC RELEASE VIA LUA SCRIPT:
// Must verify value matches lockToken before deleting to avoid releasing someone else's lock!
public getReleaseLuaScript(): string {
return `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
}
}Use it when
Always enforce monotonic fencing tokens in SQL (e.g. WHERE fencing_token > last_fencing_token) or object storage conditional writes whenever a distributed lock guards financial or non-idempotent state.
Reconsider when
Use lightweight Redis single-instance locks with TTL if the workload is strictly performance-oriented (e.g. preventing two background cron jobs from scraping the same weather API simultaneously).
Step 5: Step-by-Step Message Flow Trace: Fencing Token Rejection
- 01Step 1: Lock Grant to Client 1. Client 1 acquires lock on
/order/101from the coordinator. The coordinator issues Fencing Token 33 with a 10s TTL. - 02Step 2: Client 1 GC Freeze. Client 1’s virtual machine pauses for 12 seconds due to host memory swapping. The 10s TTL expires in the coordinator.
- 03Step 3: Lock Grant to Client 2. Client 2 requests the lock on
/order/101. The coordinator grants the lock and issues monotonically incremented Fencing Token 34. - 04Step 4: Client 2 Commits with Token 34. Client 2 sends
UPDATE orders SET status = "SHIPPED", fencing_token = 34 WHERE order_id = 101 AND 34 > fencing_token. The database verifies34 > 0, commits the change, and storeslastFencingToken: 34. - 05Step 5: Client 1 Awakens with Stale Token 33. Client 1 awakens from its GC pause. Believing it is still the legitimate lock owner, it transmits
UPDATE orders SET status = "CANCELLED", fencing_token = 33 WHERE order_id = 101 AND 33 > fencing_token. - 06Step 6: Database Storage Rejection. The database evaluates the atomic predicate:
33 > 34evaluates to FALSE! The database rejects Client 1’s write with 0 rows affected. Client 2’s data remains pristine. Catastrophe averted!
Step 6: Real-World War Stories: GitHub, HBase, and the Redlock Debate
The Great Kleppmann vs. Antirez Redlock Debate (2016)
GitHub: The 2016 Redis Failover Lock Loss
Apache HBase: HMaster Failover via ZooKeeper Ephemeral Nodes
Step 7: Hard Trade-Offs Matrix: Distributed Lock Topologies
| Technology | Acquisition Latency | Consensus Backed? | Automatic Deadlock Recovery | Fencing Support | Operational Complexity |
|---|---|---|---|---|---|
| Redis SET NX PX | ~1 ms (in-memory) | No (single node or async replica) | TTL expiration | None (manual token generation) | Extremely low; single Redis instance |
| Redis Redlock | ~5 – 15 ms (5 nodes) | Pseudo-quorum (no log replication) | TTL expiration | None out-of-the-box | High; must maintain 5 independent Redis instances |
| Apache ZooKeeper | ~5 – 20 ms (fsync) | Yes (Zab atomic broadcast) | Ephemeral znode session drop | Native (zxid / sequential znode index) | High; complex JVM operational footprint |
| HashiCorp Consul / etcd | ~5 – 15 ms (fsync) | Yes (Raft consensus) | Session lease TTL + keepalive | Native (Raft 64-bit revision index) | Moderate; single Go binary with simple operations |
| Postgres Advisory Lock | ~2 – 5 ms (WAL) | Yes (Postgres master-replica) | Transaction end or connection close | Native (transaction IDs / sequences) | Zero if already running PostgreSQL |
Architectural Boundary · Do Not Confuse
Redis Ephemeral TTL Lock
ZooKeeper Ephemeral Sequential Lock
Redis locks rely on time elapsed (TTL). If a client is delayed, the lock silently disappears from under its feet without notification. ZooKeeper locks rely on active TCP heartbeat sessions: when a client crashes, its session expires and ZooKeeper removes the ephemeral znode. Furthermore, ZooKeeper provides the "Curator Lock Recipe": clients create sequential znodes and watch only their immediate predecessor, completely eliminating thundering herd stampedes when thousands of workers wait for the same lock.
Step 8: Failure Modes, Edge Cases & Cascading Outages
- 01The Accidental Lock Release Catastrophe: Client A acquires a lock with a 5s TTL. Due to a slow SQL query, Client A takes 7 seconds. At 5 seconds, Redis expires the lock. Client B acquires the lock. At 7 seconds, Client A finishes and blindly executes
redis.del("lock_key")! Client A has just deleted Client B’s valid lock! Now Client C acquires the lock, resulting in two active workers. Solution: Always release locks via Lua script matching a unique secret token. - 02The Clock Jump Acceleration Bug: In Redlock, if 3 out of 5 Redis servers have their physical NTP clocks adjusted forward by 10 seconds, keys with a 5s TTL expire immediately. A client that just acquired the lock loses it within milliseconds, leading to multiple clients entering the critical section simultaneously.
- 03Thundering Herd on Lock Release: 500 workers attempt to acquire the same lock in Redis via polling loops. When the lock owner releases it, all 500 workers hammer Redis with
SET NXwithin 1ms, saturating Redis CPU and network buffers. Solution: Use ZooKeeper/etcd watches where only the next sequential waiter is notified.
Step 9: Interviewer Deep-Dive Gauntlet (Staff/Principal Level)
Deep-Dive Q1: Why is releasing a Redis distributed lock with a simple `DEL` command a critical architectural bug, and how does a Lua script fix it?
DEL lock_key, it is unaware that its lease already expired at 10s. During the 2-second gap, Client B requested and was granted the lock. When Client A executes DEL, it inadvertently deletes Client B’s legitimate lock! Client C can now immediately acquire the lock, resulting in Client B and Client C operating concurrently in the critical section. To fix this, Client A must generate a cryptographically random unique value (e.g. UUIDv4) upon acquisition. Releasing the lock requires an atomic Lua script: if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end. This guarantees that a client can delete the lock ONLY if the stored value still matches its unique token.Deep-Dive Q2: How does ZooKeeper’s Ephemeral Sequential znode recipe completely eliminate the "thundering herd" problem when 5,000 clients contend for the same lock?
/locks/resource/lock-0000000001, lock-0000000002, etc. 2) The client queries all children of /locks/resource. 3) If the client’s znode has the lowest sequence number, it holds the lock! 4) If it does not have the lowest number, it does NOT watch the lock itself; it sets an asynchronous watch ONLY on the znode with the immediate predecessor sequence number. 5) When Client 1 releases its lock (or crashes), only Client 2 receives a watch notification. The remaining 4,998 clients receive zero notifications and emit zero network traffic, achieving O(1) message overhead per lock handover.Deep-Dive Q3: If shared storage must enforce monotonic fencing tokens anyway, why do we even need a distributed lock manager in the first place?
WHERE fencing_token > last_fencing_token, you might ask: why not dispense with the lock manager entirely? The answer is Efficiency and Resource Protection. While the fencing token guarantees Correctness at the final write step, acquiring a lock allows the system to avoid executing millions of CPU cycles, heavy image processing, expensive database reads, or third-party API calls that would ultimately be discarded anyway. The distributed lock acts as an efficient optimistic traffic filter to serialize execution, while the storage fencing token acts as the infallible cryptographic safety net.Deep-Dive Q4: Under what conditions does an asynchronous Redis master-replica setup fail to guarantee mutual exclusion even when using correct Lua scripts and UUIDs?
SET lock_key uuid NX PX 10000, the write is acknowledged as soon as it hits the master’s in-memory data structures. Before that write command is replicated to the async replica, the master hardware crashes. Sentinel or Redis Cluster detects the master failure and promotes the replica to master. Because the replica never received the lock write, lock_key does not exist on the new master. Client B requests the lock, and the new master immediately grants it. Both Client A and Client B now hold the lock simultaneously, completely violating mutual exclusion.Step 10: Active Recall & Verification Checkpoint
Active Recall Checkpoint
Why does checking if (lock.isValid()) immediately before writing to a database fail to prevent split-brain write corruption?
Hint: Think about Time-of-Check to Time-of-Use (TOCTOU) and garbage collection pauses.
Show answer & explanation
Because an operating system process pause, garbage collection freeze, or network socket stall can occur immediately AFTER the if (lock.isValid()) evaluation passes, but before the database write packet is transmitted. By the time the write arrives at the database, the lock has expired and been granted to another node. The storage engine itself must validate a monotonic fencing token atomically with the write.
Active Recall Checkpoint
What is the purpose of an ephemeral node in ZooKeeper or a lease keep-alive in etcd?
Hint: What happens when a client process crashes without executing its cleanup code?
Show answer & explanation
An ephemeral node exists only as long as the client maintains an active heartbeat session with the ZooKeeper cluster. If the client crashes, is killed, or is partitioned away, the session times out and ZooKeeper automatically deletes the ephemeral node, releasing the lock and preventing catastrophic cluster deadlocks.
Production Postmortem & Verification
Production Incident Postmortem
- Trigger / Setup: Client 1 acquires a Redis lock with a 10s TTL, pauses for 15s during a full GC cycle, while Client 2 acquires the expired lock.
- Production Symptom: Both Client 1 and Client 2 simultaneously write conflicting data to shared storage, corrupting customer state.
- Root Cause & Fix: A lock lease cannot guarantee mutual exclusion without storage-enforced monotonic fencing tokens that reject stale write attempts.
Acceptance Criteria & Outcomes
- Diagram how GC pauses and network delays invalidate lock exclusivity assumptions.
- Explain the mechanism of monotonic fencing tokens and why storage-level validation is mandatory.
- Evaluate consensus-backed locks (ZooKeeper/etcd ephemeral nodes) versus memory-backed locks (Redis).
End-of-Chapter Synthesis & Key Takeaway
Distributed Locking & Monotonic Fencing is built around one fundamental rule: Establish clear responsibility and explicit trade-offs.. Before adding components, establish explicit quantitative SLAs for latency, throughput, and consistency. Choose the simplest architecture that meets stated constraints.
Architectural & Technical Interview Review
Core FAANG Interview Questions
Q01How do you justify introducing Distributed Locking & Monotonic Fencing into a high-scale production architecture?Click to reveal model answer →Hide answer ↑Requirements & Design Brief
Introduce Distributed Locking & Monotonic Fencing only when measured scale, data size, or failure boundaries exceed single-node capacity. State the exact bottleneck, evaluate operational overhead, and verify that the trade-offs justify the added moving parts.
Q02What is the primary trade-off and hidden operational cost of Distributed Locking & Monotonic Fencing?Click to reveal model answer →Hide answer ↑Architecture Trade-offs
It improves scalability and availability, but introduces network hops, serialization overhead, and eventual consistency or coordination complexity.
Q03How do you handle catastrophic failure or dependency degradation in Distributed Locking & Monotonic Fencing?Click to reveal model answer →Hide answer ↑Failure Review & Recovery
Implement connect/read timeouts, exponential backoff with jitter, and fallback defaults so downstream degradations do not cause cascading outages.
Production Pitfalls & Anti-Patterns
Critical Failure Anti-Patterns
Premature Distributed Complexity
Scattering distributed components before identifying concrete write, read, or storage limits creates accidental points of failure and operational debt.
Production Mitigation: Start with the simplest modular design that satisfies requirements, evolving it only as measured constraints dictate.
Unbounded Retries and Cascading Storms
When a dependency degrades, immediate synchronized retries multiply downstream traffic, turning a minor slowdown into a total fleet outage.
Production Mitigation: Enforce strict retry budgets, exponential backoff, and full randomized jitter on all client calls.
Neglecting Observability and Metrics
Deploying architecture without distributed tracing or p99 tail latency alerts leaves engineers blind when partial partitions occur.
Production Mitigation: Export metrics for request rate, error rate, p95/p99 duration, and saturation at every service boundary.