SETNX+过期时间不能直接作分布式锁,因业务超时会导致锁提前释放引发并发冲突;正确解法是加锁后由客户端用Lua脚本原子续期(校验value+更新TTL),并绑定context管控看门狗生命周期。
为什么 SETNX + 过期时间不能直接当分布式锁用
因为业务执行时间可能超过 redis 键的过期时间,锁提前释放,其他节点趁机抢入,导致并发冲突。单纯靠预估一个“足够长”的过期时间不可靠——超长会拖慢故障恢复,超短又容易误释放。
真正的解法是:加锁后启动一个后台协程,在锁到期前不断用 GETSET 或 PEXPIRE 延长 TTL,这个协程就是“看门狗”。但必须确保只由加锁成功的客户端续期,不能让别的客户端越权续。
- 续期前必须校验锁的 value 是当前客户端的唯一标识(比如 UUID),否则 A 加的锁被 B 续了,就彻底乱套
- 续期操作本身得是原子的:
EVAL脚本最稳妥,避免 GET + 判断 + SET 的竞态 - 看门狗协程生命周期要和业务逻辑绑定,业务结束或主动解锁时必须停止它,否则残留 goroutine 会持续刷 Redis
用 Lua 脚本安全续期:eval 是关键
Redis 执行 Lua 脚本是原子的,适合做“检查 value 是否匹配 + 更新 TTL”这一对操作。Go 里调用 Eval 方法传入脚本和参数即可。
典型续期脚本逻辑:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
对应 Go 调用:
立即学习“go语言免费学习笔记(深入)”;
script := redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`)
result, err := script.Run(ctx, rdb, []string{lockKey}, clientID, renewalTTL).Result()
-
clientID必须是加锁时 set 的 value,建议用uuid.NewString()生成,全局唯一且无状态 -
renewalTTL推荐设为锁初始 TTL 的 1/3~1/2(如初始 30s,续期设 10s),留出网络和执行余量 - 脚本返回
1表示续期成功,0表示锁已不属于当前 client,此时应立即停止看门狗
看门狗协程怎么启、怎么停才不泄漏
不能简单用 go func() { ... }() 启动后就撒手不管。必须能响应业务完成、上下文取消、锁被主动释放等信号。
- 用
context.WithCancel(parentCtx)派生子 ctx,业务函数退出时调用cancel(),看门狗内用select监听ctx.Done() - 主动解锁时(比如 defer 解锁),除了删 key,还要显式 cancel 看门狗 ctx —— 否则它还在后台跑着刷 Redis
- 看门狗内部每次续期前先
select { case ,避免 cancel 后还执行一次续期 - 别用
time.Ticker无脑 tick;改用time.AfterFunc或带 jitter 的重试,防止多个实例续期时间完全同步造成 Redis 尖峰
实际加锁流程中哪些地方最容易漏掉续期逻辑
不是写完看门狗代码就万事大吉。常见断点在:加锁成功但没来得及启协程、panic 导致 defer 没执行、context 被意外覆盖、错误处理分支忘记 stop 看门狗。
- 加锁函数返回后,必须立刻检查是否成功,只在
ok == true时才启动看门狗 —— 失败时启了也是空转 - 业务逻辑外层包一层
defer,里面做两件事:调用unlock()和cancelWatchdog(),缺一不可 - 如果业务函数本身接收 context,别直接用传入的 ctx 启看门狗;应该用
context.WithTimeout(ctx, lockTTL)派生,否则上游 timeout 不会触发看门狗退出 - 测试时故意让业务 sleep 超过初始 TTL,观察 Redis 中 key 的 TTL 是否动态延长,以及日志里有没有 “lock lost during renewal” 类提示
续期不是加个 goroutine 就完事,它是锁生命周期里的隐式状态机——启停时机、value 校验、脚本原子性、上下文传播,漏掉任何一环,分布式锁就从保护伞变成定时雷。


















