Note: Code examples in this article use Go. The concepts apply to any language.

Why lock at all?⌗

Imagine two users trying to withdraw money from the same bank account simultaneously. Without proper synchronization, both might read the same balance, deduct their amounts independently, and write back—resulting in a lost update. One withdrawal effectively disappears.

Two users read the same balance concurrently and both write back, losing one withdrawal

This is a classic race condition: the correctness of the outcome depends on the timing of operations.

Types of Read Anomalies⌗

When concurrent operations access shared data without proper isolation, several anomalies can occur:

AnomalyDescription
Dirty ReadReading uncommitted data from another transaction that might roll back
Non-repeatable ReadSame query returns different values because another transaction modified the row
Phantom ReadSame query returns different rows because another transaction inserted/deleted records
Stale ReadReading outdated data due to caching or replication lag

These anomalies are why databases offer different isolation levels, each trading off between consistency and performance.

The Traditional Solution: Locks⌗

In a single-process environment, we have well-established primitives:

  • Mutex (Mutual Exclusion): Only one thread can hold the lock at a time. Simple, effective, but can cause contention.
  • Semaphore: Allows up to N threads to access a resource concurrently. Useful for rate limiting or pooled resources.
var mu sync.Mutex

func withdraw(amount int) error {
    mu.Lock()
    defer mu.Unlock()
    
    if balance < amount {
        return errors.New("insufficient funds")
    }
    balance -= amount
    return nil
}

This works beautifully… on a single machine.

But What About Distributed Systems?⌗

Modern applications don’t run on a single server. They’re deployed across multiple instances in multiple geographic regions. Your sync.Mutex becomes useless because:

  1. No shared memory: Each instance has its own memory space
  2. No shared OS: There’s no kernel to arbitrate between processes
  3. Network is unreliable: Messages can be delayed, duplicated, or lost

We need a distributed lock, a coordination mechanism that works across the network. This is where Redis comes in.

Redis as a Distributed Lock⌗

The Basic Idea⌗

Redis can serve as a shared coordination point that all application instances can access. Since Redis is single-threaded, commands execute serially, one after another. This gives us the highest isolation level, serializable: no dirty reads, phantom reads, or other anomalies. Each operation sees a consistent view of the data. Serialization has a cost, but Redis is an in-memory database, making it orders of magnitude faster than traditional SQL databases that must write to disk for durability.

This property makes Redis an excellent choice for coordination primitives like locks.

The strategy is simple: use a Redis key as the lock. If the key exists, the lock is held. If it doesn’t, the lock is available.

Problem 1: Acquiring the Lock⌗

The naive approach is to check if the lock exists, then set it:

func acquire(key string) bool {
    exists := redis.Get(key)
    if exists == nil {
        redis.Set(key, "locked")
        return true
    }
    return false
}

This has a fatal flaw. Two clients can both read that the key doesn’t exist, then both set it, each thinking they acquired the lock:

Check-then-set is not atomic, so both clients end up believing they hold the lock

The solution is to use SET NX (Set if Not eXists), which performs the check-and-set atomically:

func acquire(key string, ttl time.Duration) bool {
    result := redis.SetNX(key, "locked", ttl)
    return result
}

Or use the full SET command with options:

SET lock_key "locked" NX EX 30

This command succeeds only if the key does not exist. The check-and-set is atomic, so it avoids the race condition above.

Problem 2: Deadlocks⌗

What happens if a client acquires the lock and then crashes before releasing it?

A lock with no expiry deadlocks the system when the client holding it crashes

Client A acquires the lock and crashes. Client B tries to acquire it but fails because the key still exists. B waits, but the lock is never released. Even if A recovers, it cannot help: the crash erased its in-memory state, including whether it owned the lock.

The solution is to always set a TTL (time-to-live) on the lock:

SET lock_key "locked" NX EX 30

EX 30 means the key automatically expires after 30 seconds. Even if the client crashes, the lock will eventually be released.

Warning: Choose your TTL carefully!

  • Too short: Lock expires while you’re still working, causing safety violations
  • Too long: System waits unnecessarily if a client crashes

For long-running operations, implement a watchdog that periodically extends the TTL while work is in progress. For example, Process A acquires a lock with a 10-second TTL, but the operation takes 25 seconds. Without a watchdog, the lock expires at 10 seconds, and another process could acquire it while A is still working. A watchdog runs in a background goroutine, extending the TTL (say, every 3 seconds) as long as the process is alive and working. If the process crashes, the watchdog dies with it, and the lock naturally expires.

Problem 3: Releasing the Lock⌗

The naive approach is to just delete the key:

func release(key string) {
    redis.Del(key)
}

This seems fine until you consider: what if you’re deleting someone else’s lock?

A lock expires mid-work, a second client acquires it, and the first client's DEL releases a lock it no longer owns

In the diagram above, Client A acquires a lock with a 10-second TTL, but takes too long to complete its work. The lock expires, and Client B acquires it. When Client A finally finishes, it blindly calls DEL, deleting Client B’s lock. Now Client B thinks it still holds the lock, but it’s gone, and a third client could swoop in.

The solution is to use a unique ownership token (or lock token) for each acquisition and delete the key only if its value matches:

func acquire(key string, token string, ttl time.Duration) bool {
    result := redis.SetNX(key, token, ttl)
    return result
}

func release(key string, token string) bool {
    // Must be atomic! Use a Lua script
    script := `
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
    `
    result := redis.Eval(script, []string{key}, token)
    return result == 1
}

The Lua script ensures the check-and-delete is atomic. Redis executes Lua scripts without interleaving other commands.

This token identifies the lock acquisition. It does not provide the ordering of a fencing token, which must increase with each acquisition and be checked by the protected resource to reject stale operations.

Why not use separate GET and DEL commands? The problem is the same as with GET followed by SET during acquisition. Between checking the token with GET and releasing the lock with DEL, another client could acquire it. You would then delete their lock. The Lua script makes the check and deletion a single atomic operation.

Implementation⌗

I’ve published a complete Go implementation of single-instance Redis locking at github.com/trviph/redlock. The Lock type provides:

  • Acquire / TryAcquire: Atomic lock acquisition with SET NX EX and automatic UUID ownership-token generation
  • AcquireWithFencing: Supply your own ownership token for idempotent retries
  • Release: Safe release via a Lua script that validates the ownership token
  • Extend / TryExtend: Refresh the TTL without releasing the lock
  • AcquireOrExtend: Acquire the lock if available, or extend it if you already hold it
  • Watch: Built-in watchdog for automatic TTL renewal

Despite the name AcquireWithFencing, the library uses UUID ownership tokens by default. Supplying a token does not by itself provide fencing; that requires a monotonically increasing token and enforcement by the protected resource.

The library also includes DistributedLock, which implements the full Redlock algorithm across multiple independent Redis instances with quorum-based consensus and clock drift validation. We’ll explore that in the next post.

References⌗