秒杀系统设计(二):从Lua分布式锁到Redisson的进阶之路
在上一章我们用说对于超卖问题和一人一单可以用悲观锁/乐观锁来解决,但这些都是单机锁,在分布式环境下是完全失效的。我们需要一种跨越多个 JVM、跨越多个进程的全局锁机制——分布式锁。
一、Lua 脚本与Lua脚本的问题
我们的项目在之前的超卖问题里,采用乐观锁的方式解决了单机环境下的并发安全问题,但在分布式环境下,单机锁(无论是悲观锁还是乐观锁)都无法保证全局的互斥访问。此外的一人一单问题,目前是用 synchronized 锁住了 userId,这在单机环境下是可行的,但在分布式环境下同样会失效。因为不同 JVM 的线程无法共享同一个锁对象。所以我们需要一个分布式锁,来保证在分布式环境下的并发安全。最初我们的方案是setnx命令,这个命令很简单,给redis设置一个key,如果这个key不存在就设置成功,存在就失败。我们可以利用这个命令来实现分布式锁的功能:
1 | // SETNX 命令:如果 key 不存在则设置,存在则返回 false |
但是问题是,这个方案存在一些严重的缺陷:
- 死锁风险:如果持有锁的线程在业务执行过程中崩溃了,那么这个锁就永远不会被释放,导致其他线程无法获取锁,形成死锁。
- 续期问题:如果业务执行时间超过了锁的过期时间,那么锁就会被自动释放,其他线程可能会获取到这个锁,导致并发安全问题。
- 性能问题:在高并发场景下,频繁的
setnx和del操作会对 Redis 造成很大的压力,可能会成为系统的性能瓶颈。
所以我们考虑一步步来升级,先看lua,lua脚本是redis提供的一种功能,可以让我们在redis服务器端执行一段lua脚本,这段脚本可以包含多个redis命令,并且这些命令会被原子性地执行。我们可以利用lua脚本来实现一个更安全的分布式锁,解决上面提到的死锁和续期问题,当然lua本身是一种语言,只不过redis可以执行它,所以我们需要写一段lua脚本来实现分布式锁的功能:
我们可以用lua来做到以下几点:
- 原子性:通过lua脚本,我们可以把获取锁和设置过期时间的操作放在一起执行,保证它们的原子性,避免死锁问题。
- 续期机制:我们可以在lua脚本中实现一个续期机制,当业务执行时间超过锁的过期时间时,自动续期,保证锁不会被意外释放。
- 性能优化:通过lua脚本,我们可以减少与redis的交互次数,提升性能。
不过主要是可以用来先做一人一单的改造,锁住userId,保证同一用户只能买一单。
或者通过redis的set结构搭配lua脚本来实现一个分布式的布隆过滤器,来判断用户是否已经购买过了,从而实现一人一单的功能。
这个项目结束的时候,我们采用的便是Redis+Lua做预拦截,也就是所谓的快速响应和处理,把判断资格和扣库存的逻辑都放在lua脚本里执行,保证原子性和性能。具体落库我们可以用RabbitMQ来异步处理,保证秒杀系统的高性能和高可用。不过不代表这就完全安全了,还是需要分布式锁,来保证在分布式环境下的并发安全。因为在分布式环境下,单机锁(无论是悲观锁还是乐观锁)都无法保证全局的互斥访问。
这里Lua脚本被我们用来做一人一单而不做最终的分布式锁,主要是因为还是有这些问题: - 复杂性:编写和维护lua脚本相对较复杂,尤其是在处理错误和异常情况时,可能会增加开发和调试的难度。
- 可读性:对于不熟悉lua的开发者来说,lua脚本可能不太直观,降低了代码的可读性和可维护性。
- 功能限制:虽然lua脚本可以实现分布式锁的基本功能,但它缺乏一些高级特性,如自动续期、看门狗机制等,这些特性对于高并发场景下的分布式锁来说是非常重要的。
因此,我们最终选择了Redisson这个第三方库来实现分布式锁,Redisson提供了丰富的功能和更好的性能,能够满足我们在秒杀系统中对分布式锁的需求。
二、Redisson 是什么?我们如何改造
Redisson 是一个基于 Redis 的 Java 分布式对象与工具库,提供了分布式锁、队列、布隆过滤器、延迟队列等高级特性。它把“锁”从一个 Lua 脚本,升级为一整套完整协议:可重入、可重试、自动续期、看门狗、订阅通知、故障保护。
项目接入非常简单,我们只需要配置 RedissonClient,通过 getLock 取得 RLock,再像本地锁一样调用 lock/unlock。
1 | // 1. 配置 |
注意,我们的“改造”并没有在业务层复杂化,核心只是替换锁实现。但真正的价值不在接口层,而在 Redisson 内部的实现机制。
为什么我们选Redisson?因为它提供了自动续期和看门狗机制,解决了分布式锁的死锁和续期问题,同时它的性能也经过了大量的优化,能够满足我们秒杀系统的高并发需求。Redisson在项目里用于秒杀系统的分布式锁,主要是为了保证在分布式环境下的并发安全,防止超卖和一人多单的问题。通过Redisson,我们可以轻松地实现分布式锁的功能,同时也能享受到它提供的高级特性,如自动续期和看门狗机制,进一步提升系统的稳定性和可靠性。在Redis+Lua快速拦截后,Reddison再解决下单的落库等问题,保证分布式环境下的并发安全。
三、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 把“锁”做成了一个体系:可重入、可重试、自动续期、锁通知、联锁保障,真正适用于生产环境。Lua最终和Redis一起负责快速拦截和处理,而Redisson则负责下单的落库等问题。不过这个项目的视频看完后毕竟只是了解,个人感觉如果真的用于项目开发,还是需要多加了解。



