jonnify·redis /redis-patterns/coordination/distributed-locking/set-nx-px

R2.02.1 · Distributed locking — dive 1

Acquire in one command.

A lock is acquired with a single SET: one holder, a mortal lease, and a token to release it safely. SET resource:lock <token> NX PX 30000 reads-and-writes in one atomic step on the server.

Grounding: the generic mutex taught as the contrast; the applied form is the lease-scored active set — echo/apps/echo_mq/jobs.ex:283, claim/3's @claim script, no SET NX PX mutex.

Four workers issue the acquire at once — one OK, the rest nil. Hover a worker to light its path and read its result.
worker w1 worker w2 worker w3 worker w4 resource:lockabsent OK nil nil nil
readout · Four workers each hold a token and issue SET resource:lock <token> NX PX 30000 at once. The key is absent for exactly one, who acquires the lock; the rest get nil. Hover a worker to read its outcome.

Static verdicts — w1 → finds the key absent, writes token t1, OK (the sole holder) · w2, w3, w4 → the key is now present, nil. Holders of the lock: 1. Double-acquired: no.

§1

The basic lock

distributed-locking.md.txt · The Basic Lock

A lock is the SET command with three options at once. One command carries the whole acquire. It sets the key only if it does not exist (NX), attaches a 30-second expiration (PX 30000), and stores a unique token that identifies this acquisition. If SET returns OK, the lock was acquired; if it returns nil, another client holds the lock.

SET resource:lock <token> NX PX 30000   # OK = acquired · nil = held

That single reply is the whole admission test. There is no separate “is the lock free” read to race against the write: SET … NX reads-and-writes in one atomic step on the server, so of many callers issuing it in the same instant, exactly one finds the absent key and writes, and the rest find the now-present key and get nil.

SET key token NX PX ms
Acquire: set key to token only if absent (NX), with a ms-millisecond TTL (PX).
OK / nil
OK means this caller acquired the lock; nil means another client holds it.
token
A random value (typically a UUID) naming this acquisition, minted at acquire and checked at release.
the lease
The lock held under its TTL: it lapses on its own, so a crashed holder is eventually evicted.

This dive acquires the lock and mints its token. The token-checked release — and why a blind DEL can hit the wrong client's lock — is the next dive, R2.02.2 · Fencing tokens.

§2

Why NX, PX, and a token

distributed-locking.md.txt · Why These Options Matter

Each option closes a distinct failure, and all three are needed. Drop any one and a specific hazard returns.

NX (only if not exists) is the mutual exclusion. Without it, a plain SET would overwrite a held lock, and two clients would both proceed as the owner.

PX (expiration) is the deadlock guard. The expiry makes the lock a lease: it lapses on its own, so a crashed holder is eventually evicted and the resource frees.

The token is a random value naming this acquisition. It is needed for safe release: a release must delete the lock only when this token still matches, so a caller never deletes a lock that has since expired and been re-acquired by someone else.

NX without PX deadlocks on a crash. PX without NX lets two holders in. A lock without a token is a delete waiting to hit the wrong client. The one command carries all three.

A correct acquire is not one property but three, fused into a single command: admit one holder, make the lock mortal, name the acquisition.

§3

The non-atomic acquire is a race

distributed-locking.md.txt · Common Mistakes

The common mistake is to acquire the lock and set its expiry as two separate commands. The two look equivalent to the combined SET, but they leave a gap.

SETNX lock <token>    # if the process crashes here...
EXPIRE lock 30      # ...this never runs — the lock has no expiry

SETNX sets the key if absent; EXPIRE attaches the TTL afterward. Between the two there is a window. If the process crashes after SETNX succeeds but before EXPIRE runs — or the connection drops, or the node is killed — the lock exists with no expiration. Nothing will ever evict it. The next caller's SETNX fails forever, and the resource is deadlocked: the exact outcome PX was meant to prevent, reintroduced by splitting the acquire in two. Hover the two acquires below; the readout reports whether the lock ends up with an expiry, and whether a crash deadlocks the resource.

