想象一下,如果现在让你从零开始开发一个商品的秒杀系统,你会怎么做呢?
- 完整秒杀业务过程究竟会遇到什么问题?
- 头部大厂关于秒杀业务的复杂架构又是如何构建起来的呢?
本文以渐进式推演 的视角,从最原始的数据库CRUD 开始,通过"发现问题 ➔ 解决问题 ➔ 再发现问题 ➔ 再迭代改进"的演化方式,还原一个秒杀系统的进化全过程,带你真正掌握商业级秒杀系统的核心架构设计。
秒杀的本质是什么?
所谓的秒杀,就是:基于先到先得的原则,海量并发请求下,对有限资源的争抢。
- 有限资源(目标对象):数据库中库存表的库存数值,这里的库存数值显然必须是正整数,为0就表示资源被抢光了。
- 争抢(具体操作):在剩余库存还未归0时,就允许对其进行扣减,并生成订单。
更直白一点,秒杀就是针对多个并发请求,实时检查库存数,按检查结果操作如下:
- 当库存数大于0时,执行库存数扣减,触发后续业务操作,并返回秒杀成功提示;
- 当库存数归0时,直接结束请求,并返回售罄提示。
第一阶段:最粗暴的起点(V1.0 - 纯 CRUD 时代)
最原始的SQL操作
最开始,我们只要跟着直觉走,核心业务逻辑就是:
css
[ 用户请求 ]
➔ [ 校验库存 count > 0 ]
➔ [ 扣减库存 update stock = stock - 1 ]
➔ [ 生成订单 ]
第一个问题出现了:严重超卖
假设数据库里只有 10 个库存,当 100个人在同一时间点击抢购,存在一种情况:
- 所有人的动作都一样快,在扣减动作还没被执行时,线程 A 和线程 B 同时查到
stock = 10。 - 线程 A 扣减成 9,线程 B 也扣减成 9。
结果 :10 个商品被卖给了 100 个人,出现严重超卖事故。
第二阶段:引入数据库防御(V2.0 - 锁与 CAS 防线)
为了解决超卖问题,最直接的想法是:加锁。
方式一:悲观锁(排他锁)
在读取数据时就持悲观态度,认为一定会发生冲突,使用SELECT ... FOR UPDATE锁住指定的商品库存记录,谁先拿到锁谁处理,其他排队。
核心问题:
- 锁粒度大、锁持有时间长:读取到更新之间(包括业务逻辑校验、网络开销)一直持有 DB 行锁。
- 并发极低 :所有并发请求被迫串行化,数据库连接被大量阻塞的事务长期占用。
结果 :应用的连接池爆满与线程阻塞。MySQL 单行悲观锁在包含业务耗时的情况下,吞吐量通常连几百 QPS 都很难达到。海量请求将全部卡死在 DB 外部,数据库连接池瞬间爆满,HTTP 请求超时,系统直接崩溃。
方式二:乐观锁(CAS校验/条件更新)
既然悲观锁太慢,在读取数据时持乐观态度,不显式加锁(那就改用CAS乐观锁):
ini
UPDATE product SET stock = stock - 1 WHERE id = 101 AND stock > 0;
核心问题:
- 高并发下的行锁争用(Lock Contention) :虽然没有显式
FOR UPDATE,但 MySQL InnoDB 执行UPDATE语句时,会在引擎层自动对目标行加 X 锁(排他锁) 。 - 数据库CPU 消耗剧增 :成千上万的并发请求同时争抢同一行数据的行锁,造成大量的锁等待、上下文切换以及死锁检测(Deadlock Detection)。
结果:MySQL 单行更新极限也就几千 QPS,当 10 万请求同时冲向这一行数据,MySQL 的 CPU 瞬间 100%,整个数据库瘫痪,拖垮了全站所有业务。
原理分析:乐观锁与悲观锁
对于 MySQL InnoDB,无论是 SELECT ... FOR UPDATE 还是 UPDATE ... WHERE ...,在引擎层都会对目标记录所在的聚簇索引(Cluster Index)加 Record Lock(行级排他锁,即 X 锁)。
| 对比维度 | 悲观锁 (SELECT FOR UPDATE) | 乐观锁 (UPDATE ... WHERE) |
|---|---|---|
| 底层锁类型 | InnoDB X 锁 (排他锁) | InnoDB X 锁 (排他锁) |
| 加锁触发点 | 事务刚开始读取数据时 | 执行 UPDATE 语句的那一瞬间 |
| 锁持有时间 | 长(包含:查库 + 网络传输 + 应用层业务逻辑 + 更新) | 极短(仅包含:UPDATE 语句执行 + 事务提交) |
| 内核等待机制 | 直接挂起/休眠(Park/Sleep),不占用 CPU | CPU 自旋(Spin Lock) + 高频死锁检测(遍历 O(N^2) 等待图) |
| 数据库 CPU 占用 | 极低 / 闲置(等待线程处于休眠状态) | 瞬间 100% 满载(算力全部浪费在自旋与内核上下文切换) |
| 崩溃主因 | 连接与线程耗尽(长事务把 DB 连接池占满挂死) | 内核算力内耗(极高频行锁争用打爆 CPU,拖塌 DB 后反噬连接池) |
| 并发性能 | 阻塞严重,吞吐量极低 | 单行更新极限更高,但极高并发下依然死锁/争用 |
sql
【悲观锁 SELECT ... FOR UPDATE】
│
├─► 1. 开启事务 (BEGIN)
├─► 2. 执行 SELECT ... FOR UPDATE ────────┐ [立即申请并拿到 X 锁]
├─► 3. 执行应用层业务逻辑 (耗时 50ms) │ (锁一直被占用;后续未抢到锁的线程进入【挂起/休眠】,CPU 极低)
├─► 4. 执行 UPDATE 扣减 │
└─► 5. 提交事务 (COMMIT) ──────────────────┘ [释放 X 锁]
----------------------------------------------------------------------
【乐观锁 UPDATE ... WHERE stock > 0】
│
├─► 1. 开启事务 (BEGIN)
├─► 2. 普通 SELECT 查询库存 (不加锁)
├─► 3. 执行应用层业务逻辑 (耗时 50ms)
├─► 4. 执行 UPDATE 扣减 ────────────────────┐ [此时才申请并拿到 X 锁]
└─► 5. 提交事务 (COMMIT) ───────────────────┘ [立即释放 X 锁;但成千上万 UPDATE 瞬间争抢,触发【CPU自旋+死锁检测】,CPU 100%]
两者的核心差异不在于"锁的种类",而在于锁的生命周期(持有时间)、申请锁的时机以及内核等待时的 CPU 算力消耗。
- 悲观锁:早加锁、长持锁,但不消耗 CPU 算力。
- 乐观锁:晚加锁、短持锁,但触发CPU自旋。
两种锁的风险分析
值得注意的是:在海量请求场景下,悲观锁由于不占用cpu可能只是应用挂掉了,乐观锁由于会触发cpu自旋,可能直接把数据库也整瘫痪了,死得更惨了!
1. 悲观锁:应用层"挂掉"(局部瘫痪)
- 故障范围 :主要是应用服务和连接池。
- 数据库状态:DB 此时就像一个满载但"停工"的停车场,CPU 很空闲,磁盘 IO 也很低。
- 影响 :虽然该秒杀业务的 HTTP 请求全超时了,但因为数据库 CPU 没爆,数据库里其他不需要这行锁的非热点业务(比如用户登录、个人中心、订单查询)可能还能勉强运行。
2. 乐观锁:数据库"整瘫痪"(全站沦陷) - 故障范围:直接导致数据库(CPU100%占满)卡死。
- 数据库状态 :DB 内核因为死锁检测和自旋锁(Spin-Lock)处于超负荷计算黑洞,响应时间从 1ms 飙升到几十秒,甚至触发 MySQL 进程崩溃。
- 影响 :数据库是全站的最底层基础设施。一旦数据库 CPU 100% 拖垮了整个 DB 实例,不仅秒杀业务挂了,全站所有依赖该数据库的业务(主页、支付、物流、客服系统)会瞬间全线崩溃,造成真正的"灾难级故障"。
核心教训
在秒杀这种针对单一热点行 的极高并发场景下,虽然能解决超卖问题,但效率实在太低了,挂掉的风险极高。需要换个角度思考问题,绝对不能让海量请求直接冲向数据库的单行数据。
第三阶段:Redis内存前化与MQ流量削峰(V3.0 - 构建内存屏障)
1. 核心逻辑:
既然 DB 扛不住,就必须引入高性能内存数据库和 异步队列。在这个阶段,我们将构建一个支持高并发的高性能的屏障层:
- 在流量进入数据库或核心业务链路之前,在最前端(通常是 JVM 内存 + Redis 内存层)构建的一道高性能、具备 Fail-Fast(快速失败)能力的防御屏障。
- 把海量请求挡在数据库大门之外。它像一道闸门,只放行正好等于库存数量(或极少量余量)的合法请求通过,剩下的 99.9% 无效请求直接在屏障层被拦截并返回"已售罄"。
css
[ 秒杀预热:活动开始前的商品库存Redis初始化 ]
➔ [ 用户请求 ]
➔ [ Redis + Lua 原预扣 ]
➔ [ MQ 消息队列 ]
➔ [ DB 异步落库 ]
- 秒杀预热:活动开始前,将库存存入 Redis 计数器。
- 原子扣减 :利用 Redis 的单线程特性 与 Lua 脚本的状态值返回 ,实现带有下限约束的原子扣减(
stock > 0才能-1)。 - MQ 削峰:抢到 Redis 库存的请求,放进 MQ 队列;没抢到的(返回小于 0),直接在服务层抛弃,返回"已售罄"。
秒杀系统的最核心逻辑已经就位,然而事情又似乎没那么简单。
2. 性能量级对比
| 组件/环节 | 典型吞吐量级 (Single Node/Cluster) | 延迟/耗时 | 核心价值 |
|---|---|---|---|
| MySQL 单行更新 | 1,000 ~ 3,000 QPS (极限) | 毫秒级 ~ 阻塞 | 瓶颈所在:行锁竞争严重导致连接池耗尽、CPU 100% |
| Redis + Lua 预扣 | 80,000 ~ 100,000 QPS (单节点) 500,000+ QPS (拆 Slot 集群) | < 2 ms | 前置拦截:纯内存原子扣减,直接阻断 99.9% 失败请求 |
| MQ 消息发送 (写入) | 100,000 ~ 1,000,000 TPS | < 1 ms (异步) | 削峰填谷:极高吞吐接纳下单请求,保护后端 |
| MQ 消息消费 (落库) | 2,000 ~ 5,000 QPS (受限于 DB) | 可控拉长 | 控频平抑:按数据库最佳吞吐节奏平滑落库 |
(1)Redis + Lua 预扣屏障(内存级的原子冲锋) Redis 纯内存操作且单线程天然避开锁竞争。即使把校验库存、防重标记、扣减计数封装在 Lua 脚本 中保证原子性,单节点 Redis 也能轻松跑出 8 万 ~ 10 万 QPS 的预扣吞吐 。如果进一步叠加 Hot Key 多 Slot 拆分 (如将热点库存分散到 10 个 Hash Key 上),单机集群瞬间可支撑 50 万+ QPS 的核心预扣,将原本能瞬间击垮 MySQL 的数万级并发写,在 几毫秒 内优雅地在内存中消化完毕。
(2)MQ 消息组件(万级写与百万级吞吐的蓄水池) 面对预扣成功后蜂拥而至的订单,MQ 充当了完美的'流量蓄水池'。对于生产者(Producer)而言,异步发送订单消息的吞吐量可达 10 万 ~ 50 万 TPS (批量发送/Kafka 场景下甚至可达 百万级 ),耗时仅微秒级;而下游消费者(Consumer)则根据 MySQL 的真实承受极限(如 2,000 ~ 5,000 QPS )进行平滑的微批(Batch)削峰落库。MQ 成功将前端瞬间爆表的高峰流量,为后端数据库能够吃下的匀速流水。
3. 引入中间件带来新技术问题(各种各样的一致性问题)
最终一致性问题定义 :系统在某一个中间状态或时间片段 内,不同组件的数据是不一致的(甚至存在矛盾);但只要经过一定的时间窗口,通过重试、消息消费或补偿机制,数据最终会达成一致。
在单体数据库中,我们依赖 ACID 事务的强一致性(Strong Consistency) ------要么全部成功,要么全部失败,中间状态不可见。
在分布式/高并发架构下,为了追求极致的 QPS,我们将系统拆分为 Redis、MQ、DB 三个独立的组件,此时就引入了最终一致性问题。让我们拆解一下,针对秒杀场景的"不一致"表现:
合理的暂时不一致
如下所示,由于是分布式的非原子操作,必然存在T1时间点不同组件之间库存数值的暂时不一致,但只要在后续的某个时间点T2,能够保证组件之间的库存数值最终达成一致,那么我们就认为这是合理的暂时不一致。
yaml
[时间点 T1] 用户点击抢购
├── Redis: 库存 -1 (已扣减)
├── MQ: 消息正在排队
└── DB: 还没有订单,库存未变
➔ 状态:Redis 认为抢到了,DB 认为还没发生。(暂时的不一致)
[时间点 T2] 2 秒后,MQ 消息被消费
├── DB: 订单成功创建,DB 扣减完成
➔ 状态:数据最终达成了一致!
不合理的永久不一致
由于"Redis 扣减"、"MQ 消息投递"、"DB 异步落库"这三个核心动作分散在不同的网络节点和物理组件中,任何一处的宕机、网络抖动或超时,都会导致"暂时的不一致"变为"永久的分布式不一致"。详细异常场景如下:
| 异常场景 | 故障发生节点 | 根本原因 | 最终不一致的结果 |
|---|---|---|---|
| 1. MQ 积压与超时错乱 | MQ / 消费者 | 生产与消费速率极度不匹配;超时基准错乱 | 订单状态流转错乱,用户无法正常支付 |
| 2. Redis 扣了,MQ 没发 | 应用服务 / MQ | 跨组件操作缺乏原子事务保障 | 永久少卖(虚扣):库存占用但无订单 |
| 3. DB 落库失败无回滚 | DB / 消费端 | 缺乏正逆向链路闭环与对账补偿 | 永久少卖(虚扣):DB 无单,Redis 已扣 |
| 4. MQ 消息重复消费 | MQ / 消费端 | 网络 ACK 丢失引发重试,消费端非幂等 | 超卖 / 重复建单:同一资格产生多单 |
| 5. Redis 主备异步丢数据 | Redis 集群 | Redis 主从复制为异步机制,宕机丢日志 | 超卖:已扣减库存复活,超出真实库存 |
3. 技术上层面的不一致问题治理
3.1 场景 1:MQ 积压与超时错乱问题
- 分库分表 + 异步单条高并发写 :订单表按
user_id进行 Sharding(分库分表),将单 DB 的写入压力分散到多个数据库节点。 - MQ 消费端并发度控制 :通过增加 MQ 消费队列(Queue/Partition)的数量和消费者线程数,实现并行单条写入。
- 基于数据库写入时间的超时判断 :订单超时取消的时间点,必须以 DB 成功写入订单的时间(
created_time) 作为基准起点,并在落库成功后动态投递延迟关单消息。
3.2 场景 2:Redis 扣了 ,MQ 没发
这里需要做的是借助Redis Stream的ACK确认机制,将MQ消息发送从"不可重试的隐式内存状态",转换为了"可重试、可对账、高可用的持久化状态"。
- Lua脚本补充订单消息生成 :在 Redis 的 Lua 脚本里,扣减库存的同时,将
order_id等信息存入 Redis 的Stream中(同一线程,单机原子操作)。 - 异步MQ投递:由专门的后台 Worker 进程,批量(Batch)从 Redis 队列中读取已成功的秒杀记录,再批量发送到 MQ 中(可以使用普通 MQ 消息)。
3.3 场景 3:落库失败无回滚
面对"硬故障"(如网络中断、服务器宕机、死锁、主从切换、进程被 kill -9),逆向补偿库存恢复的做法往往是无法解决永久少卖问题的,并且且存在严重副作用的"伪补偿"。并且对于),根本无法解决问题。
值得注意的是,此处需要解决的问题并不是"让落库 100% 成功",而是必须确保"Redis 扣减"与"DB 落库"的【最终一致性】。标准套路是:MQ重试 + 状态机幂等 + 异步延迟队列反向对账。
scss
[消费端] ──写DB失败──► 1. 抛出异常, 依赖 MQ 原生重试 (应对临时抖动)
│
├── (重试 N 次依然失败)
▼
2. 投递到死信队列 (DLQ) 或 记录 Failover 表
│
▼
3. 【兜底保障】延迟对账服务 (Debezium/Canal/Job)
- 定时扫描 Redis 预占记录 vs DB 真实订单
- 发现 "Redis 有扣减,但 DB 超过 X 分钟无订单" ──► 执行安全的逆向回滚 Lua
│
▼
4. 【兜底保障】MQ落库前的订单状态标记检查(防止被回滚的订单迟到了落库)
- 检查 Redis 订单状态是否正常
- 发现 "Redis订单状态为 已超时取消" ──► 直接抛弃这条 MQ
3.3.1 第一道防线:利用 MQ 重试机制 + 数据库乐观锁/幂等
- 消费端写入 DB 报错时,不要捕获异常去还库存,而是直接向 MQ 抛出异常,触发 MQ 的重试机制(如指数退避重试)。
- 当重试N次依然失败,则投递到死信队列 (DLQ) 或 记录 Failover 表,数据依然"有据可查、有路可补",绝不丢单、绝不留糊涂账。
- DB 写入逻辑必须做到幂等(通过订单号
order_id做主键或唯一索引) ,确保重复重试不会插入多条订单。
3.3.2 第二道防线:基于延迟队列 / 对账 Job 的"反向对账"(核心解法)
为了防范节点 Crash 或多次重试依然失败的情况,系统引入 "延迟对账" :
- 订单状态标记 :应用在 Redis 扣库存时,除了
stock - 1,同时写入一条带有 TTL 的预占记录:SET order:state:{order_id} "RESERVED" EX 1800(有效期 30 分钟)。 - 延迟检查 :在扣库存的同时,发送一条 15~30 分钟后的延迟消息 到 MQ(或者由后台定时 Job 扫描)。注意:此处的延迟时长需对应用户的支付超时配置,确保检查操作晚于正常的订单落库。
- 状态核对 :当延迟消息被消费时,拿着
order_id去查 MySQL 数据库:
- 如果 DB 里存在该订单:说明已成功落库,修改订单状态标记为"CREATED/UNPAID"。
- 如果 DB 里查不到该订单且状态仍为"RESERVED"时 :说明落库彻底失败了(或者用户超时未支付)。此时才触发 安全的回滚 Lua 脚本 :
stock + 1,并修改订单状态标记为"CANCELLED_BY_TIMEOUT"。
3.3.3 第三道防线:落库前的订单状态标记检查(解决"极端严重 MQ 积压")
假设发生了极端故障:MQ 积压了整整 16 分钟,导致 15 分钟的对账消息都到了,正常的"创单 MQ"居然还没被消费! 此时对账服务去查 DB,确实查不到订单。对账服务通过Lua脚本做了两个Redis操作:
- 把 Redis 中的商品库存还回去(
stock + 1) - 修改Redis中的订单状态标记为"CANCELLED_BY_TIMEOUT" 在 4 分钟之后(第 20 分钟),那条积压的"创单 MQ"终于被消费端拉取到了,怎么办?
- MQ消费落库创单的前置校验: 在MQ消费端准备写 MySQL 前,通过Lua脚本先检查并同步订单状态【必须通过Lua脚本保证与延迟检查的互斥与原子性】
-
- 若状态为"RESERVED",则执行正常写入操作。
-
- 若DB写入成功,则订单状态标记为"CREATED/UNPAID"
- 若DB写入失败,则订单状态标记为"CANCELLED_BY_ERROR"
- 若状态为"CANCELLED_BY_TIMEOUT",则直接抛弃这条 MQ,不再写入数据库。
通过Redis订单状态检查,彻底阻止了"迟到的正常落库"覆盖"已回滚的对账"。
3.3.4 场景 4:MQ消息重复消费
消费端防重表/分布式锁 :落库逻辑以 request_id 或 order_id 为唯一索引,利用数据库 ON DUPLICATE KEY UPDATE 或 Redis 幂等屏障,确保同一条抢购消息仅落库一次。
3.3.5 场景 5:Redis 丢数据与全链路兜底
后台定时对账 Worker :定期抽取 Redis 的扣减日志/Set 与 DB 的真实订单记录。若发现 Redis 扣减记录存在且已超时,但 DB 无对应订单,对账 Worker 自动执行 Redis 库存回退(+1) ,修补任何未知异常引发的"永久少卖"。
3.4 需要注意的设计原则
- 如无必要,勿增设施。面对各种各样的问题,我们应优先挖掘已有组件(如 MQ、DB)的原生极限能力,而不是引入新组件或更多的操作步骤来做缓冲。
- 任何新增的操作步骤和中间存储,都必须证明其引入的'故障域'小于它所解决的问题,否则可能很多看似巧妙的解决方案,可能只是问题的不断转换,没有真正被消解。
4. 阶段小结与遗留问题说明
在这一阶段,通过 Redis 预扣 + MQ 削峰 + DB 异步落库 构建了一道坚固的技术屏障,彻底解决了高并发下的 DB 崩溃和超卖问题。
截止目前,我们的设计仅涉及 Redis / DB 里的(预扣)库存数值的维护。而在实际业务中,很显然,秒杀订单是有生命周期(流转状态:未支付/已支付/已取消/已退款)的,因此也就必然需要进行状态机的管理。
并且在不同订单状态流转过程中,必然带来库存数值的同步增减。值得注意的是:在秒杀请求操作过程中,我们维护的仅仅是预扣库存数,强调的是用户抢到了商品的购买资格,在用户完成支付前,商品实际库存显然是不能会有变动的。这里也就引出了另一个实际库存数量的维护问题。
进一步思考,当用户退款与取消时,库存数值回滚应该同步增减的是哪个库存数值呢?
以上这些问题都只能留到下一个阶段再慢慢完善了。
第四阶段:订单状态机与库存精细化管控(V4.0 - 业务初闭环)
在前三个阶段中,系统通过引入 Redis 预扣 + MQ 削峰 + DB 异步落库 解决了高并发下的"数据库抗压"与"流量削峰"问题。进入 V4.0 阶段,架构设计的重心从单纯的"高并发吞吐"转向了交易流程闭环与分布式状态治理。
下单、支付、取消、退货在时间上是天然串行、非原子化的操作,且深受用户自主决策(人工介入、超时不支付、售后发起等)的影响。本阶段旨在构建涵盖正向锁权/实扣 与逆向关单/退款的完整库存与订单状态流转闭环。
1. 库存数值拆解与订单状态映射
在完整的业务生命周期中,数据库库存管理划分为两个不同权责的阶段,且通过三个核心字段协同表达:
提交订单转移预扣待支付状态扣减实扣履约发货
1.1 业务阶段定义
- 预扣阶段 (Soft Allocation / Reserve) :
-
- 本质 :临时占用权/购买资格。在用户提交订单但尚未付款(决策期)时锁定资源,防超卖。
- 特征 :强时效性、易变性高。随时可能因超时未支付、用户主动取消或系统异常而回滚。
- 实扣阶段 (Hard Deduction / Commit) :
1.2 库存数值拆解
在完整的业务生命周期中,数据库库存管理划分为两个不同权责的阶段,且通过三个核心字段协同表达:
物理库存可用库存冻结库存
1.3 订单生命周期状态跃迁表与库存同步
为了消除业务动作、订单状态与底层数据库字段之间的认知断层,系统显式约定订单状态机与库存三字段的映射逻辑:
| 业务阶段 | 订单状态 (order_status) | Redis 操作 | DB 字段变化(公式) | 业务与财务意义 |
|---|---|---|---|---|
| 初始状态 | N/A | stock = 100 | available=100, locked=0, real=100 | 系统初始充能 |
| 1. 提交预扣 | UNPAID (待支付) | DECR | \text{available} \gets \text{available} - N \text{locked} \gets \text{locked} + N | 锁住购买资格:锁定冻结库存,real_stock 保持不变。 注意防超卖 |
| 2. 支付实扣 | PAID (已支付) | 无需操作 | \text{locked} \gets \text{locked} - N \text{real} \gets \text{real} - N | 所有权划转:解除冻结,物理真实库存扣减,准备履约。 |
| 3. 未付取消 | CANCELED (已取消) | INCR (回滚) | \text{available} \gets \text{available} + N \text{locked} \gets \text{locked} - N | 释放预扣:解冻占用额度,归还可用库存(针对未付款)。 |
| 4. 售后退款 | REFUNDED (已退款) | INCR (回补) | \text{available} \gets \text{available} + N \text{real} \gets \text{real} + N | 物理库存回补:取消物理消耗,恢复可用与真实库存(针对已付款)。 |
关键设计说明:
- 取消订单 (
CANCELED) 操作的是locked_stock与available_stock,用于解决"虚扣"问题; - 退款/退货 (
REFUNDED) 操作的是real_stock与available_stock,用于解决"物理退货"入库与再次销售问题。 - 必不可少的中间态补充:
-
PAYING(支付中)状态 : 如果用户调起微信支付,页面在等待回调,此时关单 Job 扫描到该订单。如果没有PAYING状态,关单 Job 无法感知用户正在输入密码,容易发生误杀。REFUNDING(退款中)状态 : 售后退款是异步操作(涉及第三方退款接口调用)。如果不经过REFUNDING直接到REFUNDED,一旦第三方退款接口响应慢,用户反复点击退款或重试 Task 再次发起,极易导致重复退款。
2. 全链路状态流转架构图
为了让整套逻辑清晰、直观、一目了然,我们以"订单状态(State)"为核心主干,将正向流程与逆向分支进行分层重构:

