目录
- [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 的判断可以确认以下规则:
- 活动没开始不能下单(
VoucherOrderServiceImpl.java:53) - 活动结束不能下单(
VoucherOrderServiceImpl.java:58) - 库存不足不能下单(
VoucherOrderServiceImpl.java:63) - 一个用户对同一张券只能下一单(
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 这个方案解决了什么,又没解决什么
解决了:持锁者与释放者的身份对应关系,绝大多数误删场景不会发生。
没解决:
get和delete是两条命令,中间仍有时间窗口(下一节的原子性问题);- 业务超过 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:" + nameARGV[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]);
三个分支:
- key 不存在:创建一个 field,计数 1,设置过期时间 → 首次加锁成功;
- key 存在且当前线程的 field 存在 :计数 +1,刷新过期时间 → 重入;
- 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:
- 先
tryAcquire一次,成功直接返回; - 失败拿到 TTL,计算剩余等待时间;没时间了就返回
false; subscribe(threadId)订阅解锁频道;- 循环:
- 再次
tryAcquire; - 失败就根据 TTL 和剩余时间,在
RedissonLockEntry的latch上等待; - 被解锁消息唤醒后继续循环,直到拿到锁或超时;
- 再次
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:- 按顺序对每把锁调用
tryLockAsync; - 成功的放进
acquiredLocks; - 某一把失败时,根据
failedLocksLimit决定继续还是整体回滚; - 超过限制 → 释放已获取的锁,等待剩余时间后重试;
- 全部满足条件 →
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 已确认的行为
- 锁的粒度是"用户" (
VoucherOrderServiceImpl.java:71)。它不保护库存,库存由update ... where stock > 0保证。如果误以为这把锁能防超卖,就会得出错误结论。 - 初始库存查询在锁外 (
VoucherOrderServiceImpl.java:63)。查询到的stock只用于快速失败,真正的扣减以 SQL 的stock > 0条件为准,这是正确的分层。 - 事务代理 :
createVoucherOrder通过AopContext.currentProxy()调用(VoucherOrderServiceImpl.java:81),并且启动类有@EnableAspectJAutoProxy(exposeProxy = true)(HmDianPingApplication.java:8)。如果去掉这个注解,currentProxy()会抛IllegalStateException;如果改成this.createVoucherOrder(),@Transactional不会生效,因为自调用不经过代理。 - 执行顺序正确 :加锁 → 开启事务(通过代理)→ 提交 → 解锁(
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. 理解检查题
-
锁粒度 :为什么锁的 key 是
"order:" + userId,而不是"order:" + voucherId或全局的"order"?改成后两者分别会发生什么业务后果? -
误删与原子性 :请描述一次
SimpleRedisLock老版本(GET → 比较 → DEL)的完整交错过程,说明第三个线程为什么能同时进入;再说明 Lua 版本消除了哪个窗口、又没有消除什么。 -
Redisson 数据结构 :Redisson 为什么用 Hash 的
clientId:threadId作为 field,而不是直接用一个 String 存线程 ID?如果只保留 threadId、去掉 clientId,多实例部署时会出现什么问题? -
重试与 watchdog :
lock.tryLock()和lock.tryLock(10, 30, TimeUnit.SECONDS)在"是否等待"和"是否自动续期"上分别是什么行为?如果业务执行需要 40 秒,这两种写法分别会发生什么? -
事务与调用链 :为什么
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 的原因是同时要表达两件事:
- 持有者是谁 :
clientId:threadId; - 重入了多少次: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 更新,必须与插入保持原子性。