Skip to content
Distributed Systems

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.

GoBackend.dev13 min read
GoDistributed SystemsRedisPostgreSQLConcurrency

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" NX

NX 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 30000

But 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 DEL with 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.