3. 全流程核心技术关注点
3.1 预扣阶段(前置接入层):关注"性能、防爆与防超卖"
- 内存化原子扣减 :利用 Redis Lua 脚本实现
GET -> CHECK -> DECR的原子性,将 99% 的无效流量封杀在数据库之外。 - 防少卖(虚扣)机制 :利用事务消息或本地消息表,确保"Redis 预扣"与"投递 MQ"的原子性;若 MQ 投递失败,捕获后立即同步执行反向 Lua 脚本(
INCR)恢复 Redis 库存。
3.2 预扣过渡期(异步处理层):关注"中间态流转与状态落库"
- 异步落库的状态机初始化 :DB 消费 MQ 消息创建订单时,必须严格完成
INIT -> UNPAID的首次状态跃迁。若消费失败触发 MQ 重试,需基于order_id保证落库与初始状态确立的单次幂等性。 - 逆向超时取消设计与时序基准 :提供超时补偿机制确保订单状态流入终态和库存数值的最终一致性。并且超时关单(触发
UNPAID -> CANCELED跃迁)的倒计时基准,必须以订单在 DB 正式确立UNPAID状态的时间(created_time)为准,确保状态机逆向跃迁的时间判定具备确定性。
3.3 实扣与逆向阶段(后台结算与售后层):关注"准确、幂等与物理回补"
- 防重与幂等处理 :支付回调与退款回调可能因网络超时被重复投递,实扣与退款逻辑必须基于
order_id/refund_id实现严格幂等。 - 状态机流转约束 :严格按照状态机流转(禁止越级或逆向非法覆盖,如
CANCELED状态不能直接变为PAID)。 - 退款库存回补策略:
-
- 退货入库(正向回补) :退款申请通过且检验质检入库后,触发 DB
real_stock和available_stock的原子增加,并同步更新 Redis 库存(INCR),重新上架销售。 - 仅退款不退货(不回补) :若属于商家损耗补偿(如货损直接丢弃),仅更新订单状态为
REFUNDED,不恢复real_stock与available_stock。
- 退货入库(正向回补) :退款申请通过且检验质检入库后,触发 DB
4. 极端异常边界与完备补强设计
4.1 实扣阶段的热点商品库存维护时的性能问题
针对单一热点行 的极高并发场景下的实扣阶段库存维护,若是直接让海量请求冲向数据库的单行商品库存记录,效率绝对是个问题,数据库挂掉的风险也是极高的。正如V3.0预扣屏障层中所规划设计的那样,这里同样需要引入高性能内存数据库 和异步队列,具体实现参考前文,并注意两者的业务语义与风险级别。
| 维度 | 1. 预扣阶段 (Soft Reserve) | 2. 实扣阶段 (Hard Commit) |
|---|---|---|
| 业务本质 | 抢购资格锁定 (临时占位) | 资产/商品所有权划转 (真正卖出) |
| 错误容忍度 | 高 (报错大不了提示"没抢到",可随时重试/回滚) | 极低 (用户钱已经扣了!绝不能报错丢单) |
| 核心 Redis 数据结构 | Hash 或 String (存储可用库存 available_stock) | Hash 或 Set (存储已支付流水/实扣库存 real_stock) |
| Lua 脚本核心逻辑 | 校验与扣减:检查 available >= N,成功则 DECR | 幂等与核销:检查 payment_id 是否重复,记录已支付状态,扣减 locked 与 real |
| 数据丢失后果 | 最多造成"少卖"或预扣数据不准 | 造成"用户付了钱系统没记账"的严重资损事故 |
- 预扣异步化:解决的是"高并发抢购时数据库被冲垮"的问题;
- 实扣异步化:解决的是"高并发支付回调时数据库热点行锁卡死"的问题。
4.2 逆向关单与正向支付的并发覆盖问题
当用户在超时临界点(如第 14 分 59 秒)点击支付并扣款,而关单任务(第 15 分 00 秒)刚好并发执行时,需注意两个并行的订单状态维护逻辑的干扰问题,并且关单任务还必须二次检查用户正在扣款以及的扣款是否真的成功了。
- 建议采用数据库的CAS乐观锁进行条件检查,避免执行变更状态覆盖,确保只有更新成功的线程才能触发对应的后续动作。
ini
-- 1. 关单更新场景SQL示例
-- 1.1 关单更新:只有状态为 UNPAID 时才允许关单并释放 locked_stock
UPDATE t_order
SET status = 'CANCELED', update_time = NOW()
WHERE order_id = #orderId# AND status in 'UNPAID';
-- 1.2 只有1.1中的 UPDATE 受影响行数 > 0 时,才允许执行 DB 库存回退与 Redis INCR 恢复:
UPDATE t_stock
SET available_stock = available_stock + #qty#,
-- 重点:locked_stock 采用尽力扣减,最低扣到 0,防止被扣成负数
locked_stock = GREATEST(locked_stock - #qty#, 0)
WHERE sku_id = #skuId# AND available_stock >= #qty#;
-- 2. 正向支付场景SQL示例
-- 2.1 正向支付更新:只有当前状态严格为 UNPAID 时,才允许跃迁至 PAID
UPDATE t_order
SET status = 'PAID',
payment_time = #paymentTime#,
update_time = NOW()
WHERE order_id = #orderId# AND status = 'PAYING';
-- 2.2 只有2.1中的 UPDATE 受影响行数 > 0 时,才允许执行实扣:扣减冻结库存 (locked_stock) 和真实物理库存 (real_stock)
UPDATE t_stock
SET real_stock = real_stock - #qty#,
-- 冻结库存采用"尽力扣减"或直接归零,甚至允许在异常时暂时归零,由对账修正
locked_stock = GREATEST(locked_stock - #qty#, 0)
WHERE sku_id = #skuId# AND real_stock >= #qty#;
- 在真实支付链路假如用户的钱已经确定被扣款了,库存却并被同一时刻的关单任务释放掉显然是有问题的。改进如下:
-
- 引入分布式锁 + 关单任务二次校验:订单任务与关单任务通过订单级别的分布式锁串行化处理
-
- 有了串行化的处理以后,还需记得将"支付中"的订单也纳入关单任务检查
- 在触发超时关单UPDATE操作前,必须主动向第三方支付平台发起主动查询(Query Payment Status) 。如果支付平台显示"已支付",应放弃关单操作,并在当场补发"支付成功"的内部事件,驱动订单跃迁至"已支付"。
- 逆向退款兜底 :如果极端情况下关单确实先完成(且库存已被抢占),支付回调到达时发现订单已处于
CANCELED状态,系统必须自动触发退款流水,将钱原路退回给用户(退款闭环),而不是仅仅简单地因为 SQL 更新失败就放弃处理。
5. 阶段小结与遗留问题
在这一阶段,通过在业务逻辑上补全了订单完整生命周期下单状态管理和商品库存的精细化管控,基本做到了高并发的业务闭环,能够做到"不超卖、不少卖"的自我修复与最终一致。截止目前,一个秒杀系统的整体架构设计基本成型了。
可是,到这里就针对足够完美了吗?显然并不是的,我们的系统依然面临如下一些致命的工程与架构隐患:
- 比如,如何确保秒杀活动准时准点的启动,前端静态资源渲染优化、URL动态化防刷等
- 比如,如何防止用户重复下单(账号风控与频率限制),更进一步的如何防范恶意刷单又不付款
- 比如,大量用户下单后又取消订单或支付超时等,对于秒杀活动的影响
- 比如,Redis中的Hot Key 倾斜问题(单节点瓶颈),缓存穿透/击穿/雪崩问题
- 比如,Redis、MQ、数据库等中间件挂掉了怎么办
- 还有,库存一致性对账补偿机制、全链路压测与监控、故障演练
- 还有,防DDoS攻击、流量击穿等等
第五阶段:极致高可用与安全防御(V5.0 - 生产级防护体系)
在前四个阶段中,系统完成了 Redis 预扣 + MQ 削峰 + DB 异步落库 的高并发吞吐架构设计,以及订单状态机与库存精细化管控的正逆向完整业务闭环。
在这一阶段,架构设计的重心从"业务流程跑通"转向极致高可用、黑灰产安全防御与资损兜底 。面对大促瞬时流量洪峰、网络抖动、黑客脚本攻防以及基础设施单点宕机等生产级挑战,系统必须构建一套涵盖入口防护、热点突破、容灾降级与对账补偿的多级防御体系。
- 第一级(边缘 CDN 拦截):
-
- 目标:拦截 ~90% 流量(剩余 10% 流量)
- 核心动作:强缓存 + CDN 静态化,阻断用户连续刷新的静态资源请求。
- 第二级(网关风控与限流):
-
- 目标:拦截 ~9% 流量(剩余 1% 流量)
- 核心动作 :网关令牌桶限流 + 动态 Token 校验 + 售罄标记前置拦截,只放行符合系统极限吞吐的合法请求。
- 第三级(App 本地多级缓存):
-
- 目标:拦截 ~0.9% 流量(剩余 0.1% 流量)
- 核心动作:本地多级缓存响应 Hot Key 读请求,本地防重锁拒绝用户重复下单,防刷兜底。
- 第四级(Redis 预扣屏障):
-
- 目标:处理 ~0.09% 核心写流量(剩余 0.01% 流量)
- 核心动作:Lua 脚本原子扣减库存,扣成功者下发订单,扣失败者触发售罄广播。
- 第五级(MQ & DB 异步落库):
-
- 目标:持久化 ~0.01% 成功订单(最终有效交易)
- 核心动作:MQ 削峰填谷平滑写入 MySQL,数据库唯一索引做终极防重。
1. 专题一:前端与接入层防护(流量拦截在最外围)
高并发秒杀的第一法则:将 99% 的无效流量阻断在源站服务器和数据库之前。
(1)静态资源极致化与 CDN 边缘拦截
-
页面绝对静态化:将秒杀 HTML、CSS、JS 等静态资源剥离动态逻辑,完全上传至 CDN 节点。
-
浏览器强缓存(Cache-Control) :前端静态资源设置
Cache-Control: max-age=31536000, immutable,秒杀开始前用户多次刷新页面,只消耗本地浏览器缓存或 CDN 流量,零请求打回源站。
(2)URL 动态化+验证码
-
拼接带有时效性的动态Token,构建动态化的秒杀接口URL,防止黑产脚本提前爬取接口。
-
秒杀 Token 通常是
MD5(goodsId + userId + serverSecret),在服务器端实时计算生成,且带有时效性。 -
秒杀开始前,接口地址是无法被感知的;在活动开始前几分钟/几秒或开始时,通过采用"分级风控(无感通过 vs 动态挑战)"与"流量削峰(排队中)"等策略,将动态将Token分发给到用户,并激活秒杀按钮。
-
每个 Token 在 Redis 中仅可使用一次,用户下单后即销毁,彻底杜绝重放攻击。
(3)API 网关层令牌桶限流与过滤
在 API 网关入口配置基于 Nginx / OpenResty + Lua 的令牌桶限流策略:
- 全局阈值防护:设定系统整体所能承受的最大 QPS 阀值(根据全链路压测结果确定),保护后端集群不被超出容量的流量瞬间冲塌。
- 动态削峰与允许突发流量:令牌桶可以动态设置桶容量(Capacity)和生成速率(Refill Rate)。比如,在秒杀前,已针对指定时间窗口的令牌桶放满令牌(比如 30,000 个),则秒杀开始瞬间,网关仅允许这 30,000 个突发请求瞬间通过。
- 黑白名单与恶意 Header 过滤:针对携带特定异常 User-Agent、缺少特定签名 Header 的请求直接限流/丢弃。
3. 专题二:高并发与组件高可用(热点突破与系统容灾)
核心设计原则
- 宁可少卖/拒绝,绝不超卖,绝不撑爆 DB。
- 故障隔离:读写路径解耦,单个基础组件宕机不得引发全链路雪崩。
基础组件发生故障时,系统需具备降级与自我保护能力:
| 故障组件 | 故障场景 | 潜在风险 | 自动化降级与防护机制 |
|---|---|---|---|
| Redis | 集群整体宕机 | 预扣屏障失效,海量写流量直冲 DB | 1. 网关层绝对熔断: Sentinel/Nginx 网关瞬间切断所有写路径,拦截 100% 抢购请求,直接返回静态兜底页("活动太火爆,请稍后再试")。 2. 禁绝 DB 兜底写:严禁将库存扣减下沉至 MySQL。 3. 本地缓存接管"读":应用节点启用 JVM 本地缓存(Caffeine)提供极简的售罄状态标记读服务。 |
| Redis | 单节点故障 / 局部热点 | 部分商品预扣卡顿,节点倾斜 | 1. 主从极速切换:哨兵/Cluster 节点 5s 内完成主从 Failover。 2. 热点 key 分散:将热点库存 Hash 分散到多个 Slot(如 item_1001_slot1...slotN)。 |
| MQ | 消息积压 (Backlog) | 下单延迟拉长,用户重复支付 | 1. 动态扩容:临时增加 Topic Partition 并按倍数扩容 Consumer 消费者线程/实例。 2. 批量消费【不建议】:Consumer 由单条写入切换为微批(Batch)写入 DB,降低 DB 连接开销。 |
| MQ | MQ 集群宕机 (Outage) | 异步订单落库中断 | 1. 本地消息表/磁盘暂存:应用层拦截 MQ 异常,将预扣成功的订单同步写入本地磁盘 WAL 机制或本地内存队列。 2. 同步限流落库(备用):若无本地暂存能力,网关瞬间开启极低限流(如 200 QPS),仅放行极少流量同步落库,其余请求 Fail-Fast。 3. 恢复后重放:MQ 恢复后,后台补偿任务将暂存数据重新推入队列。 |
| MySQL | 主库宕机 / 卡死 | 最终落库失败,数据无法持久化 | 1. 自动主备切换:MHA / Orchestrator 启动自动切换(秒级)。 2. 网关层 Fail-Fast:若切换失败,网关层阻断所有下单接口,仅允许查询(读写分离走 Read-Replica)。 3. 异步订单暂留:已在 MQ 中的消息停止消费并排队,等待 DB 恢复,避免消息丢弃或抛错。 |
Redis缓存Hot Key 倾斜与本地多级缓存
单一热点商品(如 1499 抢茅台)的读请求集中在 Redis 单节点时,极易引发 CPU 100% 或网卡打爆(Hot Key 倾斜)。不同key其作用、访问频次、管控方式,显然都是有巨大差别的,首先基于读写类型差异对比分析如下:
| 对比维度 | 读类型 Hot Key (Read Hot Key) | 写类型 Hot Key (Write Hot Key) |
|---|---|---|
| 典型业务场景 | 商品详情 (goods:detail)、活动状态 (act:status)、优惠券信息 | 秒杀库存计数器 (stock:count)、抢购名额限制 (quota) |
| 业务核心诉求 | 高吞吐、低延迟,允许毫秒级的数据延迟 | 绝对的数据一致性,必须保证"不超卖、不漏扣" |
| 高并发下的瓶颈 | 单节点 CPU 100%、网络带宽打爆、引发雪崩 | 单 Slot/单线程串行处理极限,主从同步延迟拉大 |
| 核心解决思路 | 数据冗余与多级分流(把数据复制到离用户最近的地方) | 前置流量拦截与状态转换(把无效写转化为本地拒绝) |
- 基于"动态感知"的多级本地缓存(主攻:读 Key) :
-
- 在微服务客户端(或网关层)挂载热点检测组件(如 Jd-hotkey / Sentinel)。当某个读 Key 在 1 秒内的访问频次超过设定阈值(如 500 次/秒),系统将其自动标记为 Hot Key。
- 本地推送与短 TTL:热点感知系统通过轻量级通道,将该 Key 的数据广播推送到所有应用节点的本地内存(如 Caffeine)中,并设置一个非常短的过期时间(例如 3~5 秒)。
- 命中本地直接返回:后续 99% 的读请求在应用层即可直接获取数据返回,不再穿透到 Redis。
- 售罄信号广播与前置全网拦截(主攻:写 Key)
-
- 当某个应用节点的请求收到 Redis 返回的
-1(库存归零)时,该应用节点立刻异步向 MQ(如 RocketMQ)发送一条广播消息:Item_1001_SoldOut。 - 网关与应用全网拦截:
-
- API 网关节点与所有微服务应用节点都订阅了该 MQ 广播。
- 几毫秒内,全集群所有节点的本地内存(Local Cache)都会更新标记:
Item_1001_SoldOut = true。 - 后续剩下的 99.9% 抢购流量在到达 API 网关或应用层时,直接被本地状态拦截并返回"该商品已被抢光",不再产生任何一次 Redis 网络请求。
- 售罄信号已经发出不允许再被随意变更,即使发生了用户订单超时取消、付款失败等情况。可考虑设置短过期时间(Pass-through TTL),比如 2 秒后本地标记自动失效,重新透传极少量请求去查 Redis。这样即使发生库存回滚,系统也能在 2 秒内自动恢复抢购,既保护了 Redis,又避免了少卖。
- 当某个应用节点的请求收到 Redis 返回的
4. 专题三:业务安全与防刷
(1)防止单个用户重复下单(包括防刷、防重复点击、防并发重提交)
- 客户端/前端:通过按钮置灰 / 防抖 / 验证码,阻断普通用户的连续肉眼手抖点击
- 服务端应用层:
-
- 方式1:一次性动态秒杀 Token(防直接刷接口)。 用户提交下单时,必须携带这个 Token。后端校验 Token 有效性后,立刻在 Redis 中删除(或废弃)该 Token。由于 Token 是一次性的,后续带有相同 Token 的重复请求直接失效。
- 方式2:通过 Redis + Lua脚本的原子操作,为"用户 + 商品"建立一个独一无二的抢购占位符。 这个锁在下单成功后不需要主动 delete,而是让它保持存在(超时过期),直到秒杀活动结束或订单支付超时。这样只要 Key 在,该用户就永远无法再对该商品发起第二次秒杀。
- 数据库层:在数据库的订单表(order)或防重业务表(seckill_record)中,将 user_id 和 goods_id 设置为联合唯一索引。当两个相同的并发下单请求同时尝试向数据库 INSERT 订单时,触发唯一键冲突异常,事务自动回滚,彻底切断产生重复订单的可能。
(2)恶意刷单不付款拦截(反黑灰产与风控)
黑灰产利用多账号并发抢占库存却故意不付款,会导致正常用户买不到商品,且超时释放后造成"少卖"。
- 秒杀专属短超时关单 :普通订单超时时间为 15-30 分钟,爆款秒杀订单将超时时间缩短至 2-3 分钟。关单 Task 结合延迟队列高频扫描,快速释放未付库存。
- 用户风控分层与未付率限制 :建立用户画像履约风控模型,若某一
user_id或设备指纹在过去 24 小时内出现连续 3 次秒杀下单未付款,该用户再次抢购时:
- 强行弹出复杂智能图形验证码(CAPTCHA) ,增加机器作弊成本;
- 或直接在网关层将其标记为高风险用户,阻止其参与高风险爆款秒杀。
善于利用业务规则去堵漏洞!
5. 专题四:数据一致性与兜底对账(资损零容忍)
分布式异步架构(Redis + MQ + DB)在极端网络抖动或崩溃重发时,可能导致 Redis 与 DB 数据失配。可根据需要构建实时对账或离线定时对账补偿机制,作为系统的最后一道资产安全防线。
(1)基于 Canal 的 Binlog 实时对账(分钟级)
- 实现原理 :通过 Canal 监听数据库
t_stock与t_order的 Binlog 变更,实时解析数据库的实际变化。 - 实时比对 :当 Canal 捕获到订单变更事件时,异步校验 Redis 中的
stock增减与 DBavailable_stock是否对齐。一旦发现偏差大于 0,立即向钉钉/飞书推送告警通知。
(2)离线定时对账 Job(小时/天级重对账)
在深夜低峰期或秒杀活动结束后,后台离线对账 Task 执行以下三大对账公式:
校验物理底线系统初始充能总量订单状态为的销售量
校验可用平衡系统初始充能总量订单状态为的总量
校验缓存对齐
(3)差异自动修复策略
当对账 Task 扫描到数据不一致时,严格按照以下优先级处理:
Redis stock < DB available_stock(少卖) :对账系统安全触发 Redis 库存回补(INCR),将少卖的库存重新释放回市场。Redis stock > DB available_stock(超卖隐患) :立刻自动下调 Redis 中的stock数值,并将差异记录插入t_stock_reconciliation_log(对账异常表),人工介入复核。
6. 阶段总结与全链路技术演进全景
经过五个阶段的演进,秒杀系统完成了从最原始的CRUD 到生产级高可用、高安全交易引擎的全面升维:
| 阶段 | 核心架构演进 | 解决的关键技术问题 |
|---|---|---|
| V1.0 - 纯CRUD | Demo示例 | 识别核心问题 |
| V2.0 - 锁与 CAS 防线 | DB 强扣库存 + 事务 | 业务逻辑跑通,但高并发下 DB 行锁卡死、严重超卖 |
| V3.0 - 预扣屏障 | Redis + Lua预扣、 MQ 异步落库 | 读写分离,实现高并发 Fail-Fast 防超卖 异步解耦,将海量峰值流量平滑写入数据库 |
| V4.0 - 业务闭环 | 状态机跃迁 + 拆解三字段库存 | 引入正逆向交易流转(正向实扣、逆向取消/退款),实现资损级闭环 |
| V5.0 - 极致高可用 | 多级缓存 + 接入层防刷 + 双轨对账 | 攻克 Hot Key 倾斜、网络/组件宕机容灾、黑灰产刷单以及数据一致性兜底 |
至此,一套"能抗极限吞吐、能防机器刷单、能容组件宕机、能保资损零容忍"的生产级秒杀系统架构设计已经成型。以上拆解与组合显然还有很多没有讲清楚的模糊地带、或大或小的缺陷、甚至是设计上的错误,能力有限就不展开了;如何真正去构建一个符合生产要求的秒杀系统相信大家都能找到一些感觉了,本文目标已经达成,推演到此结束。
结束语:积木、演进与敬畏
回顾整个秒杀系统的问题拆解与设计推演,是不是比你想象的复杂得多?是不是其实也不是复杂到让你无从下手?
从最基础的一条 UPDATE SQL 开始,我们沿着业务与并发的脉络一步步向外拓展:加 Redis 挡流量、引入 MQ 做削峰、引入状态机做正逆向闭环、再到建立多级缓存与防御护城河。每一次架构的升维,本质上都是在真实的工程痛点倒逼下,做出的"遇到问题,解决问题"的自然演进。这就是一场以"场景穷举"为推演线索、以"拆解与组合"为动手方式的积木游戏。
在这个推演的过程中,比"用了哪些硬核技术"更宝贵的,是那些沉淀下来的业务设计原则与技术哲学:
- 对资损的绝对敬畏:在极端的并发面前,我们恪守"宁可少卖,绝不超卖"的铁律,让数据底线约束始终贯穿于所有的逻辑代码之中;
- 对流量的层层消化:遵循"分层漏斗"的思想,让 99% 的无效流量和高频读击穿在源头,只把最精纯的有效写操作放行至核心流程;
- 对复杂度的克制与收敛:秉持"无必要,勿增设施"的原则,不为了炫技而引入架构复杂度。我们拥抱"最终一致性",用容错与对账去化解分布式系统的必然不确定性。
系统是演进出来的,而架构设计本质上就是要在"性能极限"与"系统复杂性"之间寻找极致的平衡。 拆解与组合的纸上谈兵结束了,感觉自己又从"只会写 CRUD 的码农"向"高并发架构师"迈出了结实的一步了。