秒杀系统设计(二):数据正确性——防超卖、分布式锁与一人一单

【系列说明】 本文是《秒杀系统设计》系列的第 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);
}

看起来也能防超卖(扣成负数再回滚),但有两个问题:

  1. 回滚期间库存是错的(负数),如果有其他逻辑读这个值会出问题
  2. 回滚本身不是原子的 ,如果应用在 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):

  1. 尝试 SET NX 加锁;
  2. 失败后不轮询,而是订阅一个专门的解锁频道 (如 redisson_lock__channel:{锁名}),然后让当前线程阻塞等待;
  3. 持锁者释放锁时,向这个频道发一条消息;
  4. 等待中的线程被唤醒,再去尝试一次加锁(如果又失败,就再次订阅等待)。

#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 主从架构下,具体过程是这样的:

  1. 客户端 A 向主节点申请锁,主节点在内存里加锁成功,返回 A 成功
  2. 主节点还没来得及把这条加锁记录同步给从节点,就宕机了
  3. 哨兵(Sentinel)检测到主节点挂了,触发故障转移,把一个没有这把锁记录的从节点提升为新主节点
  4. 客户端 B 来申请同一把锁,新主节点里查不到记录,于是也加锁成功了
  5. 结果: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 代理实现的,事务的提交发生在目标方法返回之后,由代理来完成。

于是出现了一个致命窗口:

  1. 线程 A 加锁 → 扣减库存(未提交)→ 释放锁
  2. 线程 B 立刻拿到锁 → 读取库存 → 读到的是线程 A 未提交的旧库存 → 判断通过 → 再次扣减
  3. 两个线程都扣了,超卖

一句话总结:锁提前释放了,事务还没提交,后来者读到了脏数据。

正确写法:把锁放在事务外面

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 / 单行 / 单库"就会成为新的瓶颈。下一篇《秒杀系统设计(三):规模扩展》讲库存分片,并回答那个最实际的问题:到底要几台机器。

相关推荐
ao-weilai1 小时前
MySQL数据库:MySQL基础概要
数据库·mysql
海宇服务1 小时前
零信任架构实战:基于海宇车辆估值构建自动化车队残值重估网关
运维·人工智能·架构·自动化
KING-WU5121 小时前
Linux 工具之 yum、vim、gcc
linux·运维·服务器·后端
蜗牛互联网1 小时前
GPT-6.1 Sol迁移指南:从token单价转向每任务成本门禁
java·人工智能·后端·gpt
Mortalbreeze1 小时前
MySQL 基础篇(三):一文掌握 MySQL 常见数据类型
linux·服务器·数据库·mysql
IT_陈寒1 小时前
Redis键过期失效?这个坑我踩得明明白白
前端·人工智能·后端
Dawson Zhu1 小时前
AI Agent架构选型建议
人工智能·语言模型·架构·aigc·agi
蜗牛互联网1 小时前
HSTU在Dynamo-Triton中的AOTI与KV缓存验收方法
java·人工智能·后端·缓存