直接用 set key value nx px 不够安全,因其仅保证加锁原子性,未校验锁持有者且删锁非原子,易致误删;须用唯一标识+lua脚本删锁,并设合理过期时间。

为什么直接用 SET key value NX PX 不够安全
很多初学者以为发一条带过期时间的 SET 命令就能当分布式锁用,但实际会出问题:如果业务执行超时,锁自动过期了,另一个节点拿到锁,而前一个节点还继续删锁(DEL),就可能误删别人的锁。核心问题是「加锁」和「删锁」不是原子操作,且没有持有者校验。
正确做法是加锁时写入唯一标识(比如 UUID),删锁时用 Lua 脚本比对标识再删除——这样删锁动作才是原子的。
无序列表说明关键点:
-
SET key value NX PX 30000只保证加锁原子性,不解决误删 - 客户端必须生成全局唯一、可追溯的
lock_value(推荐uuid.NewString()) -
DEL永远不能直接调用,必须走 Lua 脚本:if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end - 过期时间(PX)要明显大于单次业务最大耗时,否则锁提前释放会导致并发冲突
如何用 github.com/go-redis/redis/v9 实现可重入、带续期的锁
官方 Redis Go 客户端 v9 提供了 redis.NewLock,但它默认不支持自动续期(renewal),也不处理锁等待逻辑。生产中建议封装一层,重点控制三件事:锁获取、心跳续期、异常释放。
实操建议:
初始化并管理阿里云SDK Go 客户端,涵盖 sync.Once 单例、goroutine 安全、endpoint 与 region 配置、VPC 端点及调试。
- 用
redis.NewLock初始化时,传入redis.WithTries(1)和redis.WithTimeout(5 * time.Second)避免无限重试 - 手动续期需另起 goroutine,每
ttl/3时间调用lock.Renew(ctx, ttl),注意检查err == redis.ErrNil(锁已丢失) - 务必在 defer 中调用
lock.Unlock(ctx),但得包一层 recover,防止 panic 导致锁没释放 - 不要依赖
lock.Lock()的返回值判断是否持有锁——它只表示加锁命令是否成功,不反映业务是否真正进入临界区
SET 命令参数选 NX 还是 XX?
必须用 NX(Not eXists),这是实现“抢占式加锁”的前提。用 XX 是想“只更新已有锁”,但初始加锁时 key 根本不存在,XX 直接失败,锁永远拿不到。
另外两个常被忽略的细节:
-
PX(毫秒)比EX(秒)更精确,尤其当业务耗时在秒级以内时,避免锁残留过久 - 不要拼接字符串构造 value,例如
"host:port:" + time.Now().String()—— 时间重复、无唯一性;必须用随机 token - Redis 7.0+ 支持
SET key value NX PX 10000 GET,能原子读旧值,可用于实现更复杂的锁升级逻辑,但 v6 及以下不支持,兼容性需评估
锁失效后如何避免“脑裂”式双写
即使锁逻辑写对了,网络分区仍可能导致两个节点都认为自己持锁。这不是锁本身能解决的,得靠业务层防御。
可行手段包括:
- 所有写操作前,先用 Lua 脚本双重校验:
if redis.call("get", KEYS[1]) == ARGV[1] then ... do work ... end - 数据库写入时加上 version 字段或条件 WHERE lock_token = ?,让冲突落地到 DB 层回滚
- 记录锁生命周期日志(加锁 IP、goroutine id、业务 trace_id),出问题时可快速定位哪个节点“以为自己有锁”
- 避免在锁内做 RPC 调用或 IO 等待,这类操作极易超时,把锁拖成“长尾锁”
最麻烦的从来不是怎么加锁,而是怎么证明你真的还在用它——心跳、上下文取消、panic 捕获,每一处都得对齐业务真实生命周期。










