高并发秒杀库存超卖

高并发秒杀库存超卖

一、业务背景

电商平台举办秒杀活动,初始库存为 1000 件。

技术架构:

  • 后端: Spring Boot
  • 缓存: Redis
  • 数据库: MySQL
  • 消息队列: MQ
  • 业务流程: 用户请求 → 库存扣减 → 创建订单 → 异步处理后续业务

活动过程中出现以下问题:

  1. 活动结束后,有效订单数量超过库存。
  2. 用户请求超时后重试,产生重复订单。
  3. Redis 与 MySQL 库存不一致。
  4. 订单创建失败,但已经扣减的库存没有及时恢复。
  5. 服务创建订单后、更新库存预占状态前宕机,导致业务状态不明确。
  6. 库存确认任务与超时释放任务并发执行,可能造成错误释放。

核心目标:

保证库存不超卖、订单不重复、库存操作可恢复,并让异常情况下的数据最终达到一致。


二、问题一:为什么 Redis 扣库存仍然可能超卖?

1. Redis 是共享的,不代表业务逻辑天然原子

假设库存剩余 1 件,两个请求同时执行:

text 复制代码
请求 A:读取库存 = 1
请求 B:读取库存 = 1

请求 A:判断库存充足
请求 B:判断库存充足

请求 A:扣减库存
请求 B:扣减库存

如果读取、判断、扣减是独立操作,两个请求就可能都认为自己能够购买。

2. 使用 Redis Lua 脚本实现原子扣减

将库存检查和扣减放进同一个 Lua 脚本中:

lua 复制代码
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
local quantity = tonumber(ARGV[1])

if stock < 0 then
    return -1
end

if stock < quantity then
    return 0
end

redis.call('DECRBY', KEYS[1], quantity)
return 1

返回值含义:

  • 1:扣减成功。
  • 0:库存不足。
  • -1:库存键不存在,需要按业务策略处理。

Redis 会原子执行脚本中的操作,因此多个请求不会在脚本执行过程中交错修改同一个库存键。

注意:这只保证 Redis 内部的扣减操作原子执行,不代表 Redis、MySQL、订单和 MQ 之间自动保持一致。

此外,真实生产环境还需要防范缓存初始化错误、缓存重建时覆盖并发扣减、Redis 故障恢复以及库存键过期等问题。


三、问题二:如何避免重复创建订单?

1. 为什么会出现重复订单?

例如:

text 复制代码
用户发起购买请求
    ↓
服务器成功创建订单
    ↓
服务器返回响应前宕机
    ↓
用户没有收到成功响应
    ↓
用户重试
    ↓
系统再次创建订单

客户端没有收到成功响应,不代表服务端没有成功执行操作。

因此,不能简单地把超时等同于失败。

2. 使用业务唯一标识实现幂等

每次购买请求生成一个稳定的业务请求标识,例如:

text 复制代码
request_id = REQ_20261021_000001

订单表增加唯一约束:

sql 复制代码
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    status VARCHAR(32) NOT NULL,
    UNIQUE KEY uk_request_id (request_id)
);

这里的 request_id 仅用于演示,生产环境应使用可靠的唯一标识生成方案。

当相同请求被重复执行时:

  1. 首次请求创建订单。
  2. 重试请求尝试创建同一笔订单。
  3. 数据库唯一约束阻止重复记录。
  4. 系统查询已存在的订单,确认真实状态并返回对应结果。

不能仅依靠"先查询订单是否存在,再决定是否创建",因为两个并发请求可能同时查询到订单不存在。

3. 必须区分两类请求

  • 同一业务请求重试: 应返回原请求的处理结果,不应重复创建订单或重复扣减库存。
  • 用户主动再次购买: 是否允许创建新订单,应根据业务规则判断。

因此,幂等键必须与业务语义对应,不能简单地使用用户 ID 作为唯一键,否则可能错误限制用户的后续购买。

关键结论:应用层幂等判断与数据库唯一约束配合,才能可靠地防止重复订单。


四、问题三:如何设计完整的库存预占流程?

不能把"扣减库存"和"创建订单"看成一个天然的原子操作,因为 Redis、MySQL 和 MQ 之间通常不存在统一事务。

可以引入库存预占记录,跟踪每次购买的处理状态。

1. 推荐的业务流程

text 复制代码
用户提交购买请求
        ↓
生成唯一 request_id
        ↓
检查该请求是否已经处理
        ↓
Redis 原子检查并预占库存
        ↓
持久化库存预占记录
        ↓
创建订单
        ↓
确认库存预占
        ↓
返回购买成功

其中,实际执行顺序需要结合库存预占记录的持久化方式设计。

尤其需要注意:如果先扣减 Redis 库存,再持久化预占记录,那么服务可能恰好在两步之间宕机,导致出现无法追踪的库存扣减。

