Distributed Locks with Redis

A distributed lock is a way to guarantee that only one process, out of many running on different machines, can do a particular piece of work at a time. Redis is a natural fit for building one because every single command it runs is atomic — no other client’s command can interleave in the middle of a SET or EVAL call — so a shared Redis instance can act as a fast referee for who currently “owns” a resource. This lesson builds a correct, safe distributed lock from Redis primitives, then walks through the mistakes that quietly turn a lock into a source of bugs.

Overview / How Distributed Locks Work in Redis

Distributed locks solve a specific class of problem: two or more independent processes (separate servers, separate worker instances, separate cron schedulers) that must not do the same exclusive work at the same time. Examples include two application servers both trying to run the same nightly batch job, two workers both trying to process the same queued order, or two instances trying to regenerate the same expensive cache entry simultaneously.

The core primitive is a single command: SET key value NX PX milliseconds. The NX flag means “only set this key if it does not already exist.” If the key is already there, SET does nothing and returns (nil) instead of overwriting it — that (nil) is exactly how a client learns “someone else already holds this lock.” The PX flag attaches a millisecond time-to-live to the key as part of that same command, so the lock and its expiration are created in one indivisible step.

That atomicity matters because Redis is single-threaded: it executes one command to completion on its main thread before starting the next, so no other client’s command can ever run in the middle of your SET ... NX PX. This is precisely why SET key value NX PX ttl is safe for acquiring a lock, while doing a GET to check for a lock followed by a separate SET in your application code is not — another client’s command can slip in between those two round trips and both clients end up believing they hold the lock.

The value you store under the lock key should not be a constant like "locked" — it should be a unique token generated fresh for each acquisition attempt (a UUID, or a client id plus a random suffix). The token lets the holder prove ownership later: before releasing, a client checks that the value stored under the key still matches its own token, rather than blindly deleting whatever is there. That distinction is the difference between a lock that’s actually safe and one that only looks safe.

A lock built on a single Redis instance is a practical, good-enough tool for avoiding duplicate work in most applications — but it is not a hard safety guarantee, because a single instance is a single point of failure and network delays can make timing assumptions unreliable. Redis’s own documentation describes a stronger multi-instance algorithm called Redlock, which requires acquiring the lock on a majority of independent Redis instances; it is more involved to implement correctly and its guarantees have been debated among distributed-systems practitioners, so it’s out of scope for this lesson’s single-instance examples. For most caching, deduplication, and “don’t run this job twice” use cases, the pattern below is sufficient.

Internally, a lock key is stored exactly like any other string key, with an absolute expiration timestamp attached to it. Redis does not run a constant background timer per key; instead it removes expired keys two ways: lazily, when a client accesses the key and Redis notices the current time is past the stored expiry, and actively, via a background cycle that periodically samples a handful of keys carrying a TTL and evicts the ones that have expired. Either path means a crashed lock holder’s lock will eventually disappear on its own — which is exactly the safety net a TTL-based lock depends on.

Syntax

SET key value NX PX milliseconds
SET key value NX EX seconds
GET key
TTL key
PTTL key
DEL key
EVAL script numkeys key [key ...] arg [arg ...]
Command / Option Meaning Time complexity
SET key value NX ... Create the key only if it does not already exist — this is the “acquire” step; fails silently (returns (nil)) if the lock is already held O(1)
PX milliseconds / EX seconds Attach a TTL to the key atomically, in the same command as the create — avoids the gap between “create the lock” and “give it an expiry” O(1)
GET key Read the current token stored under the lock, or (nil) if no one holds it O(1)
TTL key / PTTL key Remaining lifetime in seconds / milliseconds; -1 means no TTL is set, -2 means the key does not exist O(1)
DEL key Force-remove a key — never call this directly to release a lock you didn’t verify you own O(1) for a single key
EVAL script numkeys key arg Run a Lua script atomically on the server — used here to check the token and delete (or renew) in one indivisible step O(1) for the small scripts used in this lesson

Examples

Example 1: acquiring and releasing a simple lock

