Redlock vs Single-Instance Redis Locks

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

Redlock vs single-instance decision flow Starting question: can you tolerate a rare duplicate lock grant during a failover window? If yes, use single-instance SET NX PX with a 3-5 second TTL. If no, check whether five independent Redis nodes across failure domains are available; if yes, use Redlock with a quorum of three; if no, use a strongly consistent coordinator such as etcd or a database unique constraint instead. Both Redis paths converge on a mandatory fencing token check at the protected resource. Can you tolerate a rare duplicate grant on failover? yes Single-instance SET NX PX TTL 3-5s, Lua-checked release no 5 independent Redis nodes across distinct failure domains? yes Redlock, quorum = 3 of 5 TTL = processing + p99 RTT + 200ms no Use a CP coordinator instead etcd lease, or a database unique constraint Both paths require: fencing token issued at acquisition, enforced at the protected resource Neither model is safe against GC pauses or clock skew without the fencing check

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

  1. lock_acquisition_latency_ms — Histogram (p50/p95/p99), tagged by topology (single | redlock). Redlock p99 should track the slowest reachable node, not the fastest.
  2. lock_quorum_success_rate — Gauge, Redlock only, rolling 5-minute window. Alert if < 99%.
  3. replication_lag_ms — Gauge, single-instance only. A rising lag directly widens the failover duplicate-grant window; alert if > 100 ms.
  4. 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.
  5. redis_clock_drift_ms — Gauge per node, Redlock only. Alert if > 50 ms on any node.
  6. Structured log fields on every acquisitiontopology, resource, fencing_token, nodes_reached (Redlock) or replica_promoted (single-instance), ttl_ms.