【Redis | 黑马点评】分布式锁;Lua脚本;Redisson与看门狗 |源码万字详解

目录

  • [0. 先看业务:这条链路到底在防什么](#0. 先看业务:这条链路到底在防什么)
  • [1. 分布式锁的基本原理与不同实现方式对比](#1. 分布式锁的基本原理与不同实现方式对比)
  • [2. 分布式锁的误删问题](#2. 分布式锁的误删问题)
  • [3. 分布式锁的原子性问题](#3. 分布式锁的原子性问题)
  • [4. Lua 脚本为什么能解决多条命令的原子性](#4. Lua 脚本为什么能解决多条命令的原子性)
  • [5. 调用 Lua 脚本改造分布式锁(项目真实代码)](#5. 调用 Lua 脚本改造分布式锁(项目真实代码))
  • [6. Redisson 入门(项目当前实际使用的方案)](#6. Redisson 入门(项目当前实际使用的方案))
  • [7. Redisson 的可重入锁原理](#7. Redisson 的可重入锁原理)
  • [8. Redisson 的锁重试与 watchdog 机制](#8. Redisson 的锁重试与 watchdog 机制)
  • [9. Redisson 的 MultiLock 原理](#9. Redisson 的 MultiLock 原理)
  • [10. 当前实现的边界条件与风险](#10. 当前实现的边界条件与风险)
  • [11. 总结](#11. 总结)
  • [12. 理解检查题](#12. 理解检查题)
  • [13. 理解检查题参考答案与逐题反馈](#13. 理解检查题参考答案与逐题反馈)
  • [附录:Redisson 3.13.6 关键源码位置](#附录:Redisson 3.13.6 关键源码位置)

0. 先看业务:这条链路到底在防什么

0.1 业务规则

秒杀优惠券记录在 tb_seckill_voucher,下单记录写入 tb_voucher_order。从 seckillVoucher 的判断可以确认以下规则:

  1. 活动没开始不能下单(VoucherOrderServiceImpl.java:53)
  2. 活动结束不能下单(VoucherOrderServiceImpl.java:58)
  3. 库存不足不能下单(VoucherOrderServiceImpl.java:63)
  4. 一个用户对同一张券只能下一单(VoucherOrderServiceImpl.java:97-101)

0.2 调用链

入口是 VoucherOrderController.java:28-31:

java 复制代码
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
    return voucherOrderService.seckillVoucher(voucherId);
}

MySQL Redis(Redisson) VoucherOrderServiceImpl VoucherOrderController 客户端 MySQL Redis(Redisson) VoucherOrderServiceImpl VoucherOrderController 客户端 #mermaid-svg-7DqiYwb4IG81fWTx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7DqiYwb4IG81fWTx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7DqiYwb4IG81fWTx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7DqiYwb4IG81fWTx .error-icon{fill:#552222;}#mermaid-svg-7DqiYwb4IG81fWTx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7DqiYwb4IG81fWTx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7DqiYwb4IG81fWTx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7DqiYwb4IG81fWTx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7DqiYwb4IG81fWTx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7DqiYwb4IG81fWTx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7DqiYwb4IG81fWTx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7DqiYwb4IG81fWTx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7DqiYwb4IG81fWTx .marker.cross{stroke:#333333;}#mermaid-svg-7DqiYwb4IG81fWTx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7DqiYwb4IG81fWTx p{margin:0;}#mermaid-svg-7DqiYwb4IG81fWTx .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7DqiYwb4IG81fWTx text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-7DqiYwb4IG81fWTx .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7DqiYwb4IG81fWTx .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-7DqiYwb4IG81fWTx .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-7DqiYwb4IG81fWTx .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-7DqiYwb4IG81fWTx #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-7DqiYwb4IG81fWTx .sequenceNumber{fill:white;}#mermaid-svg-7DqiYwb4IG81fWTx #sequencenumber{fill:#333;}#mermaid-svg-7DqiYwb4IG81fWTx #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-7DqiYwb4IG81fWTx .messageText{fill:#333;stroke:none;}#mermaid-svg-7DqiYwb4IG81fWTx .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7DqiYwb4IG81fWTx .labelText,#mermaid-svg-7DqiYwb4IG81fWTx .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-7DqiYwb4IG81fWTx .loopText,#mermaid-svg-7DqiYwb4IG81fWTx .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-7DqiYwb4IG81fWTx .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7DqiYwb4IG81fWTx .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-7DqiYwb4IG81fWTx .noteText,#mermaid-svg-7DqiYwb4IG81fWTx .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-7DqiYwb4IG81fWTx .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7DqiYwb4IG81fWTx .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7DqiYwb4IG81fWTx .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7DqiYwb4IG81fWTx .actorPopupMenu{position:absolute;}#mermaid-svg-7DqiYwb4IG81fWTx .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-7DqiYwb4IG81fWTx .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7DqiYwb4IG81fWTx .actor-man circle,#mermaid-svg-7DqiYwb4IG81fWTx line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-7DqiYwb4IG81fWTx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 拿锁失败 拿锁成功 POST /voucher-order/seckill/{id} seckillVoucher(voucherId) getById(voucherId) 校验开始时间/结束时间/库存 tryLock("order:" + userId) 不允许重复下单 proxy.createVoucherOrder() count(user_id, voucher_id) update stock = stock - 1 where stock > 0 insert tb_voucher_order orderId unlock()(finally)

0.3 一个关键认知:这把锁保护的是"一人一单",不是库存

锁的 key 是 "order:" + userId(VoucherOrderServiceImpl.java:71):

  • 同一个用户的并发请求会被串行化,保证一人一单;
  • 不同用户 之间不会互相阻塞,库存由 VoucherOrderServiceImpl.java:104-107 的 update ... where stock > 0 保证不会超卖。

这是设计上的分工,后面分析锁粒度时会反复用到。


1. 分布式锁的基本原理与不同实现方式对比

1.1 为什么 JVM 锁不够

seckillVoucher 是 @Service 单例,但 Tomcat 是多线程的;更关键的是,线上通常部署多个实例。

  • synchronized / ReentrantLock 只在同一个 JVM 内互斥;
  • 两个实例各自加锁,彼此完全看不见对方。

所以锁必须放在所有实例都能访问的外部存储里,Redis、MySQL、ZooKeeper 都可以。

1.2 一个合格的分布式锁要满足什么

要求 含义 本项目是否满足
互斥 同一时刻只有一个客户端持有锁 Redisson 满足
防死锁 持锁进程崩溃后,锁最终能释放 靠 TTL / watchdog
只由持有者释放 不能删掉别人的锁 clientId:threadId 校验
可重入 同一线程可重复获取 Redisson Hash 计数
可重试 拿不到锁时可以等待 Redisson 提供,项目未用
高性能 高并发下开销小 Redis 满足

1.3 不同实现方式对比

方案 互斥实现 防死锁 可重入 本项目情况
MySQL 唯一索引 insert 一行当锁,删除释放 需要额外清理字段/定时任务 需要自己实现 未使用
MySQL select ... for update 行锁 依赖事务/连接释放 依赖连接 未使用;长事务代价高
Redis SET NX EX 一条命令同时写入并加过期 TTL 需自己用 Hash 实现 SimpleRedisLock
Redis + Redisson Lua + Hash watchdog 自动续期 内置 当前使用
ZooKeeper 临时顺序节点 会话断开自动删除 内置(同会话) 未使用;运维成本高

选型结论(推断):项目已经重度使用 Redis(缓存、登录、ID 生成),引入 Redisson 只需要加一个依赖和配置,不需要再维护一套 ZooKeeper;而手写锁要自己处理误删、原子性、续期、重试,最终必然会重写成 Redisson 的样子。


2. 分布式锁的误删问题

2.1 问题场景

假设 unlock 直接 del(key),没有任何持有者校验:

复制代码
t0  线程A 获取锁,TTL = 10s
t1  A 执行业务,遇到长时间 GC / 慢 SQL,超过 10s
t2  锁自动过期
t3  线程B 获取同一把锁
t4  A 恢复执行,执行 del(key) ------ 删掉的是 B 的锁!
t5  线程C 获取锁成功
    此时 B 和 C 同时执行业务,互斥被破坏

注意:A 删除锁时并不知道锁已经不属于自己了。这就是"误删"。

2.2 解决思路:锁里存持有者标识,删除前校验

SimpleRedisLock 的做法:

java 复制代码
// SimpleRedisLock.java:22
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

// SimpleRedisLock.java:33
String threadId = ID_PREFIX + Thread.currentThread().getId();

// SimpleRedisLock.java:36
.setIfAbsent(KEY_PREFIX + name, threadId, timeSec, TimeUnit.SECONDS);

锁的 value 不再是一个无意义的值,而是 UUID + "-" + 线程ID。释放时先比较再删除,比较逻辑在注释掉的老版本里能看到(SimpleRedisLock.java:51-59):

java 复制代码
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (threadId.equals(id)) {
    stringRedisTemplate.delete(KEY_PREFIX + name);
}

2.3 为什么要 UUID + 线程ID,不只用线程 ID

Java 的线程 ID 只在单个 JVM 内部唯一。两个应用实例各有一个线程 ID 为 67 的线程时:

  • 实例 1 的线程 67 持有的锁,value 是 "67";
  • 实例 2 的线程 67 来删锁,比较 "67".equals("67") 成立,误删。

UUID.randomUUID() 在类加载时生成一次(static final),每个 JVM 不同,把"哪个进程"也编码进去了。Redisson 里对应的概念叫 client id。

2.4 这个方案解决了什么,又没解决什么

解决了:持锁者与释放者的身份对应关系,绝大多数误删场景不会发生。

没解决:

  1. get 和 delete 是两条命令,中间仍有时间窗口(下一节的原子性问题);
  2. 业务超过 TTL 后,A 和 B 仍然会并发执行业务。标识校验只能保证"A 不删 B 的锁",不能保证"A 不继续跑"。要解决后者,需要续期机制(Redisson 的 watchdog)。

3. 分布式锁的原子性问题

3.1 一条命令是原子的,多条命令不是

Redis 执行命令是单线程的(Redis 6 之后 I/O 多线程,但命令执行 仍然串行),所以单条命令不会被打断。

但客户端发起的 GET、比较、DEL 是两次网络往返、两条命令,服务器在两条命令之间完全可能处理其他客户端的命令。

3.2 具体交错

复制代码
线程A                          Redis                      线程B
  |-- GET lock:order:1 -------->|  返回 A 的标识
  |                             |
  |                            |<-- 锁在这时过期
  |                             |
  |                             |<-- SET NX lock:order:1 --| 成功
  |-- DEL lock:order:1 -------->|  删除了 B 的锁

A 的校验在删除之前就通过了 ,但删除时锁已经换人。这就是典型的 TOCTOU(Time-of-check to time-of-use) 问题:检查和使用的对象在时间上不一致。

3.3 结论

只把"比较"加强成"先 GET 再比较再 DEL",没有真正解决问题,只是把窗口缩小了。校验和删除必须作为一个不可分割的整体执行。


4. Lua 脚本为什么能解决多条命令的原子性

4.1 Redis 对脚本的保证

Redis 把整个 Lua 脚本当作一条命令来执行:

  • 脚本开始执行到结束,服务器不会插入任何其他客户端的命令;
  • 因此脚本里的 GET、比较、DEL 对外表现是一个整体。

注意两个措辞上的区别:

  • 原子性:不会和其他客户端交错执行;
  • 回滚 :Redis 脚本不支持回滚。如果脚本执行到一半报错,前面已经执行的写命令不会撤销。所以脚本要写得简单、可预期。

4.2 KEYS 和 ARGV

调用时传两个数组:

  • KEYS[n]:脚本要操作的 key,Redis Cluster 要求同一脚本的 key 落在同一个 slot;
  • ARGV[n]:普通参数,不参与 key 路由。

本项目:

  • KEYS[1] = "lock:" + name
  • ARGV[1] = 线程标识

4.3 为什么不用 MULTI/EXEC

方式 能力 问题
MULTI/EXEC 把多条命令打包执行 命令在 EXEC 前只是入队,无法根据前一条命令的结果决定下一条;没有 if/else
WATCH + MULTI 乐观锁,值变化则失败 需要客户端重试,逻辑复杂
Lua 服务端执行任意判断逻辑 完全满足"读 → 比较 → 条件删除"

4.4 代价

脚本执行期间会阻塞 Redis 的命令处理。所以脚本必须短小,不能有长循环、不能做大量计算。本项目的脚本只有一次 get 和一次 del,是合适的用法。


5. 调用 Lua 脚本改造分布式锁(项目真实代码)

5.1 脚本本身

src/main/resources/unlock.lua:7-11:

lua 复制代码
if(redis.call('get', KEYS[1]) == ARGV[1]) then
    return redis.call('del', KEYS[1])
end
return 0

逐句看:

  • redis.call('get', KEYS[1]):取出锁当前的持有者标识;key 不存在时返回 Lua 的 false,不会报错;
  • == ARGV[1]:和当前线程标识比较;
  • 相等才 del,返回删除数量 1;
  • 不相等返回 0,表示"不是我的锁,什么都不做"。

5.2 Java 侧加载脚本

SimpleRedisLock.java:23-28:

java 复制代码
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
    UNLOCK_SCRIPT = new DefaultRedisScript<>();
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

几个设计点:

  • static final + 静态代码块:脚本只从 classpath 读一次,DefaultRedisScript 对象全局复用;
  • ClassPathResource("unlock.lua"):文件必须放在 src/main/resources 根目录下,打包后在 classes 根目录;
  • setResultType(Long.class):脚本的返回值是整数(del 的结果或 0),所以用 Long 接收。类型不匹配会抛转换异常。

5.3 真正执行

SimpleRedisLock.java:41-48:

java 复制代码
@Override
public void unlock() {
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
            Collections.singletonList(KEY_PREFIX + name),   // KEYS
            ID_PREFIX + Thread.currentThread().getId()      // ARGV
    );
}

Collections.singletonList(...) 只包含一个元素,刚好对应脚本里的 KEYS[1];第二个可变参数是 ARGV。顺序不能错位。

5.4 为什么 tryLock 不需要 Lua

SimpleRedisLock.java:36:

java 复制代码
.setIfAbsent(KEY_PREFIX + name, threadId, timeSec, TimeUnit.SECONDS);

Spring Data Redis 最终发的是 SET key value NX EX seconds。SET NX EX 本身就是一个原子命令:判断是否存在、写入、设置过期时间在 Redis 内部一次完成,不存在"先 SETNX 再 EXPIRE"这种两条命令的窗口。所以加锁端不需要脚本。

5.5 这个实现的一个观察点

unlock() 返回 void,脚本返回 0 时也没有任何日志。这意味着"锁已经过期、别的线程持有锁"这种情况会被静默忽略。从并发安全角度没问题,但从排查问题角度,缺少可观测性。


6. Redisson 入门(项目当前实际使用的方案)

6.1 依赖

pom.xml 中新增:

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.13.6</version>
</dependency>

6.2 配置类

src/main/java/com/hmdp/config/RedissonConfig.java:

java 复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://127.0.0.1:6379")
                .setPassword("123456");
        return Redisson.create(config);
    }
}

需要理解的点:

  • @Configuration + @Bean:把 RedissonClient 交给 Spring 管理,VoucherOrderServiceImpl 里用 @Resource 注入;
  • Redisson 使用自己的一套 Netty 连接 ,和 spring-boot-starter-data-redis 的 Lettuce 连接是两套,只是连的是同一个 Redis 地址;
  • Redisson.create(config) 会主动建立连接,Redis 没启动时应用启动就会失败;
  • useSingleServer() 表示单机/单主从模式,后面讲 MultiLock 时会用到这个前提。

6.3 业务代码中的使用

VoucherOrderServiceImpl.java:70-86:

java 复制代码
//SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
RLock lock = redissonClient.getLock("order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
    return Result.fail("不允许重复下单!");
}
try {
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
} finally {
    lock.unlock();
}
  • getLock(name) 在 Redisson 源码里就是 new RedissonLock(connectionManager.getCommandExecutor(), name)(Redisson.java:345-347);
  • RLock 继承自 JDK 的 Lock,所以 tryLock() / unlock() 的语义与 ReentrantLock 对应;
  • 注意这里用的是无参 tryLock(),它不等待,具体行为在第 8 节展开;
  • SimpleRedisLock 并没有删除,只是被注释掉了,文件还在,方便对比学习。

6.4 从手写锁到 Redisson,补上了什么

能力 SimpleRedisLock Redisson
互斥 SET NX EX Lua + Hash
防误删 标识 + Lua clientId:threadId + Lua
可重入 不支持 计数
自动续期 不支持(固定 TTL) watchdog
等待重试 不支持 订阅 + 超时
多节点 不支持 MultiLock / RedLock

7. Redisson 的可重入锁原理

7.1 数据结构:Hash,而不是 String

Redisson 锁在 Redis 里不是一个字符串,而是一个 Hash:

复制代码
key   = "order:123"
field = "<clientId>:<threadId>"
value = 重入次数
  • clientId:每个 Redisson 客户端实例在创建时生成的 UUID(MasterSlaveConnectionManager.java:163),用来区分不同 JVM;
  • threadId:Java 线程 ID;
  • value:同一线程重入的次数。

这正好对应第 2 节手写锁里的 UUID + 线程ID,区别是 Redisson 还把"重入次数"存了下来。

7.2 加锁的 Lua(RedissonLock.java:355-371)

lua 复制代码
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
return redis.call('pttl', KEYS[1]);

三个分支:

  1. key 不存在:创建一个 field,计数 1,设置过期时间 → 首次加锁成功;
  2. key 存在且当前线程的 field 存在 :计数 +1,刷新过期时间 → 重入;
  3. key 存在但不是自己的 field :返回 pttl,即别人还要持有多久 → 加锁失败,客户端拿到这个 TTL 用来决定等待多久。

ARGV[1] 是锁的租期(毫秒),ARGV[2] 是 clientId:threadId。

7.3 解锁的 Lua(RedissonLock.java:573-589)

lua 复制代码
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
    redis.call('pexpire', KEYS[1], ARGV[2]);
    return 0;
else
    redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1;
end;
  • field 不存在 → 返回 nil,Java 侧会转成 IllegalMonitorStateException("不是你持有的锁");
  • 计数减 1 后仍 > 0 → 说明还有外层重入,只刷新 TTL,不删 key;
  • 计数减到 0 → 删除 key,并向 redisson_lock__channel:{lockName} 发布解锁消息,唤醒等待者。

7.4 重入的实际意义

Java 的 synchronized 和 ReentrantLock 都是可重入的:同一线程重复进入不会自己把自己锁死。分布式锁要在跨进程、跨线程的环境里模拟这个语义,就必须记录"谁 + 重入了几次",所以用 Hash 而不是简单的 String。

一个假设性的例子(当前项目没有这种嵌套调用):如果 createVoucherOrder 内部也需要 order:userId 这把锁,第一次加锁计数为 1,内层再获取计数变 2;内层解锁减到 1,外层解锁才真正删除。如果只存 String,第二次尝试会被自己挡住。


8. Redisson 的锁重试与 watchdog 机制

8.1 两种 tryLock 的语义完全不同

调用 等待 租期 watchdog
lock.tryLock() 不等待,只尝试一次 默认 30s 开启
lock.tryLock(waitTime, leaseTime, unit) 最多等待 waitTime leaseTime 关闭
lock.lock() 一直等待 默认 30s 开启

项目当前用的是第一种(VoucherOrderServiceImpl.java:73)。这意味着:同一用户的第二个并发请求不会排队,而是立刻收到"不允许重复下单!"。对这个业务来说这是想要的行为------用户重复下单应该被拒绝,而不是等待。

8.2 无参 tryLock() 的源码路径

RedissonLock.java:715-720:

java 复制代码
public RFuture<Boolean> tryLockAsync() {
    return tryLockAsync(Thread.currentThread().getId());
}

public RFuture<Boolean> tryLockAsync(long threadId) {
    return tryAcquireOnceAsync(-1, -1, null, threadId);
}

tryAcquireOnceAsync 中(RedissonLock.java:224-242):leaseTime == -1,所以用 watchdog 的默认时间作为租期,并在加锁成功后调用 scheduleExpirationRenewal(threadId)。

加锁成功的判定还有一个细节:Lua 成功时返回 nil,Redisson 用 BooleanNullReplayConvertor 把 null 转成 true(BooleanNullReplayConvertor.java);失败时返回的 TTL 数字转成 false。所以我们拿到的 boolean 是真假分明的。

8.3 带等待的 tryLock(waitTime, leaseTime, unit) 怎么重试

核心在 RedissonLock.java:394-463:

  1. 先 tryAcquire 一次,成功直接返回;
  2. 失败拿到 TTL,计算剩余等待时间;没时间了就返回 false;
  3. subscribe(threadId) 订阅解锁频道;
  4. 循环:
    • 再次 tryAcquire;
    • 失败就根据 TTL 和剩余时间,在 RedissonLockEntry 的 latch 上等待;
    • 被解锁消息唤醒后继续循环,直到拿到锁或超时;
  5. finally 里取消订阅。

不是轮询,而是发布/订阅:解锁的线程 publish 一条消息,等待的客户端被唤醒后重新抢锁。这样既避免了空转,也避免了固定间隔带来的延迟。

8.4 watchdog 的工作方式

默认超时时间在 Config.java:83:

java 复制代码
private long lockWatchdogTimeout = 30 * 1000;

续期逻辑在 RedissonLock.java:269-325:

java 复制代码
// renewExpiration() 里:
commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
    public void run(Timeout timeout) {
        ...
        RFuture<Boolean> future = renewExpirationAsync(threadId);
        future.onComplete((res, e) -> {
            if (res) {
                renewExpiration();   // 自己续约自己
            }
        });
    }
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

续期的 Lua(RedissonLock.java:316-325):

lua 复制代码
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
return 0;

要点:

  • 默认每 10 秒(30s / 3)续一次,把 TTL 重新刷成 30s;
  • 续期前会 hexists 检查 field 还是不是自己的,避免锁过期后误续别人的锁;
  • 如果检查失败(返回 0),renewExpiration() 不再重排,定时任务自然停止;
  • 解锁时会 cancelExpirationRenewal(threadId)(RedissonLock.java:327-343),把定时任务取消,避免内存泄漏和无意义的续期。

8.5 两种失败场景

场景 结果
JVM 崩溃 / 进程被杀 续期停止,锁在最后一次续期后的 30s 内自动过期
业务执行超过显式 leaseTime(比如传了 30s,业务跑了 40s) 锁到期自动释放,其他线程可以进入,互斥失效

所以在生产代码里,如果要用显式租期,必须保证租期 > 业务最大执行时间;如果无法预估,就用无参的 tryLock() / lock() 让 watchdog 兜底。

8.6 回到项目代码

VoucherOrderServiceImpl.java:73 用的是无参 tryLock():

  • 不等待:重复请求立即失败;
  • watchdog 开启:事务执行多久都会自动续期,锁不会因为业务慢而提前释放;
  • finally 里 unlock():无论事务提交还是抛异常都会释放。

这三点和秒杀场景是匹配的:一人一单需要快速拒绝重复请求,事务时间又不完全可控,watchdog 正好补上。


9. Redisson 的 MultiLock 原理

9.1 它解决什么问题

单机 Redis 有主从复制。如果主节点在持锁期间宕机、锁还没同步到从节点,从节点升主后锁就"消失"了。RedLock 的思路是:在多个相互独立的 Redis 实例上同时加同一把锁,多数成功才算成功。

对应 Redisson API:

java 复制代码
RLock lock1 = redissonClient1.getLock("order:1");
RLock lock2 = redissonClient2.getLock("order:1");
RLock lock3 = redissonClient3.getLock("order:1");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2, lock3);

9.2 源码结构

  • Redisson.java:350-352:getMultiLock 返回 new RedissonMultiLock(locks);
  • RedissonMultiLock.java:41:public class RedissonMultiLock implements RLock,对外仍是一把锁;
  • 核心循环在 RedissonMultiLock.java:86-190 的 tryAcquireLockAsync:
    1. 按顺序对每把锁调用 tryLockAsync;
    2. 成功的放进 acquiredLocks;
    3. 某一把失败时,根据 failedLocksLimit 决定继续还是整体回滚;
    4. 超过限制 → 释放已获取的锁,等待剩余时间后重试;
    5. 全部满足条件 → checkLeaseTimeAsync 统一设置租期。

9.3 两种失败容忍度

RedissonMultiLock.java:348-350:

java 复制代码
protected int failedLocksLimit() {
    return 0;
}

普通 MultiLock 要求所有锁都成功。

RedissonRedLock.java:44-48:

java 复制代码
protected int failedLocksLimit() {
    return locks.size() - minLocksAmount(locks);
}

protected int minLocksAmount(final List<RLock> locks) {
    return locks.size() / 2 + 1;
}

RedissonRedLock 允许失败,只要成功的数量达到 n/2 + 1(多数派)。三个实例时,成功两个即可。

9.4 租期处理

  • lock(leaseTime, unit) 时,newLeaseTime = waitTime * 2,给"逐个加锁"的过程留出余量;
  • 全部获取成功后,再统一 expireAsync(leaseTime) 把所有锁的租期改成最终值(RedissonMultiLock.java:146-170);
  • unlock() 会对每把锁逐个释放。

9.5 它的局限

RedLock 的安全性在业界有争议(Martin Kleppmann 与 antirez 的著名辩论),核心争议点包括:

  • 各实例的时钟漂移;
  • 网络延迟导致的"以为加锁成功但实际已过期";
  • 故障转移期间的时间窗口。

因此它适合对可用性要求高于绝对正确性的场景。更重要的结论是:

  • 当前项目 RedissonConfig 用的是 useSingleServer(),只有一台 Redis,根本没有 MultiLock 的使用场景;
  • MultiLock 也不能替代"单个 Redis 的正确使用",它解决的是多实例故障转移下的锁安全问题。

10. 当前实现的边界条件与风险

以下只列和这段代码直接相关的点,并区分"已确认"和"可选优化"。

10.1 已确认的行为

  1. 锁的粒度是"用户" (VoucherOrderServiceImpl.java:71)。它不保护库存,库存由 update ... where stock > 0 保证。如果误以为这把锁能防超卖,就会得出错误结论。
  2. 初始库存查询在锁外 (VoucherOrderServiceImpl.java:63)。查询到的 stock 只用于快速失败,真正的扣减以 SQL 的 stock > 0 条件为准,这是正确的分层。
  3. 事务代理 :createVoucherOrder 通过 AopContext.currentProxy() 调用(VoucherOrderServiceImpl.java:81),并且启动类有 @EnableAspectJAutoProxy(exposeProxy = true)(HmDianPingApplication.java:8)。如果去掉这个注解,currentProxy() 会抛 IllegalStateException;如果改成 this.createVoucherOrder(),@Transactional 不会生效,因为自调用不经过代理。
  4. 执行顺序正确 :加锁 → 开启事务(通过代理)→ 提交 → 解锁(finally)。事务提交发生在释放锁之前,不会出现"锁已释放但订单还没提交"的窗口。

10.2 潜在风险

风险 触发条件 当前代码 说明
券不存在导致 NPE voucherId 无效,getById 返回 null,:53 直接调用 getBeginTime() 未处理 可以加空值判断
数据库无唯一约束 tb_voucher_order 只有主键 id,没有 (user_id, voucher_id) 唯一索引(见 db/hmdp.sql) 依赖分布式锁 锁失效(Redis 故障、租期丢失)时可能重复下单;可作为兜底加唯一索引
unlock() 抛异常 锁已过期/已被他人持有,unlockInnerAsync 返回 nil,Redisson 抛 IllegalMonitorStateException finally 未捕获 订单可能已经提交,但接口返回 500;可以考虑记录日志而不是直接抛出
Redis 单点 Redis 宕机 未处理 整个秒杀接口不可用;这是当前部署形态的固有风险
密码硬编码 RedissonConfig 中 setPassword("123456") 已写死 更合适的做法是读 application.yaml 的配置
重复请求直接失败 同一用户并发两次 立即返回"不允许重复下单" 这是产品行为,不是 bug;如果希望第二个请求等待后看到"已购买",应改用带 waitTime 的 tryLock

11. 总结

  • 业务层:秒杀接口依次校验时间、库存、一人一单;锁的 key 按用户划分,锁保护的是"一人一单",库存由带条件的 SQL 保护。
  • 手写锁阶段 :ILock + SimpleRedisLock 用 SET NX EX 加锁,用 UUID + 线程ID 标记持有者解决误删,用 Lua 把"比较 + 删除"合并成原子操作。
  • Redisson 阶段 :RLock 用 Hash 存 clientId:threadId → 计数 支持可重入;用 Lua 完成加解锁;用 watchdog 每 10 秒续期;用发布订阅实现等待重试;用 MultiLock 在多实例场景下组合多把锁。
  • 项目当前选择 :无参 tryLock(),不等待、开启 watchdog,配合 finally 解锁,符合"快速拒绝重复下单"的业务需求。

12. 理解检查题

  1. 锁粒度 :为什么锁的 key 是 "order:" + userId,而不是 "order:" + voucherId 或全局的 "order"?改成后两者分别会发生什么业务后果?

  2. 误删与原子性 :请描述一次 SimpleRedisLock 老版本(GET → 比较 → DEL)的完整交错过程,说明第三个线程为什么能同时进入;再说明 Lua 版本消除了哪个窗口、又没有消除什么。

  3. Redisson 数据结构 :Redisson 为什么用 Hash 的 clientId:threadId 作为 field,而不是直接用一个 String 存线程 ID?如果只保留 threadId、去掉 clientId,多实例部署时会出现什么问题?

  4. 重试与 watchdog :lock.tryLock() 和 lock.tryLock(10, 30, TimeUnit.SECONDS) 在"是否等待"和"是否自动续期"上分别是什么行为?如果业务执行需要 40 秒,这两种写法分别会发生什么?

  5. 事务与调用链 :为什么 seckillVoucher 要通过 AopContext.currentProxy() 调用 createVoucherOrder,而不是直接 this.createVoucherOrder()?这个问题和 @EnableAspectJAutoProxy(exposeProxy = true) 是什么关系?如果去掉这个注解,运行时会在哪一行报错?


13. 理解检查题参考答案与逐题反馈

第 1 题:锁的粒度

锁的 key 用 "order:" + userId,是因为这段代码要保护的业务规则是**"同一个用户对同一张券只能下一单"**。并发冲突只发生在同一个用户的多个请求之间,所以只需要把这个用户的请求串行化:

  • 同一用户的请求互斥 → count(user_id, voucher_id) > 0 的检查和插入不会并发执行;
  • 不同用户的请求互不影响 → 保持并发能力。

如果改成 "order:" + voucherId:所有抢同一张券的用户会被串行化,同一时刻只有一个用户能下单,吞吐量退化成单线程;虽然"一人一单"也不会被破坏,但完全没有必要,而且热门券会成为严重瓶颈。

如果改成全局 "order":所有券、所有用户全部串行化,问题更严重。

另外要明确:这把锁不负责防超卖 。库存由 update ... where stock > 0 保证。锁的粒度只需要覆盖"一人一单"的检查与插入。

常见误区
  • ❌ "这把锁是防止库存超卖的。"------超卖由 SQL 的条件更新兜底,锁只管一人一单。
  • ❌ "锁的粒度越小越好,所以应该按订单号加锁。"------订单号在加锁时还不存在,锁必须围绕共享资源的冲突维度设计。
  • ❌ "按 voucherId 加锁也能实现一人一单,所以两者等价。"------功能上都行,但并发能力差一个数量级,不属于等价方案。

第 2 题:误删与原子性

老版本(GET → 比较 → DEL)的交错过程:

复制代码
t0  线程A 获取锁,TTL=10s,value = A 的标识
t1  A 执行业务超过 10s
t2  锁过期
t3  线程B 获取锁,value = B 的标识
t4  A 恢复,执行 GET → 如果发生在 t2 之后,拿到 B 的标识,比较失败,不会删
t5  但在更窄的窗口里:A 在锁过期前执行 GET,拿到自己的标识
t6  在 GET 和 DEL 之间,锁过期,B 获取锁
t7  A 执行 DEL,删除了 B 的锁
t8  线程C 获取锁成功,B 和 C 并发执行

第二个交错(t5-t7)就是"校验通过之后、删除之前锁换了主人"的 TOCTOU 窗口。

Lua 版本消除了什么:

Lua 把 GET、比较、DEL 放进一个脚本,Redis 执行期间不会插入其他客户端的命令,所以"比较通过"和"删除"之间不再有窗口,A 不可能删掉 B 的锁。

Lua 没有消除什么:

它只保证"删除操作不会误删别人的锁",不保证 A 的业务不会和 B 并发执行。如果 A 的业务超过 TTL,锁仍然会过期,B 仍然能获取锁,A 和 B 仍然可能同时跑业务。这个问题要靠续期(Redisson watchdog)或足够长的租期来解决。

常见误区
  • ❌ "Redis 是单线程的,所以 GET 和 DEL 天然是原子的。"------单条命令原子,不等于多条命令原子;两次命令之间会处理其他客户端的命令。
  • ❌ "加了标识校验就不会误删了。"------GET 和 DEL 之间的窗口仍然存在。
  • ❌ "Lua 解决了业务超时的问题。"------Lua 只解决删除的原子性,业务超时是租期问题。

第 3 题:Redisson 的 Hash 结构

Redisson 用 Hash 存锁,结构是:

复制代码
key   = 锁名(例如 order:123)
field = clientId:threadId
value = 重入次数

需要 Hash 的原因是同时要表达两件事:

  1. 持有者是谁 :clientId:threadId;
  2. 重入了多少次:value 计数。

为什么需要 clientId:Java 线程 ID 只在单个 JVM 内唯一。多个应用实例可能同时存在线程 ID 为 67 的线程。如果 field 只写 threadId:

  • 实例 1 的线程 67 持有锁;
  • 实例 2 的线程 67 来加锁,hexists 判断为"自己",于是把计数 +1,误以为自己拿到了锁;
  • 实例 2 的线程 67 解锁时又会把计数 -1,甚至删掉实例 1 的锁。

最终两个实例的线程会同时进入临界区,互斥失效。clientId 是 Redisson 客户端实例创建时生成的 UUID,保证跨 JVM 唯一。

为什么不能用 String:String 只能存一个值。要同时存"持有者"和"重入次数",要么把两者拼成字符串再在 Lua 里解析(低效且易错),要么放弃重入。Hash 天然支持"一个 key 下多个 field、每个 field 一个计数",而且 Lua 的 hincrby 可以原子地完成自增。

常见误区
  • ❌ "Hash 是为了节省内存。"------主要目的是表达持有者和重入计数,不是省内存。
  • ❌ "value 就是线程 ID。"------value 是重入次数,field 才是持有者标识。
  • ❌ "可重入是指任意线程都能重复获取。"------可重入的前提是同一个持有者(同一 client 的同一线程)。
  • ❌ "clientId 是 Redis 的实例 ID。"------它是 Redisson 客户端(连接管理器)的 UUID,不是 Redis 服务端的 ID。

第 4 题:tryLock 的等待与 watchdog

lock.tryLock():

  • 不等待,只尝试一次;拿不到立即返回 false;
  • 不指定 leaseTime,走 leaseTime == -1 分支,租期使用 watchdog 默认值 30 秒;
  • 加锁成功后启动 watchdog,每 10 秒(30s / 3)检查并续期一次,只要 JVM 和线程还活着,锁就不会过期。

lock.tryLock(10, 30, TimeUnit.SECONDS):

  • 最多等待 10 秒;这期间通过订阅解锁频道、被唤醒后重试;
  • 显式指定租期为 30 秒,不会启动 watchdog;
  • 拿到锁后,30 秒后锁一定过期,除非业务在 30 秒内结束并主动解锁。

业务需要 40 秒时:

  • tryLock():watchdog 在 10s、20s、30s......持续续期,锁一直有效,40 秒后业务结束、unlock() 释放,安全;
  • tryLock(10, 30, SECONDS):第 30 秒锁过期,其他线程可以拿到锁并进入临界区,而第一个线程还在执行业务 → 互斥失效,可能出现重复下单或并发修改。
常见误区
  • ❌ "watchdog 对所有锁都生效。"------只有 leaseTime == -1(不传租期)时才启用。
  • ❌ "tryLock(10, 30) 里的 30 秒也会被自动续期。"------不会,显式租期会关闭 watchdog。
  • ❌ "tryLock(10, 30) 的 10 是租期。"------10 是等待时间,30 才是租期,参数顺序是 (waitTime, leaseTime, unit)。
  • ❌ "tryLock() 拿不到锁会一直等。"------无参版本不等待;一直等的是 lock()。

第 5 题:事务代理与自调用

createVoucherOrder 上的 @Transactional 是 Spring AOP 实现的:只有通过代理对象 调用方法,事务拦截器才会执行。this.createVoucherOrder() 是在目标对象内部自调用,绕过了代理,@Transactional 完全不生效,方法会在没有事务的情况下执行。

代码里用 AopContext.currentProxy() 拿到当前线程正在使用的代理对象,再通过它调用方法,这样才会经过事务拦截器:

java 复制代码
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);

AopContext.currentProxy() 依赖 @EnableAspectJAutoProxy(exposeProxy = true)(HmDianPingApplication.java:8)。exposeProxy = true 的作用是把当前代理对象放进 ThreadLocal,供 AopContext 读取。如果去掉这个注解:

  • AopContext.currentProxy() 会抛出 IllegalStateException: Cannot find current proxy...;
  • 报错位置就是 VoucherOrderServiceImpl.java:81。

如果改为 this.createVoucherOrder():

  • 不会抛异常,但 @Transactional 被静默绕过;
  • "查询是否已下单 → 扣库存 → 创建订单"三步不在同一个事务里,中途失败会留下不一致的数据(例如库存扣了、订单没插入)。
常见误区
  • ❌ "@Transactional 写在方法上就一定生效。"------自调用、private 方法、非 public 方法都可能不生效。
  • ❌ "this. 和 proxy. 只是写法不同。"------前者绕过代理,后者经过代理,事务行为完全不同。
  • ❌ "AopContext.currentProxy() 是 Spring 默认开启的。"------需要显式 exposeProxy = true。
  • ❌ "没有事务也没关系,反正只有一条 insert。"------这里还有一次 count 查询和一次 stock 更新,必须与插入保持原子性。
相关推荐
stark张宇2 小时前
库存扣减的“锁”事:悲观锁、乐观锁、分布式锁
redis·分布式·mysql
xianyuCcCcCCCcc5 小时前
Ceph 分布式存储:集群配置与存储池管理
运维·分布式·ceph
Sayuanni%36 小时前
Redis(初阶)
数据库·redis·缓存
未秃头的程序猿7 小时前
一次重复扣款的复盘:分布式锁加错了维度,比不加锁更危险
java·redis·后端
萧瑟余晖7 小时前
Spring Cloud分布式事务与链路追踪详解
分布式·spring·spring cloud
汤姆宝库7 小时前
预训练工程深拆:Scaling_Law、数据管线与分布式并行
分布式
pjj198548 小时前
Spark RDD 核心知识详解:算子、依赖与执行模型
大数据·分布式·spark
浔溺8 小时前
Spark核心基石:RDD全方位深度解析(原理、算子、实战、优化)
大数据·分布式·spark
catino9 小时前
RabbitMQ详解
分布式·rabbitmq·ruby