SET lock:order:1001 "token-abc123" NX PX 30000
TTL lock:order:1001
GET lock:order:1001
DEL lock:order:1001
TTL lock:order:1001

Output:

OK
(integer) 30
"token-abc123"
(integer) 1
(integer) -2

The first SET succeeds because the key didn’t exist, creating it with a 30-second TTL in one atomic step — that’s the lock being acquired. TTL confirms about 30 seconds remain. After the work is done, DEL removes the lock, and a final TTL returns -2, confirming the key is gone entirely (not just expired-but-present).

Example 2: a second client fails to acquire a held lock

SET lock:order:1002 "clientA-token" NX PX 30000
SET lock:order:1002 "clientB-token" NX PX 30000
GET lock:order:1002

Output:

OK
(nil)
"clientA-token"

Client A’s SET ... NX succeeds and creates the lock. When client B tries the identical command, Redis sees the key already exists and refuses to overwrite it, returning (nil) — that (nil) is client B’s signal to back off, wait, or fail fast. The final GET proves the lock still belongs to client A; B’s attempt changed nothing.

Example 3: safely releasing a lock by verifying ownership

SET lock:order:1003 "clientA-9f8e" NX PX 30000
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:1003 "clientB-wrong-token"
TTL lock:order:1003
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:1003 "clientA-9f8e"
GET lock:order:1003

Output:

OK
(integer) 0
(integer) 30
(integer) 1
(nil)

Client A acquires the lock with its own token. When something tries to release it using the wrong token ("clientB-wrong-token"), the Lua script compares the stored value against the supplied token, finds no match, and returns 0 without deleting anything — TTL right after confirms the lock is still fully intact. Only when the real owner’s token is supplied does the script’s GET-then-DEL succeed, returning 1, and the final GET shows the key is gone. Because the whole comparison and deletion happen inside one EVAL call, no other command can run on Redis’s single thread between the check and the delete — the release is just as atomic as the acquire.

How It Works Step by Step

1. The client generates a unique token locally (a UUID or similar) before attempting to acquire anything.
2. The client sends SET lock:key token NX PX 30000.
3. Because Redis is single-threaded, this command runs to completion with nothing interleaved: Redis checks whether the key exists and, if not, creates it with the token as its value and attaches a 30000ms expiry — all as one indivisible operation.
4. If the key already existed, SET returns (nil) and creates nothing; the caller now knows another client holds the lock and should retry later (ideally with a small random delay) or give up.
5. While the lock exists, Redis refuses any other SET ... NX on that same key — that’s the mutual exclusion guarantee.
6. When the client finishes its critical section, it runs the check-and-delete Lua script rather than a bare DEL. Inside the script, Redis performs the GET comparison and the DEL as one atomic unit, so no other client can grab the lock and have it deleted out from under them by the original owner’s late release.
7. If the client crashes or the network drops before releasing, no manual cleanup is required — the TTL guarantees the lock disappears on its own, via lazy expiration on next access or the active expire cycle sampling it in the background, whichever happens first.

Common Mistakes

Mistake 1: acquiring with two separate commands instead of one atomic SET

SETNX lock:job:5 "worker-1"
EXPIRE lock:job:5 30

Output:

(integer) 1
(integer) 1

Both commands succeed here, which makes this look fine — but SETNX and EXPIRE are two separate round trips, not one atomic operation. If the process crashes (or the connection drops) after SETNX succeeds but before EXPIRE runs, the lock is created with no TTL at all and will never expire, permanently blocking every other client. Use the single-command form instead: SET lock:job:5 "worker-1" NX PX 30000, which creates the key and its TTL as one atomic step with no window for a crash to land in between.

Mistake 2: releasing with a bare DEL instead of checking ownership

SET lock:order:2001 "clientA-token" NX PX 30000
DEL lock:order:2001

Output:

OK
(integer) 1

This looks harmless in isolation, but imagine client A’s job runs slower than expected: its 30-second TTL expires, client B then legitimately acquires the same key with a new token, and only afterwards does client A’s slow process finally reach its cleanup code and call plain DEL — deleting client B’s active lock, not its own. The fix is the ownership-checked release from Example 3: an EVAL script that only deletes the key if its value still matches the caller’s own token.

