← blog 架构与系统 · 2026-09-03

秒杀系统设计:从架构到数据库设计

秒杀系统里排第一的只有一件事:不超卖。这篇按挡在库存之外、原子裁决、异步收尾三层,讲清架构和数据库设计怎么围绕这一个不变量展开。

18 min read

业务场景和四个约束

拆开看,秒杀只做一件事:用户点一下”抢购”,后台把库存减 1,减成功就生成一张订单。

这个动作要同时满足四个要求:

约束具体要求没做到的后果
防超卖10 万件库存不能卖出第 100001 件真实的履约成本和赔付,业务事故
高性能单个请求毫秒级返回用户重复点击,流量进一步放大
高并发瞬时读请求是日常的百倍千倍服务被打挂,谁都买不到
最终一致性 + 高可用库存、订单、支付三方数据对得上,活动期间服务不中断少卖、重复下单、资损

四个都写进需求,但设计时不能并列对待。只有防超卖是目标,其余三个都是手段:高性能和高并发是为了让裁决逻辑不被流量冲垮,最终一致是为了让裁决结果不出错也不丢。这个优先级一旦摆反,就会做出”读得很快、库存也对不上”的系统。

本文用一组贯穿始终的假设量级:库存 10 万,开抢瞬间到达 500 万 QPS,参与用户 800 万,每人限购 1 件。数字是我为了讲清数量级设定的,不是任何平台的真实数据。

不变量:库存只能被扣一次,且只有一个地方说了算

超卖的成因非常朴素:判断和扣减分成了两步。

库存 available = 1,两个请求同时进来:

请求 A: GET  available -> 1     ✓ 有货
请求 B: GET  available -> 1     ✓ 有货
请求 A: SET  available = 0      写回
请求 B: SET  available = 0      写回,覆盖掉 A 的结果
结果:两件都判为成功,库存只扣了一次 → 超卖 1 件

if (stock > 0) stock-- 在任何并发环境下都不是安全代码,因为 > 和 -- 之间有一个时间窗口。所以整篇设计只有一条硬要求:

让同步的“判断 + 预扣 + 记录事件”变成不可分割的原子动作;让数据库条件更新成为最终落账的防线。

所有架构手段都围绕这句话展开:要么减少打到这个原子操作上的请求量,要么保证它自身不会因为压力或故障而失效。

按这个原则,系统分三层。

flowchart TB
    U["500 万 QPS 抢购请求"] --> L1
    subgraph L1["第一层 挡在库存之外:无状态判断"]
        A1["CDN 静态化详情页"] --> A2["网关限流 / 用户配额"] --> A3["进程内售罄标记"]
    end
    L1 -->|"剩下的几万 QPS"| L2
    subgraph L2["第二层 原子裁决:同步发放资格"]
        B1["Redis Lua:限购校验 + 预扣库存 + XADD 预扣事件"]
    end
    L2 -->|"资格已预扣,事件已持久化"| L3
    subgraph L3["第三层 异步收尾:这一层才允许最终一致"]
        C1["Redis Stream 消费组:削峰建单"] --> C2["MySQL 单事务:条件扣库存 + 建单 + 库存流水"]
        C2 --> C3["支付 / 超时条件更新 + 回补任务"]
        C3 --> C4["Redis 库存同步 / 对账修复"]
    end

第一层:让绝大多数请求到不了库存

秒杀流量有个特征:99% 以上的请求注定失败(800 万人抢 10 万件)。既然注定失败,就不该让它们消耗任何跟库存有关的资源。这一层的判断全部是无状态的,可以横向扩容。

动静分离。 详情页的活动图、价格、规则全部预生成静态文件,走 CDN;页面里唯一动态的是”库存余量提示”和抢购按钮,用一个独立的轻量接口拉。开抢前几分钟源站的读流量几乎为零。活动数据提前预热到 Redis 和各个应用的本地缓存,不要在开抢那一刻才回源查数据库——那一刻的回源并发会直接打死主库。

时间打散。 客户端随机延迟只能改善正常用户的体验,脚本完全可以绕过,不能作为容量保障。真正的削峰应在服务端完成:网关排队、令牌桶、验证码和动态 Token 先过滤请求,再以固定速率把请求交给秒杀应用。秒杀的峰值是”同一秒”造成的,把同一秒摊成 3 秒,峰值就掉一个数量级;代价是部分用户要看到排队结果。

