Distributed Lock with Redis - Single Instance
Table of Contents
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.
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:
| Anomaly | Description |
|---|---|
| Dirty Read | Reading uncommitted data from another transaction that might roll back |
| Non-repeatable Read | Same query returns different values because another transaction modified the row |
| Phantom Read | Same query returns different rows because another transaction inserted/deleted records |
| Stale Read | Reading 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:
- No shared memory: Each instance has its own memory space
- No shared OS: There’s no kernel to arbitrate between processes
- 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, all commands are executed serially, one after another. This gives us the highest isolation level, serializable. No dirty reads, no phantom reads, no anomalies, every operation sees a consistent view of the data. While serialization has a cost, 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:
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 using the full SET command with options:
SET lock_key "locked" NX EX 30
This command only succeeds if the key doesn’t exist, and it’s atomic, no race condition possible.
Problem 2: Deadlocks⌗
What happens if a client acquires the lock and then crashes before releasing it?
Client A acquires the lock, then crashes. Client B tries to acquire the lock but fails since the key still exists. B waits, but the lock is never released. Even if Client A eventually recovers, it can’t help, when it crashed, it lost all in-memory state, whether it is the owner of the lock or not.
The solution is to always set a TTL (time-to-live) on the lock:
SET lock_key "locked" NX EX 30
The 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?
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 token (fencing token) and only delete if it 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 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.
You might wonder: why not just use separate GET and DEL commands? The same reason as before, just like GET then SET failed for acquiring the lock, GET then DEL would fail here. Between your GET (checking the token) and DEL (releasing the lock), another client could acquire the lock, and you’d delete their lock instead of yours. The Lua script bundles both operations into a single atomic unit.
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 EXand automatic fencing token generation - AcquireWithFencing: Bring your own fencing token for idempotent retries
- Release: Safe release via Lua script that validates token ownership
- Extend / TryExtend: Refresh TTL without releasing the lock
- AcquireOrExtend: Acquire if available, or extend if you already hold it
- Watch: Built-in watchdog pattern for automatic TTL renewal
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⌗
- Distributed Locks with Redis — Official Redis documentation on distributed locking patterns