Why your Redis lock is probably not safe

Redis Course · lesson 18 of 19 · 7 min read

The correct implementation, its real guarantees, and when it is not enough.

Open this lesson in the learning hub

Key points

  • The naive lock - SETNX then DEL - has two independent bugs. Without a TTL a crashed holder locks the resource forever; with a TTL, a slow holder can have its lock expire and then delete someone else lock.
  • The fix for the second is a fencing token: store a unique value with the lock and release it only if the value still matches, atomically in a Lua script.
  • Even correct, this is a lease, not a lock. If the holder pauses - a GC pause, a scheduling delay, a network partition - the lease expires and a second holder starts while the first still believes it holds it.
  • That is a real failure, not a theoretical one. A 200ms GC pause against a 5-second lease is fine; a 6-second pause is not, and nothing in Redis prevents it.
  • Redlock across several independent nodes is widely debated, and the debate matters: it does not make the lease safe under process pauses, only under node failure.
  • The honest conclusion: use a Redis lock for efficiency - preventing duplicate work - and never for correctness. If double execution would corrupt data, you need a fencing token the resource itself checks, or a database transaction.

Example

# BROKEN: no TTL. A crashed holder locks it forever.
> SETNX lock:order:42 1

# STILL BROKEN: TTL, but anyone can delete anyone lock.
> SET lock:order:42 1 NX EX 30
#   ... holder is slow, lock expires, B acquires ...
> DEL lock:order:42          # A deletes B lock, and now two holders

# CORRECT acquire - unique token, atomic with the TTL:
> SET lock:order:42 "a1b2c3-unique-token" NX EX 30

# CORRECT release - compare and delete, atomically in Lua:
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0                 -- not ours any more; do NOT delete
end

// In Java:
String token = UUID.randomUUID().toString();
boolean acquired = Boolean.TRUE.equals(redis.opsForValue()
        .setIfAbsent("lock:order:42", token, Duration.ofSeconds(30)));

if (acquired) {
    try {
        doWork();
    } finally {
        // compare-and-delete, never a bare DEL
        redis.execute(RELEASE_SCRIPT, List.of("lock:order:42"), token);
    }
}

/*
 * WHAT THIS STILL DOES NOT PREVENT:
 *
 *   t=0   A acquires, lease 30s
 *   t=5   A pauses (GC, CPU starvation, VM migration)
 *   t=30  lease EXPIRES - Redis has no idea A is alive
 *   t=31  B acquires the same lock and starts working
 *   t=35  A resumes, still believing it holds the lock
 *         -> TWO holders, and A has no way to know
 *
 * A lease cannot be made safe against an arbitrary pause. So:
 *
 *   EFFICIENCY (fine)     avoid two workers doing the same import
 *   CORRECTNESS (not)     "only one process may debit this account"
 *
 * For correctness, the RESOURCE must reject the stale writer - a
 * monotonic fencing token it stores and compares, or a database
 * transaction with optimistic locking.
 */

A Redis lock is a lease that a process pause can silently break - use it to avoid duplicate work, never to protect correctness.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the Redis Course course, and every lesson in it is listed on the Redis Course contents page.