【黑马点评】Redisson 分布式锁核心原理剖析
1. 背景与问题定义
在《黑马点评》秒杀业务的高并发场景下,核心挑战在于如何保证“一人一单”的数据一致性。
项目初期,我们经历了从 JVM 本地锁 (synchronized) 到 Redis 简易分布式锁 (setnx) 的演进。然而,这两种方案在生产环境中均存在显著缺陷:
- JVM 锁:受限于 JVM 进程,无法在集群部署下保证跨节点的互斥性。
- SETNX 锁:虽然实现了分布式互斥,但缺乏可重入性(Reentrancy)、锁续期机制(Renewal)以及高效的阻塞重试机制。
为了解决上述问题,引入 Redisson 框架成为必然选择。Redisson 不仅是一个 Redis 客户端,更是一套基于 Redis 实现的分布式 Java 对象和服务框架。本文将深入拆解 Redisson 分布式锁的核心设计思想与底层原理。
2. 解决方案重构:Redisson 落地实践
在引入 Redisson 后,代码结构从“手写轮子”转向了“面向接口编程”。以下是重构后的生产级代码,解决了事务提交与锁释放的时序问题。
Java
@Service
public class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService {
@Resource
private RedissonClient redissonClient;
@Resource
private RedisIdWorker redisIdWorker;
/**
* 秒杀下单入口
* 核心设计:锁的范围必须包裹事务,防止“事务未提交锁先释放”导致的并发安全问题
*/
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 基础校验(略)
// ...
Long userId = UserHolder.getUser().getId();
// 2. 获取分布式锁
// 锁粒度细化至用户级,兼顾并发性能与数据安全
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 3. 尝试加锁
// tryLock() 无参模式:不等待(Fail-fast),开启 WatchDog 自动续期
boolean isLock = lock.tryLock();
if (!isLock) {
// 快速失败策略,避免从众效应导致系统雪崩
return Result.fail("不允许重复下单!");
}
try {
// 4. 获取代理对象执行事务方法,避免 Spring AOP 自调用导致事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 5. 释放锁
lock.unlock();
}
}
@Transactional(rollbackFor = Exception.class)
@Override
public Result createVoucherOrder(Long voucherId) {
// 幂等性校验 + 乐观锁扣减库存 + 订单落库
// ... 具体业务逻辑
}
}
3. Redisson 核心原理深度剖析
Redisson 之所以被视为 Redis 分布式锁的标准答案,在于它通过复杂的 Lua 脚本 和 Netty 机制,完美解决了可重入、自动续期和主从一致性三大难题。
3.1 可重入机制 (Reentrancy) 的实现
问题:原生的 setnx 是基于 Key-Value 结构的,一旦 Key 存在,任何线程(包括持有者自己)都无法再次写入,这会导致死锁。
Redisson 方案: Redisson 抛弃了 String 结构,改用 Hash 结构 来存储锁状态。
- Key: 锁名称(如
lock:order:101) - Field: 唯一标识(
UUID+ThreadId),用于区分不同 JVM 的不同线程。 - Value: 计数器(Counter),记录重入次数。
Lua 脚本逻辑推演: 当线程尝试获取锁时,底层执行的 Lua 脚本逻辑如下(伪代码):
- 判断锁是否存在 (
exists):
-
- 如果不存在:直接创建 Hash,Field 设为当前线程,Value 设为 1,并设置过期时间。
- 判断是否是自己的锁 (
hexists):
-
- 如果是(重入):将 Value 值 +1 (
hincrby),并重置过期时间。
- 如果是(重入):将 Value 值 +1 (
- 其他情况:
-
- 返回锁当前的剩余生存时间 (PTTL),表示获取失败。
这种设计使得 Redisson 的锁表现得和 Java 的 ReentrantLock 一样,支持递归调用而不死锁。
3.2 看门狗机制 (WatchDog) 与 自动续期
问题:业务执行时间不可控。如果硬编码过期时间(TTL),业务执行过长会导致锁意外释放;如果不设置 TTL,Redis 宕机则会导致永久死锁。
Redisson 方案: Redisson 引入了“看门狗”机制,本质上是一个基于 Netty 的后台定时任务(TimeTask)。
- 触发条件:调用
tryLock()时不传 leaseTime(释放时间)。 - 默认配置:
lockWatchdogTimeout默认为 30 秒。 - 运行流程:
-
- 加锁成功后,后台启动定时任务。
- 每隔
lockWatchdogTimeout / 3(即 10 秒),任务执行一次。 - 任务逻辑:判断当前线程是否还持有锁?如果持有,通过 Lua 脚本将锁的过期时间重置为 30 秒。
- 递归续期:只要业务不结束,这个循环就会一直持续。
- 宕机兜底:如果客户端服务宕机,定时任务销毁,不再续期。Redis 里的锁会在 30 秒后自动过期,避免死锁。
3.3 锁的互斥与阻塞机制 (Semaphore & Pub/Sub)
问题:当锁被占用时,其他线程该怎么办?如果无限循环重试(自旋),会极大地消耗 CPU 资源。
Redisson 方案: Redisson 利用了 Redis 的 Pub/Sub(发布订阅) 机制和 Java 的 Semaphore(信号量) 来优化等待逻辑。
- 订阅:获取锁失败的线程,会订阅一个以锁名称为 Channel 的频道。
- 阻塞:线程通过
Semaphore.tryAcquire()进入阻塞状态(休眠),不再占用 CPU。 - 唤醒:当持有锁的线程释放锁时(执行
unlock),会通过 Lua 脚本publish一条消息。 - 抢锁:等待的线程收到消息后,释放信号量,被唤醒并再次尝试执行 Lua 脚本抢锁。
这种 "订阅-通知-唤醒" 的模式,比无脑 while(true) 自旋要优雅且高效得多。
3.4 MultiLock 与主从一致性 (RedLock)
问题:Redis 的主从复制是异步的。极端场景下,Master 节点加锁成功后宕机,锁数据未同步到 Slave,Slave 晋升为 Master,导致锁丢失,产生脏数据。
Redisson 方案: Redisson 实现了 RedLock 算法(通过 RedissonMultiLock)。
- 去中心化:抛弃主从架构,使用多个独立的 Redis 节点(例如 3 个 Master)。
- 多数派原则:加锁时,依次向所有节点申请锁。只有当 N/2 + 1 个节点(过半数)加锁成功,且总耗时小于锁的 TTL 时,才视为加锁成功。
注:在实际生产中,RedLock 由于运维成本高且性能损耗大,使用场景较少。通常我们会选择容忍极低概率的主从切换锁丢失,或者通过数据库层面的唯一索引进行最终兜底。
4.基于核心原理的再次深挖
Q1:Lua 脚本的“原子性”是否意味着“事务回滚”?如果脚本执行报错或宕机,锁状态会受损吗?
A: 这是一个常见的概念误区。Redis 中 Lua 脚本的“原子性”定义与关系型数据库事务(ACID)的原子性并不完全等同。
- 排他性而非回滚性:Redis 保证脚本在执行期间,整个服务器被当前脚本独占,不会被其他客户端的命令插入。但是,Redis 不支持回滚(Rollback)。如果脚本在执行到第三行时因语法或逻辑错误抛出异常,前两行已经修改的数据不会自动撤销。
- 宕机风险:如果脚本执行中途 Redis 节点发生宕机,数据可能会处于“半修改”状态(取决于 AOF/RDB 的落盘策略)。
在 Redisson 中的兜底策略: 在分布式锁的场景下,即使出现上述极端情况(脚本半途报错或宕机),系统也不会因此陷入永久死锁。 Redisson 的设计依赖 TTL(生存时间) 作为最终的安全网。无论锁的数据结构处于何种状态,只要不再续期,Key 最终都会因过期而被 Redis 自动清除,从而让出资源。因此,我们利用 Redis 的排他性来解决并发冲突,利用 TTL 来解决故障兜底。
Q2:在高并发极端竞争下,Redisson 能够保证“先来后到”的公平性吗?
A: 默认情况下,Redisson 是非公平的。
- 默认机制(Non-Fair): 在
tryLock()的底层实现中,当锁被释放并发布(Publish)消息后,所有处于订阅等待状态的线程会被同时唤醒,并开始竞争抢锁。这意味着,刚来的线程可能比等待了很久的线程先抢到锁。 设计权衡:这种非公平模式减少了维护等待队列的内存开销和线程调度的上下文切换,能提供更高的吞吐量。 - 如果业务必须公平(Fair Lock): Redisson 提供了
RedissonFairLock实现。它在 Redis 端额外维护了一个 List(作为等待队列) 和一个 ZSet(利用 Score 记录请求的时间戳)。线程在获取锁之前,必须先进入队列排队,严格按照时间戳顺序获取。 代价:引入了更多的 Redis 数据结构操作,性能相较于默认锁会有显著下降。
Q3:当 Redis 部署模式为 Cluster(分片集群)时,复杂的锁逻辑会遇到什么限制?
A: 这是分布式系统中的典型拓扑限制问题。
- Slot 限制:Redis Cluster 将数据分散在 16384 个 Slot(哈希槽)中。Lua 脚本执行的一个硬性前提是:脚本中操作的所有 Key 必须位于同一个 Slot 上。否则,Redis 会抛出
CROSSSLOT错误。 - 场景冲突:基础的
RLock只操作一个 Key,不存在此问题。但在使用MultiLock(联锁)或自定义复杂脚本时,如果涉及的 Key 被分哈希算法分散到了不同的节点,脚本将无法执行。
解决方案:Hash Tag 我们需要通过人为干预 Key 的生成规则来控制 Slot 分配。在 Key 中使用 {} 包裹核心标识,例如 lock:{order}:1 和 lock:{order}:2。 Redis Cluster 在计算 Hash 时,只会使用 {} 内部的字符串(即 order)进行计算。这样可以强制将这一组相关的 Key 映射到同一台机器的同一个 Slot 上,确保 Lua 脚本的原子性执行环境。
5.典型错误:架构设计中的反模式 (Anti-Patterns)
在集成 Redisson 时,最典型的错误是将锁的范围置于事务内部(Transaction > Lock):
Java
// 错误示范:锁范围过小
@Transactional
public void createOrder() {
lock.lock();
try {
// 业务逻辑...
} finally {
lock.unlock(); // 1. 释放锁
}
} // 2. 事务提交
时序分析:
- 线程 A 执行完业务,释放锁。
- 时间差:Spring 尚未完成事务提交(数据库事务未 commit)。
- 线程 B 获取锁,读取数据库。此时数据库仍为旧数据(因为线程 A 未 commit)。
- 线程 B 基于旧数据判断库存充足,导致超卖。
结论:分布式锁必须包裹在事务的最外层,遵循 Lock > Transaction 的包含关系(也就是说我们要让提交事务这件事,发生在释放锁之前)。
6.总结与展望
Redisson 通过工业级的封装,为我们屏蔽了 Redis 底层复杂的原子性操作和网络通信细节。理解其Hash 存储结构、WatchDog 续期机制以及Pub/Sub 等待机制,是掌握分布式锁技术的关键。
然而,Redisson 方案的瓶颈依然存在:并发性能受限于数据库。 在后续的架构优化中,我们将探讨基于 Redis + Lua + Stream 消息队列 的异步秒杀方案,将“同步下单”优化为“异步削峰”,彻底释放系统的吞吐能力。