因此,不能只在 Redis 中保存预占状态。应设计持久化的业务记录、可靠的执行顺序和恢复机制,保证每笔库存预占都能被追踪。

2. 库存预占状态机

可以定义以下状态:

状态 含义
PROCESSING 库存已预占,业务尚未完成
CONFIRMED 订单已成功,预占已确认
RELEASED 预占已释放,库存可以重新使用

状态流转:

text 复制代码
PROCESSING ──────→ CONFIRMED
     │
     └──────────→ RELEASED

正常情况下:

text 复制代码
预占库存
    ↓
创建订单成功
    ↓
PROCESSING → CONFIRMED

创建订单失败且确认没有成功订单时:

text 复制代码
PROCESSING
    ↓
确认订单未创建成功
    ↓
释放库存
    ↓
PROCESSING → RELEASED

关键原则:同一笔预占只能确认一次,或者释放一次,不能既确认又释放。


五、问题四:服务宕机后如何恢复?

这是整个场景最重要的故障恢复问题。

假设:

  1. A 成功预占库存。
  2. A 的订单成功写入 MySQL。
  3. 服务还没来得及将预占状态更新为 CONFIRMED,就宕机了。
  4. 服务重启后,发现预占状态仍然是 PROCESSING。

此时不能直接创建新订单,也不能直接释放库存。

1. 恢复任务的正确思路

text 复制代码
扫描长期处于 PROCESSING 的预占记录
                ↓
查询对应 request_id 的订单状态
                ↓
          ┌─────┴─────┐
          ↓           ↓
      订单已存在    订单不存在
          ↓           ↓
     核对订单状态   判断是否仍可继续处理
          ↓           ↓
     确认预占       重试创建订单
          ↓           ↓
      CONFIRMED    根据结果决定确认或释放

实际生产环境中,订单不存在不一定意味着创建过程没有正在执行的事务。

因此,恢复任务还需要考虑:

  • 订单事务是否已经提交。
  • 原请求是否仍在执行。
  • 是否存在重复执行的恢复任务。
  • 当前预占是否已经被其他任务确认或释放。

对于结果暂时无法确定的请求,应继续查询、重试或进入人工处理流程,而不是贸然释放库存。

2. 为什么不能只靠服务重启后的对账?

对账适合发现和修复不一致,但不能替代正常业务流程中的状态管理。

如果所有恢复都依赖重启后的对账,可能导致:

  • 库存长时间被占用。
  • 用户等待时间过长。
  • 异常无法及时发现。
  • 重复订单或错误释放在对账前持续存在。

更可靠的做法是组合使用:

  1. 持久化预占记录。
  2. 定时扫描超时记录。
  3. 根据订单真实状态恢复业务。
  4. 对失败操作进行有限重试。
  5. 对无法自动处理的异常告警或人工介入。
  6. 定期对账,修复残留的不一致。

六、问题五:如何防止确认任务和释放任务发生并发冲突?

假设两个任务同时处理同一笔预占:

  • 确认任务发现订单已经成功,准备确认库存。
  • 超时补偿任务认为预占已经超时,准备释放库存。

如果没有并发控制,就可能发生一边确认订单,一边释放库存的错误。

1. 加锁是一种思路,但不是完整答案

可以使用分布式锁协调多个任务,但必须考虑:

  • 锁的粒度是否合理。
  • 服务宕机后锁如何释放。
  • 锁过期后旧任务是否还会继续执行。
  • 多个服务实例是否都遵守同一套锁规则。

如果对所有库存补偿使用一把全局锁,虽然实现简单,却可能造成大量无关业务互相阻塞。

2. 使用条件更新控制状态流转

可以让确认任务和释放任务都尝试从 PROCESSING 更新状态。

确认操作:

sql 复制代码
UPDATE inventory_reservation
SET status = 'CONFIRMED'
WHERE request_id = ?
  AND status = 'PROCESSING';

释放操作:

sql 复制代码
UPDATE inventory_reservation
SET status = 'RELEASED'
WHERE request_id = ?
  AND status = 'PROCESSING';

数据库会协调并发更新同一行的竞争。两个任务中,只有一个能成功完成这次状态转换;另一个任务应检查受影响行数,并重新查询当前状态。

但要注意:

条件更新只能保护这次状态转换,不能自动保证 Redis 库存操作与数据库状态一致。

如果库存状态保存在 Redis,而预占状态保存在 MySQL,就需要额外的可靠执行机制,确保状态更新和实际库存变更能够恢复。

3. 推荐的组合方式

