直接用 SETNX 加 EXPIRE 会因非原子性导致死锁:SETNX 成功后进程崩溃则锁无法释放;必须用 SET key value NX EX ttl 原子命令,释放时需 Lua 脚本校验 value 防误删。

为什么直接用 SETNX 加 EXPIRE 会出问题
因为 SETNX 和 EXPIRE 是两个独立命令,中间存在时间窗口:如果进程在执行 SETNX 成功后、还没来得及 EXPIRE 就崩溃或被杀,锁就永远不释放——变成死锁。
常见错误现象:redis-cli 里能查到 key 存在但没 TTL,后续所有请求都卡住;日志里反复出现“获取锁超时”,但 Redis 中锁 key 持续存在。
- 必须用原子操作,不能拆成两步
- Go 客户端(如
github.com/go-redis/redis/v9)的Set方法支持一次性设置值 + 过期时间 + 条件(SET NX EX) - 底层实际发的是
SET key value NX EX 30这类单条命令,Redis 保证原子性
Go 用 Set 实现带超时的互斥锁(v9 客户端)
核心是调用 client.Set(ctx, key, value, ttl) 并传入 redis.SetArgs{Mode: redis.SetNX},而不是自己拼 SETNX + EXPIRE。
示例中 value 用随机 UUID 是为了防止误删别人持有的锁(后续释放时要校验 value):
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
lockKey := "order:12345"
lockValue := uuid.NewString() // 唯一标识当前持有者
ttl := 10 * time.Second
<p>ok, err := client.Set(ctx, lockKey, lockValue, redis.SetArgs{
Mode: redis.SetNX,
Expire: ttl,
}).Result()
if err != nil {
return false, err
}
if !ok {
return false, nil // 锁已被占用
}
return true, nil
-
Mode: redis.SetNX等价于NX参数,确保只在 key 不存在时设值 - 不要用
SETNX+SETEX组合,SETEX不支持条件写入 - 超时时间建议设为业务最大执行时间的 2–3 倍,避免正常流程未完成就被释放
释放锁时为什么必须用 Lua 脚本校验 value
直接 DEL key 会删掉别人刚抢到的锁。比如 A 设置了锁但执行慢,B 等超时后重新获得锁,此时 A 执行完调 DEL,就把 B 的锁干掉了。
正确做法:用 Lua 脚本读取 key 当前值,仅当等于自己的 lockValue 时才删除:
script := redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
_, err := script.Run(ctx, client, []string{lockKey}, lockValue).Result()
- 脚本中
KEYS[1]是锁 key,ARGV[1]是当初设置的随机 value - 返回
1表示删除成功,0表示 value 不匹配(锁已易主) - 不要用 Go 自己读+判断+删——这三步非原子,同样有竞态
锁自动续期(renew)和可重入怎么处理
标准 SETNX 锁不支持重入;自动续期也需额外设计。简单场景下,建议避开这两个需求;真需要,得自己维护持有者计数或用 Redlock + 客户端心跳。
但一个容易被忽略的点是:即使加了超时,如果业务逻辑里有阻塞 IO(如 HTTP 调用、数据库慢查询),锁仍可能提前过期。所以:
- 锁超时时间 ≠ 业务总耗时,而是「预期最坏情况下的单次执行时间」
- 关键路径上避免同步等待外部服务;必须等的话,考虑降级或异步化
- 不要依赖锁长期持有——它只是防并发写,不是任务队列

