入口限流。 网关按用户维度限制频率(同一 uid 1 秒 1 次),超了直接 429,不进业务代码。再叠一层风控:脚本流量、代理池、黑名单设备。抢链接也要处理——把抢购 URL 做成带时效的加密参数,否则活动开始前就有脚本挂着接口空转。

热点隔离。 秒杀 SKU 走独立的服务集群和独立的数据库实例,不和主站交易链路共用资源。它挂了就影响这一个活动,不该把整个下单链路拖下去。

进程内售罄标记。 这一条在库存被抢空的瞬间最值钱。库存归零后,通过发布订阅或配置中心把 soldOut=true 广播到各实例;后续请求在应用第一行代码就返回,连 Redis 都不用打。广播丢失或延迟只会让少量请求继续访问 Redis,不能把它当作库存事实。余量接近零时也可以提前打开标记,代价是可能少卖几十件,换来最后一件不被 200 万请求围攻。

这一层做完,500 万 QPS 里能走到库存判断的通常只剩几万。一个注定失败的请求走完全程是这样:

sequenceDiagram
    participant U as 用户
    participant CDN as CDN/静态页
    participant GW as 网关
    participant APP as 秒杀应用
    participant R as Redis
    participant S as Redis Stream
    U->>CDN: 打开详情页
    CDN-->>U: 静态资源命中,不打源站
    U->>GW: 点击抢购
    GW->>GW: uid 维度 1 次/秒,超出返回 429
    GW->>APP: 放行
    APP->>APP: 本地售罄标记为真,直接返回已售罄
    APP-->>U: 结束,全程未触碰库存
    U->>APP: 换个用户,仍有货
    APP->>R: Lua:判断 + 预扣 + 写入预扣事件
    R->>S: XADD reservation event
    R-->>APP: 成功,返回 reservationId
    APP-->>U: 排队中,结果稍后推送

第二层:原子裁决

到这里的请求才有资格碰库存。这一层的两个动作要同时成立:扣减是原子的,且每个用户只能成功一次。

Redis + Lua:判断和扣减合一

-- KEYS[1] = seckill:stock:{activityId:skuId}   Redis 预扣余量
-- KEYS[2] = seckill:buyers:{activityId:skuId}  已拿资格的用户集合
-- KEYS[3] = seckill:events:{activityId:skuId}  预扣事件 Stream
-- ARGV[1] = userId, ARGV[2] = reservationId, ARGV[3] = quantity, ARGV[4] = requestId
if redis.call('EXISTS', KEYS[1]) == 0 then
    return -1                                    -- 活动未预热,不放行
end
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return -3                                    -- 同一用户重复请求
end
local avail = tonumber(redis.call('GET', KEYS[1]))
if not avail or avail < tonumber(ARGV[3]) then
    return -2                                    -- 已售罄
end
redis.call('DECRBY', KEYS[1], ARGV[3])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('XADD', KEYS[3], '*',
    'reservationId', ARGV[2],
    'userId', ARGV[1],
    'quantity', ARGV[3],
    'requestId', ARGV[4])
return 0                                         -- 拿到资格,事件也已入队

Redis 扣成功只代表用户抢到了资格,真正的账在数据库。Lua 不能只返回成功:如果应用在 Redis 成功后、发送 MQ 前崩溃,资格就会被永久占住。因此这里在同一个 Redis 原子操作中完成预扣和 XADD,把预扣事件写入 Redis Stream;消费者在数据库事务提交后才 ACK。Redis 需要开启合适的 AOF 和副本持久化,并监控 Stream Pending Entries;对极高资损场景,也可以把 Stream 替换为支持事务消息的可靠 MQ。

关键在于 Redis 执行命令是单线程的,一段 Lua 脚本作为一个命令整体执行,中间不会插入其他客户端的请求。“读余量 → 判断 → 自减 → 写事件”这几步在其他客户端看来就是一个原子动作,没有窗口,所以不需要锁。Stream 消费者应使用消费组,处理失败时保留 Pending 状态并重试;超过阈值进入死信队列,由补偿任务处理,不能直接 ACK 丢掉。

如果用 GET 判断完再 DECR,或者用 SETNX 手搓分布式锁,超卖窗口又回来了——这是大多数实现真正翻车的位置。

