SET key value NX PX as the atomic lock primitive, UUID lock values to prevent accidental release, lock extension with conditional PEXPIRE, the critical GC-pause failure mode, and why distributed locks need fencing tokens.
A-1 — Single-Instance Locking: SET NX PX and Lock Correctness
Who this module is for: You need to ensure only one process at a time executes a critical section — a payment deduction, a job processing step, a cache recompute. This module covers the correct Redis single-instance lock primitive, the mistakes that make naive implementations unsafe, and the fundamental limitation that requires fencing tokens for true correctness.
The Lock Primitive: SET NX PX
A Redis distributed lock uses a single key with three properties:
- Existence — the key exists means the lock is held
- Identity — the key's value identifies the lock holder (prevents accidental release)
- Expiry — the key has a TTL so it auto-releases if the holder crashes
The atomic primitive that satisfies all three in a single command:
SET lock:resource "lock-value" NX PX 30000
NX— only set if the key does Not eXist (acquire only if nobody holds the lock)PX 30000— expire in 30,000 milliseconds (auto-release if holder crashes)
Returns OK if the lock was acquired, nil if already held by another client.
Why this must be a single command: A non-atomic acquire would be:
# Wrong (race condition):
EXISTS lock:resource → 0 (not held)
SET lock:resource "holder" → sets the lock
EXPIRE lock:resource 30000 → sets expiry
Between EXISTS and SET, another client can acquire the lock. Between SET and EXPIRE, a crash leaves a permanent lock. The SET key value NX PX single command eliminates both races.
Lock Value: UUID for Identity
The lock value must uniquely identify the holder. Use a cryptographically random UUID:
import { randomUUID } from 'crypto';
const lockValue = randomUUID(); // e.g., "f47ac10b-58cc-4372-a567-0e02b2c3d479"
await redis.set('lock:payment:1001', lockValue, 'NX', 'PX', 30000);
Why identity matters: Without a unique value, any client can release any lock:
# Wrong (no identity):
Client A: SET lock:payment "" → acquires lock
# ... lock expires while A is slow ...
Client B: SET lock:payment "" → acquires lock (A's lock expired)
Client A: DEL lock:payment → releases B's lock! A doesn't know it lost the lock.
With a UUID, releasing the lock requires presenting the same UUID that was set:
// Safe release via Lua (atomic check-and-delete)
const releaseScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
async function releaseLock(key: string, lockValue: string): Promise<boolean> {
const result = await redis.eval(releaseScript, 1, key, lockValue);
return result === 1;
}
The Lua script atomically checks that the current lock value matches before deleting — if the lock expired and was acquired by another client, the check fails and we do not release their lock.
Full Lock Implementation
import { randomUUID } from 'crypto';
import Redis from 'ioredis';
const redis = new Redis();
const RELEASE_SCRIPT = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
interface Lock {
release: () => Promise<boolean>;
extend: (ttlMs: number) => Promise<boolean>;
}
async function acquireLock(
resource: string,
ttlMs: number,
retries = 3,
retryDelayMs = 100
): Promise<Lock | null> {
const key = `lock:${resource}`;
const value = randomUUID();
for (let attempt = 0; attempt <= retries; attempt++) {
const result = await redis.set(key, value, 'NX', 'PX', ttlMs);
if (result === 'OK') {
return {
release: () => releaseLock(key, value),
extend: (newTtlMs) => extendLock(key, value, newTtlMs),
};
}
if (attempt < retries) {
await new Promise(r => setTimeout(r, retryDelayMs + Math.random() * retryDelayMs));
}
}
return null; // Could not acquire after all retries
}
async function releaseLock(key: string, value: string): Promise<boolean> {
const result = await redis.eval(RELEASE_SCRIPT, 1, key, value);
return result === 1;
}
async function extendLock(key: string, value: string, newTtlMs: number): Promise<boolean> {
const extendScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
`;
const result = await redis.eval(extendScript, 1, key, value, String(newTtlMs));
return result === 1;
}
// Usage:
async function processPayment(paymentId: string) {
const lock = await acquireLock(`payment:${paymentId}`, 30000);
if (!lock) {
throw new Error('Could not acquire lock — payment already in progress');
}
try {
await executePaymentLogic(paymentId);
} finally {
await lock.release();
}
}Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion