高并发秒杀库存超卖
一、业务背景
电商平台举办秒杀活动,初始库存为 1000 件。
技术架构:
- 后端:Spring Boot
- 缓存:Redis
- 数据库:MySQL
- 消息队列:MQ
- 业务流程:用户请求 → 库存扣减 → 创建订单 → 异步处理后续业务
活动过程中出现以下问题:
- 活动结束后,有效订单数量超过库存。
- 用户请求超时后重试,产生重复订单。
- Redis 与 MySQL 库存不一致。
- 订单创建失败,但已经扣减的库存没有及时恢复。
- 服务创建订单后、更新库存预占状态前宕机,导致业务状态不明确。
- 库存确认任务与超时释放任务并发执行,可能造成错误释放。
核心目标:
保证库存不超卖、订单不重复、库存操作可恢复,并让异常情况下的数据最终达到一致。
二、问题一:为什么 Redis 扣库存仍然可能超卖?
1. Redis 是共享的,不代表业务逻辑天然原子
假设库存剩余 1 件,两个请求同时执行:
请求 A:读取库存 = 1 请求 B:读取库存 = 1 请求 A:判断库存充足 请求 B:判断库存充足 请求 A:扣减库存 请求 B:扣减库存如果读取、判断、扣减是独立操作,两个请求就可能都认为自己能够购买。
2. 使用 Redis Lua 脚本实现原子扣减
将库存检查和扣减放进同一个 Lua 脚本中:
localstock=tonumber(redis.call('GET',KEYS[1])or'-1')localquantity=tonumber(ARGV[1])ifstock<0thenreturn-1endifstock<quantitythenreturn0endredis.call('DECRBY',KEYS[1],quantity)return1返回值含义:
1:扣减成功。0:库存不足。-1:库存键不存在,需要按业务策略处理。
Redis 会原子执行脚本中的操作,因此多个请求不会在脚本执行过程中交错修改同一个库存键。
注意:这只保证 Redis 内部的扣减操作原子执行,不代表 Redis、MySQL、订单和 MQ 之间自动保持一致。
此外,真实生产环境还需要防范缓存初始化错误、缓存重建时覆盖并发扣减、Redis 故障恢复以及库存键过期等问题。
三、问题二:如何避免重复创建订单?
1. 为什么会出现重复订单?
例如:
用户发起购买请求 ↓ 服务器成功创建订单 ↓ 服务器返回响应前宕机 ↓ 用户没有收到成功响应 ↓ 用户重试 ↓ 系统再次创建订单客户端没有收到成功响应,不代表服务端没有成功执行操作。
因此,不能简单地把超时等同于失败。
2. 使用业务唯一标识实现幂等
每次购买请求生成一个稳定的业务请求标识,例如:
request_id = REQ_20261021_000001订单表增加唯一约束:
CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,request_idVARCHAR(64)NOTNULL,user_idBIGINTNOTNULL,product_idBIGINTNOTNULL,statusVARCHAR(32)NOTNULL,UNIQUEKEYuk_request_id(request_id));这里的request_id仅用于演示,生产环境应使用可靠的唯一标识生成方案。
当相同请求被重复执行时:
- 首次请求创建订单。
- 重试请求尝试创建同一笔订单。
- 数据库唯一约束阻止重复记录。
- 系统查询已存在的订单,确认真实状态并返回对应结果。
不能仅依靠“先查询订单是否存在,再决定是否创建”,因为两个并发请求可能同时查询到订单不存在。
3. 必须区分两类请求
- 同一业务请求重试:应返回原请求的处理结果,不应重复创建订单或重复扣减库存。
- 用户主动再次购买:是否允许创建新订单,应根据业务规则判断。
因此,幂等键必须与业务语义对应,不能简单地使用用户 ID 作为唯一键,否则可能错误限制用户的后续购买。
关键结论:应用层幂等判断与数据库唯一约束配合,才能可靠地防止重复订单。
四、问题三:如何设计完整的库存预占流程?
不能把“扣减库存”和“创建订单”看成一个天然的原子操作,因为 Redis、MySQL 和 MQ 之间通常不存在统一事务。
可以引入库存预占记录,跟踪每次购买的处理状态。
1. 推荐的业务流程
用户提交购买请求 ↓ 生成唯一 request_id ↓ 检查该请求是否已经处理 ↓ Redis 原子检查并预占库存 ↓ 持久化库存预占记录 ↓ 创建订单 ↓ 确认库存预占 ↓ 返回购买成功其中,实际执行顺序需要结合库存预占记录的持久化方式设计。
尤其需要注意:如果先扣减 Redis 库存,再持久化预占记录,那么服务可能恰好在两步之间宕机,导致出现无法追踪的库存扣减。
因此,不能只在 Redis 中保存预占状态。应设计持久化的业务记录、可靠的执行顺序和恢复机制,保证每笔库存预占都能被追踪。
2. 库存预占状态机
可以定义以下状态:
| 状态 | 含义 |
|---|---|
PROCESSING | 库存已预占,业务尚未完成 |
CONFIRMED | 订单已成功,预占已确认 |
RELEASED | 预占已释放,库存可以重新使用 |
状态流转:
PROCESSING ──────→ CONFIRMED │ └──────────→ RELEASED正常情况下:
预占库存 ↓ 创建订单成功 ↓ PROCESSING → CONFIRMED创建订单失败且确认没有成功订单时:
PROCESSING ↓ 确认订单未创建成功 ↓ 释放库存 ↓ PROCESSING → RELEASED关键原则:同一笔预占只能确认一次,或者释放一次,不能既确认又释放。
五、问题四:服务宕机后如何恢复?
这是整个场景最重要的故障恢复问题。
假设:
- A 成功预占库存。
- A 的订单成功写入 MySQL。
- 服务还没来得及将预占状态更新为
CONFIRMED,就宕机了。 - 服务重启后,发现预占状态仍然是
PROCESSING。
此时不能直接创建新订单,也不能直接释放库存。
1. 恢复任务的正确思路
扫描长期处于 PROCESSING 的预占记录 ↓ 查询对应 request_id 的订单状态 ↓ ┌─────┴─────┐ ↓ ↓ 订单已存在 订单不存在 ↓ ↓ 核对订单状态 判断是否仍可继续处理 ↓ ↓ 确认预占 重试创建订单 ↓ ↓ CONFIRMED 根据结果决定确认或释放实际生产环境中,订单不存在不一定意味着创建过程没有正在执行的事务。
因此,恢复任务还需要考虑:
- 订单事务是否已经提交。
- 原请求是否仍在执行。
- 是否存在重复执行的恢复任务。
- 当前预占是否已经被其他任务确认或释放。
对于结果暂时无法确定的请求,应继续查询、重试或进入人工处理流程,而不是贸然释放库存。
2. 为什么不能只靠服务重启后的对账?
对账适合发现和修复不一致,但不能替代正常业务流程中的状态管理。
如果所有恢复都依赖重启后的对账,可能导致:
- 库存长时间被占用。
- 用户等待时间过长。
- 异常无法及时发现。
- 重复订单或错误释放在对账前持续存在。
更可靠的做法是组合使用:
- 持久化预占记录。
- 定时扫描超时记录。
- 根据订单真实状态恢复业务。
- 对失败操作进行有限重试。
- 对无法自动处理的异常告警或人工介入。
- 定期对账,修复残留的不一致。
六、问题五:如何防止确认任务和释放任务发生并发冲突?
假设两个任务同时处理同一笔预占:
- 确认任务发现订单已经成功,准备确认库存。
- 超时补偿任务认为预占已经超时,准备释放库存。
如果没有并发控制,就可能发生一边确认订单,一边释放库存的错误。
1. 加锁是一种思路,但不是完整答案
可以使用分布式锁协调多个任务,但必须考虑:
- 锁的粒度是否合理。
- 服务宕机后锁如何释放。
- 锁过期后旧任务是否还会继续执行。
- 多个服务实例是否都遵守同一套锁规则。
如果对所有库存补偿使用一把全局锁,虽然实现简单,却可能造成大量无关业务互相阻塞。
2. 使用条件更新控制状态流转
可以让确认任务和释放任务都尝试从PROCESSING更新状态。
确认操作:
UPDATEinventory_reservationSETstatus='CONFIRMED'WHERErequest_id=?ANDstatus='PROCESSING';释放操作:
UPDATEinventory_reservationSETstatus='RELEASED'WHERErequest_id=?ANDstatus='PROCESSING';数据库会协调并发更新同一行的竞争。两个任务中,只有一个能成功完成这次状态转换;另一个任务应检查受影响行数,并重新查询当前状态。
但要注意:
条件更新只能保护这次状态转换,不能自动保证 Redis 库存操作与数据库状态一致。
如果库存状态保存在 Redis,而预占状态保存在 MySQL,就需要额外的可靠执行机制,确保状态更新和实际库存变更能够恢复。
3. 推荐的组合方式
| 技术 | 主要作用 |
|---|---|
| Redis Lua | 原子检查并扣减 Redis 库存 |
| 业务唯一标识 | 识别同一笔购买请求 |
| 数据库唯一约束 | 防止重复订单 |
| 条件更新、状态机 | 防止非法状态流转 |
| 分布式锁 | 协调特定场景下的并发任务 |
| 幂等释放 | 防止重复补偿导致库存增加过多 |
| 重试与补偿 | 恢复执行失败的业务 |
| 定期对账 | 发现并修复残留的不一致 |
这些手段解决的是不同问题,不能互相替代。
七、问题六:如何保证库存释放不会执行多次?
补偿任务可能重复执行,例如:
第一次释放库存成功 ↓ 服务返回结果前宕机 ↓ 补偿任务重试 ↓ 再次释放库存如果每次执行都直接增加库存,就可能导致库存被重复返还。
因此,释放操作必须具备幂等性。
可以为每次库存预占建立唯一记录,并为释放操作设置明确状态。
释放时:
- 检查该预占是否已经释放。
- 确保当前状态允许释放。
- 以可靠的方式执行库存返还。
- 记录释放成功状态。
- 重试时识别已成功的操作,不重复返还库存。
如果库存返还与状态更新分布在 Redis 和 MySQL 中,不能简单地依靠一个数据库事务解决所有问题。需要使用持久化操作记录、幂等标识和可重试的执行流程,处理跨系统操作的中断。
补偿本身也必须是幂等的,否则修复机制可能制造新的数据错误。
八、Redis 与 MySQL 库存如何保持一致?
Redis 常用于高并发库存检查与扣减,MySQL 用于持久化订单和业务数据。
二者的更新无法天然形成一个跨系统原子事务。
例如:
Redis 库存扣减成功 ↓ MySQL 订单创建失败 ↓ 需要恢复库存也可能发生:
MySQL 订单创建成功 ↓ Redis 状态更新失败 ↓ 订单与缓存状态不一致建议:
- 明确 Redis 与 MySQL 各自承担的职责。
- 持久化库存预占及业务状态。
- 让库存操作、订单操作具备幂等性。
- 使用可靠的异步消息或任务驱动后续处理。
- 对执行失败的操作进行重试和补偿。
- 定期核对订单、预占记录和库存数据。
需要特别注意:不能简单地将 Redis 库存定期覆盖为 MySQL 库存,因为 MySQL 中的库存数据也可能处于异步更新过程中。库存重建必须以明确的业务事实为依据,并避免覆盖正在进行的有效操作。
九、完整方案总结
对于秒杀系统,可以从以下几个层面设计。
第一层:入口防护
- 限流与请求校验。
- 使用唯一业务请求标识。
- 防止同一请求被重复处理。
第二层:库存扣减
- 使用 Redis Lua 实现原子检查和扣减。
- 正确处理库存不足、缓存异常和重复请求。
- 通过可靠的业务记录追踪每次预占。
第三层:订单创建
- 使用数据库事务保证订单自身的数据完整性。
- 使用业务唯一标识与唯一约束避免重复订单。
- 订单创建结果不明确时,先查询真实状态,不直接认定失败。
第四层:异步处理
- 使用 MQ 或可靠任务处理后续业务。
- 消费端保证幂等。
- 对消息发送失败、消费失败、重复消费和消息丢失风险制定恢复策略。
第五层:异常恢复
- 持久化库存预占状态。
- 定时扫描长期未完成的预占。
- 根据订单实际状态决定确认、重试或释放。
- 确保库存释放幂等。
- 对无法自动恢复的异常告警或人工介入。
第六层:一致性校验
- 定期对账。
- 检查订单、预占记录和库存之间的不一致。
- 通过可追踪的修复任务恢复业务。
- 对持续异常建立监控与告警。