最朴素的开始:SETNX
解决两店同时扣款,老王的第一版方案朴素得可爱:Redis 里放个 key,谁的请求能把这个 key 写进去,谁就拿到锁。
SETNX lock:card:8801 1 # key 不存在才写入,返回 1 表示抢到锁
# ……执行扣款业务……
DEL lock:card:8801 # 干完活,释放锁SETNX(SET if Not eXists)的妙处在于「判断存在」和「写入」是一条命令,Redis 单线程执行命令,天然原子——不存在「两个请求同时看到 key 不存在、然后都写入」的竞态。互斥这一关,过了。但这套代码上线第二天就炸了:A 实例抢到锁之后,执行扣款时恰好碰上老王的服务器断电重启,DEL 没来得及执行。这把锁就这么永远留在 Redis 里,后续所有扣款请求都在 SETNX 上碰壁,整张卡的业务停摆——锁是防死锁的,结果自己成了死锁。
第一代坑:加过期时间,却拆成了两步
解决「持有人失联」的直觉答案是加过期时间,锁超时自动释放。于是代码变成了:
SETNX lock:card:8801 1 # 抢锁
EXPIRE lock:card:8801 10 # 设 10 秒过期两步写法,原子性碎了。SETNX 成功之后、EXPIRE 执行之前,客户端崩了——锁写进去了但没设过期,永生锁死灰复燃。这不是抬杠,进程被 kill -9、网络抖断、GC 停顿,任何一种都能让程序死在两行代码之间。根治要等 Redis 2.6.12:SET 命令支持一条顶三条——
SET lock:card:8801 8f3a9c NX PX 10000
# NX:不存在才设置(互斥);PX 10000:10 秒过期(防死锁)
# 返回 OK 拿到锁,返回 nil 被别人抢了一条命令,原子地把「互斥」和「防死锁」两个指标同时拿下。顺手把 value 从固定值 1 换成了随机字符串 8f3a9c——这是为下一个坑埋的伏笔。
第二代坑:DEL 删了别人的锁
设想过这样一个时间线:A 抢到锁,执行扣款;碰上数据库抖动,业务跑了 12 秒;锁 10 秒到期自动释放;B 顺势抢到锁开始干活;A 终于跑完了,很自觉地去 DEL——删掉的是 B 的锁。C 又抢到锁,A、B、C 三个「持有人」同时在临界区里跑,互斥碎了一地。锁过期本身没错,错在 A 释放锁的时候根本没看这把锁还是不是自己的。这就是第一篇说的「可辨识」:value 里存唯一标识(UUID 或机器号加线程号),删除之前先核对——
// 错误示范:校验和删除分两步,照样有竞态
if (redis.get(lockKey).equals(myId)) { // 校验:还是我的
redis.del(lockKey); // ← 就在这一瞬间,锁过期,B 抢到
}校验通过到 DEL 执行之间,锁可能恰好过期、B 可能恰好抢到——校验的是「过去」,删除的是「现在」,两步之间隔着无限可能。GET 和 DEL 必须原子完成,Redis 单线程能原子执行的是「一条命令」,那就把两步打包成一条——Lua 脚本在 Redis 端被当作一个整体执行:
-- 安全释放:身份匹配才删,整个脚本原子执行
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end三代写法一张表
| 写法 | 解决的问题 | 留下的坑 |
|---|---|---|
| SETNX + DEL | 互斥 | 持有人失联,锁永生 |
| SETNX + EXPIRE | 锁超时自动释放 | 两步不原子,永生锁复活 |
| SET NX PX + UUID + Lua 删除 | 互斥、防死锁、可辨识全齐 | 业务时长没有上限,锁先过期怎么办 |
第三行最后一格是全系列最深的一个坑:锁的过期时间必须大于业务执行时长,但业务时长理论上没有上限——数据库就是可以抖 30 秒,GC 就是可以停 20 秒(分布式事务系列第 14 篇讲过三态结果,这里的根源一样)。过期时间设多短都赌不赢,正确的姿势是「锁快过期了,持有人还在干活,就给它续命」——这就是大名鼎鼎的看门狗机制,下一篇的主角。
评论 (0)