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:
- As the lock itself where the key's existence indicates the lock is held. The
SETcommand with theNX(not exists) option atomically creates the key only if it is not already there. Whoever successfully sets the key owns the lock, andDELreleases it. - With expiration to prevent deadlocks. The
SETcommand acceptsEX(seconds) orPX(milliseconds) options to set a TTL at creation time. If a process crashes while holding the lock, the key expires automatically. - 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.
- Redis
- Python
- TypeScript
- Go
# 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)
def acquire_lock(client, lock_name: str) -> bool:
"""Try to acquire a lock. Returns True if successful."""
result = client.set(f'lock:{lock_name}', '1', nx=True)
return result is True
def release_lock(client, lock_name: str) -> bool:
"""Release a lock. Returns True if the lock was released."""
return client.delete(f'lock:{lock_name}') == 1
# Usage
if acquire_lock(client, 'payments'):
try:
process_payments()
finally:
release_lock(client, 'payments')
else:
print('Could not acquire lock, another process is working')
async function acquireLock(client: Redis, lockName: string): Promise<boolean> {
const result = await client.set(`lock:${lockName}`, '1', 'NX');
return result === 'OK';
}
async function releaseLock(client: Redis, lockName: string): Promise<boolean> {
const result = await client.del(`lock:${lockName}`);
return result === 1;
}
// Usage
if (await acquireLock(client, 'payments')) {
try {
await processPayments();
} finally {
await releaseLock(client, 'payments');
}
} else {
console.log('Could not acquire lock, another process is working');
}
func acquireLock(ctx context.Context, client *redis.Client, lockName string) (bool, error) {
result, err := client.SetNX(ctx, "lock:"+lockName, "1", 0).Result()
return result, err
}
func releaseLock(ctx context.Context, client *redis.Client, lockName string) (bool, error) {
result, err := client.Del(ctx, "lock:"+lockName).Result()
return result == 1, err
}
// Usage
if acquired, _ := acquireLock(ctx, client, "payments"); acquired {
defer releaseLock(ctx, client, "payments")
processPayments()
} else {
log.Println("Could not acquire lock, another process is working")
}
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.
- Redis
- Python
- TypeScript
- Go
# 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)
import uuid
# Lua script: release lock only if token matches
RELEASE_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
def acquire_lock(client, lock_name: str, ttl_seconds: int = 30) -> str | None:
"""Try to acquire a lock. Returns the lock token if successful, None otherwise."""
token = str(uuid.uuid4())
acquired = client.set(f'lock:{lock_name}', token, nx=True, ex=ttl_seconds)
return token if acquired else None
def release_lock(client, lock_name: str, token: str) -> bool:
"""Release a lock if we own it. Returns True if released."""
release = client.register_script(RELEASE_SCRIPT)
result = release(keys=[f'lock:{lock_name}'], args=[token])
return result == 1
# Usage
token = acquire_lock(client, 'payments', ttl_seconds=30)
if token:
try:
process_payments()
finally:
release_lock(client, 'payments', token)
else:
print('Could not acquire lock')
import { v4 as uuidv4 } from 'uuid';
// Lua script: release lock only if token matches
const RELEASE_SCRIPT = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`;
async function acquireLock(
client: Redis,
lockName: string,
ttlSeconds: number = 30
): Promise<string | null> {
const token = uuidv4();
const result = await client.set(`lock:${lockName}`, token, 'NX', 'EX', ttlSeconds);
return result === 'OK' ? token : null;
}
async function releaseLock(
client: Redis,
lockName: string,
token: string
): Promise<boolean> {
const result = await client.eval(RELEASE_SCRIPT, 1, `lock:${lockName}`, token);
return result === 1;
}
// Usage
const token = await acquireLock(client, 'payments', 30);
if (token) {
try {
await processPayments();
} finally {
await releaseLock(client, 'payments', token);
}
} else {
console.log('Could not acquire lock');
}
import "github.com/google/uuid"
// Lua script: release lock only if token matches
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`)
func acquireLock(ctx context.Context, client *redis.Client, lockName string, ttl time.Duration) (string, bool) {
token := uuid.New().String()
result, err := client.SetNX(ctx, "lock:"+lockName, token, ttl).Result()
if err != nil || !result {
return "", false
}
return token, true
}
func releaseLock(ctx context.Context, client *redis.Client, lockName string, token string) bool {
result, err := releaseScript.Run(ctx, client, []string{"lock:" + lockName}, token).Int()
return err == nil && result == 1
}
// Usage
token, acquired := acquireLock(ctx, client, "payments", 30*time.Second)
if acquired {
defer releaseLock(ctx, client, "payments", token)
processPayments()
} else {
log.Println("Could not acquire lock")
}
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.
- Redis
- Python
- TypeScript
- Go
# 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)
import threading
import uuid
RELEASE_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
EXTEND_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end
"""
class DistributedLock:
def __init__(self, client, name: str, ttl: int = 30, extend_interval: int = 10):
self.client = client
self.name = f'lock:{name}'
self.ttl = ttl
self.extend_interval = extend_interval
self.token = None
self._extend_timer = None
self._lock_lost = False
def acquire(self) -> bool:
self.token = str(uuid.uuid4())
acquired = self.client.set(self.name, self.token, nx=True, ex=self.ttl)
if acquired:
self._start_extension()
return True
return False
def release(self) -> bool:
self._stop_extension()
if not self.token:
return False
release = self.client.register_script(RELEASE_SCRIPT)
result = release(keys=[self.name], args=[self.token])
self.token = None
return result == 1
def _extend(self):
if not self.token:
return
extend = self.client.register_script(EXTEND_SCRIPT)
result = extend(keys=[self.name], args=[self.token, self.ttl])
if result == 1:
self._start_extension()
else:
self._lock_lost = True
def _start_extension(self):
self._extend_timer = threading.Timer(self.extend_interval, self._extend)
self._extend_timer.daemon = True
self._extend_timer.start()
def _stop_extension(self):
if self._extend_timer:
self._extend_timer.cancel()
@property
def is_held(self) -> bool:
return self.token is not None and not self._lock_lost
# Usage
lock = DistributedLock(client, 'batch-job', ttl=30, extend_interval=10)
if lock.acquire():
try:
for item in items:
if not lock.is_held:
raise Exception('Lost lock during processing')
process(item)
finally:
lock.release()
import { v4 as uuidv4 } from 'uuid';
const RELEASE_SCRIPT = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`;
const EXTEND_SCRIPT = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end
`;
class DistributedLock {
private token: string | null = null;
private extendTimer: NodeJS.Timeout | null = null;
private lockLost = false;
constructor(
private client: Redis,
private name: string,
private ttl: number = 30,
private extendInterval: number = 10
) {}
async acquire(): Promise<boolean> {
this.token = uuidv4();
const result = await this.client.set(
`lock:${this.name}`, this.token, 'NX', 'EX', this.ttl
);
if (result === 'OK') {
this.startExtension();
return true;
}
this.token = null;
return false;
}
async release(): Promise<boolean> {
this.stopExtension();
if (!this.token) return false;
const result = await this.client.eval(
RELEASE_SCRIPT, 1, `lock:${this.name}`, this.token
);
this.token = null;
return result === 1;
}
get isHeld(): boolean {
return this.token !== null && !this.lockLost;
}
private startExtension() {
this.extendTimer = setTimeout(() => this.extend(), this.extendInterval * 1000);
}
private stopExtension() {
if (this.extendTimer) clearTimeout(this.extendTimer);
}
private async extend() {
if (!this.token) return;
const result = await this.client.eval(
EXTEND_SCRIPT, 1, `lock:${this.name}`, this.token, this.ttl
);
if (result === 1) {
this.startExtension();
} else {
this.lockLost = true;
}
}
}
// Usage
const lock = new DistributedLock(client, 'batch-job', 30, 10);
if (await lock.acquire()) {
try {
for (const item of items) {
if (!lock.isHeld) throw new Error('Lost lock during processing');
await process(item);
}
} finally {
await lock.release();
}
}
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`)
var extendScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end
`)
type DistributedLock struct {
client *redis.Client
name string
token string
ttl time.Duration
extendInterval time.Duration
stopChan chan struct{}
lockLost atomic.Bool
}
func NewDistributedLock(client *redis.Client, name string, ttl, extendInterval time.Duration) *DistributedLock {
return &DistributedLock{
client: client,
name: "lock:" + name,
ttl: ttl,
extendInterval: extendInterval,
stopChan: make(chan struct{}),
}
}
func (l *DistributedLock) Acquire(ctx context.Context) bool {
l.token = uuid.New().String()
result, err := l.client.SetNX(ctx, l.name, l.token, l.ttl).Result()
if err != nil || !result {
return false
}
go l.extendLoop(ctx)
return true
}
func (l *DistributedLock) Release(ctx context.Context) bool {
close(l.stopChan)
if l.token == "" {
return false
}
result, _ := releaseScript.Run(ctx, l.client, []string{l.name}, l.token).Int()
l.token = ""
return result == 1
}
func (l *DistributedLock) IsHeld() bool {
return l.token != "" && !l.lockLost.Load()
}
func (l *DistributedLock) extendLoop(ctx context.Context) {
ticker := time.NewTicker(l.extendInterval)
defer ticker.Stop()
for {
select {
case <-l.stopChan:
return
case <-ticker.C:
result, _ := extendScript.Run(ctx, l.client,
[]string{l.name}, l.token, int(l.ttl.Seconds())).Int()
if result != 1 {
l.lockLost.Store(true)
return
}
}
}
}
// Usage
lock := NewDistributedLock(client, "batch-job", 30*time.Second, 10*time.Second)
if lock.Acquire(ctx) {
defer lock.Release(ctx)
for _, item := range items {
if !lock.IsHeld() {
log.Fatal("Lost lock during processing")
}
process(item)
}
}
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.