热点 key 的下一步。 一个 key 只会落在集群的一个分片上,单分片的写能力就是整场活动的上限。库存量级大时要分桶:

单 key:seckill:stock:{act:sku}       = 100000  所有写压到一个分片
分桶:  seckill:stock:{act:sku}:0     = 12500   按 userId % 8 选桶
        seckill:stock:{act:sku}:1     = 12500
        ...

代价是桶之间会不均:某个桶提前为空,其他桶还有货。可以在服务端有限次尝试其他桶,也可以接受少量少卖;不要无限重试,把热点重新集中到 Redis。限购判重仍然要放在全量维度的 buyers 集合里,不能跟着桶走,否则同一用户能占两个桶的名额。若要让分桶真正分散到 Redis Cluster,还要让不同桶使用不同 hash tag;这时一次 Lua 不能跨桶,需要改成令牌预发放或由单一分片/队列负责全局裁决,不能同时要求”跨桶原子”和”跨槽分片”。

MySQL:最后一道闸门,也是唯一可信的账

订单消费者落库用一条带条件的 UPDATE;它是最终库存账本的防线:

-- 受影响行数 = 1 才算扣减成功;= 0 说明没货,直接返回失败
UPDATE seckill_stock
   SET available = available - 1
 WHERE activity_id = ?
   AND sku_id = ?
   AND available > 0;

这里不需要显式加锁,也不需要 version 字段。InnoDB 执行 UPDATE 是当前读:取该行的排他锁,读到的是已提交最新值,判断 available > 0 后更新。若它和建单、流水写入处于同一个事务,行锁会一直持有到事务提交,因此事务必须短小,不能在事务内调用支付、通知等远程服务。

对比一下常见的另一种写法:

方案写法问题
悲观锁SELECT ... FOR UPDATE 后 UPDATE锁从查询就持有,跨两次往返,事务里夹了业务逻辑就是灾难
乐观锁UPDATE ... WHERE version = ?高并发下版本冲突率极高,大量无效重试
条件扣减UPDATE ... WHERE available > 0一次往返,行锁最短,重试无必要

防重复下单靠唯一索引,不靠应用判重。 消息还必须带 reservation_id 和 request_id,让消费者可以安全重试;同一 reservation 已创建订单时,重复消息直接返回原订单。

CREATE TABLE seckill_order (
  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  activity_id  BIGINT UNSIGNED NOT NULL,
  user_id      BIGINT UNSIGNED NOT NULL,
  sku_id       BIGINT UNSIGNED NOT NULL,
  order_no     CHAR(24)        NOT NULL,
  reservation_id VARCHAR(64)   NOT NULL,
  request_id   VARCHAR(64)     NOT NULL,
  status       TINYINT         NOT NULL COMMENT '0 待支付 1 已支付 2 已超时',
  expire_at    DATETIME(3)     NOT NULL,
  created_at   DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_act_user_sku (activity_id, user_id, sku_id),
  UNIQUE KEY uk_order_no  (order_no),
  UNIQUE KEY uk_reservation (reservation_id),
  UNIQUE KEY uk_request (request_id),
  KEY idx_status_expire (status, expire_at)
) ENGINE = InnoDB;

uk_act_user_sku 让”同一活动、同一 SKU 每人一单”变成数据库承诺,和示例中的 Redis buyers Key 一致。若业务要求一场活动内所有 SKU 合计限购一件,则两者都必须改为活动维度,并接受这会让对应的 Redis 热点更集中。应用层的判重在并发下不可信——两个请求同时查”是否已下单”都会得到否,然后都插一条。唯一索引是唯一可靠的兜底,插入报 Duplicate entry 时查询并返回已有订单,而不是当异常。

顺带说一句主键:seckill_order 是全系统写入最密集的表。自增 id 让 B 树永远在最后一个页追加,不会分裂;如果把 24 位随机 order_no 拿去做主键,就落进了我上一篇写的随机主键页分裂,一次插入触发 2-3x 的写入放大,在秒杀峰值下这是能直接观测到的性能差。

表结构里的第三张表:库存流水

CREATE TABLE seckill_stock_log (
  id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  activity_id BIGINT UNSIGNED NOT NULL,
  sku_id      BIGINT UNSIGNED NOT NULL,
  user_id     BIGINT UNSIGNED NOT NULL,
  change_type TINYINT         NOT NULL COMMENT '1 扣减 2 回补',
  biz_id      VARCHAR(64)     NOT NULL COMMENT 'reservation_id',
  created_at  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_biz (biz_id, change_type)
) ENGINE = InnoDB;

