Distributed Locks in Go With Redis and Their Limits
Build a Redis lock in Go with SET NX PX and a token, see why a naive DEL frees someone else's lock, and why an expiring lock needs a fencing token

Sometimes two instances of a service must not do the same thing at once: send the same invoice, run the same rebuild, process the same file. With one process a mutex is enough. With several, you need a lock they all can see, and Redis is the tool many teams already have. A Redis lock is easy to write and easy to write wrongly, and the wrong versions work in every test you will think of. This article builds the correct single instance version on Go 1.25.6 with go-redis v9.17.0, breaks it in the two ways that matter, and shows the fix for the second. It is not about Redlock, the multi node algorithm, which has its own controversy.
Tested with Go 1.25.6, go-redis v9.17.0, Redis 7 (redis:7-alpine) in Docker, macOS on Apple Silicon (arm64)
It belongs to the database cornerstone. A lock that lives in the database instead, with the pitfalls of pooled connections, is in Postgres advisory locks, and the client is introduced in go-redis explained.
The correct basic lock
package main
import (
"context"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
var release = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0`)
// Lock is a single instance Redis lock: SET key token NX PX ttl.
type Lock struct {
rdb *redis.Client
key string
token string
}
func Acquire(ctx context.Context, rdb *redis.Client, key, token string, ttl time.Duration) (*Lock, bool) {
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil || !ok {
return nil, false
}
return &Lock{rdb, key, token}, true
}
func (l *Lock) Release(ctx context.Context) bool {
n, _ := release.Run(ctx, l.rdb, []string{l.key}, l.token).Int()
return n == 1
}
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:56379"})
defer rdb.Close()
_ = rdb.FlushDB(ctx).Err()
// 1. Mutual exclusion while the lock is held.
a, okA := Acquire(ctx, rdb, "lock:report", "token-a", time.Second)
_, okB := Acquire(ctx, rdb, "lock:report", "token-b", time.Second)
fmt.Println("1. A acquires:", okA, "| B acquires while A holds it:", okB)
// 2. Releasing with the wrong token does nothing.
wrong := &Lock{rdb, "lock:report", "token-b"}
fmt.Println("2. B releasing with its own token:", wrong.Release(ctx), "| A releasing:", a.Release(ctx))
// 3. A plain DEL by key is the classic bug: A's lock expired, B holds it, A finishes and deletes B's lock.
a, _ = Acquire(ctx, rdb, "lock:job", "token-a", 100*time.Millisecond)
time.Sleep(150 * time.Millisecond)
b, okB := Acquire(ctx, rdb, "lock:job", "token-b", time.Second)
_ = rdb.Del(ctx, "lock:job") // what A does when it finishes late, with a naive unlock
_, okC := Acquire(ctx, rdb, "lock:job", "token-c", time.Second)
fmt.Println("3. A's lock expired, B took it:", okB, "| A's naive DEL freed it and C also got it:", okC)
_ = a
_ = b
_ = rdb.Del(ctx, "lock:job")
// 4. The token check blocks that: the late release is a no-op.
a, _ = Acquire(ctx, rdb, "lock:job2", "token-a", 100*time.Millisecond)
time.Sleep(150 * time.Millisecond)
_, okB = Acquire(ctx, rdb, "lock:job2", "token-b", time.Second)
fmt.Println("4. with the token check, A's late release returned:", a.Release(ctx), "| B still holds the lock:", rdb.Get(ctx, "lock:job2").Val() == "token-b" && okB)
// 5. The lock can still expire during the work. Two workers end up inside the critical section together.
_ = rdb.Set(ctx, "stored", 0, 0).Err()
var wg sync.WaitGroup
var mu sync.Mutex
overlap := 0
inside := 0
work := func(name string, delay, duration time.Duration, fenced bool) {
defer wg.Done()
time.Sleep(delay)
var l *Lock
for tries := 0; tries < 20; tries++ {
var ok bool
l, ok = Acquire(ctx, rdb, "lock:work", name, 100*time.Millisecond)
if ok {
break
}
time.Sleep(20 * time.Millisecond)
}
if l == nil {
return
}
var fence int64
if fenced {
fence, _ = rdb.Incr(ctx, "fence").Result()
}
mu.Lock()
inside++
if inside > 1 {
overlap++
}
mu.Unlock()
time.Sleep(duration) // a pause longer than the lock's life, a GC stall or a slow call
// The write that the lock was supposed to protect.
if fenced {
// The store accepts a write only if its fencing token is the newest one it has seen.
ok, _ := rdb.Eval(ctx, `
local last = tonumber(redis.call('GET', KEYS[1]) or '0')
if tonumber(ARGV[1]) > last then redis.call('SET', KEYS[1], ARGV[1]); redis.call('INCR', KEYS[2]); return 1 end
return 0`, []string{"stored:fence", "stored"}, fence).Int()
fmt.Printf(" worker %s wrote with fencing token %d: accepted=%v\n", name, fence, ok == 1)
} else {
rdb.Incr(ctx, "stored")
}
mu.Lock()
inside--
mu.Unlock()
l.Release(ctx)
}
wg.Add(2)
go work("A", 0, 400*time.Millisecond, false)
go work("B", 20*time.Millisecond, 150*time.Millisecond, false)
wg.Wait()
n, _ := rdb.Get(ctx, "stored").Int()
fmt.Printf("5. lock ttl 100ms, A works 400ms and B 150ms: workers inside at the same time %d time(s); both wrote, stored=%d\n", overlap, n)
_ = rdb.Set(ctx, "stored", 0, 0).Err()
overlap = 0
wg.Add(2)
go work("A", 0, 400*time.Millisecond, true)
go work("B", 20*time.Millisecond, 150*time.Millisecond, true)
wg.Wait()
n, _ = rdb.Get(ctx, "stored").Int()
fmt.Printf("6. the same with fencing tokens checked at the store: stored=%d (the late writer was refused)\n", n)
}1. A acquires: true | B acquires while A holds it: false
2. B releasing with its own token: false | A releasing: true
3. A's lock expired, B took it: true | A's naive DEL freed it and C also got it: true
4. with the token check, A's late release returned: false | B still holds the lock: true
5. lock ttl 100ms, A works 400ms and B 150ms: workers inside at the same time 1 time(s); both wrote, stored=2
worker B wrote with fencing token 2: accepted=true
worker A wrote with fencing token 1: accepted=false
6. the same with fencing tokens checked at the store: stored=1 (the late writer was refused)Acquire is one command: SET lock:report token-a NX PX 1000. NX sets the key only if it does not exist, PX gives it a time to live in milliseconds, and the value is a token unique to the holder. Because it is one command, there is no gap between checking and setting where another client could slip in. The expiry is not optional: a holder that crashes cannot release, and without a ttl the lock would stay forever.
The first lines of output show the basics. A acquired the lock (true), and B, trying while A held it, did not (false). B releasing with its own token returned false and left the lock alone, while A releasing with its token returned true.
Release must check the token
The release is the part people get wrong, and the third line shows how. A takes a lock with a 100ms ttl and runs for longer than that. The key expires, B acquires, and A finishes and calls DEL lock:job as a naive release. It deletes B's lock. Then C acquires it too: output A's lock expired, B took it: true | A's naive DEL freed it and C also got it: true. B and C now both believe they hold the lock. The lock was never broken by Redis; it was broken by A deleting a key it no longer owned.
The fix is to delete only if the value is still your token, atomically. Two separate commands, GET then DEL, are not atomic, so use a Lua script, which Redis runs as one operation:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0With it, the same late release returned false and B still held the lock (with the token check, A's late release returned: false | B still holds the lock: true). The token also makes the lock safe against accidentally released by code that never owned it. Generate the token from a random source (crypto/rand or a UUID), never from the process id or the hostname.
The part a lock cannot fix
The token check stops A from destroying B's lock. It does not stop A from acting after its lock has expired, and that is the deeper problem. The lab simulates it: the lock's ttl is 100ms, A holds it and works for 400ms, which stands in for a long garbage collection pause, a slow network call, a process frozen by the operating system, or a paused virtual machine. After 100ms the lock expires. B, which has been retrying, acquires it at about 120ms and works for 150ms. For a while both are inside the critical section.
The output: workers inside at the same time 1 time(s); both wrote, stored=2. Two workers ran the protected section together and both wrote. The lock gave mutual exclusion only as long as every holder finished inside its ttl, and nothing in the lock can force that. A holder cannot even detect it: after a pause it wakes up believing it still holds the lock, and checking the lock first does not help, because the pause can happen between the check and the write.
No ttl fixes this. A longer one makes recovery from a crashed holder slower and still cannot bound a pause. This is the central limit of expiring locks, and it is a property of the idea and not of Redis.
Fencing tokens
The solution is to make the resource reject stale holders. When a client acquires the lock it also takes a number that only goes up, a fencing token, here from INCR fence. It sends the token with every write, and the store remembers the highest token it has seen and refuses anything lower.
In the lab each worker gets a token on acquire (A got 1, B got 2) and writes through a script that accepts the write only if its token is greater than the last one stored. B, working for 150ms, wrote first with token 2 and was accepted. A, which had been working for 400ms, wrote afterwards with token 1 and was refused: worker A wrote with fencing token 1: accepted=false. The final value was stored=1: only B's write landed. The lock was violated, and the data was still correct.
For this to work the check must be done by the thing being written, atomically with the write. A database can do it with a where clause on a version column; see optimistic locking, which is the same idea. A file or an external API that cannot compare tokens cannot be protected this way, and for those the lock gives only best effort.
When a Redis lock is appropriate
- Efficiency locks. The goal is to avoid doing work twice, such as two instances refreshing the same cache. If the lock fails occasionally, the cost is a duplicate that is harmless. A simple
SET NX PXwith a token is right. - Correctness locks. The goal is to protect data from two writers. A Redis lock with a ttl is not enough alone. Use fencing tokens, or move the guarantee into the database with a transaction, a unique constraint or a row lock, which do not depend on timing.
Redis adds its own caveats. A single instance lock disappears if that instance fails and restarts without the key, or when a replica is promoted before the key replicated; both let a second client acquire while the first still believes it holds the lock. Multi instance schemes such as Redlock try to answer that, and their safety rests on timing assumptions that critics have argued are not valid in real systems. For a correctness lock, do not depend on it.
Practical rules
- Always use
NX,PXand a unique token in one command. - Always release with a compare-and-delete script.
- Choose the ttl as several times the expected work, and extend it with a heartbeat for long jobs, checking the token in the extension script too.
- Never assume you hold the lock after a pause; protect the resource with a fencing token or a database constraint.
- Retry acquisition with a small random delay and a limit, not a tight loop.
- Put a
contexttimeout on every Redis call, and note that go-redis needsContextTimeoutEnabledto honour it, as shown in the go-redis article.
The Redis documentation describes the single instance pattern and the Redlock variant in its page on distributed locks, and the SET options are in the command reference.