技术 主要作用
Redis Lua 原子检查并扣减 Redis 库存
业务唯一标识 识别同一笔购买请求
数据库唯一约束 防止重复订单
条件更新、状态机 防止非法状态流转
分布式锁 协调特定场景下的并发任务
幂等释放 防止重复补偿导致库存增加过多
重试与补偿 恢复执行失败的业务
定期对账 发现并修复残留的不一致

这些手段解决的是不同问题,不能互相替代。


七、问题六:如何保证库存释放不会执行多次?

补偿任务可能重复执行,例如:

text 复制代码
第一次释放库存成功
        ↓
服务返回结果前宕机
        ↓
补偿任务重试
        ↓
再次释放库存

如果每次执行都直接增加库存,就可能导致库存被重复返还。

因此,释放操作必须具备幂等性。

可以为每次库存预占建立唯一记录,并为释放操作设置明确状态。

释放时:

  1. 检查该预占是否已经释放。
  2. 确保当前状态允许释放。
  3. 以可靠的方式执行库存返还。
  4. 记录释放成功状态。
  5. 重试时识别已成功的操作,不重复返还库存。

如果库存返还与状态更新分布在 Redis 和 MySQL 中,不能简单地依靠一个数据库事务解决所有问题。需要使用持久化操作记录、幂等标识和可重试的执行流程,处理跨系统操作的中断。

补偿本身也必须是幂等的,否则修复机制可能制造新的数据错误。


八、Redis 与 MySQL 库存如何保持一致?

Redis 常用于高并发库存检查与扣减,MySQL 用于持久化订单和业务数据。

二者的更新无法天然形成一个跨系统原子事务。

例如:

text 复制代码
Redis 库存扣减成功
        ↓
MySQL 订单创建失败
        ↓
需要恢复库存

也可能发生:

text 复制代码
MySQL 订单创建成功
        ↓
Redis 状态更新失败
        ↓
订单与缓存状态不一致

建议:

  1. 明确 Redis 与 MySQL 各自承担的职责。
  2. 持久化库存预占及业务状态。
  3. 让库存操作、订单操作具备幂等性。
  4. 使用可靠的异步消息或任务驱动后续处理。
  5. 对执行失败的操作进行重试和补偿。
  6. 定期核对订单、预占记录和库存数据。

需要特别注意:不能简单地将 Redis 库存定期覆盖为 MySQL 库存,因为 MySQL 中的库存数据也可能处于异步更新过程中。库存重建必须以明确的业务事实为依据,并避免覆盖正在进行的有效操作。


九、完整方案总结

对于秒杀系统,可以从以下几个层面设计。

第一层:入口防护

  • 限流与请求校验。
  • 使用唯一业务请求标识。
  • 防止同一请求被重复处理。

第二层:库存扣减

  • 使用 Redis Lua 实现原子检查和扣减。
  • 正确处理库存不足、缓存异常和重复请求。
  • 通过可靠的业务记录追踪每次预占。

第三层:订单创建

  • 使用数据库事务保证订单自身的数据完整性。
  • 使用业务唯一标识与唯一约束避免重复订单。
  • 订单创建结果不明确时,先查询真实状态,不直接认定失败。

第四层:异步处理

  • 使用 MQ 或可靠任务处理后续业务。
  • 消费端保证幂等。
  • 对消息发送失败、消费失败、重复消费和消息丢失风险制定恢复策略。

第五层:异常恢复

  • 持久化库存预占状态。
  • 定时扫描长期未完成的预占。
  • 根据订单实际状态决定确认、重试或释放。
  • 确保库存释放幂等。
  • 对无法自动恢复的异常告警或人工介入。

第六层:一致性校验

  • 定期对账。
  • 检查订单、预占记录和库存之间的不一致。
  • 通过可追踪的修复任务恢复业务。
  • 对持续异常建立监控与告警。
相关推荐
一条泥憨鱼18 小时前
【Redis | 黑马点评】分布式锁;Lua脚本;Redisson与看门狗 |源码万字详解
redis·分布式·lua
stark张宇19 小时前
库存扣减的“锁”事:悲观锁、乐观锁、分布式锁
redis·分布式·mysql
xianyuCcCcCCCcc21 小时前
Ceph 分布式存储:集群配置与存储池管理
运维·分布式·ceph
萧瑟余晖1 天前
Spring Cloud分布式事务与链路追踪详解
分布式·spring·spring cloud
汤姆宝库1 天前
预训练工程深拆:Scaling_Law、数据管线与分布式并行
分布式
pjj198541 天前
Spark RDD 核心知识详解:算子、依赖与执行模型
大数据·分布式·spark
浔溺1 天前
Spark核心基石:RDD全方位深度解析(原理、算子、实战、优化)
大数据·分布式·spark
catino1 天前
RabbitMQ详解
分布式·rabbitmq·ruby
爱吃火鸡面呀1 天前
Spark RDD 创建与转换算子详解
大数据·分布式·spark