All posts

Implementing Distributed Locks with Redis: Go, NestJS, Redisson & Fencing Tokens

When your backend runs on a single server, an in-memory mutex like Go's sync.Mutex or Java's synchronized is enough to prevent two threads from running the same critical section at once. Once you scale out to multiple server instances, however, in-memory mutexes know nothing about other servers. Coordinating mutual exclusion across a fleet of stateless servers using a shared Redis instance is known as a Redis Distributed Lock. This article covers the canonical single-instance Redis lock implementation (atomic `SET ... NX PX` and Lua token-checked unlock), language examples in Go, NestJS, and Spring Boot (Redisson), and—most importantly—the edge cases where distributed locks fail without fencing tokens.

1. Intuitive Analogy: The Study Room Key with a Name Tag & Timer

Imagine borrowing a study room key from a library desk. To keep the system safe, two rules apply: first, the room has an automatic 2-hour lease timer (TTL) so that if a student walks out of the building with the key in their pocket, the room doesn't stay locked forever. Second, your name tag (unique random token) is attached to the reservation so that if your 2-hour lease expires and Student B checks into the room, you cannot walk back up to the desk 5 minutes late and accidentally cancel Student B's active session. Every correct Redis lock relies on both primitives: a TTL lease (`PX`) and a unique caller token.

2. Concrete Scenario: Preventing Duplicate External Fulfillment Calls

Suppose three worker servers consume payment-completed events and call an external 3rd-party logistics API to dispatch a shipment for `order_id = 1001`. If a message broker delivers the event twice or a user triggers a webhook retry simultaneously, two workers could call the shipping API at the same time. Acquiring a Redis lock on `lock:order:1001` ensures only one worker proceeds—though as we will see in Section 5, the downstream database or API should still enforce idempotency as the final safety net.

3. Correct Acquisition (`SET NX PX`) & Safe Release (Lua Script)

The official Redis documentation specifies acquiring a lock in a single atomic command:

SET lock:order:1001 <unique-random-token> NX PX 30000
  • NX (Not Exists): Succeeds only if the key does not currently exist in Redis.
  • PX 30000: Sets an automatic 30,000 ms (30-second) time-to-live so a crashed server never leaves a permanent deadlock.
  • Unique Token (UUID v4): Generated fresh per lock acquisition attempt to prove ownership when unlocking.

Never split lock acquisition into a legacy `SETNX` call followed by a separate `EXPIRE` call—if the application process crashes in the network gap between those two commands, the key remains in Redis forever without a TTL.

Similarly, never release a lock with a plain `DEL` command! If Server A acquires a 30-second lock, hits a slow network pause or GC pause taking 35 seconds, its lock expires at 30 seconds and Server B acquires the lock. When Server A finishes at 35 seconds and calls `DEL`, it deletes Server B's active lock! Instead, release the lock atomically via a Lua script that deletes the key only if its current value still matches your unique token (or via `DELEX key IFEQ token` in Redis 8.4+):

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

4. Production Implementations in Go, NestJS, and Spring Boot

Go (`go-redis/v9`)

var unlockScript = redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0`)

func WithLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration, job func() error) error {
	token := uuid.NewString()
	ok, err := rdb.SetNX(ctx, key, token, ttl).Result() // Emits SET key token NX PX
	if err != nil {
		return err
	}
	if !ok {
		return ErrLockBusy
	}
	defer unlockScript.Run(context.Background(), rdb, []string{key}, token)
	return job()
}

NestJS / TypeScript (`ioredis`)

const token = randomUUID(); // node:crypto
const ok = await this.redis.set(key, token, 'PX', ttlMs, 'NX');
if (ok !== 'OK') throw new ConflictException('Resource is currently locked');
try {
  return await job();
} finally {
  await this.redis.eval(UNLOCK_SCRIPT, 1, key, token);
}

Spring Boot / Kotlin (`Redisson`)

val lock = redissonClient.getLock("lock:order:$orderId")
if (!lock.tryLock(3, TimeUnit.SECONDS)) throw LockBusyException()
try {
    job()
} finally {
    if (lock.isHeldByCurrentThread) lock.unlock()
}

Redisson Watchdog nuance: When you call `lock.tryLock(waitTime, unit)` without specifying an explicit `leaseTime`, Redisson activates its background Watchdog timer—automatically renewing the lock's 30-second TTL every 10 seconds as long as the holding JVM thread is still alive. If you pass an explicit `leaseTime` (e.g., `tryLock(3, 10, TimeUnit.SECONDS)`), the Watchdog is disabled and the lock will forcibly expire after 10 seconds even if `job()` is still running!

5. What Redis Locks Cannot Guarantee (GC Pauses, Failover & Fencing Tokens)

Every TTL-based distributed lock has two fundamental distributed-systems limitations:

  1. Process Pauses Exceeding TTL: If Client 1 acquires a lock with a 10-second TTL, pauses for 12 seconds due to a JVM stop-the-world GC pause or OS scheduling delay, the lock expires and Client 2 acquires it. When Client 1 wakes up, both processes believe they hold the lock concurrently.
  2. Asynchronous Redis Primary-Replica Failover: Redis replication is asynchronous. If Client 1 acquires a lock on the Redis Primary and the Primary crashes a millisecond before replicating that key to its Replica, the promoted Replica will not have the key—allowing Client 2 to acquire the exact same lock.

As distributed systems researcher Martin Kleppmann demonstrated in his critique of Redlock, when true data correctness is required, you must pair your distributed lock with a monotonically increasing Fencing Token (such as an incrementing counter issued via Redis `INCR` or a database sequence) and verify that token at the storage layer:

UPDATE shipment
   SET status = 'REQUESTED', lock_token = :token
 WHERE order_id = :orderId AND lock_token < :token;

If Client 1 (holding `token = 33`) pauses and wakes up late after Client 2 (holding `token = 34`) has already updated the row, Client 1's `UPDATE ... WHERE lock_token < 33` matches zero rows and is safely rejected by the database!

Goal of LockingTypical ExampleRecommended Architecture
Efficiency (avoid duplicate work)Cache Stampede rebuild, report generationSingle-instance Redis lock (`SET NX PX`) is sufficient
Correctness (prevent data corruption)Wallet balance deduction, inventory reservationDB Row/Optimistic Locks, Idempotency Keys, or Fencing Tokens

6. Common Mistakes in Production

  • Releasing locks with plain `DEL` without checking the caller token: Causes cascading lock-stealing bugs under load.
  • Calling `SETNX` and `EXPIRE` as two separate commands: Leaves immortal locks if the client crashes between commands.
  • Using Redis locks when a simple DB row lock or unique constraint suffices: If your state already lives in PostgreSQL or MySQL, database row locks or DB advisory locks avoid cross-system failure modes altogether.

7. Summary

  • Always acquire Redis locks atomically with SET key token NX PX ttl.
  • Always release locks atomically using a token-checking Lua script (or Redis 8.4+ `DELEX`).
  • Treat Redis locks as an efficiency tool that prevents duplicate work 99.99% of the time—and enforce final data integrity at the database boundary using unique constraints, optimistic versions, or fencing tokens.

References