必须用独立goroutine跑看门狗续期锁,绑定请求context并显式cancel,续期需lua脚本校验value匹配,否则必然导致数据竞争。

加锁后不续期,goroutine 一卡住锁就丢了
Go 的 goroutine 执行时间不可控,比如调第三方 API、批量写库、网络抖动,都可能让业务耗时远超预设 TTL。这时候如果没续期,锁自动过期,其他节点就会闯入临界区——不是偶发问题,是必然发生的数据竞争。
关键点在于:续期不是“多加几秒”,而是「持续证明自己还活着且仍持锁」。必须用独立 goroutine 跑看门狗,且该 goroutine 生命周期要和单次锁绑定,不能全局复用或泄漏。
- TTL 建议设为业务 P99 × 2(如 P99 是 1.2s,就设
2 * time.Second) - 续期间隔设为
TTL / 3(如 TTL=3s,则每 1s 检查一次),太密压 Redis,太疏容易掉锁 - 续期 goroutine 必须绑定到本次请求的
context.Context,主流程结束前调cancel(),否则会变成“幽灵续期” - 别依赖
WithAutoRelease这类封装——它只在锁仍属当前 client 时才生效,你得知道它何时失效、怎么 fallback
续期必须走 Lua 脚本,否则可能续了别人的锁
直接调 client.Expire(ctx, key, ttl) 是高危操作:A 正在续期,B 刚抢到锁并写入新 value,A 的 Expire 就把 B 的锁 TTL 又拉长了。B 还以为自己持锁在改数据,结果 A 续的不是自己的锁。
唯一安全方式是原子脚本校验 value 是否匹配再续期:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))
else
return 0
end
Go 中定义并调用:
renewScript := redis.NewScript(renewLua)
result, err := renewScript.Run(ctx, rdb, []string{key}, token, int64(ttl.Seconds())).Int()
- 返回
int64(1)表示续期成功;0或 error 都代表锁已丢失,必须立刻中止后续业务逻辑 - 参数顺序不能错:
[]string{key}是 KEYS,token和ttl 秒数是 ARGV - 脚本别硬编码在函数里,用
embed.FS或const管理,方便审计和灰度替换
goroutine 启动和停止必须显式配对,defer 不可靠
很多人在 Lock() 后起 goroutine 续期,然后在函数末尾 defer unlock(),但这样有问题:如果业务 panic 或提前 return,unlock() 没执行,续期 goroutine 却还在跑——它可能把别人刚抢到的锁又续上了。
正确做法是:续期 goroutine 启动时就绑定一个子 context,解锁时主动 cancel:
ctx, cancel := context.WithCancel(context.Background())
go func() {
ticker := time.NewTicker(ttl / 3)
defer ticker.Stop()
for {
select {
case
- 别在
defer里直接调cancel(),因为defer执行时机晚于函数 return,而续期 goroutine 可能已触发下一轮续期 -
Unlock()方法内部必须显式调cancel(),且不能忽略其返回值(有些封装库的Unlock()返回 error) - 若用
redis.NewLock()(v9+),它的WithAutoRelease内部就是这么做的,但你要确认它是否真的绑定了本次锁实例的生命周期
value 必须每个 goroutine 独立生成,不能复用
常见错误是把 token 声明成全局变量或 handler 外部字段,导致多个并发请求共用同一个 value。结果 A 加锁成功,B 也用相同 value 成功加锁(因为 SETNX 检查的是 key 是否存在,不是 value 是否重复),接着 A 解锁时用 Lua 脚本删掉 key,B 的锁也被顺手干掉。
正确做法是每次加锁前生成唯一 token:
token := uuid.NewString() // 推荐 // 或 b := make([]byte, 16) rand.Read(b) token = hex.EncodeToString(b)
- 禁用
"1"、time.Now().Unix()、os.Getpid()这类低熵值,它们不具备全局唯一性和不可预测性 - token 生成后必须全程只用一次:加锁时传给
SET,解锁和续期时原样传给 Lua 脚本 - 如果用
redis.NewLock(rdb, key, opts),它内部会自动生成 UUID,但你要确保没手动覆盖opts.Value
续期机制最易被忽略的点,是它和加锁、解锁一样,必须严格绑定单次请求上下文——不是启动一个 goroutine 就完事,而是从 token 生成、加锁、续期、解锁,全部链条都要隔离,否则一个 goroutine 的异常会污染整个锁系统。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











