Distributed Locks in Go: Redis, PostgreSQL and the Problems You Need to Understand
Implementing distributed locks in Go with Redis and PostgreSQL advisory locks — lock ownership, TTLs, fencing tokens, and why a naive SET-based lock is unsafe.
The problem
A sync.Mutex solves a problem that only exists inside one process: two
goroutines racing to touch the same in-memory value. The moment a service
runs as more than one replica — behind a load balancer, or as several
worker instances pulling from the same queue — that problem reappears in a
form a mutex can't touch, because the two things racing are no longer
goroutines in one address space. They're separate processes, on separate
machines, each with its own memory, each perfectly capable of believing
it's the only one doing the work.
Rendering diagram…
Both instances run the exact same code. Both can reach the same resource — a scheduled job that must run exactly once, a batch export that must not be generated twice, a row that must not be updated by two processes at once. A mutex inside either process does nothing to stop the other process; they don't share memory, so they don't share the lock.
Why it matters
Without coordination, "run this job every hour" running on three replicas doesn't run once an hour — it runs up to three times an hour, simultaneously, each replica unaware of the other two. Depending on what the job does, that's wasted work at best (three identical emails sent) or corrupted state at worst (three processes each decrementing the same inventory count for the same sale). A distributed lock exists to make "only one of you should be doing this right now" enforceable across processes that have no other way of knowing about each other.
Why a naive SET-based lock is unsafe
The instinctive first implementation is a single Redis command:
SET lock:job-x "held" NXNX means "only set if it doesn't already exist," so this is at least
atomic as an acquire step. But without more thought, it's unsafe in a way
that isn't obvious until it fails in production.
No expiration is the first problem: if the process holding the lock crashes before deleting the key, the lock is held forever. Nothing ever runs again. The fix looks easy — add a TTL:
SET lock:job-x "held" NX PX 30000But a TTL introduces a second, much less obvious problem. Picture this timeline:
Rendering diagram…
The lock's TTL protects against a crashed holder, but a holder that's merely slow — a garbage collection pause, a stalled disk write, a momentary network hiccup — looks identical to Redis. The TTL expires, a second instance acquires the lock, and now two processes are simultaneously convinced they have exclusive access. The lock did its job exactly as designed; the design just doesn't account for "alive but slow" being a real, common state.
Fencing tokens
A TTL-based lock, by itself, can never fully close this gap — there is no TTL long enough to rule out an arbitrarily long pause, and a TTL short enough to fail over quickly is exactly the one likely to expire under a holder that's just briefly slow.
The actual fix isn't a smarter lock — it's making the protected resource
reject stale writes. Each successful lock acquisition returns a fencing
token: a number that increases every time the lock is acquired (a simple
counter works; Redis's INCR or a PostgreSQL sequence both provide one for
free). Every write to the protected resource must include the token it
acquired, and the resource itself remembers the highest token it has
already accepted — any write arriving with a lower token is rejected outright,
regardless of what the lock currently thinks.
In the timeline above: Instance A acquires the lock with token 7.
Instance B later acquires it with token 8. When Instance A finally
resumes and tries to write using token 7, the resource — which has
already accepted a write at token 8 from B — rejects it. Both processes
briefly believed they held the lock; only one was ever allowed to act.
This is also, roughly, what more elaborate schemes like Redlock try to
provide across multiple Redis instances — and why Redlock has drawn
real criticism (notably from Martin Kleppmann): without fencing at the
resource itself, no amount of cleverness at the locking layer alone
closes this specific gap.
Practical implementation
Redis: acquire with ownership, release safely
The lock value must be unique per holder — a random token, not just
"held" — so a holder can never accidentally release a lock that a
different instance now owns after its own lock expired and was
re-acquired elsewhere:
func AcquireLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (token string, ok bool, err error) {
token = uuid.NewString()
ok, err = rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return "", false, fmt.Errorf("acquire lock: %w", err)
}
return token, ok, nil
}
// releaseScript only deletes the key if its value still matches the token
// this holder was given — checking with GET first and deleting with a
// separate DEL is not safe, because another process could acquire the
// lock in the gap between the two commands. The comparison and the
// delete have to be one atomic operation.
const releaseScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
func ReleaseLock(ctx context.Context, rdb *redis.Client, key, token string) error {
res, err := rdb.Eval(ctx, releaseScript, []string{key}, token).Result()
if err != nil {
return fmt.Errorf("release lock: %w", err)
}
if res == int64(0) {
return fmt.Errorf("lock was not held by this token (expired or already released)")
}
return nil
}PostgreSQL: advisory locks
If the service already talks to PostgreSQL, an advisory lock needs no new infrastructure at all. Session-scoped locks are held until explicitly released or the connection closes:
func TryAcquireJobLock(ctx context.Context, conn *pgx.Conn, jobID int64) (bool, error) {
var acquired bool
err := conn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", jobID).Scan(&acquired)
if err != nil {
return false, fmt.Errorf("try advisory lock: %w", err)
}
return acquired, nil
}
func ReleaseJobLock(ctx context.Context, conn *pgx.Conn, jobID int64) error {
_, err := conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", jobID)
return err
}The meaningful advantage here: a session-scoped advisory lock is released automatically if the connection drops — a crashed process doesn't hold the lock forever the way a forgotten Redis TTL might, because PostgreSQL itself notices the connection is gone. The trade-off is that the lock ties up a dedicated connection for its entire duration, which competes with the connection pool sizing covered in PostgreSQL Connection Pooling in Go — an advisory lock held via a pooled connection that gets returned to the pool early will release unexpectedly, so advisory locks generally need their own dedicated connection, not one borrowed from the application's normal pool.
Network failures and split-brain
From the lock service's point of view, a holder that's crashed and a holder that's merely network-partitioned look identical: both stop renewing the lock. The partitioned instance, however, is still alive and may still believe it holds the lock and keep acting on that belief — this is exactly the scenario fencing tokens exist to catch, and it's worth internalizing that no lock implementation can distinguish "dead" from "unreachable" from the outside. Any design that assumes it can is the design that breaks during a real network partition, not during testing.
Deadlocks between multiple locks
Needing two locks at once introduces the classic ordering problem: process A acquires lock 1, then wants lock 2; process B acquires lock 2, then wants lock 1. Neither can proceed. The standard fix doesn't require anything clever — always acquire locks that could be held together in the same global order (sort resource IDs before locking them, for instance), so two processes contending for the same pair of locks always try to acquire them in the same sequence and one of them simply waits its turn instead of both waiting forever.
Redis vs PostgreSQL
Redis needs a separate service, but works regardless of what your primary datastore is, and a well-implemented version (ownership tokens, a short TTL, fencing tokens on the protected resource) is a reasonable default when the protected resource isn't in PostgreSQL at all.
PostgreSQL advisory locks need no new infrastructure if a Postgres connection is already available, and connection-lifetime release is a real safety property a TTL can only approximate — but they tie up a dedicated connection for the lock's duration and don't help if the resource being protected lives outside Postgres entirely.
When NOT to use a distributed lock
If the actual goal is "don't process this event twice," that's an idempotency problem, not a locking problem — solve it with the approach in Idempotent Consumers in Go instead, which is simpler and doesn't require coordinating a lock's lifetime at all. If a database unique constraint can enforce the exclusivity directly — two processes racing to create "the one row for this order" — let the constraint do the job; it's atomic, requires no separate lock service, and can't leak the way a TTL-based lock can. And if the actual cost of occasional duplicate work is genuinely low (a cache warm that runs twice is wasteful, not harmful), the complexity of a correct distributed lock is very likely not worth paying for.
Common mistakes
- No TTL at all — a crashed holder locks the resource forever.
- Releasing without checking ownership — a
DELwith no token comparison can delete a lock a different process now legitimately holds, if the original holder's TTL already expired. - No fencing token on the protected resource, leaving the TTL-expires- while-working race fully open regardless of how careful the locking code itself is.
- Assuming a lost connection means immediate, guaranteed release — true for PostgreSQL session-scoped advisory locks, not true for a TTL-based Redis lock, which stays held until the TTL actually elapses.
Testing
The property worth testing directly is that two concurrent acquire attempts for the same key never both succeed:
func TestAcquireLock_OnlyOneWinnerConcurrently(t *testing.T) {
rdb := newTestRedisClient(t)
key := "lock:test-job"
var acquired int32
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, ok, err := AcquireLock(context.Background(), rdb, key, 5*time.Second)
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if ok {
atomic.AddInt32(&acquired, 1)
}
}()
}
wg.Wait()
if acquired != 1 {
t.Fatalf("expected exactly 1 successful acquisition, got %d", acquired)
}
}Running this against a real Redis instance (not a fake) matters here more
than usual — the whole point being tested is the atomicity guarantee of
SET ... NX, which a hand-written fake can't meaningfully verify.
Summary
A distributed lock solves a real problem — coordinating exclusive access across separate processes — but a naive implementation only looks solved. An expiring lock is necessary to survive a crashed holder, and that same expiration is exactly what lets two processes believe they hold the lock simultaneously if one of them is merely slow rather than dead. Fencing tokens close that gap by making the protected resource, not the lock, the final authority on which write is allowed to win. Redis and PostgreSQL advisory locks are both reasonable building blocks — the mistake isn't picking the wrong one, it's skipping ownership checks, TTLs, or fencing tokens because the basic version "worked" in testing, where processes don't pause for thirty-five seconds in the middle of holding a lock.
funcRelated()[]Article
Database Transactions in Go: Isolation Levels, Locks and Real-World Failures
PostgreSQL transaction isolation levels in Go, explained through a real race condition: lost updates, SELECT FOR UPDATE, and why app-level locking isn't enough.
Idempotent Consumers in Go: Handling Duplicate Messages Safely
Why message consumers must assume duplicate delivery, and how to implement deduplication in Go and PostgreSQL without a race condition.
Idempotency in APIs: Preventing Duplicate Payments and Requests
Implementing idempotency keys in Go and PostgreSQL to prevent duplicate payments and orders when clients retry after a timeout.