秒杀系统设计(二):数据正确性——防超卖、分布式锁与一人一单
【系列说明】 本文是《秒杀系统设计》系列的第 2 篇,共 3 篇。系列主线只有一句话:秒杀的本质是漏斗,不是堆机器。
- 第 1 篇:需求拆解与流量治理——解决"数据库被打爆"
- 第 2 篇(本文):数据正确性——解决"库存超卖"与"一人多单"
- 第 3 篇:规模扩展——大库存、多商品的分片方案,以及到底要几台机器
【承接上文】 第一篇结尾,流量已经被逐层削了下来:数据库不再被打爆,系统算是"活下来"了。但活下来不等于活得对——本篇要解决的是"库存不能超卖,一个人只能有一单"。分三层展开:Redis 侧怎么保证扣减原子(第一章)、数据库侧怎么控制并发(第二章)、业务侧怎么保证一人一单(第三章),最后用幂等和超时回补收尾。
一、Redis 侧:Lua 原子扣减
超卖会发生在两个环节:Redis 扣减时、数据库扣减时。这一章先解决 Redis 侧的。
1.1 Redis 侧的并发问题:扣减不原子导致超卖
先看会出现什么情况。
假设库存只剩 1 件,两个请求几乎同时到达。如果扣减不是原子的,最终可能卖出 2 件:
sequenceDiagram
participant A as 请求A
participant B as 请求B
participant R as Redis
Note over R: 库存只剩 1 件
A->>R: 1. GET stock → 1
B->>R: 2. GET stock → 1
Note over A,B: 两个请求都判断 1 > 0,都认为可以扣
A->>R: 3. DECR stock → 0
B->>R: 4. DECR stock → -1
Note over R: ❌ 库存变成 -1,卖出了 2 件为什么会这样?
第一篇 6.4 里的 deductStock 看起来只是"读一下、减一下",但如果把它拆成两条命令:
1 | Integer stock = redis.get("seckill:stock:" + goodsId); // 1. 读 |
问题就出在"读"和"写"中间那段时间:请求 A 和请求 B 都在这个窗口里读到了 stock = 1,都通过了 stock > 0 的判断,然后各自执行了一次 DECR。
这和 2.4 是同一个问题——"读—判断—写"不原子。Redis 命令本身是单线程执行的,但"读"和"写"是两条独立的命令,中间完全可以插入别的请求。
1.2 用 Lua 保证原子扣减
把"读—判断—扣减"整体塞进一个 Lua 脚本:
1 | public Long deductStock(Long goodsId) { |
为什么必须是 Lua:
GET 和 DECR 是两条命令,中间有窗口期。用 Lua 之后,"读取—判断—扣减"整体原子执行,绝对不会扣成负数。
1.3 坑点:为什么不用 DECR 后判断
有人会写:
1 | Long remain = redisTemplate.opsForValue().decrement("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 库存:
1 | SELECT stock FROM goods WHERE id = 1; -- 线程A读到 10 |
如果消费者的并发度大于 1(这是必须的,否则 MQ 削峰就没意义),同一商品的库存行会被多个线程同时修改。这和第一篇 2.4 节是同一个问题——只是这次发生在 DB 层。
所以这一层必须有并发控制。
2.2 先试最直觉的方案:加锁
最直觉的修复是加锁。但如果用 Java 的 synchronized,在多节点部署下会完全失效:
sequenceDiagram
participant UA as 用户A
participant LB as 负载均衡
participant SA as 服务A (JVM-A)
participant SB as 服务B (JVM-B)
participant DB as 共享库存
UA->>LB: 扣减请求
LB->>SA: 路由到服务A
LB->>SB: 路由到服务B
SA->>SA: synchronized 加锁 ✅
SB->>SB: synchronized 加锁 ✅
Note over SA,SB: 两套 JVM 互不可见,双双加锁成功
SA->>DB: 扣减
SB->>DB: 扣减
Note over DB: ❌ 依然超卖关键点:单机锁只能保证单个 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 乐观锁:为什么高并发下"不能用"
"乐观"的意思是假设冲突很少,所以不加锁,而是在更新时校验数据有没有被人改过:
1 | -- 1. 先查出版本号 |
单看逻辑很优雅,但它的致命问题在秒杀场景下会被无限放大:
库存只剩 1 件,1000 个请求同时读到 version = 100,然后一起执行 WHERE version = 100——只有一个能成功,其余 999 个全部更新失败。失败之后怎么办?只能重试:重新查版本号、重新更新。而重试又会引发新一轮竞争、继续大量失败,形成活锁(大家都在重试,谁也做不完)。
所以乐观锁适合的是"冲突很少"的场景,比如后台管理系统两个人改同一条数据。在秒杀这种"几千人抢 1 件"的极端竞争下,失败率高到不可接受。
(3)CAS 原子 SQL:为什么它又能用了
看起来它和乐观锁几乎一样,都是"带条件更新",区别到底在哪?
1 | 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 就够了:
1 | UPDATE goods |
1 | int affected = goodsMapper.deductStock(goodsId); |
原理和 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 有锁管理器和锁等待队列,请求会自动排进队列,等前面的锁释放后再被唤醒。
可以用一个参数控制最多等多久:
1 | -- 最多等 5 秒,超时就报锁等待超时,而不是无限等下去 |
超时会抛 Lock wait timeout exceeded,由业务决定是重试还是失败返回。
(3)Redis 分布式锁:没有任何内置等待机制,必须自己实现
这是三者里最"原始"的一个。Redis 只管存一个 key,它并不知道"有谁在等锁"这件事。所以 SET NX 失败之后怎么办,完全由你的代码决定,通常有三种做法。
做法一:直接失败(不自旋)
1 | Boolean ok = redisTemplate.opsForValue() |
最简单,秒杀场景下往往也最合适——抢不到就该快速失败,没必要卡着用户。
做法二:自旋重试(循环 + 间隔)
如果想给用户更多机会,就自己写一个循环,隔一小段时间重试一次:
1 | public boolean tryLockWithSpin(String lockKey, String uuid, long timeoutMs) { |
这里有两个必须自己把控的点:
- 间隔时间:太短会疯狂打 Redis(相当于自己在制造 QPS),太长会让用户等太久,一般取 20~100ms;
- 超时上限:必须有。不加超时的自旋会一直转下去,和死锁没区别,还会把线程池占满。
这个"循环 + 间隔"就是自旋。 synchronized 和 DB 行锁的等待是"内核/数据库帮你排队",而分布式锁的这个队列需要你自己用轮询实现。
做法三:Redisson 的 pub/sub 唤醒(推荐)
自旋的缺点是一直在无谓地轮询 Redis——明明前面那个人还要 200ms 才出来,我却每 50ms 问一次,四次全是白问。
Redisson 的做法更优雅,它基于 Redis 的发布订阅(pub/sub):
- 尝试
SET NX加锁; - 失败后不轮询,而是订阅一个专门的解锁频道(如
redisson_lock__channel:{锁名}),然后让当前线程阻塞等待; - 持锁者释放锁时,向这个频道发一条消息;
- 等待中的线程被唤醒,再去尝试一次加锁(如果又失败,就再次订阅等待)。
flowchart TD
A["尝试加锁 SET NX"] --> B{"加锁成功?"}
B -->|是| OK["拿到锁,执行业务"]
B -->|否| C["订阅解锁频道阻塞等待"]
C --> D{"收到解锁消息或等待超时?"}
D -->|超时| FAIL["放弃,返回失败"]
D -->|收到消息| A
OK --> E["执行业务后释放锁"]
E --> F["向频道发布解锁消息"]
F --> C
style OK fill:#d4edda
style FAIL fill:#ffe0e0这样等待就从主动轮询变成了锁释放时通知我,既没有无效轮询,又能在锁释放的第一时间被唤醒。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 同时持有同一把锁,互斥性被打破
sequenceDiagram
participant A as 客户端A
participant M as Redis 主节点
participant S as Redis 从节点
participant B as 客户端B
A->>M: SET lock NX → 加锁成功
Note over M,S: 加锁记录还没来得及同步给从节点
M--xM: 主节点宕机 💥
Note over S: 哨兵把从节点提升为新的主节点
B->>S: SET lock NX → 加锁
Note over S: 新主节点里没有这把锁的记录
S-->>B: 加锁成功 ❌
Note over A,B: A 和 B 同时持有了同一把锁这两类问题指向同一个结论:Redis 分布式锁提供的是高性能的互斥,但它不是数据正确性的最终保证——锁可能因为过期或脑裂而失效,失效的那一瞬间就可能产生脏数据。
所以:锁用来保性能(让并发串行化),正确性的最后一道防线必须放在数据库(唯一索引 + 原子 SQL)。这一点在 2.9 会展开。
先做一个整体对比:
| 维度 | DB 行锁 | Redis 分布式锁 |
|---|---|---|
| 性能 | 低 | 高 |
| 正确性 | 强(DB 保证,不受客户端崩溃影响) | 弱(受锁过期、脑裂影响,必须用 DB 兜底) |
| 实现复杂度 | 低(一条 SQL) | 高(要处理误删、续期、可重入) |
| 额外依赖 | 无 | 需要 Redis 高可用 |
| 客户端崩溃 | 事务回滚,锁自动释放 | 必须靠 TTL 兜底,存在不安全窗口 |
所以正确的取舍是:
- 能用原子 SQL 解决 → 用
WHERE stock > 0,不需要任何锁(首选) - 并发不高、正确性优先 → 用 DB 行锁,简单可靠
- 必须锁住多步操作、且并发极高 → 用 Redis 分布式锁换性能,但要接受它的可靠性边界,并用唯一索引 + 幂等兜底
2.7 坑点一:锁必须放在事务外面(最重要的坑)
这是生产环境最常见、也最隐蔽的错误。先看错误写法:
1 |
|
看起来"加了锁也加了事务",但执行顺序是错的。
实际执行顺序(错误版)——注意看线程 B 是怎么插进来的:
sequenceDiagram
participant P as Spring 事务代理
participant A as 线程A
participant L as Redis 锁
participant B as 线程B(另一个请求)
participant DB as MySQL
Note over DB: 库存 = 1
P->>A: 开启事务,调用 createOrder
A->>L: lock() 加锁 ✅
A->>DB: 扣减库存(事务内,尚未提交)
A->>L: unlock() 释放锁 ❌
Note over L: 锁已释放!但线程A 的事务还没提交
B->>L: lock() 加锁 ✅ 立刻拿到了锁
B->>DB: 查询库存
DB-->>B: 读到 1(线程A 未提交,读到的是旧值)
Note over B: 判断 1 > 0 通过,认为自己可以扣
B->>DB: 扣减库存
A-->>P: 方法返回
P->>P: COMMIT 提交事务
Note over DB: ❌ 库存被扣了两次,最终 = -1,超卖问题出在哪:
unlock() 在 finally 里执行,此时方法体执行完了,但事务还没提交——Spring 的 @Transactional 是通过 AOP 代理实现的,事务的提交发生在目标方法返回之后,由代理来完成。
于是出现了一个致命窗口:
- 线程 A 加锁 → 扣减库存(未提交)→ 释放锁
- 线程 B 立刻拿到锁 → 读取库存 → 读到的是线程 A 未提交的旧库存 → 判断通过 → 再次扣减
- 两个线程都扣了,超卖
一句话总结:锁提前释放了,事务还没提交,后来者读到了脏数据。
正确写法:把锁放在事务外面
1 | // ✅ 外层方法:负责加锁,本身不开启事务 |
正确执行顺序:
sequenceDiagram
participant O as 外层方法
participant L as Redis 锁
participant P as Spring 事务代理
O->>L: lock() 加锁
O->>P: 调用事务方法
P->>P: 开启事务
P->>P: 扣减库存 + 创建订单
P->>P: COMMIT 提交事务 ✅
P-->>O: 方法返回
O->>L: unlock() 释放锁 ✅
Note over L: 事务已提交,锁才释放顺序变成了:加锁 → 开启事务 → 业务 → 提交事务 → 释放锁。
铁律:锁的范围必须完整包住事务,而不是被困在事务里面。
判断标准很简单: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 表插入两行"同一用户 + 同一商品"的数据,数据库就一定会拒绝第二行:
1 | -- 用户 + 商品唯一索引:同一个用户对同一个商品,只允许存在一条订单 |
有了它,即使锁失效、两个线程同时插入,也只会有一条成功,另一条被数据库直接拦下。代码上要做的,就是把"唯一索引冲突"当成一个正常分支来处理,而不是当成异常抛给用户:
1 | try { |
它还有一个很大的好处:一份兜底,多处复用。
同一个唯一索引,同时解决了三个不同环节的问题:
| 问题 | 出现的原因 | 高发位置 |
|---|---|---|
| 锁失效导致重复下单 | 锁过期 / 脑裂 | 本节(2.9) |
| MQ 重复投递导致重复消费 | 消息队列的 at-least-once 语义 | 3.3 |
| 用户手速/脚本导致重复提交 | "先查再插"的窗口期 | 3.2 |
也就是说,它并不是"某一章的补丁",而是整条链路最终一致性的基石。
一句话总结:锁是为了性能,唯一索引是为了正确性。 性能可以妥协,正确性不能——所以哪怕锁写得再完美,唯一索引也必须加。
关于"一人一单"的完整实现,下一章展开。
三、业务侧:一人一单与幂等
前面两章解决了"库存不会被扣成负数",但还剩第一篇 2.5 节提到的另一个问题没解决——"一人多单"。
3.1 为什么"先查再插"的一人一单会失效
最直觉的一人一单写法是"查一下,没有就插":
1 | // 先查这个用户有没有下过单 |
单看逻辑没问题,但在并发下它会失效——因为"查"和"插"之间有一个窗口期:
sequenceDiagram
participant U as 同一用户
participant APP as 应用
participant DB as MySQL
U->>APP: 请求1(脚本 / 手速,几乎同时)
U->>APP: 请求2
APP->>DB: 请求1:查询是否已下单 → 0 条
APP->>DB: 请求2:查询是否已下单 → 0 条
Note over APP: 两个请求都认为"没下过单",都通过了校验
APP->>DB: 请求1:INSERT 订单
APP->>DB: 请求2:INSERT 订单
Note over DB: ❌ 同一用户生成了 2 条订单为什么会失效? 和 1.1 的 Redis 超卖、2.1 的 DB 超卖是同一个根因——"读—判断—写"不原子。两个请求在同一个窗口里都读到了"没下过单",于是都通过了校验,然后各自插入了一条订单。
用户只要用脚本、或者手速快一点同时发多个请求,就能抢到多件。这违反了 F1(一人一单)和 F8(防刷)。
所以一人一单不能靠"应用层自己查一下"来解决——应用层的查询天然有窗口期。真正的解法是把这个判断交给一个无法绕过、天然原子的东西,也就是下面这三个方案。
3.2 一人一单的三种实现
方案一:数据库唯一索引(最可靠,必做)
1 | ALTER TABLE orders ADD UNIQUE KEY uk_user_goods (user_id, goods_id); |
插入重复数据直接抛异常,捕获后视为"已抢过"。这是最后的兜底,无论前面怎么写,这条都必须有。
方案二:Redis 原子标记(性能好,用于前置拦截)
1 | String dedupKey = "seckill:dedup:" + userId + ":" + goodsId; |
SETNX 本身是原子的,所以并发下只会有一个请求成功,其余全部被拒。这个方案挡在最前面,能大幅减少后面的无效处理。
方案三:数据库去重表
1 | CREATE TABLE seckill_dedup ( |
坑点:Redis 标记方案存在"标记成功但业务失败"的问题。
用户被标记了(SETNX 成功),但后续下单失败(发 MQ 失败、库存不足),此时用户就被永久标记了,再也无法下单——这是少卖。
怎么解决? 有两种思路,第二种更推荐。
思路一:标记失败即撤销(回滚标记)
在业务失败的每个分支里,把标记删掉:
1 | String dedupKey = "seckill:dedup:" + userId + ":" + goodsId; |
这个方案能解决"业务失败"的情况,但它自己又引入了新问题:delete 也可能失败(Redis 抖动、进程刚好崩溃),标记还是会残留。所以它是"降低概率",而不是"彻底解决"。
思路二:把标记当成"结果"而不是"过程"(推荐)
换一个思路——不要在下单前打标记,而是在下单成功后打标记。
具体做法是把两件事分开:
- 前置拦截用 Redis 标记(快,但允许漏判,漏判了用户重试一次就行);
- 真正的判定交给数据库唯一索引(慢,但绝对准确)。
1 | // 第 1 步:前置拦截(允许漏判,只求快) |
这样即使标记写失败、或者标记被误删,最多也只是多走一次数据库查询,绝不会出现"用户被永久拦住"的情况。
两种思路的取舍:
| 思路 | 标记时机 | 业务失败时 | 对用户的影响 |
|---|---|---|---|
| 前置标记 + 失败撤销 | 下单前 | 撤销标记 | 标记没撤销掉,用户就再也抢不了 |
| 前置查询 + 成功后标记 | 下单成功后 | 无需处理 | 不会误拦,最多多查一次库 |
结论: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
- 回补时要考虑:回补到"哪个桶"(见《秒杀系统设计(三)》的库存分片)
订单的状态流转必须清晰,避免状态错乱:
stateDiagram-v2
[*] --> 待支付: MQ 消费成功
待支付 --> 已支付: 用户支付
待支付 --> 已取消: 超时未支付 / 用户取消
已取消 --> [*]: 回补库存
已支付 --> [*]坑点:回补和取消必须幂等。
定时任务可能重复触发,必须用状态判断(WHERE status = 待支付)配合原子 SQL,保证"取消 + 回补"只执行一次。
【下一篇预告】 到这里,单商品、小库存的秒杀已经能做对了。但真实业务往往不是这样——大促可能放量十万件,一场活动可能同时开抢几百个商品。这时"单个热点 key / 单行 / 单库"就会成为新的瓶颈。下一篇《秒杀系统设计(三):规模扩展》讲库存分片,并回答那个最实际的问题:到底要几台机器。










