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.
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.
The basic lock
distributed-locking.md.txt · The Basic LockA 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
keytotokenonly if absent (NX), with ams-millisecond TTL (PX). - OK / nil
OKmeans this caller acquired the lock;nilmeans 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.
Why NX, PX, and a token
distributed-locking.md.txt · Why These Options MatterEach 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.
The non-atomic acquire is a race
distributed-locking.md.txt · Common MistakesThe 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.
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.
The applied form — EchoMQ leases, it does not mutex
echo_mq/jobs.ex:283 · claim/3EchoMQ 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.
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.
@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.
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.
Recap — one command, or a race
next → R2.02.2A 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.
References
sources · relatedSources
- Redis — Distributed Locks — the single-instance lock, the one-command acquire, and where Redlock begins.
- Valkey — SET — the
NXandPXoptions 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
- R2.02 · Distributed lockingthe module hub
- R2.02.2 · Fencing tokensthe attempts fence; next
- R2.01 · Atomic updateswhy the claim is one Lua EVAL
- /echomqthe EchoMQ protocol · the worker-side lock plane