Redis

Distributed Locking with Redis to Orchestrate Access for a Shared Resource

June 9, 2026
10 min read

When multiple servers compete to update the same record, send the same notification, or run the same scheduled job — race conditions happen. Redis distributed locks enforce mutual exclusion across any number of processes or machines. Let's look at exactly how that works at the Redis command level.

Acquiring the Lock: SET NX PX

The entire acquisition is a single Redis command:

SET lock:inventory:item42 "550e8400-e29b-41d4-a716-446655440000" NX PX 30000

Three flags do all the work:

  • NX — only set if the key does not exist. This is the mutex. Redis is single-threaded, so only one caller wins; everyone else gets nil.
  • PX 30000 — expire in 30,000 milliseconds. If the lock holder crashes, the key disappears automatically. No deadlock.
  • The UUID value — identity. The holder stores a unique token, not a static string like "locked". This becomes critical at release time.

Note: PX (milliseconds) is preferred over EX (seconds) because it gives finer-grained control over validity windows — especially important when multiple Redis nodes are involved.

Releasing the Lock: Atomic Lua Check-and-Delete

A naive DEL lock:inventory:item42 is dangerous. Consider: Worker A's TTL expires while it's still running. Redis auto-deletes the key. Worker B acquires the lock. Worker A finishes and calls DEL — it just deleted Worker B's lock.

The fix is an atomic check-and-delete via a Lua script. Redis executes Lua atomically — no other command can run between the GET and the DEL:

-- Unlock script (executed atomically by Redis)
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

Only the process holding the matching UUID can delete the key. A late worker whose token no longer matches gets 0 — its lock was already expired and re-acquired by someone else.

Why Lua?

Redis does have a native transaction mechanism — MULTI/EXEC. But it can't do what we need here. Inside a MULTI block, commands are queued and sent as a batch; you cannot branch on the result of one command to decide whether to run the next. There is no "if the GET returned this value, then DEL" inside a transaction.

The naive alternative is two separate round trips:

# NOT safe — another client can act between these two commands
GET lock:inventory:item42      # → "our-uuid"
DEL lock:inventory:item42      # ← a different worker may have taken the lock here

Between the GET and the DEL there is a window — however small — where another process can acquire the lock. On a loaded system or a slow network, that window is real.

Lua closes it. Redis is single-threaded: while a Lua script is executing, no other client command runs. The GET and the DEL are fused into one indivisible operation from the perspective of every other client. There is no window. That is the only reason Lua is used here — not for its language features, but for the atomicity guarantee Redis gives to any script it runs.

Script Caching: SCRIPT LOAD and EVALSHA

Sending the full Lua source on every unlock call wastes bandwidth. Redis has a smarter path: preload the script once, then call it by its SHA1 hash.

# Preload once at startup — Redis stores the script server-side
SCRIPT LOAD "if redis.call(\"get\",KEYS[1]) == ARGV[1] then ..."
# → "3c78c2a4f5f3c2f39c28b7a91e2d5c3d6e7f8a9b"  (SHA1 hash)

# Every subsequent call uses the hash, not the full script
EVALSHA 3c78c2a4f5f3c2f39c28b7a91e2d5c3d6e7f8a9b 1 lock:inventory:item42 "uuid"

The hash is deterministic — the same script always produces the same SHA1. A robust implementation verifies this at startup: compute the expected hash locally, compare it to what Redis returned for SCRIPT LOAD, and reject a mismatch. This catches silent script corruption.

Extending a Lock: PEXPIRE with the GT Flag

Sometimes a critical section runs longer than anticipated. You need to extend the TTL without releasing and re-acquiring. The extend Lua script uses PEXPIRE with the GT flag:

-- Extend script
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("pexpire", KEYS[1], ARGV[2], "GT")
else
  return redis.error_reply("NOT LOCKED")
end

GT means "only update the expiry if the new TTL is greater than the current one." This prevents a race where two threads both try to extend — the second call cannot accidentally shorten the TTL that the first call just set.

If the GET returns a different UUID, Redis returns an error. Your process doesn't own the lock anymore — it expired and someone else took it. The right response is to abort your work, not blindly extend a lock you no longer hold.

The Redlock Algorithm: Quorum and Validity

A single Redis node is a single point of failure. If it goes down after granting your lock but before you use it, you have no lock. Redlock solves this by running the same SET NX PX against N independent Redis masters (typically 3 or 5) and requiring a majority:

quorum = floor(N / 2) + 1   # e.g. 3 nodes → quorum = 2

But winning the majority vote is not enough on its own. The algorithm also measures how long the acquisition round took and subtracts a drift budget:

drift    = ttl * drift_factor + 0.002   # e.g. 1% of TTL + 2ms fixed
validity = ttl - elapsed_time - drift

drift_factor accounts for clock skew between nodes and network jitter. The fixed 0.002 (2ms) covers the minimum overhead of a round-trip even on a local network.

The lock is accepted only when both conditions hold:

  • successes >= quorum — a majority of nodes confirmed the lock
  • validity > 0 — there is still meaningful time left before expiry

If validity is negative it means by the time you finished talking to all the nodes, the TTL you set on the first node may have already expired. The lock is rejected and retried — even though you technically "won" the vote.

Retry with Exponential Backoff

When acquisition fails, spinning immediately creates a thundering herd — all contending processes hammering Redis at the same instant. The safe pattern is exponential backoff with a cap:

wait_ms = min(retry_interval_max, retry_interval_base * attempts²)

# Example with base=300ms, max=3000ms:
# attempt 1 → min(3000, 300 * 1) = 300ms
# attempt 2 → min(3000, 300 * 4) = 1200ms
# attempt 3 → min(3000, 300 * 9) = 2700ms

The squared growth means processes spread out quickly. The cap prevents unbounded waits. After max_retry attempts the caller gets :error — it's up to the application to decide whether to surface that as a retry later or a user-visible failure.

Putting It Together with a Library

All of the above — SET NX PX, EVALSHA unlock, quorum voting, validity window, exponential backoff — is what a solid Redlock implementation wires together. In Elixir, lyokato/redlock handles all of it. The surface API collapses to three lines:

case Redlock.transaction("lock:order:#{order_id}", 10, fn ->
  deduct_inventory(order_id)
  charge_payment(order_id)
  {:ok, :done}
end) do
  {:ok, :done}            -> :ok
  {:error, :lock_failure} -> {:error, :try_again}
end

transaction/3 acquires across all configured nodes, verifies quorum and validity, runs your callback, and releases via EVALSHA — even if the callback raises. The machinery above is invisible, but understanding it tells you exactly what guarantees you're getting and where the edge cases live.