【系列说明】 本文是《秒杀系统设计》系列的第 2 篇,共 3 篇。系列主线只有一句话:秒杀的本质是漏斗,不是堆机器。
- 第 1 篇:需求拆解与流量治理------解决"数据库被打爆"
- 第 2 篇(本文):数据正确性------解决"库存超卖"与"一人多单"
- 第 3 篇:规模扩展------大库存、多商品的分片方案,以及到底要几台机器
【承接上文】 第一篇结尾,流量已经被逐层削了下来:数据库不再被打爆,系统算是"活下来"了。但活下来不等于活得对 ------本篇要解决的是"库存不能超卖,一个人只能有一单"。分三层展开:Redis 侧怎么保证扣减原子(第一章)、数据库侧怎么控制并发(第二章)、业务侧怎么保证一人一单(第三章),最后用幂等和超时回补收尾。
一、Redis 侧:Lua 原子扣减
超卖会发生在两个环节:Redis 扣减时、数据库扣减时。这一章先解决 Redis 侧的。
1.1 Redis 侧的并发问题:扣减不原子导致超卖
先看会出现什么情况。
假设库存只剩 1 件 ,两个请求几乎同时到达。如果扣减不是原子的,最终可能卖出 2 件:
Redis 请求B 请求A Redis 请求B 请求A #mermaid-svg-KCbaPKU2qvgH99AI{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-KCbaPKU2qvgH99AI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KCbaPKU2qvgH99AI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KCbaPKU2qvgH99AI .error-icon{fill:#552222;}#mermaid-svg-KCbaPKU2qvgH99AI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KCbaPKU2qvgH99AI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KCbaPKU2qvgH99AI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KCbaPKU2qvgH99AI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KCbaPKU2qvgH99AI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KCbaPKU2qvgH99AI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KCbaPKU2qvgH99AI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KCbaPKU2qvgH99AI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KCbaPKU2qvgH99AI .marker.cross{stroke:#333333;}#mermaid-svg-KCbaPKU2qvgH99AI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KCbaPKU2qvgH99AI p{margin:0;}#mermaid-svg-KCbaPKU2qvgH99AI .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KCbaPKU2qvgH99AI text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-KCbaPKU2qvgH99AI .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KCbaPKU2qvgH99AI .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-KCbaPKU2qvgH99AI .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-KCbaPKU2qvgH99AI .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-KCbaPKU2qvgH99AI #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-KCbaPKU2qvgH99AI .sequenceNumber{fill:white;}#mermaid-svg-KCbaPKU2qvgH99AI #sequencenumber{fill:#333;}#mermaid-svg-KCbaPKU2qvgH99AI #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-KCbaPKU2qvgH99AI .messageText{fill:#333;stroke:none;}#mermaid-svg-KCbaPKU2qvgH99AI .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KCbaPKU2qvgH99AI .labelText,#mermaid-svg-KCbaPKU2qvgH99AI .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-KCbaPKU2qvgH99AI .loopText,#mermaid-svg-KCbaPKU2qvgH99AI .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-KCbaPKU2qvgH99AI .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-KCbaPKU2qvgH99AI .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-KCbaPKU2qvgH99AI .noteText,#mermaid-svg-KCbaPKU2qvgH99AI .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-KCbaPKU2qvgH99AI .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KCbaPKU2qvgH99AI .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KCbaPKU2qvgH99AI .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KCbaPKU2qvgH99AI .actorPopupMenu{position:absolute;}#mermaid-svg-KCbaPKU2qvgH99AI .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-KCbaPKU2qvgH99AI .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KCbaPKU2qvgH99AI .actor-man circle,#mermaid-svg-KCbaPKU2qvgH99AI line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-KCbaPKU2qvgH99AI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 库存只剩 1 件 两个请求都判断 1 > 0,都认为可以扣 ❌ 库存变成 -1,卖出了 2 件 1. GET stock → 12. GET stock → 13. DECR stock → 04. DECR stock → -1
为什么会这样?
第一篇 6.4 里的 deductStock 看起来只是"读一下、减一下",但如果把它拆成两条命令:
java
Integer stock = redis.get("seckill:stock:" + goodsId); // 1. 读
if (stock > 0) {
redis.decr("seckill:stock:" + goodsId); // 2. 写
}
问题就出在"读"和"写"中间那段时间 :请求 A 和请求 B 都在这个窗口里读到了 stock = 1,都通过了 stock > 0 的判断,然后各自执行了一次 DECR。
这和 2.4 是同一个问题------"读---判断---写"不原子 。Redis 命令本身是单线程执行的,但"读"和"写"是两条独立的命令,中间完全可以插入别的请求。
1.2 用 Lua 保证原子扣减
把"读---判断---扣减"整体塞进一个 Lua 脚本:
java
public Long deductStock(Long goodsId) {
// 扣减 + 判断原子化:库存不足时不再扣减,避免扣成负数
String script =
"local stock = tonumber(redis.call('GET', KEYS[1])) " +
"if stock == nil then return -2 end " + -- key 不存在,预热失败
"if stock <= 0 then return -1 end " + -- 已售罄
"redis.call('DECR', KEYS[1]) " +
"return stock - 1";
return redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList("seckill:stock:" + goodsId)
);
}
为什么必须是 Lua:
GET 和 DECR 是两条命令,中间有窗口期。用 Lua 之后,"读取---判断---扣减"整体原子执行,绝对不会扣成负数。
1.3 坑点:为什么不用 DECR 后判断
有人会写:
java
Long remain = redisTemplate.opsForValue().decrement("seckill:stock:" + goodsId);
if (remain < 0) {
// 库存不足,回滚
redisTemplate.opsForValue().increment("seckill:stock:" + goodsId);
}
看起来也能防超卖(扣成负数再回滚),但有两个问题:
- 回滚期间库存是错的(负数),如果有其他逻辑读这个值会出问题
- 回滚本身不是原子的 ,如果应用在 DECR 之后、INCREMENT 之前崩溃,库存就永久少了一件------少卖
能在一个 Lua 里干完的事,绝不要拆成多条命令。
1.4 关键认知:这一层为什么不需要分布式锁
这一点很重要,因为它纠正了很多人的直觉。
在 1.2 的 deductStock 里,我们没有加任何锁。原因是:
Lua 脚本本身就是原子的。 Redis 执行 Lua 期间不会执行其他命令,"读库存---判断---扣减"这三步在 Redis 内部是不可分割的整体。既然操作本身不可分割,再加一把锁就是多余的。
这也是"原子操作优于加锁"的典型场景:
- 加锁的代价:一次 Redis 加锁 + 一次解锁(2 次网络往返)+ 锁超时风险 + 锁误删风险 + 看门狗续期开销
- 原子操作的代价:一次 Lua 脚本调用(1 次网络往返),无锁风险
记住这条取舍原则:能用原子操作解决的,就不要用锁。锁是最后的手段。
但------Redis 侧不超卖,不代表数据库侧也不超卖。下一章就是关键。
二、数据库侧:并发控制与锁
Redis 只保证了"最多 100 个请求被放行",但这 100 个请求最终都要落到数据库 。在数据库这一层,超卖问题会再出现一次。
2.1 数据库侧的超卖是怎么发生的
消费者从 MQ 取到消息后,要真正扣减 DB 库存:
sql
SELECT stock FROM goods WHERE id = 1; -- 线程A读到 10
SELECT stock FROM goods WHERE id = 1; -- 线程B读到 10
UPDATE goods SET stock = stock - 1 WHERE id = 1; -- A → 9
UPDATE goods SET stock = stock - 1 WHERE id = 1; -- B → 8
如果消费者的并发度大于 1(这是必须的,否则 MQ 削峰就没意义),同一商品的库存行会被多个线程同时修改。这和第一篇 2.4 节是同一个问题------只是这次发生在 DB 层。
所以这一层必须有并发控制。
2.2 先试最直觉的方案:加锁
最直觉的修复是加锁。但如果用 Java 的 synchronized,在多节点部署下会完全失效:
共享库存 服务B (JVM-B) 服务A (JVM-A) 负载均衡 用户A 共享库存 服务B (JVM-B) 服务A (JVM-A) 负载均衡 用户A #mermaid-svg-NqLzS0VuZwlwZdZr{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-NqLzS0VuZwlwZdZr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NqLzS0VuZwlwZdZr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NqLzS0VuZwlwZdZr .error-icon{fill:#552222;}#mermaid-svg-NqLzS0VuZwlwZdZr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NqLzS0VuZwlwZdZr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NqLzS0VuZwlwZdZr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NqLzS0VuZwlwZdZr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NqLzS0VuZwlwZdZr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NqLzS0VuZwlwZdZr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NqLzS0VuZwlwZdZr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NqLzS0VuZwlwZdZr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NqLzS0VuZwlwZdZr .marker.cross{stroke:#333333;}#mermaid-svg-NqLzS0VuZwlwZdZr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NqLzS0VuZwlwZdZr p{margin:0;}#mermaid-svg-NqLzS0VuZwlwZdZr .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NqLzS0VuZwlwZdZr text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-NqLzS0VuZwlwZdZr .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-NqLzS0VuZwlwZdZr .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-NqLzS0VuZwlwZdZr .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-NqLzS0VuZwlwZdZr .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-NqLzS0VuZwlwZdZr #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-NqLzS0VuZwlwZdZr .sequenceNumber{fill:white;}#mermaid-svg-NqLzS0VuZwlwZdZr #sequencenumber{fill:#333;}#mermaid-svg-NqLzS0VuZwlwZdZr #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-NqLzS0VuZwlwZdZr .messageText{fill:#333;stroke:none;}#mermaid-svg-NqLzS0VuZwlwZdZr .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NqLzS0VuZwlwZdZr .labelText,#mermaid-svg-NqLzS0VuZwlwZdZr .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-NqLzS0VuZwlwZdZr .loopText,#mermaid-svg-NqLzS0VuZwlwZdZr .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-NqLzS0VuZwlwZdZr .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-NqLzS0VuZwlwZdZr .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-NqLzS0VuZwlwZdZr .noteText,#mermaid-svg-NqLzS0VuZwlwZdZr .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-NqLzS0VuZwlwZdZr .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NqLzS0VuZwlwZdZr .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NqLzS0VuZwlwZdZr .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-NqLzS0VuZwlwZdZr .actorPopupMenu{position:absolute;}#mermaid-svg-NqLzS0VuZwlwZdZr .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-NqLzS0VuZwlwZdZr .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-NqLzS0VuZwlwZdZr .actor-man circle,#mermaid-svg-NqLzS0VuZwlwZdZr line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-NqLzS0VuZwlwZdZr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 两套 JVM 互不可见,双双加锁成功 ❌ 依然超卖 扣减请求路由到服务A路由到服务Bsynchronized 加锁 ✅synchronized 加锁 ✅扣减扣减
关键点 :单机锁只能保证单个 JVM 内 的互斥。要跨节点互斥,必须有一把所有节点都能看到的锁 ------也就是分布式锁。Redis 分布式锁的完整原理(锁争抢、僵尸锁、锁过期、锁丢失)我在《一把 Redis 分布式锁,踩透四个坑》里已经详细展开,这里重点讲它在秒杀链路里的位置和取舍。
2.3 四种并发控制方案的对比
先给一张总表,下面再逐个说明"它是什么、为什么是这个性能、秒杀能不能用"。
| 方案 | 实现方式 | 持锁范围 | 性能 | 高并发下能否用 |
|---|---|---|---|---|
| DB 悲观锁 | SELECT ... FOR UPDATE |
整个事务 | 低 | 能用,但很慢 |
| DB 乐观锁 | WHERE version = #{v} |
单条 SQL | 中 | 不能用(失败率极高) |
| CAS 原子 SQL | UPDATE ... WHERE stock > 0 |
单条 SQL | 高 | 可以,首选 |
| Redis 分布式锁 | SET NX PX + Lua 释放 |
锁住临界区 | 高 | 可以 |
(1)DB 悲观锁:为什么慢
"悲观"的意思是假设冲突一定会发生 ,所以先把资源锁住再操作。SELECT ... FOR UPDATE 会给这一行加上排他锁,其他事务想读这一行都要排队。
它慢的原因有三个,都跟"锁由事务持有"这一点有关:
- 持锁范围是整个事务 ------数据库行锁必须等事务提交或回滚才释放,而事务里往往不止"扣库存"这一件事,还有创建订单、插入明细等耗时操作。锁的持有时间被整个事务撑得极长;
- 锁等待会占用数据库连接 ------拿不到锁的请求会排队等待,而等待期间它一直占着数据库连接 。并发一高,连接池迅速耗尽,整个应用线程池跟着阻塞,引发雪崩。这是 DB 锁最危险的地方;
- 锁粒度由索引决定 ------命中唯一索引时只锁单行;一旦没走索引,InnoDB 会锁住所有扫描到的行,等价于锁全表。
对比一下:Redis 分布式锁可以自由控制临界区范围,只锁"扣减库存"那几行代码,创建订单等操作完全放在锁外------持锁时间从"整个事务"缩短到"核心几行代码",这就是两者性能差距的根本来源。
(2)DB 乐观锁:为什么高并发下"不能用"
"乐观"的意思是假设冲突很少,所以不加锁,而是在更新时校验数据有没有被人改过:
sql
-- 1. 先查出版本号
SELECT stock, version FROM goods WHERE id = 1;
-- 2. 更新时带上版本号;如果版本对不上,说明这行被别人改过,本次更新就会失败
UPDATE goods SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = #{oldVersion};
单看逻辑很优雅,但它的致命问题在秒杀场景下会被无限放大:
库存只剩 1 件,1000 个请求同时读到 version = 100,然后一起执行 WHERE version = 100------只有一个能成功,其余 999 个全部更新失败 。失败之后怎么办?只能重试 :重新查版本号、重新更新。而重试又会引发新一轮竞争、继续大量失败,形成活锁(大家都在重试,谁也做不完)。
所以乐观锁适合的是"冲突很少 "的场景,比如后台管理系统两个人改同一条数据。在秒杀这种"几千人抢 1 件"的极端竞争下,失败率高到不可接受。
(3)CAS 原子 SQL:为什么它又能用了
看起来它和乐观锁几乎一样,都是"带条件更新",区别到底在哪?
sql
UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0;
关键差别在"条件"上:乐观锁的条件是 version = 旧值,CAS 的条件是 stock > 0。
- 乐观锁要求"这一行必须完全没被人动过"------所以并发越高,越容易失败,因为总有人比你快;
- CAS 只要求"库存还有 "------所以只要还有库存,多少个请求来都能依次成功,不需要重试,一次就拿到确定结果。
这就是 CAS 在秒杀场景下可用的根本原因:它把"版本有没有变"这种强条件,换成了"业务上到底还能不能扣"这种弱条件。 而秒杀恰好只需要后者------我们并不关心库存是被谁扣走的,只关心"还有没有"。
一点补充:这里的 "CAS" 来自 CPU 的 Compare-And-Swap 指令,思想就是"比较并交换"。2.4 讲的首选方案用的正是它------
WHERE stock > 0就是那个"比较",SET stock = stock - 1就是那个"交换"。
(4)Redis 分布式锁:为什么性能高,但复杂度也高
它的性能优势来自"锁的范围由你自己控制"------可以只锁"扣库存"那几行代码,把创建订单等耗时操作放在锁外,持锁时间远短于 DB 悲观锁。代价是要自己处理锁超时、误删、可重入、以及"等待机制"等一堆问题(见 2.5、2.6、2.7)。
2.4 首选方案:CAS 原子 SQL
大多数情况下,秒杀在 DB 层根本不需要额外加锁。用一条带条件的原子 SQL 就够了:
sql
UPDATE goods
SET stock = stock - 1
WHERE id = #{goodsId}
AND stock > 0;
java
int affected = goodsMapper.deductStock(goodsId);
if (affected == 0) {
// 影响行数为 0,说明 stock > 0 不成立,库存已空
throw new BizException("库存不足");
}
原理和 1.2 的 Lua 完全一样 :把"判断"和"扣减"合并进一条 SQL。InnoDB 执行这条 UPDATE 时会对目标行加行锁 ,天然保证同一行的串行执行,同时 WHERE stock > 0 又保证了不会扣成负数。
但这里要先纠正一个误解:CAS 并不是"没有锁"。
上面这条 SQL 之所以能防住超卖,靠的正是 InnoDB 对目标行加的排他锁 ------执行 UPDATE 时,InnoDB 会先锁住这一行,再修改,事务提交时才释放。所以准确的说法是:CAS 只是把"加锁"这件事交给了数据库自己做,而不是真的不需要锁。
理解这一点,才能看清 CAS 的能力边界:
- CAS 的原子性只覆盖一条 SQL,它能保证"这一行的这一次修改"是原子的;
- 一旦业务需要跨多条记录、跨多个步骤 保持原子,一条 SQL 就表达不了,这时就轮到分布式锁出场。
什么时候必须用分布式锁?
| 场景 | 能否用 CAS | 说明 |
|---|---|---|
| 只扣一行库存 | ✅ 可以 | 一条 SQL 搞定,首选 |
| 扣库存 + 创建订单(要求整体原子) | ❌ 不行 | 跨表、跨多步,一条 SQL 表达不了 |
| 库存分桶后需要"桶间借调" | ❌ 不行 | 要同时操作多个 key / 多行,必须先锁住再判断 |
所以取舍很清楚:能用 CAS 就用 CAS;只有当"多个操作必须作为一个整体"时,才升级到分布式锁。
还有一个容易被忽略的问题:CAS 拦不住请求到达数据库。
CAS 和 DB 行锁都是在数据库内部做互斥 ------它们能保证"数据不算错",但请求本身已经打到数据库了:数据库得先去抢行锁、再排进锁等待队列,才能开始干活。
高并发下会出什么问题?假设 Redis 放行了 1 万个请求(对应 1 万件库存):
- 1 万个请求全部连到数据库,一起竞争同一行的锁;
- 只有 1 个能拿到锁,其余 9999 个进入锁等待队列;
- 等待期间,这些请求一直占着数据库连接;
- 连接池很快被占满 → 后来的请求连不上数据库 → 应用线程池阻塞 → 雪崩。
行锁保证了库存不会算错,但数据库被这 1 万个并发连接压垮了。
换成 Redis 分布式锁,情况就不一样了:
- 1 万个请求先去 Redis 抢锁,抢不到的请求在 Redis 这一层就被挡住,根本不会连到数据库;
- 真正打到数据库的,只有拿到锁的那一个(加上在 Redis 侧等待的少量请求);
- 相当于把 1 万 QPS 的压力从数据库转移到了 Redis------而 Redis 的内存操作性能远高于数据库,扛得住。
这正是分布式锁在秒杀中的另一层价值:它不只是一把"互斥的锁",同时还是拦在数据库前面的一道"流量闸门"。
需要客观补充一句:这种"被打爆"在常规规模下概率并不高。 因为 Redis 预减库存已经把流量砍到了库存量级(100 件就是 100 个请求),100 个并发对数据库毫无压力。只有当库存规模本身就很大 (比如放量几万件)时,这个风险才真正显现。而那种情况下,更合适的做法是库存分片(见《秒杀系统设计(三)》),把压力分散到多行上,而不是靠一把锁硬扛。
2.5 三种锁的等待机制:"自旋"这件事由谁负责
前面反复说"拿不到锁就等待",但有一个很容易被忽略、却直接决定代码怎么写的问题:等待到底由谁来实现,三种锁的答案完全不同。
(1)synchronized:JVM 内置了等待队列
synchronized 拿不到锁时,不需要写任何代码 。JVM 内部有一套 Monitor 机制:线程会进入该对象的同步等待队列(EntryList)并进入阻塞状态(不消耗 CPU),等锁释放后由 JVM 唤醒竞争。整个过程对使用者完全透明。
(2)DB 行锁:InnoDB 内置了锁等待队列
SELECT ... FOR UPDATE 抢不到锁时,同样不用操心。InnoDB 有锁管理器和锁等待队列,请求会自动排进队列,等前面的锁释放后再被唤醒。
可以用一个参数控制最多等多久:
sql
-- 最多等 5 秒,超时就报锁等待超时,而不是无限等下去
SET innodb_lock_wait_timeout = 5;
超时会抛 Lock wait timeout exceeded,由业务决定是重试还是失败返回。
(3)Redis 分布式锁:没有任何内置等待机制,必须自己实现
这是三者里最"原始"的一个。Redis 只管存一个 key,它并不知道"有谁在等锁"这件事 。所以 SET NX 失败之后怎么办,完全由你的代码决定,通常有三种做法。
做法一:直接失败(不自旋)
java
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockKey, uuid, 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(ok)) {
// 拿不到锁,直接告诉用户稍后重试
return SeckillResult.fail("当前抢购人数过多,请稍后再试");
}
最简单,秒杀场景下往往也最合适------抢不到就该快速失败,没必要卡着用户。
做法二:自旋重试(循环 + 间隔)
如果想给用户更多机会,就自己写一个循环,隔一小段时间重试一次:
java
public boolean tryLockWithSpin(String lockKey, String uuid, long timeoutMs) {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockKey, uuid, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(ok)) {
return true; // 抢到了
}
try {
Thread.sleep(50); // 间隔 50ms 再试
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false; // 超时,放弃
}
这里有两个必须自己把控的点:
- 间隔时间:太短会疯狂打 Redis(相当于自己在制造 QPS),太长会让用户等太久,一般取 20~100ms;
- 超时上限 :必须有。不加超时的自旋会一直转下去,和死锁没区别,还会把线程池占满。
这个"循环 + 间隔"就是自旋。 synchronized 和 DB 行锁的等待是"内核/数据库帮你排队",而分布式锁的这个队列需要你自己用轮询实现。
做法三:Redisson 的 pub/sub 唤醒(推荐)
自旋的缺点是一直在无谓地轮询 Redis------明明前面那个人还要 200ms 才出来,我却每 50ms 问一次,四次全是白问。
Redisson 的做法更优雅,它基于 Redis 的发布订阅(pub/sub):
- 尝试
SET NX加锁; - 失败后不轮询,而是订阅一个专门的解锁频道 (如
redisson_lock__channel:{锁名}),然后让当前线程阻塞等待; - 持锁者释放锁时,向这个频道发一条消息;
- 等待中的线程被唤醒,再去尝试一次加锁(如果又失败,就再次订阅等待)。
#mermaid-svg-xY6K8CXnIM3KOADy{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-xY6K8CXnIM3KOADy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xY6K8CXnIM3KOADy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xY6K8CXnIM3KOADy .error-icon{fill:#552222;}#mermaid-svg-xY6K8CXnIM3KOADy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xY6K8CXnIM3KOADy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xY6K8CXnIM3KOADy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xY6K8CXnIM3KOADy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xY6K8CXnIM3KOADy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xY6K8CXnIM3KOADy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xY6K8CXnIM3KOADy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xY6K8CXnIM3KOADy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xY6K8CXnIM3KOADy .marker.cross{stroke:#333333;}#mermaid-svg-xY6K8CXnIM3KOADy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xY6K8CXnIM3KOADy p{margin:0;}#mermaid-svg-xY6K8CXnIM3KOADy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xY6K8CXnIM3KOADy .cluster-label text{fill:#333;}#mermaid-svg-xY6K8CXnIM3KOADy .cluster-label span{color:#333;}#mermaid-svg-xY6K8CXnIM3KOADy .cluster-label span p{background-color:transparent;}#mermaid-svg-xY6K8CXnIM3KOADy .label text,#mermaid-svg-xY6K8CXnIM3KOADy span{fill:#333;color:#333;}#mermaid-svg-xY6K8CXnIM3KOADy .node rect,#mermaid-svg-xY6K8CXnIM3KOADy .node circle,#mermaid-svg-xY6K8CXnIM3KOADy .node ellipse,#mermaid-svg-xY6K8CXnIM3KOADy .node polygon,#mermaid-svg-xY6K8CXnIM3KOADy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xY6K8CXnIM3KOADy .rough-node .label text,#mermaid-svg-xY6K8CXnIM3KOADy .node .label text,#mermaid-svg-xY6K8CXnIM3KOADy .image-shape .label,#mermaid-svg-xY6K8CXnIM3KOADy .icon-shape .label{text-anchor:middle;}#mermaid-svg-xY6K8CXnIM3KOADy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xY6K8CXnIM3KOADy .rough-node .label,#mermaid-svg-xY6K8CXnIM3KOADy .node .label,#mermaid-svg-xY6K8CXnIM3KOADy .image-shape .label,#mermaid-svg-xY6K8CXnIM3KOADy .icon-shape .label{text-align:center;}#mermaid-svg-xY6K8CXnIM3KOADy .node.clickable{cursor:pointer;}#mermaid-svg-xY6K8CXnIM3KOADy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xY6K8CXnIM3KOADy .arrowheadPath{fill:#333333;}#mermaid-svg-xY6K8CXnIM3KOADy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xY6K8CXnIM3KOADy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xY6K8CXnIM3KOADy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xY6K8CXnIM3KOADy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xY6K8CXnIM3KOADy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xY6K8CXnIM3KOADy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xY6K8CXnIM3KOADy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xY6K8CXnIM3KOADy .cluster text{fill:#333;}#mermaid-svg-xY6K8CXnIM3KOADy .cluster span{color:#333;}#mermaid-svg-xY6K8CXnIM3KOADy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xY6K8CXnIM3KOADy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xY6K8CXnIM3KOADy rect.text{fill:none;stroke-width:0;}#mermaid-svg-xY6K8CXnIM3KOADy .icon-shape,#mermaid-svg-xY6K8CXnIM3KOADy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xY6K8CXnIM3KOADy .icon-shape p,#mermaid-svg-xY6K8CXnIM3KOADy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xY6K8CXnIM3KOADy .icon-shape .label rect,#mermaid-svg-xY6K8CXnIM3KOADy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xY6K8CXnIM3KOADy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xY6K8CXnIM3KOADy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xY6K8CXnIM3KOADy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
超时
收到消息
尝试加锁 SET NX
加锁成功?
拿到锁,执行业务
订阅解锁频道
阻塞等待
收到解锁消息
或等待超时?
放弃,返回失败
执行业务后释放锁
向频道发布解锁消息
这样"等待"就从"我主动轮询"变成了"锁释放时通知我" ,既没有无效轮询,又能在锁释放的第一时间被唤醒。Redisson 的 lock() 默认就是这套机制。
顺带澄清一个容易混淆的点:看门狗不是用来解决自旋的。
Redisson 有两个不同的机制,常被混在一起讲:
| 机制 | 解决的问题 | 具体做什么 |
|---|---|---|
| pub/sub 等待 | 拿不到锁时怎么等 | 订阅解锁频道,锁释放时被唤醒(即做法三) |
| 看门狗(watchdog) | 业务没跑完锁就过期 | 后台定时任务给锁自动续期,默认每 10 秒把 TTL 续回 30 秒 |
一个是"等锁 ",一个是"保锁",完全是两个问题。
但看门狗也不是万能的------它依赖"持有锁的 JVM 还活着"。一旦进程被 kill、或发生长时间 Full GC,续期就会中断,锁照样会过期。这正是 8.7 要讨论的问题。
三种锁的等待机制汇总:
| 锁类型 | 等待由谁实现 | 等待时是否消耗 CPU | 是否需自己写代码 |
|---|---|---|---|
| synchronized | JVM Monitor(同步队列) | 否(阻塞挂起) | 不需要 |
| DB 行锁 | InnoDB 锁管理器(锁等待队列) | 否(阻塞挂起) | 不需要(可配置超时) |
| Redis 分布式锁 | 使用者自己(轮询自旋 / pub-sub 订阅) | 轮询会消耗 | 需要 |
结论 :前两种锁的"排队"是语言和数据库的内置能力,第三种必须自己补齐。而无论用哪种等待方式,都必须设置超时上限------否则一旦锁异常,就会从"等待"变成"卡死"。
2.6 Redis 分布式锁不是银弹
性能虽好,但 Redis 分布式锁有两类绕不开的可靠性问题。先把它们解释清楚,后面才知道为什么必须兜底。
问题一:锁过期提前释放。
Redisson 的看门狗(watchdog)会定期给锁续期,看起来挺安全。但它有个前提:持有锁的那个 JVM 必须还活着 。如果 JVM 发生了长时间的 Full GC、或者进程被 kill 掉,看门狗也就停了,锁会在 TTL 到期后自动释放------而此时业务可能还没执行完。一旦业务代码在锁释放后继续往下跑,就出现了"两个人同时操作同一行数据"。
问题二:脑裂,导致锁丢失。
"脑裂"这个词听起来唬人,其实就是指分布式系统里出现了两个都认为"我是主"的节点。放到 Redis 主从架构下,具体过程是这样的:
- 客户端 A 向主节点申请锁,主节点在内存里加锁成功,返回 A 成功
- 主节点还没来得及把这条加锁记录同步给从节点,就宕机了
- 哨兵(Sentinel)检测到主节点挂了,触发故障转移,把一个没有这把锁记录的从节点提升为新主节点
- 客户端 B 来申请同一把锁,新主节点里查不到记录,于是也加锁成功了
- 结果:A 和 B 同时持有同一把锁,互斥性被打破
客户端B Redis 从节点 Redis 主节点 客户端A 客户端B Redis 从节点 Redis 主节点 客户端A #mermaid-svg-h1wCNMZuoCn8Jpoz{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-h1wCNMZuoCn8Jpoz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-h1wCNMZuoCn8Jpoz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-h1wCNMZuoCn8Jpoz .error-icon{fill:#552222;}#mermaid-svg-h1wCNMZuoCn8Jpoz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-h1wCNMZuoCn8Jpoz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-h1wCNMZuoCn8Jpoz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-h1wCNMZuoCn8Jpoz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-h1wCNMZuoCn8Jpoz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-h1wCNMZuoCn8Jpoz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-h1wCNMZuoCn8Jpoz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-h1wCNMZuoCn8Jpoz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-h1wCNMZuoCn8Jpoz .marker.cross{stroke:#333333;}#mermaid-svg-h1wCNMZuoCn8Jpoz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-h1wCNMZuoCn8Jpoz p{margin:0;}#mermaid-svg-h1wCNMZuoCn8Jpoz .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-h1wCNMZuoCn8Jpoz text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-h1wCNMZuoCn8Jpoz .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-h1wCNMZuoCn8Jpoz .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-h1wCNMZuoCn8Jpoz .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-h1wCNMZuoCn8Jpoz .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-h1wCNMZuoCn8Jpoz #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-h1wCNMZuoCn8Jpoz .sequenceNumber{fill:white;}#mermaid-svg-h1wCNMZuoCn8Jpoz #sequencenumber{fill:#333;}#mermaid-svg-h1wCNMZuoCn8Jpoz #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-h1wCNMZuoCn8Jpoz .messageText{fill:#333;stroke:none;}#mermaid-svg-h1wCNMZuoCn8Jpoz .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-h1wCNMZuoCn8Jpoz .labelText,#mermaid-svg-h1wCNMZuoCn8Jpoz .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-h1wCNMZuoCn8Jpoz .loopText,#mermaid-svg-h1wCNMZuoCn8Jpoz .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-h1wCNMZuoCn8Jpoz .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-h1wCNMZuoCn8Jpoz .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-h1wCNMZuoCn8Jpoz .noteText,#mermaid-svg-h1wCNMZuoCn8Jpoz .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-h1wCNMZuoCn8Jpoz .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-h1wCNMZuoCn8Jpoz .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-h1wCNMZuoCn8Jpoz .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-h1wCNMZuoCn8Jpoz .actorPopupMenu{position:absolute;}#mermaid-svg-h1wCNMZuoCn8Jpoz .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-h1wCNMZuoCn8Jpoz .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-h1wCNMZuoCn8Jpoz .actor-man circle,#mermaid-svg-h1wCNMZuoCn8Jpoz line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-h1wCNMZuoCn8Jpoz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 加锁记录还没来得及同步给从节点 哨兵把从节点提升为新的主节点 新主节点里没有这把锁的记录 A 和 B 同时持有了同一把锁 SET lock NX → 加锁成功主节点宕机 💥SET lock NX → 加锁加锁成功 ❌
这两类问题指向同一个结论 :Redis 分布式锁提供的是高性能的互斥 ,但它不是数据正确性的最终保证------锁可能因为过期或脑裂而失效,失效的那一瞬间就可能产生脏数据。
所以:锁用来保性能(让并发串行化),正确性的最后一道防线必须放在数据库(唯一索引 + 原子 SQL)。这一点在 2.9 会展开。
先做一个整体对比:
| 维度 | DB 行锁 | Redis 分布式锁 |
|---|---|---|
| 性能 | 低 | 高 |
| 正确性 | 强(DB 保证,不受客户端崩溃影响) | 弱(受锁过期、脑裂影响,必须用 DB 兜底) |
| 实现复杂度 | 低(一条 SQL) | 高(要处理误删、续期、可重入) |
| 额外依赖 | 无 | 需要 Redis 高可用 |
| 客户端崩溃 | 事务回滚,锁自动释放 | 必须靠 TTL 兜底,存在不安全窗口 |
所以正确的取舍是:
- 能用原子 SQL 解决 → 用
WHERE stock > 0,不需要任何锁(首选) - 并发不高、正确性优先 → 用 DB 行锁,简单可靠
- 必须锁住多步操作、且并发极高 → 用 Redis 分布式锁换性能,但要接受它的可靠性边界,并用唯一索引 + 幂等兜底
2.7 坑点一:锁必须放在事务外面(最重要的坑)
这是生产环境最常见、也最隐蔽的错误。先看错误写法:
java
@Transactional(rollbackFor = Exception.class)
public void createOrder(SeckillMessage msg) {
RLock lock = redissonClient.getLock("lock:stock:" + msg.getGoodsId());
lock.lock();
try {
// 1. 扣减库存
// 2. 创建订单
} finally {
lock.unlock(); // ❌ 锁在这里就释放了
}
// 方法返回后,Spring 事务代理才会提交事务
}
看起来"加了锁也加了事务",但执行顺序是错的。
实际执行顺序(错误版)------注意看线程 B 是怎么插进来的:
MySQL 线程B(另一个请求) Redis 锁 线程A Spring 事务代理 MySQL 线程B(另一个请求) Redis 锁 线程A Spring 事务代理 #mermaid-svg-valOSB57Hx7Ukddy{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-valOSB57Hx7Ukddy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-valOSB57Hx7Ukddy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-valOSB57Hx7Ukddy .error-icon{fill:#552222;}#mermaid-svg-valOSB57Hx7Ukddy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-valOSB57Hx7Ukddy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-valOSB57Hx7Ukddy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-valOSB57Hx7Ukddy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-valOSB57Hx7Ukddy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-valOSB57Hx7Ukddy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-valOSB57Hx7Ukddy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-valOSB57Hx7Ukddy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-valOSB57Hx7Ukddy .marker.cross{stroke:#333333;}#mermaid-svg-valOSB57Hx7Ukddy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-valOSB57Hx7Ukddy p{margin:0;}#mermaid-svg-valOSB57Hx7Ukddy .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-valOSB57Hx7Ukddy text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-valOSB57Hx7Ukddy .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-valOSB57Hx7Ukddy .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-valOSB57Hx7Ukddy .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-valOSB57Hx7Ukddy .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-valOSB57Hx7Ukddy #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-valOSB57Hx7Ukddy .sequenceNumber{fill:white;}#mermaid-svg-valOSB57Hx7Ukddy #sequencenumber{fill:#333;}#mermaid-svg-valOSB57Hx7Ukddy #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-valOSB57Hx7Ukddy .messageText{fill:#333;stroke:none;}#mermaid-svg-valOSB57Hx7Ukddy .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-valOSB57Hx7Ukddy .labelText,#mermaid-svg-valOSB57Hx7Ukddy .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-valOSB57Hx7Ukddy .loopText,#mermaid-svg-valOSB57Hx7Ukddy .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-valOSB57Hx7Ukddy .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-valOSB57Hx7Ukddy .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-valOSB57Hx7Ukddy .noteText,#mermaid-svg-valOSB57Hx7Ukddy .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-valOSB57Hx7Ukddy .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-valOSB57Hx7Ukddy .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-valOSB57Hx7Ukddy .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-valOSB57Hx7Ukddy .actorPopupMenu{position:absolute;}#mermaid-svg-valOSB57Hx7Ukddy .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-valOSB57Hx7Ukddy .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-valOSB57Hx7Ukddy .actor-man circle,#mermaid-svg-valOSB57Hx7Ukddy line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-valOSB57Hx7Ukddy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 库存 = 1 锁已释放!但线程A 的事务还没提交 判断 1 > 0 通过,认为自己可以扣 ❌ 库存被扣了两次,最终 = -1,超卖 开启事务,调用 createOrderlock() 加锁 ✅扣减库存(事务内,尚未提交)unlock() 释放锁 ❌lock() 加锁 ✅ 立刻拿到了锁查询库存读到 1(线程A 未提交,读到的是旧值)扣减库存方法返回COMMIT 提交事务
问题出在哪:
unlock() 在 finally 里执行,此时方法体执行完了,但事务还没提交 ------Spring 的 @Transactional 是通过 AOP 代理实现的,事务的提交发生在目标方法返回之后,由代理来完成。
于是出现了一个致命窗口:
- 线程 A 加锁 → 扣减库存(未提交)→ 释放锁
- 线程 B 立刻拿到锁 → 读取库存 → 读到的是线程 A 未提交的旧库存 → 判断通过 → 再次扣减
- 两个线程都扣了,超卖
一句话总结:锁提前释放了,事务还没提交,后来者读到了脏数据。
正确写法:把锁放在事务外面
java
// ✅ 外层方法:负责加锁,本身不开启事务
public void createOrder(SeckillMessage msg) {
RLock lock = redissonClient.getLock("lock:stock:" + msg.getGoodsId());
lock.lock();
try {
// 调用事务方法,锁的范围包住整个事务
orderService.doCreateOrder(msg);
} finally {
lock.unlock();
}
}
// ✅ 内层事务方法:只管事务
@Transactional(rollbackFor = Exception.class)
public void doCreateOrder(SeckillMessage msg) {
// 1. 扣减库存
// 2. 创建订单
}
正确执行顺序:
Spring 事务代理 Redis 锁 外层方法 Spring 事务代理 Redis 锁 外层方法 #mermaid-svg-mVn8KQKK5XqvPOSS{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-mVn8KQKK5XqvPOSS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mVn8KQKK5XqvPOSS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mVn8KQKK5XqvPOSS .error-icon{fill:#552222;}#mermaid-svg-mVn8KQKK5XqvPOSS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mVn8KQKK5XqvPOSS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mVn8KQKK5XqvPOSS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mVn8KQKK5XqvPOSS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mVn8KQKK5XqvPOSS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mVn8KQKK5XqvPOSS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mVn8KQKK5XqvPOSS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mVn8KQKK5XqvPOSS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mVn8KQKK5XqvPOSS .marker.cross{stroke:#333333;}#mermaid-svg-mVn8KQKK5XqvPOSS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mVn8KQKK5XqvPOSS p{margin:0;}#mermaid-svg-mVn8KQKK5XqvPOSS .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-mVn8KQKK5XqvPOSS text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-mVn8KQKK5XqvPOSS .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-mVn8KQKK5XqvPOSS .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-mVn8KQKK5XqvPOSS .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-mVn8KQKK5XqvPOSS .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-mVn8KQKK5XqvPOSS #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-mVn8KQKK5XqvPOSS .sequenceNumber{fill:white;}#mermaid-svg-mVn8KQKK5XqvPOSS #sequencenumber{fill:#333;}#mermaid-svg-mVn8KQKK5XqvPOSS #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-mVn8KQKK5XqvPOSS .messageText{fill:#333;stroke:none;}#mermaid-svg-mVn8KQKK5XqvPOSS .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-mVn8KQKK5XqvPOSS .labelText,#mermaid-svg-mVn8KQKK5XqvPOSS .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-mVn8KQKK5XqvPOSS .loopText,#mermaid-svg-mVn8KQKK5XqvPOSS .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-mVn8KQKK5XqvPOSS .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-mVn8KQKK5XqvPOSS .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-mVn8KQKK5XqvPOSS .noteText,#mermaid-svg-mVn8KQKK5XqvPOSS .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-mVn8KQKK5XqvPOSS .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-mVn8KQKK5XqvPOSS .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-mVn8KQKK5XqvPOSS .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-mVn8KQKK5XqvPOSS .actorPopupMenu{position:absolute;}#mermaid-svg-mVn8KQKK5XqvPOSS .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-mVn8KQKK5XqvPOSS .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-mVn8KQKK5XqvPOSS .actor-man circle,#mermaid-svg-mVn8KQKK5XqvPOSS line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-mVn8KQKK5XqvPOSS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 事务已提交,锁才释放 lock() 加锁调用事务方法开启事务扣减库存 + 创建订单COMMIT 提交事务 ✅方法返回unlock() 释放锁 ✅
顺序变成了:加锁 → 开启事务 → 业务 → 提交事务 → 释放锁。
铁律:锁的范围必须完整包住事务,而不是被困在事务里面。
判断标准很简单:
unlock()必须发生在COMMIT之后 。如果你看到lock()和@Transactional写在同一个方法里,那基本就是错的。
坑点延伸 :同样的道理适用于 synchronized。在事务方法内用 synchronized 加锁,锁释放同样早于事务提交,一样会超卖。
2.8 坑点二:锁的粒度设计
锁的粒度直接决定吞吐量:
- 锁商品 ID (推荐):
lock:stock:goodsId。不同商品互不影响,粒度最细 - 锁全局 :
lock:seckill。所有商品串行,吞吐量极低
注意:秒杀场景下,同一商品的库存本来就是"必须串行"的(否则无法保证不超卖),所以锁商品维度是最小且正确的粒度。
2.9 坑点三:锁不可靠怎么办------唯一索引兜底
先说为什么"必须"要兜底,而不是"最好"要兜底。
2.6 已经说清楚了:Redis 分布式锁会因为锁过期 、脑裂 而失效。这意味着一件事------只要你用了分布式锁,就必须假设"在某一刻它可能不起作用"。这不是"能不能做好的问题",而是分布式系统的固有属性:锁的状态存在 Redis 里,Redis 会宕机、会主从切换、网络会分区,任何依赖它们的机制都有可能失效。
那锁失效的那一刻,具体会发生什么?
回到 2.7 的那张图:线程 A 和线程 B 同时进入了临界区,各自执行了一遍"扣库存 + 创建订单"。结果就是------同一个用户,对同一个商品,生成了两条订单。
注意这里其实有两层损失:
- 显性损失(业务层):一个用户抢到了 2 件,本该只能 1 件,活动公平性被破坏;
- 隐性损失(数据层,更麻烦) :如果这两条订单里有一条后来"超时未支付被回补库存",数据会变得极其难对账------你既不能简单删掉一条订单(用户可能已经付款了),也很难判断库存到底该回补几件。
而这类问题的特点是:发生概率极低,但一旦发生就是线上事故。
锁只能把"发生概率"降低,不能把它降到 0。所以必须有一道不依赖任何中间件状态、绝对可靠的防线------它不能依赖 Redis、不能依赖锁、不能依赖网络。
这道防线就是数据库的唯一索引。
它的可靠性来自一个前提:唯一索引是由数据库内核强制的 。无论前面的链路怎么出问题------锁失效也好、消息重复也好、代码有 bug 也好------只要最终要往 orders 表插入两行"同一用户 + 同一商品"的数据,数据库就一定会拒绝第二行:
sql
-- 用户 + 商品唯一索引:同一个用户对同一个商品,只允许存在一条订单
ALTER TABLE orders ADD UNIQUE KEY uk_user_goods (user_id, goods_id);
有了它,即使锁失效、两个线程同时插入,也只会有一条成功,另一条被数据库直接拦下。代码上要做的,就是把"唯一索引冲突"当成一个正常分支来处理,而不是当成异常抛给用户:
java
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 唯一索引冲突 → 说明这个用户已经抢过了
// 视为"重复请求",静默处理掉
log.warn("重复下单被唯一索引拦截, userId={}, goodsId={}", userId, goodsId);
throw new BizException("您已参与过本次抢购");
}
它还有一个很大的好处:一份兜底,多处复用。
同一个唯一索引,同时解决了三个不同环节的问题:
| 问题 | 出现的原因 | 高发位置 |
|---|---|---|
| 锁失效导致重复下单 | 锁过期 / 脑裂 | 本节(2.9) |
| MQ 重复投递导致重复消费 | 消息队列的 at-least-once 语义 | 3.3 |
| 用户手速/脚本导致重复提交 | "先查再插"的窗口期 | 3.2 |
也就是说,它并不是"某一章的补丁",而是整条链路最终一致性的基石。
一句话总结:锁是为了性能,唯一索引是为了正确性。 性能可以妥协,正确性不能------所以哪怕锁写得再完美,唯一索引也必须加。
关于"一人一单"的完整实现,下一章展开。
三、业务侧:一人一单与幂等
前面两章解决了"库存不会被扣成负数 ",但还剩第一篇 2.5 节提到的另一个问题没解决------"一人多单"。
3.1 为什么"先查再插"的一人一单会失效
最直觉的一人一单写法是"查一下,没有就插":
java
// 先查这个用户有没有下过单
Long count = orderMapper.countByUserAndGoods(userId, goodsId);
if (count > 0) {
return SeckillResult.fail("请勿重复提交");
}
// 没有就插入订单
orderMapper.insert(new Order(userId, goodsId, OrderStatus.UNPAID));
单看逻辑没问题,但在并发下它会失效------因为"查"和"插"之间有一个窗口期:
MySQL 应用 同一用户 MySQL 应用 同一用户 #mermaid-svg-SAsXKilcSZP8avBX{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-SAsXKilcSZP8avBX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SAsXKilcSZP8avBX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SAsXKilcSZP8avBX .error-icon{fill:#552222;}#mermaid-svg-SAsXKilcSZP8avBX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SAsXKilcSZP8avBX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SAsXKilcSZP8avBX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SAsXKilcSZP8avBX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SAsXKilcSZP8avBX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SAsXKilcSZP8avBX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SAsXKilcSZP8avBX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SAsXKilcSZP8avBX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SAsXKilcSZP8avBX .marker.cross{stroke:#333333;}#mermaid-svg-SAsXKilcSZP8avBX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SAsXKilcSZP8avBX p{margin:0;}#mermaid-svg-SAsXKilcSZP8avBX .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SAsXKilcSZP8avBX text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-SAsXKilcSZP8avBX .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-SAsXKilcSZP8avBX .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-SAsXKilcSZP8avBX .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-SAsXKilcSZP8avBX .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-SAsXKilcSZP8avBX #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-SAsXKilcSZP8avBX .sequenceNumber{fill:white;}#mermaid-svg-SAsXKilcSZP8avBX #sequencenumber{fill:#333;}#mermaid-svg-SAsXKilcSZP8avBX #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-SAsXKilcSZP8avBX .messageText{fill:#333;stroke:none;}#mermaid-svg-SAsXKilcSZP8avBX .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SAsXKilcSZP8avBX .labelText,#mermaid-svg-SAsXKilcSZP8avBX .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-SAsXKilcSZP8avBX .loopText,#mermaid-svg-SAsXKilcSZP8avBX .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-SAsXKilcSZP8avBX .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-SAsXKilcSZP8avBX .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-SAsXKilcSZP8avBX .noteText,#mermaid-svg-SAsXKilcSZP8avBX .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-SAsXKilcSZP8avBX .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SAsXKilcSZP8avBX .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SAsXKilcSZP8avBX .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SAsXKilcSZP8avBX .actorPopupMenu{position:absolute;}#mermaid-svg-SAsXKilcSZP8avBX .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-SAsXKilcSZP8avBX .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SAsXKilcSZP8avBX .actor-man circle,#mermaid-svg-SAsXKilcSZP8avBX line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-SAsXKilcSZP8avBX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 两个请求都认为"没下过单",都通过了校验 ❌ 同一用户生成了 2 条订单 请求1(脚本 / 手速,几乎同时)请求2请求1:查询是否已下单 → 0 条请求2:查询是否已下单 → 0 条请求1:INSERT 订单请求2:INSERT 订单
为什么会失效? 和 1.1 的 Redis 超卖、2.1 的 DB 超卖是同一个根因 ------"读---判断---写"不原子。两个请求在同一个窗口里都读到了"没下过单",于是都通过了校验,然后各自插入了一条订单。
用户只要用脚本、或者手速快一点同时发多个请求,就能抢到多件。这违反了 F1(一人一单)和 F8(防刷)。
所以一人一单不能靠"应用层自己查一下"来解决 ------应用层的查询天然有窗口期。真正的解法是把这个判断交给一个"无法绕过、天然原子的东西",也就是下面这三个方案。
3.2 一人一单的三种实现
方案一:数据库唯一索引(最可靠,必做)
sql
ALTER TABLE orders ADD UNIQUE KEY uk_user_goods (user_id, goods_id);
插入重复数据直接抛异常,捕获后视为"已抢过"。这是最后的兜底,无论前面怎么写,这条都必须有。
方案二:Redis 原子标记(性能好,用于前置拦截)
java
String dedupKey = "seckill:dedup:" + userId + ":" + goodsId;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(first)) {
return SeckillResult.fail("请勿重复提交");
}
SETNX 本身是原子的,所以并发下只会有一个请求成功,其余全部被拒。这个方案挡在最前面,能大幅减少后面的无效处理。
方案三:数据库去重表
sql
CREATE TABLE seckill_dedup (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
goods_id BIGINT NOT NULL,
msg_id VARCHAR(64) NOT NULL,
UNIQUE KEY uk_msg (msg_id)
);
坑点:Redis 标记方案存在"标记成功但业务失败"的问题。
用户被标记了(SETNX 成功),但后续下单失败(发 MQ 失败、库存不足),此时用户就被永久标记 了,再也无法下单------这是少卖。
怎么解决? 有两种思路,第二种更推荐。
思路一:标记失败即撤销(回滚标记)
在业务失败的每个分支里,把标记删掉:
java
String dedupKey = "seckill:dedup:" + userId + ":" + goodsId;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(first)) {
return SeckillResult.fail("请勿重复提交");
}
try {
// 后续业务流程(发 MQ、异步下单......)
doSeckill(userId, goodsId);
} catch (Exception e) {
// 业务失败就把标记撤销,让用户还能重试
redisTemplate.delete(dedupKey);
throw e;
}
这个方案能解决"业务失败"的情况,但它自己又引入了新问题 :delete 也可能失败(Redis 抖动、进程刚好崩溃),标记还是会残留。所以它是"降低概率",而不是"彻底解决"。
思路二:把标记当成"结果"而不是"过程"(推荐)
换一个思路------不要在下单前打标记,而是在下单成功后打标记。
具体做法是把两件事分开:
- 前置拦截用 Redis 标记(快,但允许漏判,漏判了用户重试一次就行);
- 真正的判定 交给数据库唯一索引(慢,但绝对准确)。
java
// 第 1 步:前置拦截(允许漏判,只求快)
if (Boolean.TRUE.equals(redisTemplate.hasKey(dedupKey))) {
return SeckillResult.fail("请勿重复提交");
}
// 第 2 步:真正的判定交给数据库唯一索引
try {
orderMapper.insert(order); // 唯一索引冲突会抛 DuplicateKeyException
} catch (DuplicateKeyException e) {
// 说明确实已经下过单了,此时补上标记
redisTemplate.opsForValue().set(dedupKey, "1", 24, TimeUnit.HOURS);
return SeckillResult.fail("您已参与过本次抢购");
}
// 第 3 步:下单成功后才写标记,供后续请求快速拦截
redisTemplate.opsForValue().set(dedupKey, "1", 24, TimeUnit.HOURS);
return SeckillResult.success();
这样即使标记写失败、或者标记被误删,最多也只是"多走一次数据库查询",绝不会出现"用户被永久拦住"的情况。
两种思路的取舍:
| 思路 | 标记时机 | 业务失败时 | 对用户的影响 |
|---|---|---|---|
| 前置标记 + 失败撤销 | 下单前 | 撤销标记 | 标记没撤销掉,用户就再也抢不了 |
| 前置查询 + 成功后标记 | 下单成功后 | 无需处理 | 不会误拦,最多多查一次库 |
结论 :Redis 标记只能用来做性能优化 (前置拦截、减少无效请求),绝不能承担正确性职责。正确性必须交给数据库唯一索引:
- 防刷场景(宁可误杀):可以用 Redis 标记
- 下单场景 (不能误杀):Redis 标记只做前置拦截,真正判定靠数据库唯一索引
3.3 MQ 消费端的幂等
MQ 为了保证可靠性,普遍采用 at-least-once 语义,消息会重复投递。消费端必须幂等,否则会重复创建订单。
| 方案 | 实现 | 说明 |
|---|---|---|
| DB 唯一索引 | 插入失败即视为已处理 | 最可靠,推荐 |
| 去重表 | msg_id 唯一索引 |
适合需要记录消息的场景 |
| Redis 标记 | SETNX msg_id |
性能好,但要注意原子性 |
坑点一:Redis 标记必须和业务操作保持原子。
如果"打标记"和"创建订单"分两步,中间崩溃就会导致"标记了但没下单"。所以推荐先插入订单(唯一索引兜底),成功后再记标记;或者干脆只用 DB 唯一索引。
坑点二:消费失败要重试,但不能无限重试。
设置最大重试次数,超过后进入死信队列,由人工或定时任务处理,避免消息被反复消费拖垮系统。
坑点三:消费者并发度要可控。
并发度太低,MQ 起不到削峰作用;并发度太高,又会加重 DB 锁竞争。合理值通常通过压测确定(见《秒杀系统设计(三)》的容量规划)。
3.4 超时未支付与库存回补
订单创建后如果用户 15 分钟不支付,需要:
- 取消订单(更新状态,不是删除)
- 回补库存到 Redis 和 DB
- 回补时要考虑:回补到"哪个桶"(见《秒杀系统设计(三)》的库存分片)
订单的状态流转必须清晰,避免状态错乱:
#mermaid-svg-TKieDWEsR3Lev4mD{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-TKieDWEsR3Lev4mD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-TKieDWEsR3Lev4mD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-TKieDWEsR3Lev4mD .error-icon{fill:#552222;}#mermaid-svg-TKieDWEsR3Lev4mD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-TKieDWEsR3Lev4mD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-TKieDWEsR3Lev4mD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-TKieDWEsR3Lev4mD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-TKieDWEsR3Lev4mD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-TKieDWEsR3Lev4mD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-TKieDWEsR3Lev4mD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-TKieDWEsR3Lev4mD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-TKieDWEsR3Lev4mD .marker.cross{stroke:#333333;}#mermaid-svg-TKieDWEsR3Lev4mD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-TKieDWEsR3Lev4mD p{margin:0;}#mermaid-svg-TKieDWEsR3Lev4mD defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-TKieDWEsR3Lev4mD g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-TKieDWEsR3Lev4mD g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-TKieDWEsR3Lev4mD g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-TKieDWEsR3Lev4mD g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-TKieDWEsR3Lev4mD g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-TKieDWEsR3Lev4mD .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-TKieDWEsR3Lev4mD .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-TKieDWEsR3Lev4mD .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-TKieDWEsR3Lev4mD .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-TKieDWEsR3Lev4mD .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-TKieDWEsR3Lev4mD .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-TKieDWEsR3Lev4mD .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-TKieDWEsR3Lev4mD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-TKieDWEsR3Lev4mD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-TKieDWEsR3Lev4mD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-TKieDWEsR3Lev4mD .edgeLabel .label text{fill:#333;}#mermaid-svg-TKieDWEsR3Lev4mD .label div .edgeLabel{color:#333;}#mermaid-svg-TKieDWEsR3Lev4mD .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-TKieDWEsR3Lev4mD .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-TKieDWEsR3Lev4mD .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-TKieDWEsR3Lev4mD .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-TKieDWEsR3Lev4mD .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-TKieDWEsR3Lev4mD .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TKieDWEsR3Lev4mD .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TKieDWEsR3Lev4mD #statediagram-barbEnd{fill:#333333;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-TKieDWEsR3Lev4mD .cluster-label,#mermaid-svg-TKieDWEsR3Lev4mD .nodeLabel{color:#131300;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-TKieDWEsR3Lev4mD .note-edge{stroke-dasharray:5;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-note text{fill:black;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram-note .nodeLabel{color:black;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagram .edgeLabel{color:red;}#mermaid-svg-TKieDWEsR3Lev4mD #dependencyStart,#mermaid-svg-TKieDWEsR3Lev4mD #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-TKieDWEsR3Lev4mD .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-TKieDWEsR3Lev4mD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} MQ 消费成功
用户支付
超时未支付 / 用户取消
回补库存
待支付
已支付
已取消
坑点:回补和取消必须幂等。
定时任务可能重复触发,必须用状态判断(WHERE status = 待支付)配合原子 SQL,保证"取消 + 回补"只执行一次。
【下一篇预告】 到这里,单商品、小库存的秒杀已经能做对了。但真实业务往往不是这样------大促可能放量十万件,一场活动可能同时开抢几百个商品 。这时"单个热点 key / 单行 / 单库"就会成为新的瓶颈。下一篇《秒杀系统设计(三):规模扩展》讲库存分片,并回答那个最实际的问题:到底要几台机器。