Part of: Distributed Lock Acquisition Patterns
Both approaches solve the same problem covered in distributed lock acquisition patterns — granting mutual exclusion over a shared key — but they trade off availability, latency, and correctness guarantees differently. A single Redis instance answers SET NX PX in under a millisecond but has one failure domain: if that instance fails over to a replica before the lock key replicates, the new primary can grant the same lock again. Redlock spreads acquisition across 5 independent masters and requires a majority of 3 to grant, surviving the loss of up to 2 nodes — at the cost of 5× the round trips and a correctness model that Martin Kleppmann’s widely cited critique argues is unsafe under GC pauses or clock skew without additional enforcement. This is broader context from Distributed Coordination & Locking Strategies applied to one concrete choice: which Redis topology should grant your lock.
Prerequisites: a working understanding of distributed lock acquisition patterns, Python 3 with redis-py and redlock-py installed, and — for the Redlock path — 5 independent Redis instances across distinct failure domains.
Decision path
Comparison
| Dimension | Single-instance Redis | Redlock (5-node quorum) |
|---|---|---|
| Nodes required | 1 (plus optional replica) | 5 independent masters, no shared storage |
| Acquisition latency | Sub-millisecond, one round trip | 3-5 round trips in parallel, bounded by the slowest reachable node |
| Availability on node loss | 0 lock capacity until failover completes and replicates | Full capacity with up to 2 of 5 nodes down (quorum of 3 still met) |
| Failure mode | Async replication gap lets a failover grant the same lock twice | Requires simultaneous majority failure or correlated clock/GC fault to double-grant |
| Clock dependency | None for acquisition; TTL is relative | Validity window subtracts elapsed time and a drift factor from TTL — sensitive to wall-clock skew |
| Operational cost | One instance to run, monitor, and patch | 5 instances across separate failure domains, each independently monitored |
| Correctness without fencing tokens | Not safe for financially significant operations | Not safe either — per Kleppmann’s critique, quorum does not prevent a paused holder from resuming with a since-superseded lock |
| Acceptable use cases | Cache-warming dedup, non-billing webhook processing, best-effort retries | Payment processing, saga orchestration steps, any operation where duplicate execution is a financial or safety incident |
The quorum only buys you tolerance to node loss and network partitions among the lock nodes themselves. It does not buy you tolerance to a process pause on the client holding the lock — a GC pause or scheduler stall on the application host can still let a stale holder wake up after its lease was reassigned, on either topology. That gap is closed only by enforcing a fencing token at the resource, not by adding more lock nodes.
Step-by-step implementation
Step 1 — Determine failure tolerance before choosing a topology
Ask: if this lock is granted twice during a failover window measured in hundreds of milliseconds, what happens downstream? If the answer is “a duplicate cache refresh” or “a webhook processed twice into an already-idempotent handler,” single-instance locking is proportionate. If the answer is “a payment captured twice” or “a saga step runs concurrently,” move to Redlock — and still enforce fencing tokens.
Step 2 — Implement single-instance locking (Python, redis-py)
import redis
import uuid
r = redis.Redis(host="redis-primary", port=6379, decode_responses=True)
def acquire(resource: str, ttl_ms: int = 4000) -> str | None:
token = str(uuid.uuid4())
acquired = r.set(f"lock:{resource}", token, px=ttl_ms, nx=True)
return token if acquired else None
RELEASE_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
def release(resource: str, token: str) -> bool:
result = r.eval(RELEASE_SCRIPT, 1, f"lock:{resource}", token)
return result == 1
A TTL of 3-5 seconds is a reasonable default for synchronous request-scoped locks. Never rely on a bare DEL — the atomic ownership-checked release above prevents a slow holder from deleting a lock a faster successor already acquired.
Step 3 — Provision Redlock across five independent nodes (Python, redlock-py)
from redlock import Redlock
dlm = Redlock([
{"host": "redis1", "port": 6379, "db": 0},
{"host": "redis2", "port": 6379, "db": 0},
{"host": "redis3", "port": 6379, "db": 0},
{"host": "redis4", "port": 6379, "db": 0},
{"host": "redis5", "port": 6379, "db": 0},
])
def acquire_redlock(resource: str, ttl_ms: int = 600):
lock = dlm.lock(f"dedup:{resource}", ttl_ms)
return lock # False if quorum of 3 was not reached within the retry budget
def release_redlock(lock):
dlm.unlock(lock)
Set TTL_ms = max_processing_time_ms + p99_network_latency_ms + 200. For a payment call averaging 300 ms with 50 ms p99 Redis latency, 600 ms is a safe starting point — the same formula used in implementing Redlock for high-availability deduplication, which covers the full quorum acquisition lifecycle, watchdog renewal, and Go/Node.js/Java clients in depth.
Step 4 — Attach a fencing token regardless of topology
Both paths need a monotonically increasing token checked at the resource. For single-instance Redis, derive it from INCR; for Redlock, use the lock library’s internal validity token if it exposes one, or maintain your own INCR counter alongside the quorum lock:
def acquire_with_fence(resource: str, ttl_ms: int = 4000) -> tuple[str, int] | None:
fencing_token = r.incr(f"fence:{resource}")
lock_token = acquire(resource, ttl_ms)
return (lock_token, fencing_token) if lock_token else None
Enforce it downstream with the same compare-and-reject pattern covered in handling stale locks: UPDATE ... SET fencing_token = $1 WHERE resource_id = $2 AND fencing_token < $1.
Step 5 — Choose TTL and retry budgets per topology
Single-instance: keep TTL short (3-5s) since there is no quorum overhead to absorb; retry on nil with jittered backoff per exponential backoff without overlapping retries. Redlock: budget for 3 acquisition attempts with a 200 ms retry delay and 100 ms jitter, matching the distributed lock acquisition patterns guidance on TTL/heartbeat alignment.
Verification and testing
Reproduce the single-instance failover gap:
redis-cli -h redis-primary SET lock:order-88 token-A PX 4000 NX
# Force a failover before replication completes (test/staging only):
redis-cli -h redis-primary DEBUG SLEEP 2
redis-cli -h redis-replica GET lock:order-88
# If the key is absent on the promoted replica, a second SET NX succeeds — duplicate grant reproduced
Verify Redlock quorum tolerance:
docker stop redis4 redis5
python -c "from redlock import Redlock; d = Redlock([...]); print(bool(d.lock('dedup:test', 600)))"
# Expected: True — quorum of 3 of 5 still reachable
docker stop redis3
python -c "from redlock import Redlock; d = Redlock([...]); print(bool(d.lock('dedup:test', 600)))"
# Expected: False — only 2 of 5 reachable, quorum lost
Verify fencing rejection on both topologies:
psql -c "UPDATE orders SET fencing_token = 50 WHERE order_id = 'ord_1';"
psql -c "UPDATE orders SET status='processing', fencing_token=42 \
WHERE order_id='ord_1' AND fencing_token < 42;"
# Expected: UPDATE 0 — stale token correctly rejected regardless of which lock granted it
Failure scenarios and debugging
| Failure Scenario | Remediation Steps | Observability Hooks |
|---|---|---|
| Single-instance primary fails over before the lock key replicates; new primary grants the same lock again | Enforce a fencing token check at the resource so the second grant’s write is rejected even though the lock layer double-granted. If duplicates are financially significant, move to Redlock or an etcd lease. | redis_failover_events_total (Counter); replication_lag_ms (Gauge, alert if > 100); duplicate_grant_detected_total at the resource fencing check |
| Redlock quorum flaps between 3-of-5 and 2-of-5 during a partial network partition | Route to a circuit breaker that falls back to a database unique-constraint check while quorum is degraded. Alert rather than retrying indefinitely against a flapping quorum. | lock_quorum_success_rate (Gauge, alert if < 99%); nodes_reached field on every acquisition log line |
| GC pause on the client host exceeds the Redlock validity window on either topology; holder resumes believing it still owns the lock | Fencing token rejection at the resource is the only reliable defense — reducing TTL further does not close a GC-pause gap. Track p99 GC pause and compare against configured TTL headroom. | stale_fencing_token_rejections_total (Counter); JVM/Go GC pause histogram; OTel span attribute lock.fencing_token |
| Clock skew between Redlock nodes shrinks the effective validity window below the actual processing time | Run chronyd with maxpoll 6 on all 5 nodes; reject acquisition if any node’s TIME command reports more than 50 ms drift from the client’s monotonic clock. |
redis_clock_drift_ms (Gauge per node, alert if > 50); validity_window_negative_total (Counter) |
| Team assumes Redlock alone provides linearizable correctness and skips the resource-side fencing check | Treat fencing token enforcement as a mandatory step in code review for any new lock integration, not an optional hardening pass. Document the Kleppmann critique in the runbook so the caveat survives team turnover. | Code review checklist item; fencing_token field presence check in structured acquisition logs (alert if null) |
SRE / observability checklist
lock_acquisition_latency_ms— Histogram (p50/p95/p99), tagged bytopology(single|redlock). Redlock p99 should track the slowest reachable node, not the fastest.lock_quorum_success_rate— Gauge, Redlock only, rolling 5-minute window. Alert if < 99%.replication_lag_ms— Gauge, single-instance only. A rising lag directly widens the failover duplicate-grant window; alert if > 100 ms.stale_fencing_token_rejections_total— Counter at the resource layer, both topologies. Non-zero is expected occasionally; a sustained rate increase signals TTL or clock drift trouble, not the fencing check itself failing.redis_clock_drift_ms— Gauge per node, Redlock only. Alert if > 50 ms on any node.- Structured log fields on every acquisition —
topology,resource,fencing_token,nodes_reached(Redlock) orreplica_promoted(single-instance),ttl_ms.
Related
- Distributed Lock Acquisition Patterns — parent page covering the full acquisition pattern landscape and fencing token requirements.
- Implementing Redlock for High-Availability Deduplication — full quorum acquisition runbook with Go, Node.js, and Java clients and watchdog renewal.
- Lock Timeout & Lease Management — TTL sizing and renewal cadence guidance that applies to both topologies covered here.
- Handling Stale Locks in Distributed Systems — the resource-side fencing token check that neither topology can safely omit.
- Optimistic vs Pessimistic Concurrency Control — an alternative to locking entirely when contention is low and retries are cheap.