Mistake 3: acquiring a lock with no expiry at all

SET lock:job:9 "worker-2" NX
TTL lock:job:9

Output:

OK
(integer) -1

TTL returning -1 means the key exists but has no expiration set. If worker-2 crashes before calling DEL, this lock is held forever and every other process is permanently blocked from ever acquiring lock:job:9 again. Every lock acquisition should include PX or EX in the same SET call — never rely on remembering to expire it separately, and never omit it.

Mistake 4: assuming EXPIRE did something without checking its reply

EXPIRE lock:job:missing 30
TTL lock:job:missing

Output:

(integer) 0
(integer) -2

EXPIRE on a key that doesn’t exist is not an error — it simply returns (integer) 0 and does nothing. If a lock-renewal routine calls EXPIRE (or the token-checked PEXPIRE variant) without inspecting the return value, it can silently believe it extended a lock that has already expired or been deleted, and keep running its “critical section” logic while another client has legitimately acquired the same lock. Always check that a renewal call returns 1, not 0, before assuming you still hold the lock.

Best Practices

  • Always acquire with a single atomic SET key token NX PX ttl call — never split acquisition into a separate create-then-expire pair of commands.
  • Never create a lock without a TTL; a crashed holder with no expiry means a permanently stuck lock.
  • Use a unique, unpredictable token per acquisition attempt (a UUID is fine), not a fixed string like "locked", so ownership can be verified before release.
  • Release with an atomic check-and-delete Lua script via EVAL, never a bare DEL.
  • Keep TTLs as short as the job realistically needs; if a job might run long, renew the TTL periodically from the holder using the same token-checked pattern (compare the token, then PEXPIRE) rather than picking one very large TTL up front.
  • Treat a single-instance Redis lock as “good enough to avoid duplicate work,” not as a strict correctness guarantee for safety-critical operations like moving money — those need a majority-quorum approach (Redlock) or a purpose-built coordination service.
  • When auditing which locks are currently held in production, use SCAN with MATCH lock:* rather than KEYS lock:*KEYS blocks the single-threaded server for the entire scan on a large keyspace, while SCAN walks it incrementally without blocking other clients.
  • Add small random jitter to retry delays after a failed acquisition, so a crowd of waiting clients doesn’t retry in lockstep and hammer Redis the instant a lock is released.

Practice Exercises

  • Exercise 1: Two servers both run a nightly cleanup job at midnight, and only one should actually do the work. Write the redis-cli command the winning server would use to acquire lock:nightly-cleanup with a 5-minute TTL, and describe what the losing server’s identical command would return.
  • Exercise 2: A worker holds lock:video:encode:77 with token "worker-7-9c21" and expects the job to take about 20 seconds, but it occasionally runs closer to 45. Using PTTL and an EVAL script, sketch how you’d safely extend the lock’s expiry from inside the worker without risking extending a lock it no longer owns. What must the script check before calling PEXPIRE?
  • Exercise 3: You want to count how many locks are currently active in a production keyspace with millions of keys, matching the pattern lock:*. Which command should you use, which should you deliberately avoid, and why?

Summary

  • A distributed lock in Redis is acquired with a single atomic SET key token NX PX ttl command — NX makes the acquire conditional, and the inline TTL prevents a permanently stuck lock.
  • Redis’s single-threaded execution model is what makes SET ... NX PX and EVAL scripts safe for locking: no other command can interleave in the middle of either.
  • Store a unique token as the lock’s value, not a constant, so you can verify ownership before releasing.
  • Release locks with an atomic check-and-delete Lua script, never a bare DEL — a bare DEL can delete a lock some other client has since legitimately acquired.
  • TTLs are the safety net for crashed clients; never acquire a lock without one, and always check the return value when renewing.
  • A single Redis instance’s lock is good enough for avoiding duplicate work in most applications, but is not a strict distributed-safety guarantee; use SCAN, not KEYS, when auditing locks in a large production keyspace.