基于 Redis 的 Go 分布式锁工具包:提供 simple、reentrant、fair 三种锁实现,read-write 锁目前只有占位入口(调用会 panic,见下方矩阵)。
前置条件:Go 1.20+(见 go.mod)、Redis 6+(仓库内示例连接 localhost:6379)。
go get github.com/Casper-Mars/distrlock# 1. 起一个本地 Redis;已有可跳过
docker run -d -p 6379:6379 redis:7
# 2. 拿到示例代码
git clone https://github.com/Casper-Mars/distrlock.git
cd distrlock
# 3. 直接跑 simple 示例
go run ./examples/simple期望输出(实际运行结果,退出码 0):
lock acquired
lock released
examples/simple 只有两个依赖:本包与 github.com/redis/go-redis/v9。想在自己的项目里用,把 examples/simple/main.go 抄过去即可。
fair 锁对外暴露两个构造,行为完全不同:
| 构造 | 行为 |
|---|---|
fair.NewDistributedMutex(...) |
推荐入口,Lock / Unlock 有实现,示例见 examples/fair |
fair.NewLocker(...) |
❌ 未实现:构造能成功,调用 Lock / Unlock / TryLock 任意一个都会 panic("implement me") |
fair.NewLocker 和 rw.NewLocker 一样:构造返回一个编译期完全合法的 core.Locker,直到运行到第一个方法调用才崩。它比 rw 更容易被误选——同一个 fair 包里就放着一个能用的 NewDistributedMutex。本文档推荐的 fair 用法只有 fair.NewDistributedMutex。
| 锁 | 入口 | 状态 |
|---|---|---|
| simple | simple.NewLocker |
✅ 可用 |
| reentrant | reentrant.NewLocker |
✅ 可用(仅进程内重入) |
| fair | fair.NewDistributedMutex |
|
| fair | fair.NewLocker |
❌ 未实现,调用即 panic |
| rw | rw.NewLocker |
❌ 未实现,调用即 panic |
矩阵与源码的对应关系(行号以当前 main 计):
simple.NewLocker:core/simple/redis.go:16,Lock/Unlock/TryLock三个方法均有真实实现。reentrant.NewLocker:core/reentrant/redis.go:24,三个方法均有实现,重入靠实例内的holderCnt计数。fair.NewDistributedMutex:core/fair/redis.go:51,返回*fair.DistributedMutex,只有Lock/Unlock两个方法,没有TryLock,因此不实现core.Locker,所以标「部分实现」。fair.NewLocker:core/fair/redis.go:23,返回的core.Locker三个方法体是panic("implement me")(core/fair/redis.go:32、:37、:42)。rw.NewLocker:core/rw/redis.go:14,三个方法体同样是panic("implement me")(core/rw/redis.go:23、:28、:33)。
| simple | reentrant | fair(NewDistributedMutex) |
|
|---|---|---|---|
| 适用场景 | 单次互斥,抢不到就重试 | 同一逻辑内需要重复加锁(如递归调用) | 多个实例排队,希望先到先得 |
| 是否阻塞 | Lock 阻塞(100ms 轮询),TryLock 非阻塞 |
Lock 阻塞(100ms 轮询),TryLock 非阻塞 |
Lock 阻塞(300ms 轮询 ZRank),无 TryLock |
| 是否带 TTL | 否(SETNX 无过期时间) |
否(SET 无过期时间) |
是(拿到锁后 EXPIRE lockDuration) |
| 跨实例是否成立 | 是,基于 Redis 上同一个 key | 互斥成立;但重入只在同一实例内生效,跨实例重入不成立 | 是,基于 Redis ZSET 的 member 排序 |
| 当前状态 | ✅ 可用 | ✅ 可用(仅进程内重入) |
先用三个问题自查,任一命中就要往下读对应小节:
- 你的锁 key 有没有过期时间?——没有的话,进程异常退出就会把锁永久占住(失效模式 1、2)。
- 你的
Unlock是否可能在「锁已经不属于自己」时执行?——会的话就是误释放(失效模式 3)。 - 你是否指望跨进程「重入」?——跨进程没有重入,只有互斥(失效模式 4)。
每条都给出「成因 → 触发条件 → 复现命令 → 观察结果」,全部在本机实跑过。
复现口径:本节所有
redis-cli都用-n 15(专用库),复现程序也连 db15。仓库里examples/的示例连的是默认 db0,两者不要混用。 示例拿到锁就立刻释放,来不及演示「进程被强杀」,所以复现需要一个能长时间持锁的最小程序,见下。
把下面的程序存成 repro/main.go(约 65 行,只为复现,不属于库代码):
// repro/main.go —— 仅用于复现 README「什么时候会死锁」小节的失效模式,不属于库代码。
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/Casper-Mars/distrlock/core"
"github.com/Casper-Mars/distrlock/core/reentrant"
"github.com/Casper-Mars/distrlock/core/simple"
"github.com/redis/go-redis/v9"
)
// 专用库 db15,与本节 redis-cli -n 15 对应。
var cli = redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 15})
func main() {
ctx := context.Background()
action, kind := os.Args[1], os.Args[2]
key := "distrlock:example:" + kind
var l core.Locker
if kind == "reentrant" {
l = reentrant.NewLocker(cli, key)
} else {
l = simple.NewLocker(cli, key)
}
switch action {
case "hold": // 拿到锁后一直持着,等外部 kill -9
cli.Del(ctx, key) // 从干净起点开始,避免上一节残留的 key 让 Lock 一直阻塞
if _, err := l.Lock(ctx); err != nil {
panic(err)
}
fmt.Println("locked:", key)
if ok, _ := l.TryLock(ctx); ok {
fmt.Println("same instance TryLock -> true (in-process reentrant works)")
} else {
fmt.Println("same instance TryLock -> false")
}
time.Sleep(5 * time.Minute)
case "try": // 另一个进程尝试加锁
ok, err := l.TryLock(ctx)
fmt.Printf("another process TryLock -> %v, err=%v\n", ok, err)
case "misrelease": // A 加锁 -> 锁消失 -> B 加锁 -> A 释放
cli.Del(ctx, key) // 同上,保证 A 能立刻拿到锁
if _, err := l.Lock(ctx); err != nil {
panic(err)
}
fmt.Println("A locked")
cli.Del(ctx, key)
fmt.Println("simulate A lock expired/deleted")
b := simple.NewLocker(cli, key)
if _, err := b.Lock(ctx); err != nil {
panic(err)
}
fmt.Println("B locked")
if err := l.Unlock(ctx); err != nil {
panic(err)
}
fmt.Println("A.Unlock done, B lock still exists:", cli.Exists(ctx, key).Val() == 1)
default:
fmt.Fprintln(os.Stderr, "usage: repro <hold|try|misrelease> <simple|reentrant>")
os.Exit(2)
}
}go build -o /tmp/repro-distrlock ./repro成因:core/simple/redis.go:50 是 SetNX(ctx, key, "locked", 0),go-redis 里最后一个参数 0 表示不设过期时间。
触发条件:持锁进程没走到 Unlock 就退出——kill -9、panic、容器 OOM、机器宕机。
复现命令:
/tmp/repro-distrlock hold simple & HOLD=$! # 拿锁并持住(最多 5 分钟)
sleep 2
redis-cli -n 15 TTL distrlock:example:simple # -1 表示没有过期时间
kill -9 "$HOLD" # 模拟进程被强杀
sleep 1
redis-cli -n 15 TTL distrlock:example:simple # 仍然是 -1
redis-cli -n 15 EXISTS distrlock:example:simple # 1:锁还在
/tmp/repro-distrlock try simple # 另一个进程拿不到观察结果:
locked: distrlock:example:simple
same instance TryLock -> false
-1
-1
1
another process TryLock -> false, err=<nil>
这就是死锁:TTL 恒为 -1,持锁进程已经死了,key 却不会消失。除非有人手动 DEL,这把锁永远被一个不存在的进程占着。
成因:core/reentrant/redis.go:85-97 的 Lua 脚本执行 redis.call('SET', key, lock_val)(:92),没有 EX / PX 参数,写进去的 key 没有过期时间。
触发条件:同失效模式 1。PRD 早期只提到 simple,reentrant 同样成立。
复现命令:
/tmp/repro-distrlock hold reentrant & HOLD=$!
sleep 2
redis-cli -n 15 TTL distrlock:example:reentrant # -1
kill -9 "$HOLD"
sleep 1
redis-cli -n 15 TTL distrlock:example:reentrant # 仍然是 -1
redis-cli -n 15 EXISTS distrlock:example:reentrant # 1观察结果:
locked: distrlock:example:reentrant
same instance TryLock -> true (in-process reentrant works)
-1
-1
1
成因:core/simple/redis.go:41-47 的 Unlock 只有一句 l.cli.Del(ctx, l.key)(:42),不看 key 里的值、也不校验是谁加的锁。
触发条件:A 的锁已经消失(超时、被人手动删、Redis 重启),B 拿到了同一个 key 的锁;之后 A 的 Unlock(重试、defer、超时补偿)才执行,删掉的是 B 的锁。
复现命令(程序按顺序执行:A 加锁 → 模拟锁消失 → B 加锁 → A 释放):
/tmp/repro-distrlock misrelease simple观察结果:
A locked
simulate A lock expired/deleted
B locked
A.Unlock done, B lock still exists: false
危害:B 以为自己持有锁,实际的互斥已经没了,两个进程可以同时进入临界区。这种「误释放」比死锁更难排查——不报错、不留残留 key,只是保护悄悄失效。
成因:重入计数 holderCnt int32 是 locker 的进程内字段(core/reentrant/redis.go:21;TryLock 里 +1 见 :104,Unlock 里 -1 见 :58)。跨进程时只认 Redis 里存的实例标识 ca(:28 生成;TryLock 在 :98、Unlock 在 :71 把它作为参数传给 Lua 脚本,脚本用 GET 到的值与它比对),另一个进程的 ca 不同,TryLock 直接返回 false。
触发条件:期望「同一业务跨 goroutine / 跨服务重入」。跨进程只有互斥,没有重入语义。
复现命令:
/tmp/repro-distrlock hold reentrant & HOLD=$! # 进程 A:拿锁,并在同一实例上再 TryLock 一次
sleep 2
/tmp/repro-distrlock try reentrant # 进程 B:另一个实例
kill -9 "$HOLD"
sleep 1观察结果:
locked: distrlock:example:reentrant
same instance TryLock -> true (in-process reentrant works)
another process TryLock -> false, err=<nil>
同一实例第二次 TryLock 成功(holderCnt 变成 2),另一个实例拿不到——确认重入仅限进程内。
core/fair/redis.go:108-115 的 Unlock 只做 ZRem(ctx, lockKey, mem)(:109):知道 mem 的任何进程都能把别人的排队项删掉,Lock 一侧也不校验调用者。
redis-cli -n 15 ZADD distrlock:example:fair 1 worker-1 2 worker-2
redis-cli -n 15 ZREM distrlock:example:fair worker-1 # 等价于 fair.Unlock(ctx, "worker-1")
redis-cli -n 15 ZRANGE distrlock:example:fair 0 -1 WITHSCORES观察:worker-1 被删掉后,worker-2 直接排到第一位——删的是谁的排队项,ZRem 并不关心。
redis-cli -n 15 --scan --pattern 'distrlock:*' | xargs -r redis-cli -n 15 del如果你的 xargs 不支持 -r(部分 macOS 自带版本),用等价的循环:
redis-cli -n 15 --scan --pattern 'distrlock:*' | while read -r k; do redis-cli -n 15 del "$k"; done三个示例都只依赖本包和 github.com/redis/go-redis/v9:
| 目录 | 命令 | 演示内容 |
|---|---|---|
examples/simple |
go run ./examples/simple |
取锁、打印 lock acquired、释放、打印 lock released |
examples/reentrant |
go run ./examples/reentrant |
同一实例连续 TryLock 两次重入,再 Unlock 两次释放 |
examples/fair |
go run ./examples/fair |
两个 goroutine 用 NewDistributedMutex 排队取锁,打印 lock success 与 rank;排队位次见 Lock 自身日志 |
- Redis
- Etcd
- Zookeeper
本节只记录仓库当前真实存在的问题与历史上的坑,不提供修复时间点(本仓库没有 release 计划)。三条命令全绿只说明能编译、go vet 没报、现有测试没失败,不能说明锁语义正确。
修复前(bff79da 及更早)的仓库状态:
core/fair/redis.go导入github.com/go-redis/redis/v8;core/fair/fair_test.go导入github.com/redis/go-redis/v8(注意前缀不同,这是另一个错误路径);go.mod只require github.com/redis/go-redis/v9 v9.1.0。
后果:go build ./...、go vet ./... 失败,go test ./... 无法编译 core/fair。
历史复现(需要联网拉模块;只对修复前的提交有效):
git checkout bff79da # 修复前最后一个提交
GOFLAGS=-mod=mod go vet ./... # exit=1关键输出(省略了前面的 finding module 行):
go: github.com/Casper-Mars/distrlock/core/fair tested by
github.com/Casper-Mars/distrlock/core/fair.test imports
github.com/redis/go-redis/v8: github.com/redis/go-redis/v8@v8.11.5: parsing go.mod:
module declares its path as: github.com/go-redis/redis/v8
but was required as: github.com/redis/go-redis/v8
不加 -mod=mod 直接 go vet ./... 时,Go 1.26.1 的报错是 no required module provides package github.com/go-redis/redis/v8——根因相同(go.mod 没有 v8),只是新版 Go 不再自动补依赖。
修复:commit 05ecf09(fix(fair): 迁到 go-redis/v9 解除构建阻塞)把 core/fair/redis.go 与 core/fair/fair_test.go 的 import 统一为 github.com/redis/go-redis/v9;go.mod 未改动,仓库只保留 v9 一个版本。
修复后(当前 main;Go 1.26.1,本地 Redis 6379 运行中):
go build ./... # exit 0
go vet ./... # exit 0
go test ./... # exit 0go test ./... 输出:
? github.com/Casper-Mars/distrlock/core [no test files]
ok github.com/Casper-Mars/distrlock/core/fair 4.879s
? github.com/Casper-Mars/distrlock/core/reentrant [no test files]
? github.com/Casper-Mars/distrlock/core/rw [no test files]
? github.com/Casper-Mars/distrlock/core/simple [no test files]
? github.com/Casper-Mars/distrlock/examples/fair [no test files]
? github.com/Casper-Mars/distrlock/examples/reentrant [no test files]
? github.com/Casper-Mars/distrlock/examples/simple [no test files]
复现口径:修复后该报错在 main 上不再出现,所以「复现」只对修复前的提交 bff79da 成立;当前 main 能验证的是三条命令均 exit 0。不要把「已修复」理解成「问题从未存在」。
core/fair/fair_test.go 只有一个测试 Test_FairLock:起 4 个 goroutine 调 DistributedMutex.Lock,没有任何断言——出错只 fmt.Println(err) 后 return,没有 t.Error / t.Fatal,也不校验谁先拿到锁。测试还硬编码连接 localhost:6379。
后果:go test ./... 通过只说明测试进程跑完没崩,不代表 fair 的公平性/互斥性被验证。把连接地址换成不存在的端口,测试同样 PASS(连接错误被打印后 return)——这是「无断言」的直接证据,不是对锁行为的验证。
.github/workflows/go.yml(workflow 名 Build)确实存在,步骤只有 go build -v ./... 与 go test -v ./...,没有 Redis service。
仓库 2023-08-21 初始化,但该 workflow 在 2026-10-31 之前没有任何运行记录:审计时 GitHub API workflow_runs.total_count = 0。2026-10-31 的文档 PR 合并后才产生第一批运行;截至本节写作共 4 次,全部 success:
| 时间(UTC) | 触发 | 分支 | 结论 |
|---|---|---|---|
| 2026-10-31T05:36:10Z | pull_request | feat/distrlock-s1-s4 |
success |
| 2026-10-31T05:38:21Z | push | main |
success |
| 2026-10-31T05:47:48Z | pull_request | docs/T0005-failure-modes |
success |
| 2026-10-31T05:54:17Z | push | main |
success |
Build badge 现在只会反映这 4 次(及之后)的运行。这 4 次 success 的含义仅限于「能编译、go test 没失败」:由于第 2 条(测试无断言)与 CI 没有 Redis service,这个绿色徽章不能作为锁语义可用的证据。
rw.NewLocker 与 fair.NewLocker 返回的 core.Locker 都能通过编译,但 Lock / Unlock / TryLock 任一方法都是 panic("implement me")。入口与源码行号见上方「功能矩阵」,这里不重复展开——选型时以矩阵为准,不要只看构造能否编译通过。
以上问题本期只做披露:不修 CI、不补测试断言,也不承诺修复时间点。