Atomic acquire vs SETNX-then-EXPIRE, with a crash in the gap. Hover an acquire to light its timeline and read the outcome.
time → one cmd two cmd SET lock token NX PX 30000 one command → key + TTL together, no gap to crash into SETNX lockEXPIRE 30 crash in the gap → lock with no expiry, a deadlock
readout · Hover an acquire. The one command sets key and TTL together; the two-command form has a gap a crash can land in.

Static verdicts — SET … NX PX → one command, lock always has an expiry, no deadlock · SETNX then EXPIRE → a crash between the two leaves a lock with no expiry, a deadlock.

Two commands have a gap; one command does not. A crash between SETNX and EXPIRE leaves a lock with no expiry — a deadlock — which is precisely what the combined SET … NX PX rules out.

§4

The applied form — EchoMQ leases, it does not mutex

echo_mq/jobs.ex:283 · claim/3

EchoMQ has no SET NX PX mutex and no lock-manager. Its mutual exclusion is the claim lease: the same “one holder, mortal hold” idea, expressed as a sorted-set entry instead of a string key. EchoMQ.Jobs.claim/3 runs the @claim script, which in one atomic step ZPOPMINs the oldest pending id and leases it. The lease is not a PX on a string key — it is the job's score in emq:{q}:active, computed now + lease_ms from the server's own clock (redis.call('TIME') inside the script).

# echo/apps/echo_mq/lib/echo_mq/jobs.ex — the @claim script (jobs.ex:126), one atomic lease.
local popped = redis.call('ZPOPMIN', KEYS[1])              # KEYS[1] = emq:{q}:pending
if #popped == 0 then return {} end
local id = popped[1]
local jk = ARGV[1] .. id
local att = redis.call('HINCRBY', jk, 'attempts', 1)             # the fence, +1 per grant
redis.call('HSET', jk, 'state', 'active')
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('ZADD', KEYS[2], now + tonumber(ARGV[2]), id)         # KEYS[2] = emq:{q}:active, scored now + lease_ms
return {id, redis.call('HGET', jk, 'payload'), att}

One worker holds the job because ZPOPMIN removes it from pending atomically; the lease bounds the hold because a later Jobs.reap returns any expired score to pending. The SET … NX admission becomes the atomic ZPOPMIN; the PX expiry becomes the server-clock score. Because the whole transition is one EVAL, the SETNX+EXPIRE gap cannot exist here.

Notes on ValkeyThe lease deadline is read inside the script with TIME, not the worker's clock, so a worker's clock drift cannot misprice a lease — valkey.io/commands/zadd.

the pattern

Acquire a lock in one command: SET resource:lock <token> NX PX 30000. NX admits one holder, PX makes the lock mortal, the token names the acquisition for a safe release.

the claim lease in EchoMQ

@claim pops the oldest pending id with ZPOPMIN and leases it into emq:{q}:active scored now + lease_ms on the server clock — one holder, a mortal lease, no string mutex.

door · the EchoMQ course

The worker-side lock plane

This dive cites the claim lease as proof the acquire is real. The worker-side lock-subkey plane — emq:{q}:job:<id>:lock, the §6 subkey a long-running worker holds, which remove_job already refuses to clear — is taught in the dedicated EchoMQ course. Keeping the lease fresh is the next dive, R2.02.3 · Lease renewal.

§5

Recap — one command, or a race

next → R2.02.2

A lock acquires in one command: SET resource:lock <token> NX PX 30000. NX admits one holder; PX makes the lock mortal; the random token names this acquisition for a safe release. Split the acquire into SETNX then EXPIRE and a crash in the gap leaves a lock with no expiry — a deadlock. EchoMQ does not run a mutex at all: its @claim script leases the job into emq:{q}:active scored on the server clock, the atomic equivalent of one holder under a mortal TTL. The next dive makes the fence explicit with attempts.

§6

References

sources · related

Sources

  • Redis — Distributed Locks — the single-instance lock, the one-command acquire, and where Redlock begins.
  • Valkey — SET — the NX and PX options behind the atomic acquire, and why they are one command.
  • Valkey — ZADD — the sorted-set write the claim lease uses to score a job by its lease deadline.

Related in this course