直接用 SETNX + EXPIRE 会因非原子性导致死锁,必须改用原子命令 SET key value NX PX ms;go-redis/v8 中 SetNX 需传 time.Duration(如 30*time.Second),不可传整数;释放锁须用 Lua 脚本校验 value 再删除,value 必须全局唯一;锁续期需独立 goroutine 且严格管控生命周期。

为什么直接用 SETNX + EXPIRE 会出问题
因为这两个命令不是原子操作。如果客户端执行 SETNX 成功,但还没来得及发 EXPIRE 就崩溃或网络中断,锁就永远不超时——形成死锁。
更糟的是,Redis 6.0+ 默认启用 redis-cli 的 pipeline 模式,但 Go 客户端(如 go-redis/v8)默认不自动合并命令。你写的两行代码,在 wire 上就是两次独立请求。
-
SETNX key value返回 1 → 加锁成功 - 紧接着
EXPIRE key 30失败 → 锁无过期时间 - 后续所有请求都因 key 存在被拒,服务卡死
所以必须用原子命令:SET key value NX PX 30000,其中 NX 和 PX 是同一请求的参数,Redis 内部保证执行一致性。
go-redis/v8 中正确调用 SetNX 的方式
go-redis/v8 的 SetNX 方法本身支持过期时间,但容易误用:它接受 time.Duration,不是毫秒整数。传错单位(比如传 30 当作秒)会导致锁 30 纳秒后就失效。
- ✅ 正确:
rdb.SetNX(ctx, "order:123", "req-abc", 30*time.Second) - ❌ 错误:
rdb.SetNX(ctx, "order:123", "req-abc", 30)(单位是纳秒,锁几乎立刻过期) - ⚠️ 注意:
SetNX返回bool,不是 error ——false表示 key 已存在,不是网络错误;需结合ctx.Err()判断超时或取消
别依赖返回值真假做全部判断,要加 context 控制整体等待上限:
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
ok, err := rdb.SetNX(ctx, key, value, expire).Result()
if err != nil {
// 网络错误、Redis 不可用等
return false
}
if !ok {
// 锁已被占,非错误,是预期行为
return false
}
释放锁时为什么必须校验 value
只用 DEL key 会误删别人持有的锁。比如 A 拿到锁,业务耗时 35s,锁 30s 过期;B 在 31s 时成功加锁;A 在 36s 执行 DEL,删掉的是 B 的锁。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
安全做法是 Lua 脚本原子比对 + 删除:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
Go 中调用:
script := redis.NewScript(luaScript)
result, err := script.Run(ctx, rdb, []string{key}, value).Result()
// result == int64(1) 表示删除成功,0 表示 value 不匹配
- value 必须全局唯一(推荐用 UUID 或
os.Getpid()+goroutine id+纳秒时间戳拼接) - 不要用固定字符串如
"1"或"locked",否则所有客户端都能删锁 - 脚本执行失败(如 Redis cluster 重定向)需重试或记录告警,不能静默忽略
锁续期(renew)不是可选功能,而是高危操作的必选项
业务处理时间不确定时,硬设一个长过期时间(如 10 分钟)反而更危险:锁长期滞留,故障恢复慢;而设短时间(如 5 秒)又极易提前释放。
真实场景中,应启动一个单独 goroutine 做续期,但必须满足三个条件:
- 续期动作本身要有超时控制(
context.WithTimeout),避免阻塞主流程 - 每次续期前先检查锁是否还属于自己(用上面的 Lua 校验脚本读取 value)
- 主业务结束或 panic 时,必须显式停止续期 goroutine(用
context.CancelFunc或 channel 通知)
漏掉第三点会导致:锁已释放,续期协程还在反复调用 EXPIRE,干扰其他客户端,甚至把别人的锁续上。
复杂点在于,续期逻辑和业务生命周期耦合紧密——这不是加个 middleware 就能解决的事,必须在每个锁实例里封装状态机(acquired → renewing → released)。

