没有流水表,回补和对账都无从下手。uk_biz 保证同一 reservation 的同一类动作最多生效一次——超时任务和人工补偿同时触发时不会把库存加两遍。

消费者处理一条 Stream 消息时,必须把库存、订单、流水放进同一个本地事务。一个可执行的顺序是:

BEGIN
  1. 按 reservation_id 查询订单;已存在则 COMMIT,ACK 重复消息
  2. INSERT seckill_order(唯一索引冲突则回查原订单并结束)
  3. UPDATE seckill_stock ... AND available >= quantity
  4. INSERT seckill_stock_log(change_type = 扣减)
COMMIT
ACK Stream 消息

第 3 步影响行数为 0,或任一步发生非幂等错误时,整个事务回滚,不能 ACK;由重试或死信补偿把 Redis 预扣资格释放。第 1 步到第 4 步之间没有远程调用。这样消息“至少投递一次”不会造成重复扣库存:已经提交过的消息会被唯一键识别,未提交的消息不会留下半条订单或半次扣库存。

第三层:异步收尾,这一层才允许最终一致

拿到资格不等于订单建好。Lua 写入的 Stream entry 就是预扣凭据,至少在订单完成、回补完成且对账结束前不能裁剪。建单要写订单表、流水表;扣优惠、发通知则放到订单提交后的 outbox 事件里做,避免它们拖长库存事务。

用户看到的响应是”排队中”,前端轮询或用长连接拿最终订单号。数据库的实际写入按它能承受的速度慢慢消化,10 万条建单消息摊到 30 秒,就是 3000 TPS 的普通写入压力。

为什么这一层可以异步,第二层不行? 判断标准是用户能不能感知到不一致。用户等 2 秒看到”排队中”是可以接受的;卖出去 100001 件是不可接受的。所以”资格判定”必须同步、必须原子,“建单支付”可以异步、可以对账。这条界线是整个设计里最值钱的一条经验。

订单超时未支付要回补库存,否则会有人锁着库存不付款。可以用延迟队列,或者定时扫 idx_status_expire:

-- 每 5 秒一次,走 (status, expire_at) 联合索引,量小、不扫描全表
SELECT id, activity_id, user_id, order_no
  FROM seckill_order
 WHERE status = 0
   AND expire_at < NOW()
 LIMIT 500;

支付回调和超时任务一定会并发:它们都先用条件更新抢状态,再决定后续动作。支付只能执行 UPDATE ... SET status = PAID WHERE status = PENDING;超时只能执行 UPDATE ... SET status = TIMEOUT WHERE status = PENDING AND expire_at < NOW()。谁先拿到行锁、更新成功,谁拥有结果;另一个影响行数为 0 后查询当前状态并结束。

超时回补由拿到 TIMEOUT 状态的任务在一个数据库事务内完成:available += quantity、插入 change_type = 回补 的库存流水、写入一条 stock_sync_outbox 任务。提交后异步执行 Redis INCRBY;失败就按 outbox 重试。这样 Redis 短暂偏小只会少放行一部分请求,绝不会绕过 MySQL 的最终条件扣减;Redis 偏大同样不会直接造成物理超卖,但会放大排队和失败补偿,必须通过告警与对账尽快修复。不要把“先写缓存还是先写数据库”包装成强一致性,跨 Redis 和 MySQL 的动作本质上只能靠幂等重试、补偿和对账闭环。

订单和库存的完整状态流转:

stateDiagram-v2
    [*] --> RESERVED
    RESERVED: Redis 已预扣,Stream 事件待消费
    ORDER_CREATED: 订单已建,待支付
    PAID: 已支付
    TIMEOUT: 超时未支付
    FAILED: 建单不可恢复失败
    REFILLED: 库存已回补
    RESERVED --> ORDER_CREATED: DB 事务提交,ACK 消息
    RESERVED --> FAILED: 死信确认不可恢复
    FAILED --> REFILLED: 条件回补 + outbox
    ORDER_CREATED --> PAID: 支付回调抢到 PENDING
    ORDER_CREATED --> TIMEOUT: 超时任务抢到 PENDING
    TIMEOUT --> REFILLED: 条件回补 + outbox
    PAID --> [*]
    REFILLED --> [*]

