Skip to main content

How to Build a Distributed Lock in Redis

When multiple processes need exclusive access to a shared resource, you need a lock. In a single process, a mutex works fine. Across multiple servers, you need a distributed lock: a way for any process to claim exclusive access, do its work, and release the lock so others can proceed. Common use cases include preventing duplicate cron job execution, serializing access to external APIs with strict rate limits, and ensuring only one worker processes a specific task.

Redis is well-suited for distributed locking because it is fast, supports atomic operations, and provides key expiration to handle crashed clients. This guide covers three approaches: a simple lock for basic cases, adding automatic expiration to prevent deadlocks, and extending locks for long-running operations that need to hold the lock beyond the initial timeout.

Which Redis data types we will use

String is used in three ways in this implementation:

  1. As the lock itself where the key's existence indicates the lock is held. The SET command with the NX (not exists) option atomically creates the key only if it is not already there. Whoever successfully sets the key owns the lock, and DEL releases it.
  2. With expiration to prevent deadlocks. The SET command accepts EX (seconds) or PX (milliseconds) options to set a TTL at creation time. If a process crashes while holding the lock, the key expires automatically.
  3. With a unique token value to enable safe release. Store a UUID as the lock value, then verify the token matches before deleting. This prevents one process from accidentally releasing another process's lock.

The beauty of Redis locks is their simplicity. One key, one value, one expiration. The atomic operations guarantee that exactly one process holds the lock at any time. The expiration guarantees that locks cannot be held forever.

The simplest lock: claim, work, release

The simplest distributed lock uses Redis's SET command with the NX option, which only sets the key if it does not already exist. If the key is set, you have the lock. If not, someone else has it. When you are done, delete the key to release the lock.

This approach has a critical flaw: if your process crashes while holding the lock, the key remains forever and no one else can acquire it. This is a deadlock. For throwaway scripts or situations where you can manually intervene, basic locking might be acceptable. For production systems, always use expiration as described in the next section.

# Try to acquire the lock
SET lock:payments NX
> OK (lock acquired)

# Another client tries to acquire the same lock
SET lock:payments NX
> (nil) (lock not acquired, already held)

# Release the lock when done
DEL lock:payments
> 1 (lock released)

# Now another client can acquire it
SET lock:payments NX
> OK (lock acquired)

Adding automatic expiration to prevent deadlocks

Production locks must expire automatically. If a process crashes, gets killed, or loses network connectivity, the lock should eventually release itself so other processes can continue. Redis makes this easy: SET accepts both NX (only if not exists) and EX or PX (expiration in seconds or milliseconds) in a single atomic command.

The lock value matters too. Instead of a static value like 1, store a unique identifier for the lock holder, typically a UUID or a combination of hostname and process ID. This prevents a dangerous bug: process A acquires a lock, takes too long, the lock expires, process B acquires it, then process A finishes and deletes B's lock. By checking that the value matches before deleting, each process only releases its own lock.

The release operation must be atomic: check the value and delete in one step. A Lua script handles this. If you check with GET and then DEL separately, another process could acquire the lock between those two commands.

# Acquire lock with 30 second expiry and unique token
SET lock:payments "worker-abc-123" NX EX 30
> OK (lock acquired, expires in 30 seconds)

# Check remaining time on the lock
TTL lock:payments
> 28 (28 seconds remaining)

# Another client tries to acquire - fails because lock exists
SET lock:payments "worker-xyz-456" NX EX 30
> (nil) (lock not acquired)

# Release lock only if we own it (Lua script for atomicity)
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:payments "worker-abc-123"
> 1 (lock released)

# Wrong token cannot release someone else's lock
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:payments "wrong-token"
> 0 (not released, token mismatch)

Extending locks for long-running operations

Sometimes you do not know how long an operation will take. A batch job might process 100 items quickly or 10,000 items slowly. Setting a very long TTL defeats the purpose of expiration. Setting a short TTL risks expiring mid-operation. The solution is to start with a reasonable TTL and extend it periodically while work continues.

Lock extension works by resetting the TTL while the operation runs, but only if you still own the lock. A background thread or goroutine checks periodically, say every 10 seconds for a 30-second lock, and extends the TTL. If the extension fails because the lock expired or was taken by someone else, the operation should stop.

This pattern requires careful implementation. The extension must be atomic: check ownership and reset TTL in one operation. If your process stalls completely and cannot run the extension thread, the lock expires as intended. This is correct behavior. The tricky part is detecting that you have lost the lock and stopping your work gracefully.

# Acquire lock with 30 second expiry
SET lock:batch-job "worker-abc-123" NX EX 30
> OK

# Extend lock by resetting TTL (only if we own it)
# Lua script: extend only if token matches
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end" 1 lock:batch-job "worker-abc-123" 30
> 1 (extended)

# Check new TTL
TTL lock:batch-job
> 30 (reset to 30 seconds)

# Extension fails if token doesn't match
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end" 1 lock:batch-job "wrong-token" 30
> 0 (not extended)

Choosing an approach

Use basic locking only for development, testing, or scripts where you can manually recover from a stuck lock. Never use it in production without expiration.

Use locking with automatic expiry for most production use cases. It handles crashes gracefully, prevents deadlocks, and the unique token ensures safe release. Set the TTL longer than your expected operation time with some margin, but not so long that a crashed process blocks others for minutes.

Use lock extension when operation duration is unpredictable. Batch jobs, data migrations, and external API calls with variable latency all benefit from this pattern. The added complexity is worth it when you cannot predict how long the lock needs to be held.

For high-availability requirements beyond what a single Redis instance provides, consider the Redlock algorithm, which coordinates locks across multiple independent Redis nodes. This adds significant complexity and is only necessary when a single Redis instance is not reliable enough for your use case.