PHP 8.6 的闭包优化 RFC 以 24:0 通过,但 static 自动推断在 8 月被撤回,那个 3% 只对显式写了 static 的闭包生效;同时 35 项弃用投票有 4 项被否,list() 以 23:23 保住。我把自己的 626 个 PHP 文件扫了一遍,自有代码零命中,升级计划随之改掉。
Redlock 大概是分布式系统里被讨论得最多、被真正用得最少的一种锁。antirez 和 Martin Kleppmann 2016 年那场论战把它的尴尬摆上了台面,我的看法很直接:Redis 做分布式锁就单实例加看门狗,要正确性就上 etcd,Redlock 卡在中间两头不靠,别选它。这篇文章把三种方案的真实边界讲清楚,顺便告诉你一把合格的 Redis 锁到底怎么写。
一把合格的 Redis 锁长什么样
先写对再说。90% 的"分布式锁不靠谱",问题不在 Redis,在那三行代码。
加锁:
SET lock:order:1001 7f3a9c2e-1b64-4d5a NX PX 30000
释放:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
这四行里有三个不能省的点。
value 必须是每次生成的唯一串(UUID 就行)。为什么?释放锁的时候要先确认"这把锁还是不是我的"。删库容易,删对难——value 就是你的身份证。我见过把 value 写成固定字符串的代码,两个进程互相删对方的锁,删得理直气壮,超卖照旧。
释放必须用 Lua 原子比对再删。先 GET 再 DEL 两步走,中间夹一次网络往返,恰好在你 GET 完之后锁过期、别人拿到了锁,你的 DEL 就删到了别人头上。Lua 脚本在 Redis 里原子执行,这个窗口不存在。SET 的 NX PX 一个命令同理,别用 SETNX 加 EXPIRE 两步拼——两步之间进程挂了,你就得到一把永不过期的锁,全员给你陪葬。
过期时间必须有。没有 PX 的锁在持锁进程崩溃时就是死锁。
就这些。单实例 Redis 上,这四行代码给你的保证是:同一时刻只有一个客户端持有锁,锁最多 30 秒自动释放,不会误删。对绝大多数场景——防重复提交、定时任务防多跑、缓存击穿加锁回源——这套保证够用了。
过期时间定多少
经典权衡:定短了,业务没跑完锁就被别人抢走,等于没锁;定长了,持锁进程崩了,所有人干等一个死人。
Redisson 在 Java 圈给出的答案是看门狗:默认 30 秒过期,后台每 10 秒续一次期,进程活着就一直续,进程死了最多 30 秒自动放手。这个设计把权衡变成了"你不用再选",我个人认为它是整个 Redis 锁生态里最值得抄的一个设计。
PHP 这边没有官方看门狗,php-fpm 的请求结束进程就还回去了,没有常驻的东西帮你续期。所以 PHP 圈的共识是另一个方向:临界区切短,锁内只做必须互斥的那几条 SQL,5 秒够不着的那种,别把调第三方接口、发邮件这种慢活塞进锁里。这其实是更普适的原则——Java 有看门狗也不该把慢活锁进去。
Redlock 到底在解决什么问题
单实例锁的软肋:Redis 主从是异步复制的。你在 master 上加锁成功,master 还没把这条写同步给 slave 就宕机了,slave 升级成新 master,锁没了,第二个客户端在新 master 上加锁成功。两个客户端同时持锁。
Redlock 的方案:部署 5 个完全独立的 Redis 主节点,加锁时依次向 5 个节点请求,拿到多数派(至少 3 个)且总耗时小于锁有效期,才算成功。
听起来很稳。然后 Kleppmann 2016 年那篇《How to do distributed locking》把它拆了:客户端 1 拿到锁之后发生了一次长时间 GC 停顿(或者时钟跳变),停顿期间锁过期,客户端 2 拿到锁开始写入,客户端 1 苏醒,自认为还持着锁,也开始写入——共享存储被两个"持锁者"先后写坏。Redlock 对此无能为力,因为它给不出 fencing token——一个单调递增的令牌,由共享存储端校验、拒绝旧持有者的写入。antirez 的反驳围绕 GC 停顿在实践中的可控性、Redis 的实现细节,也承认 fencing token 需要 Redis 之外的支持。
这场架吵到今年整整十年,没有胜负。我认为没有胜负本身就是答案:一个理论上吵不出正确性、实践上要多养 5 个独立节点的方案,它解决的问题(Redis 挂了锁不能丢),用 etcd 的租约加 session 更直接地解决了,还是 CP 系统。
真实经历佐证一下"标识对了链路才通"这件事有多容易翻车:我这个博客上线 Markdown 支持时踩过一次坑,文章入库了、正文也在 contents 表里躺着,前台就是不渲染,一坨原始 ## 和 ** 糊在页面上。排查半天发现是入库时漏传了 content_type 字段——前台靠它决定当 Markdown 还是富文本解析。上游差一个标识,下游全盘皆错,锁的 value 是同一个道理,只是它的代价不是渲染错乱,是你删了别人的锁、或者拿着一把已经不存在的锁在写数据。
那什么时候该换掉 Redis 锁
| 场景 | 推荐 | 理由 |
|---|---|---|
| 防重、防缓存击穿、任务防多跑 | 单实例 Redis + 唯一 value + Lua + 看门狗 | 锁偶尔丢了没有实际损失,简单压倒一切 |
| 扣库存、转账、订单状态机 | etcd/ZooKeeper + fencing token,或数据库唯一约束兜底 | 锁丢了就是超卖和资损,正确性压倒一切 |
| 多机房、Redis 本来就是集群 | etcd | Redlock 的"独立节点"在云上经常是假的 |
注意第二行:真正该兜底的从来不是锁,是锁外面的东西——数据库唯一索引、幂等表、状态机的条件更新(UPDATE ... WHERE status=1)。锁只是减少无效竞争的优化手段,谁拿锁当唯一正确性防线,谁迟早超卖一次。
收个尾
Redlock 这个方案,我赌它会在接下来几年里慢慢淡出讨论——新项目要正确性直接 etcd,要简单单实例加看门狗,它卡在中间的位置一年比一年尴尬。antirez 自己后来主推的功能里也没有它的位置。你要是现在手上有 Redlock,先去看看那五个节点是不是真的独立部署的;凑数的多数派,比单实例还危险,因为它给了你一种"我做了高可用"的错觉。