九十九个线程的游行示威
大促零点,会员卡 8801 的余额刚好够买限量套餐,一百个请求同时打进来,都要抢 lock:card:8801。一个抢到,进临界区干活,假设活要干 200 毫秒。剩下九十九个线程在干嘛?最朴素的实现是 while 循环:
// 反面教材:裸自旋
while (!tryLock()) {
// 什么都不干,立刻再试
}九十九个线程以最大频率疯狂 SET,200 毫秒内打出几十万次抢锁请求——抢锁的流量比业务本身大一千倍,Redis 的 QPS 被无效重试打满,别的业务跟着遭殃。这就是锁竞争的第一课:等待的姿势,比加锁本身更影响系统健康。
第一档:有纪律的重试
最低成本的改良是给自旋上纪律:加随机退避(失败后睡随机毫秒再试),限制总等待时长(tryLock 的 waitTime 参数),到点放弃走降级。Redisson 的 tryLock(2, 30, TimeUnit.SECONDS) 就是这个语义:最多等 2 秒,抢不到别杵着。退避把九十九路持续轰击变成稀疏试探,Redis 压力立减。但它有个天花板:重试的粒度靠猜——睡 50 毫秒,可能锁 100 毫秒前就释放了,白等;睡 200 毫秒,可能锁刚释放就被人抢走,错过。想不猜,就得让「锁释放」这个事件主动通知等待者——等待唤醒登场。
第二档:pub/sub 唤醒
Redisson 的做法:每个锁名对应一个 pub/sub 频道,抢锁失败的线程订阅这个频道然后挂起(不占 CPU);持锁线程 unlock 时,Lua 里顺手向频道 publish 一条「解锁了」;等待者被唤醒,重新参与抢锁。这就是 lock.lock() 底层的等待机制(tryLock 则用信号量加超时实现)。相比自旋,锁释放到有人知晓,从「下一次轮询」缩短到「毫秒级推送」,Redis 也不用承受无效轰炸。
但它有两个诚实的缺陷。其一,pub/sub 是即发即弃:连接抖动错过那条 publish,等待者就睡着了——所以唤醒必须配超时兜底,超时后主动重试一轮,不能纯靠通知。其二,广播不是点播:解锁唤醒的是频道里所有等待者,九十九个一起惊醒、一起扑向锁,九十八个再次失败——这叫惊群。惊群多发生在竞争激烈的锁上,而激烈的锁本身就该反思设计。
第三档:公平锁与第四档:单飞
Redisson 的 FairLock 用一个 Redis list 给请求排队:抢锁先入队,队头才有资格尝试加锁,unlock 时摘掉队头并唤醒下一个——先到先得,无惊群,天然防饿死。代价是每次加锁多一串 list 操作(入队、看队头、比对自己的位置),吞吐明显低于非公平锁,竞争越激烈衰减越狠。JVM 内 ReentrantLock 公平模式的教训在分布式下原样重演:公平是有价的,默认别开。
更高一档的解法跳出锁本身——一百个线程抢一把锁,多数时候抢的是同一份结果,那就让一个人干活,大家等结果。这就是单飞(singleflight)模式:第一个请求抢锁、干活、把结果写进 Redis(带短 TTL);其余请求抢锁失败后不再执着于锁,先查结果 key,有结果直接拿走。缓存回源防击穿正是这个套路(Redis 系列第 5 篇),把「九十九次重活」收敛成「一次重活加九十九次读」,锁的竞争从根上消失。
| 等待策略 | Redis 压力 | 响应延迟 | 适用 |
|---|---|---|---|
| 裸自旋 | 爆炸,禁用 | 最短 | 反面教材 |
| 退避 + 超时 | 低 | 取决于重试间隔 | 竞争温和,抢不到可放弃 |
| pub/sub 唤醒 | 低 | 毫秒级 | 通用默认(Redisson lock 底层) |
| 公平排队 | 中 | 按序等待,无饿死 | 必须按序执行的少数场景 |
| 单飞收敛 | 极低 | 第一个干活的慢,其余近零 | 抢同一份结果的读场景 |
老王的领悟很到位:「一百个人抢一把锁,先想想是不是本来就不该一百个人都来抢。」锁是排他工具,能用共享结果收敛的竞争,别用互斥硬扛。下一篇回到安全性主线,讲 Redis 锁最被诟病的软肋——主从切换那一瞬间,锁会凭空消失。
评论 (0)