Distributed Locks Reality Check: When Redis Redlock Is the Wrong Tool

Distributed Locks Reality Check: When Redis Redlock Is the Wrong Tool

Redis Redlock looks like mutual exclusion until a paused GC, NTP jump, or minority quorum hands two clients the same “lock.” The unfair advantage is treating correctness as fencing tokens + conditional writes on the system of record—not as a TTL flag in a cache.

⚡ TL;DR: Use Redis locks only for best-effort coordination; for money/inventory use DynamoDB conditional puts or Postgres FOR UPDATE with version tokens; always check a monotonic fence on every write. Pair with Lease-Based ECS Leadership and Exactly-Once Illusions.

Failure modes Redlock papers over

Client A acquires lock (TTL 30s)
Client A pauses 40s (GC / laptop sleep / SIGSTOP)
TTL expires → Client B acquires lock
Client A resumes and writes anyway  ← double writer

Clock skew between Redis nodes makes multi-key Redlock worse, not better, under partitions.

Mechanism Safety under pause Good for
Redis SET NX + TTL Weak Soft dedupe, cache stampede
Redlock (multi-node) Still weak Same — not safety-critical
DynamoDB conditional + version Strong Inventory, payments
DB row lock + fence Strong Single-region source of truth

Fencing tokens that actually work

# ✅ DynamoDB: lock row + monotonic fence; writers must present fence
import boto3
from botocore.exceptions import ClientError

ddb = boto3.resource("dynamodb").Table("leases")

def acquire(resource_id: str, owner: str, ttl_epoch: int) -> int:
    # Returns fencing token (version)
    try:
        out = ddb.update_item(
            Key={"pk": resource_id},
            UpdateExpression="SET #o=:o, #t=:t, #v=if_not_exists(#v,:z)+ :one",
            ConditionExpression="attribute_not_exists(pk) OR #t < :now",
            ExpressionAttributeNames={"#o": "owner", "#t": "ttl", "#v": "fence"},
            ExpressionAttributeValues={
                ":o": owner, ":t": ttl_epoch, ":now": ttl_epoch - 1,
                ":z": 0, ":one": 1,
            },
            ReturnValues="ALL_NEW",
        )
        return int(out["Attributes"]["fence"])
    except ClientError as e:
        if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
            raise RuntimeError("lock held") from e
        raise

def write_with_fence(item_table, pk: str, fence: int, body: dict) -> None:
    # ✅ reject stale owners even if they still think they hold Redis lock
    item_table.put_item(
        Item={**body, "pk": pk, "fence": fence},
        ConditionExpression="attribute_not_exists(pk) OR fence < :f",
        ExpressionAttributeValues={":f": fence},
    )
# ❌ Classic Redlock-only “safety”
# redis.set(name, owner, nx=True, ex=30)
# do_payment()  # no fence on the ledger write
# redis.delete(name)

When a soft lock is fine

Cache rebuilds, single-flight computations, and “only one metrics scraper” can use Redis NX+TTL. Document that a double run is harmless. If double run is not harmless, you do not have a soft lock problem—you have a storage correctness problem.

Closing checklist

✅ Dos
– ✅ Put fencing tokens on every authoritative write
– ✅ Prefer conditional writes on DynamoDB/Postgres
– ✅ Treat Redis locks as hints / single-flight only
– ✅ Bound critical sections; renew leases explicitly
– ✅ Chaos-test process pauses longer than TTL

❌ Don’ts
– ❌ Don’t use Redlock for payments or inventory alone
– ❌ Don’t trust wall-clock TTL under GC pauses
– ❌ Don’t delete locks without ownership check (GET then DEL race)
– ❌ Don’t stretch lock TTL to “be safe” without fencing

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply