秒杀系统设计(二):从Lua分布式锁到Redisson的进阶之路
在上一章我们用 Lua 脚本实现了一个“能用”的分布式锁,但实战一跑,问题很快暴露出来:不可重入、不可重试、锁超时释放以及主从一致性风险。表面上看只是几个角落,实际上是高并发系统里最容易出事故的地方。本篇将把视角从“自己写锁”转向“框架化落地”,引入 Redisson,并深入它的实现原理与核心源码流程,真正弄明白它为什么可靠。
一、Lua 脚本方案的四个痛点
在 Redis 上用 Lua + setnx/expire 做锁的“原型”非常清爽,但代价是稳定性与可维护性的长期欠账。
第一是不可重入。单线程同一业务流程多层调用时,第一次加锁成功,第二次会被视为“抢锁”,直接失败。业务上最常见的表现就是“本来是自己持有的锁,却把自己拒之门外”。
第二是不可重试。Lua 脚本只返回成功或失败,如果失败,调用方只能自己写 while 循环 + sleep,既丑又不精确,也缺乏被唤醒的机制;线程只能靠轮询消耗 CPU。
第三是锁超时释放的风险。为了防止死锁,我们会设置过期时间。但业务耗时无法完全预测,若业务执行超时,锁会被 Redis 自动释放,其他线程趁机进入,导致并发安全失效。
第四是主从一致性问题。在 Redis 主从切换瞬间,可能发生锁写入主节点但未同步到从节点,主节点宕机后从节点晋升,锁记录丢失,从而出现“锁丢失导致并发穿透”。
这些问题并不是某一条 Lua 能解决的,而是锁体系完整性的系统问题。这正是 Redisson 的价值所在。
二、Redisson 是什么?我们如何改造
Redisson 是一个基于 Redis 的 Java 分布式对象与工具库,提供了分布式锁、队列、布隆过滤器、延迟队列等高级特性。它把“锁”从一个 Lua 脚本,升级为一整套完整协议:可重入、可重试、自动续期、看门狗、订阅通知、故障保护。
项目接入非常简单,我们只需要配置 RedissonClient,通过 getLock 取得 RLock,再像本地锁一样调用 lock/unlock。
1 | // 1. 配置 |
注意,我们的“改造”并没有在业务层复杂化,核心只是替换锁实现。但真正的价值不在接口层,而在 Redisson 内部的实现机制。
三、Redisson 原理与源码实现(重点)
以下三部分,是理解 Redisson 的灵魂。
3.1 可重入锁原理:同一线程可多次获得锁
Redisson 的可重入锁实现并不是简单的“锁重入计数”,而是把锁的状态存成一个 Redis Hash,用 “线程粒度计数” 来表达“谁持有锁、持有了几层”。它把 lockKey 对应的 value 设计为一个 Hash:
- key:锁名,例如
lock:order:123 - field:
uuid:threadId(Redisson 客户端实例唯一标识 + 当前线程 id) - value:重入次数(同一线程第几次进入)
把这个结构想象成下面这样会更直观:
1 | lock:order:123 (Hash) |
这表示:只有一个线程持有这把锁(9c1f...:41),而且它已经重入了 2 次。
如果你在 Redis 里执行 HGETALL lock:order:123,看到的就是「field-value」对。换句话说,Redis 并不是“把线程放进锁里排队”,它只保存“当前持有者是谁 + 重入次数是多少”。其他线程在抢锁失败时不会写入 Hash(它们只会拿到一个 TTL 然后去等待/订阅)。
加锁时会走一个 Lua 脚本,核心逻辑如下:
- 如果锁不存在,设置 hash 并写入重入次数为 1。
- 如果锁存在且 field 是当前线程,重入次数 +1。
- 否则返回失败。
源码路径大致为:RedissonLock.java -> tryLockInnerAsync,内部通过 EVAL 执行一段 Lua 脚本来保证原子性。
1 | -- 参数约定(Redisson 内部传入) |
可重入的关键,是“同一线程再次获取锁时并不阻塞,而是把自己的 field 的 value 递增”。释放时则相反:每次 unlock 会把 value -1,只有当计数减到 0,才会真正删除 lockKey 并通知其他线程。
你可以把解锁想象为这两步(逻辑上):
HINCRBY lockKey field -1;如果结果 > 0,说明还有重入层,锁仍归我。- 结果 == 0,删除 key(
DEL lockKey)并PUBLISH唤醒等待者。
1 | flowchart TD |
这解决了 Lua 锁的第一个致命缺陷:可重入。
3.2 锁重试与 WatchDog 机制:安全与性能的双保险
Lua 方案失败就只能 sleep 重试,而 Redisson 把“等待”做成了两件事:该阻塞就阻塞(但不忙等)、该续期就续期(但只给真正持有锁的线程续期)。
锁重试机制(关键:不忙等)
当 tryLock 失败时,上面 Lua 会返回一个 TTL(pttl),这对 Redisson 很重要:它让等待变得“有边界”。Redisson 的典型策略是:
- 先执行一次
tryAcquire(Lua)。 - 如果返回
nil,说明拿到锁,直接进入业务。 - 如果返回 TTL(比如 17842ms),说明被别人占着。
- 这时不会
while(true){sleep(50)}轮询,而是:- 订阅解锁通知频道(内部是
redisson_lock__channel:{lockKey}这类命名) - 在本地用一个“信号量/门闩”阻塞等待
- 等到两种事件之一发生:
- 收到
unlock的PUBLISH通知:立刻醒来,马上重试 - TTL 时间到了还没通知:也会醒来重试(避免漏通知/网络抖动导致永久睡死)
- 收到
- 订阅解锁通知频道(内部是
这样做的结果是:等待线程几乎不消耗 CPU,并且在锁释放的那一刻能快速响应。
WatchDog 机制(关键:只给“仍然持有锁”的线程续期)
手写 Lua 锁通常会写死一个过期时间:比如 10 秒。业务一旦超过 10 秒,锁就“自然死亡”,并发安全直接失效。Redisson 的处理更贴近生产:
- 如果你调用的是
lock()(不指定 leaseTime),Redisson 默认开启 WatchDog。 - 默认租期通常是 30s(由
lockWatchdogTimeout控制)。 - WatchDog 会启动一个定时续期任务,典型续期间隔约为租期的 1/3(例如 30s 的租期,每 10s 续一次)。
续期时并不是无脑 PEXPIRE lockKey 30000,而是会先确认“锁还归我”。内部同样靠 Lua 保证原子性,大意是:
HEXISTS lockKey field必须为 1(我仍是持有者)- 才会
PEXPIRE刷新 TTL - 否则不续期(说明锁已释放/已转移,WatchDog 立刻停止)
这让 WatchDog 成为一个非常克制的机制:只要业务线程还在、且锁还在自己名下,锁就不会因为“固定 TTL”而误释放;一旦业务线程崩溃或执行结束,续期自然停止,锁也会在 TTL 到期后释放,避免死锁。
还有一个容易忽略的点:如果你显式调用 lock(leaseTime, unit) 或 tryLock(waitTime, leaseTime, unit) 指定了租期,Redisson 通常不会启用 WatchDog(因为你已经明确声明了租期)。这能避免无限续期带来的资源占用,同时把超时语义交给业务。
源码中关键点(便于你带着目的去看源码):
- 竞争锁:
RedissonLock.lock()→lockInterruptibly()→tryAcquire()/tryAcquireAsync() - 订阅与唤醒:内部 LockPubSub(订阅 channel,收到消息释放本地门闩)
- WatchDog 启动:
scheduleExpirationRenewal() - WatchDog 续期:
renewExpiration()(Lua 校验持有者后再PEXPIRE)
1 | sequenceDiagram |
这一套机制让“重试”不再靠 sleep,性能更优;也让“超时释放”不再由固定 TTL 决定,而是与业务线程生命周期绑定,极大提升了安全性。
3.3 MultiLock 机制:多节点一致性保证
在 Redis 主从切换问题上,单点锁有概率失效。Redisson 提供 MultiLock(也叫联锁),它会将同一把锁分布在多个独立 Redis 实例上,只有当全部实例都加锁成功时,才认为整体加锁成功。否则会释放已加的锁并重试。
核心实现位于 RedissonMultiLock.java,逻辑类似分布式事务中的“两阶段尝试”:
- 依次尝试获取所有子锁。
- 若有任意子锁失败,则释放已获取的锁并等待重试。
1 | flowchart TD |
它的优势在于“抗单点故障”,代价是性能下降与复杂度增加,因此只用于高价值场景(如资金、订单扣减)。在企业落地中,一般先用单实例 RedissonLock,极端场景再升级到 MultiLock 或 RedLock。
四、总结:别再手写锁,选择合适框架更重要
Lua 分布式锁让我们理解了锁的本质,但它解决不了工程化落地的全套问题。Redisson 把“锁”做成了一个体系:可重入、可重试、自动续期、锁通知、联锁保障,真正适用于生产环境。
企业开发中,最可贵的不是“自己写出来”,而是“用对正确的框架”。选对框架,意味着安全、稳定、可维护,意味着团队把精力放在业务增长而不是基础设施踩坑上。这正是 Redisson 给我们的核心启示。