高可用:裁决点不能是单点

裁决层一旦不可用,活动就停。这一层要回答的问题是:Redis 挂了,你还敢不敢继续卖?

答案是宁可停售,不能在预扣未结算时切到 DB 继续卖。Redis 异常时先关闭新资格发放,保留并恢复 Stream 的 Pending 消息;待预扣任务被成功建单、回补或对账确认后,才能恢复活动。直接把新请求切到 MySQL,会让后到的降级流量和 Redis 中已预扣但未落库的消息争抢同一份 DB 库存:条件更新虽然能拦住物理超卖,却会让已拿资格的用户被挤掉。

数据库直连降级只能用于两种情况:活动从一开始就采用独立的 DB 降级通道,或已经确认没有未结算的 Redis reservation;并且要把流量限制在已压测的安全阈值。Redis 主从切换还要考虑已确认写未复制的窗口,设置合适的 AOF、复制确认和活动开关;无法确认预扣状态时,停止发放资格并以 DB 流水、订单和 Stream 记录对账,而不是猜测库存。可用性让位于正确性——这个取舍要在设计评审时就定下来,不是故障时临场决定。

再往下是几个必须提前做的准备:

  • 预案开关化。 限流阈值、售罄阈值、停止售卖、降级路径,全部做成可热更的配置。秒杀当天的变更只允许关开关,不允许发代码。
  • 全链路压测。 用影子表和影子流量打到真实链路,压到目标 QPS 的 1.5 倍。压测的重点不是”能不能扛住”,而是”扛不住的时候从哪一层先失守、开关好不好用”。
  • 分钟级对账。 对同一活动 SKU 检查:初始库存 = DB available + 已提交扣减流水 - 已提交回补流水,并核对 Redis 预扣量 = 已处理 Stream 数 + 待处理/Pending 数 - 已回补 reservation 数。任何不等都告警;超卖和少卖一样是事故,少卖只是更不容易被发现。
  • 读走主库的场景。 用户建单成功后立刻跳”我的订单”,如果读到延迟的从库会显示空列表,然后他会再点一次抢购。下单回跳链路和裁决层读判断都强制走主库。

我会重点检查的五个位置

  1. 有没有”先查后改”的代码路径。 同步资格发放只允许 Redis Lua 判断余量;DB 条件更新是落账的最终防线,其他库存判断都只是加速用的猜测。
  2. 预扣事件能不能丢。 Lua 预扣和 XADD 是否是同一个原子动作?消费者是否在 DB 事务提交后才 ACK?Pending、死信和 Stream 保留期是否有监控?
  3. 事务有没有包住三件事。 DB 条件扣减、订单插入和库存流水必须一起提交或回滚,事务内不允许远程调用。
  4. 重复请求的语义。 唯一索引冲突要返回同一张已有订单,不能返回失败;重复 Stream 消息也必须如此。用户重复点击是最常见的输入。
  5. 失败路径有没有回补。 扣减成功但建单最终失败、订单超时的库存,是否都通过条件状态更新、库存流水和 outbox 回补?没有这一步,库存会越卖越少。
  6. 开抢前一分钟的预热。 活动数据、缓存、连接池、机器扩容都要提前完成,不要在峰值上触发冷启动。

如果只记一件事

把”判断 + 预扣 + 记录待处理事件”做成一个原子动作。前面所有架构都在给它减少流量,后面所有异步都在替它消化结果;MySQL 条件更新则负责最终落账。高可用方案要保证裁决点不确定时停止发放资格、清点未结算预扣,而不是换一条未经对账的路径继续卖。

Sources

  1. Redis Programmability: Lua scripts
  2. Redis Streams
  3. MySQL 8.4 Reference Manual: InnoDB Locking
  4. MySQL 8.4 Reference Manual: InnoDB Transaction Model
  5. Java 中怎么在 Spring Boot 中使用 Redis 管道与 Lua 脚本实现秒杀库存的原子扣减与防并发超卖
  6. Redis 高并发高可用集群百万级秒杀实战
  7. 秒杀系统设计(超详细 + 原理拆解)
  8. 高并发系统稳定性实战:限流、熔断、降级与异步削峰全解析

Related