秒杀系统设计:库存、防超卖与高并发治理
本文系统梳理秒杀系统的设计方法:从核心挑战出发,讲解分层架构(CDN→Nginx→网关→应用层→缓存层→数据库)、库存扣减方案(先扣缓存/数据库乐观锁/消息队列异步)、防超卖三层防护、限流策略、预热方案,以及监控与降级。包含完整架构图(Mermaid)和每层技术选型说明。
性能工程系列:高并发系统设计 · 性能优化方法论 · 相关:Redis 简介 · 消息队列核心原理
目录
| 章节 | 说明 |
|---|---|
| 秒杀核心挑战 | 瞬时高并发、超卖、恶意刷单 |
| 架构设计原则 | 4 要 1 不要 |
| 分层架构设计 | 完整架构图与每层职责 |
| 流量削峰方案 | 验证码、消息队列、限流漏斗 |
| 库存扣减方案 | 三种方案对比与极致优化 |
| 防超卖三层防护 | Redis SETNX / Lua / DB 唯一约束 |
| 限流策略 | 用户维度/商品维度/全局限流 |
| 预热方案 | 提前加载热数据与预生成令牌 |
| 隔离策略 | 秒杀与普通流量隔离 |
| 降级与容灾 | 熔断、降级、兜底方案 |
| 监控体系 | 关键指标与告警 |
秒杀核心挑战
三大挑战
1. 瞬时高并发
秒杀的流量特征:毛刺极大,几秒内爬升到峰值,然后马上掉下来。
正常流量:100 QPS
秒杀瞬间:100,000 QPS(100倍)
持续时间:< 10 秒(库存卖完即结束)
这种流量特征导致:
- 按峰值配置机器 → 资源浪费(99% 时间空转)
- 不配置峰值容量 → 秒杀时系统崩溃
2. 热点数据问题
所有用户抢购同一商品,该商品的库存 Key 成为极热点:
- Redis 单个 Key 写入上限约 5-10 万 TPS
- MySQL 单行记录并发更新,InnoDB 行锁竞争激烈
3. 刷子流量(黄牛)
通过程序直接调用 HTTP 接口,绕过前端限制:
- 挤占正常用户的抢购通道
- 给系统带来大量无效请求
- 破坏公平的抢购环境
架构设计原则
4 要 1 不要
| 原则 | 说明 | 示例 |
|---|---|---|
| 数据要尽量少 | 减少传输数据量,降低 CPU 序列化开销 | 秒杀页面去掉装修效果,只保留核心信息 |
| 请求数要尽量少 | 合并 CSS/JS,减少 DNS 解析,减少 TCP 握手 | 多个 JS 文件合并为一个 URL |
| 路径要尽量短 | 减少中间节点,每增加一个节点增加一个故障点 | RPC 调用合并,减少跨服务依赖 |
| 依赖要尽量少 | 减少强依赖,弱依赖可在紧急时降级 | 优惠券是弱依赖,可降级;库存是强依赖 |
| 不要有单点 | 服务无状态化,状态外置到存储 | 配置中心动态推送,避免机器绑定 |
架构是平衡的艺术:以上原则是方向,不是绝对。如把首屏 CSS 内联可减少请求数,但增大了页面体积,需要权衡。
分层架构设计
完整架构图
flowchart TD
User["用户"] --> DNS["DNS 层<br/>网络防攻击<br/>(DDoS 防护、IP 封禁)"]
DNS --> CDN["CDN 层<br/>静态资源缓存<br/>商品图片/页面骨架/JS/CSS"]
CDN -->|"动态请求"| Nginx["Nginx 层<br/>反向代理 + 负载均衡<br/>静态资源服务<br/>Nginx+Lua 业务校验(防刷)<br/>连接数限流"]
Nginx --> Gateway["网关层<br/>认证鉴权<br/>全局限流(令牌桶)<br/>黑名单过滤<br/>风控拦截"]
Gateway --> Web["Web 服务层<br/>业务聚合<br/>结算页渲染<br/>本地缓存(LocalCache)<br/>用户维度限流"]
Web --> RPC["RPC 服务层<br/>秒杀核心逻辑<br/>库存扣减<br/>订单生成"]
RPC --> Redis["Redis 缓存层<br/>库存预扣<br/>令牌桶<br/>用户请求去重"]
RPC --> MQ["消息队列<br/>Kafka/RocketMQ<br/>异步下单削峰"]
MQ --> OrderSvc["订单服务<br/>消费 MQ 消息<br/>生成订单记录"]
OrderSvc --> MySQL["MySQL 数据库<br/>库存最终扣减<br/>订单持久化"]
style DNS fill:#fcc,stroke:#c00
style CDN fill:#cfc,stroke:#060
style Nginx fill:#cfc,stroke:#060
style Gateway fill:#cfc,stroke:#060
style Web fill:#cfc,stroke:#060
style Redis fill:#cfc,stroke:#060
各层职责与技术选型
| 层级 | 职责 | 技术选型 | 拦截目标 |
|---|---|---|---|
| DNS 层 | 网络层防攻击 | 云厂商 DDoS 防护 | 网络攻击流量 |
| CDN 层 | 静态资源就近分发 | 阿里云 CDN / CloudFlare | 静态资源请求(图片/JS/CSS) |
| Nginx 层 | 反向代理、防刷、限流 | Nginx + Lua(OpenResty) | IP 级别高频请求 |
| 网关层 | 认证、全局限流、风控 | 自研网关 / Kong | 未登录请求、黑名单用户 |
| Web 服务层 | 业务聚合、本地缓存 | Spring Boot | 无效业务请求(活动未开始等) |
| RPC 服务层 | 秒杀核心逻辑 | Dubbo / gRPC | 超出库存的请求 |
| 缓存层 | 库存预扣、令牌 | Redis Cluster | 库存不足的请求 |
| 消息队列 | 异步削峰 | Kafka / RocketMQ | 平滑瞬时写峰值 |
| 数据库 | 最终一致性保证 | MySQL | 兜底防超卖 |
核心原则:校验前置、分层过滤。流量经过每一层后都被大幅削减,到达数据库的请求只剩极少量。
流量削峰方案
验证码与问答题(无损削峰)
目的:
- 平滑毛刺流量:将 1s 内的瞬时流量分散到 30s ~ 1min
- 防机器刷单:增加自动化程序的成本
实现原理:
生成验证码 → 计算结果存入 Redis(Key: CAPTCHA_{user}_{skuId},TTL 60s)
用户提交答案 → 与 Redis 中结果比对 → 通过则允许下单
效果:不同用户手速不同,1 万个并发请求可以平滑到 1 分钟内处理,系统只需支撑 1/60 的峰值压力。
消息队列异步化(无损削峰)
sequenceDiagram
participant User as 用户
participant Web as Web 服务
participant Redis as Redis
participant MQ as 消息队列
participant Consumer as 消费者
participant DB as MySQL
User->>Web: 点击秒杀
Web->>Redis: 预扣库存(DECR)
Redis-->>Web: 库存 > 0,扣减成功
Web->>MQ: 发送下单消息
Web-->>User: 返回"排队中"
Consumer->>MQ: 消费下单消息
Consumer->>DB: 创建订单,扣减 DB 库存
Consumer-->>User: 通知秒杀结果(推送/轮询)
容量规划:
商品库存:1000 件
单次处理时间:500ms
部署 10 个消费者 → 总处理时间:50s(用户等待上限)
消息队列堆积量 = 秒杀请求数 - 已处理数(需监控)
分层过滤(有损削峰)
graph TD
Total["总请求 100,000"] --> L1["Nginx 层过滤<br/>IP 限流、黑名单<br/>剩余:50,000"]
L1 --> L2["网关层过滤<br/>认证、风控<br/>剩余:30,000"]
L2 --> L3["应用层过滤<br/>活动状态、用户资格<br/>剩余:10,000"]
L3 --> L4["缓存层过滤<br/>Redis 库存预扣<br/>剩余:1,000(实际库存)"]
L4 --> L5["数据库<br/>最终扣减<br/>1,000 笔成功"]
style Total fill:#fcc,stroke:#c00
style L5 fill:#cfc,stroke:#060
库存扣减方案
三种方案对比
| 方案 | 时机 | 超卖风险 | 恶意下单风险 | 复杂度 |
|---|---|---|---|---|
| 下单减库存 | 下单时立即扣减 | 无 | 高(下单不付款) | 低 |
| 付款减库存 | 付款时扣减 | 高(下单数 >> 库存) | 低 | 低 |
| 预扣库存 | 下单时预扣,超时释放 | 中 | 中 | 高 |
秒杀场景推荐:下单减库存
理由:
- 秒杀商品"抢到就是赚到",成功下单后不付款的概率很低
- 逻辑简单,性能更好
- 卖家对库存有严格限制,不允许超卖
防止库存为负数
-- 方案 1:事务 + 应用层判断
BEGIN;
SELECT inventory FROM item WHERE id = ? FOR UPDATE;
-- 应用层判断 inventory >= 购买数量
UPDATE item SET inventory = inventory - ? WHERE id = ?;
COMMIT;
-- 方案 2:无符号整数(减后 < 0 则 SQL 报错)
inventory BIGINT UNSIGNED NOT NULL
-- 方案 3:CASE WHEN(推荐,避免行锁超时)
UPDATE item SET inventory = CASE
WHEN inventory >= #{count} THEN inventory - #{count}
ELSE inventory
END
WHERE id = #{itemId}
极致优化:Redis 预扣 + DB 兜底
场景:简单库存逻辑(无复杂 SKU 联动)
Redis 预扣:
DECR seckill:inventory:{itemId}
返回值 >= 0 → 预扣成功,发 MQ 消息
返回值 < 0 → 库存不足,INCR 回滚
DB 最终扣减:
消费 MQ 消息 → UPDATE item SET inventory = inventory - 1 WHERE id = ? AND inventory > 0
影响行数 = 0 → 扣减失败(兜底防超卖)
数据库层并发优化
问题:大量并发更新同一行,InnoDB 行锁竞争激烈,TPS 下降,RT 上升。
解决方案 1:应用层排队
- 按商品 ID 维度设置队列,单机串行处理同一商品的扣减请求
- 控制单个商品占用的 DB 连接数
- 局限:只能控制单机并发,集群场景效果有限
解决方案 2:热点数据隔离
- 将热点商品迁移到独立的热点库
- 减少热点商品对其他商品的影响
解决方案 3:阿里 MySQL 补丁(InnoDB 层排队)
- 在数据库层对单行记录做全局排队
- COMMIT_ON_SUCCESS + ROLLBACK_ON_FAIL:事务结束后立即提交/回滚,减少网络等待(约 0.7ms)
防超卖三层防护
graph TD
A["第一层:Redis Lua 脚本<br/>原子性检查并扣减库存"] --> B{"库存 > 0?"}
B -->|否| C["拒绝请求,返回库存不足"]
B -->|是| D["第二层:消息队列幂等消费<br/>消费者处理前检查订单是否已存在"]
D --> E{"订单已存在?"}
E -->|是| F["幂等处理,直接返回成功"]
E -->|否| G["第三层:数据库唯一约束<br/>订单表 (user_id, item_id) 唯一索引<br/>+ inventory > 0 检查"]
G --> H{"DB 扣减成功?"}
H -->|否| I["回滚,通知用户失败"]
H -->|是| J["秒杀成功"]
style C fill:#fcc,stroke:#c00
style J fill:#cfc,stroke:#060
Redis Lua 脚本(原子性)
-- 原子性检查并扣减库存
local key = KEYS[1]
local count = tonumber(ARGV[1])
local inventory = tonumber(redis.call('get', key))
if inventory == nil then
return -1 -- key 不存在
end
if inventory < count then
return 0 -- 库存不足
end
return redis.call('decrby', key, count) -- 扣减成功,返回剩余库存
为什么用 Lua:Redis 执行 Lua 脚本是原子的,避免 GET + DECR 之间的竞态条件。
数据库唯一约束(兜底)
-- 订单表唯一约束,防止重复下单
CREATE UNIQUE INDEX uk_user_item ON seckill_order(user_id, item_id, activity_id);
-- 库存扣减时附加条件
UPDATE seckill_item
SET inventory = inventory - 1
WHERE item_id = ? AND inventory > 0;
-- 影响行数 = 0 表示库存不足,回滚事务
限流策略
多维度限流
| 维度 | 算法 | 实现 | 说明 |
|---|---|---|---|
| 用户维度 | 固定窗口计数 | Redis INCR + EXPIRE | 同一用户 N 秒内最多请求 M 次 |
| 商品维度 | 令牌桶 | Redis + Lua | 控制单个商品的处理速率 |
| 全局维度 | 漏桶 / 令牌桶 | Nginx limit_req | 控制整体 QPS 上限 |
| IP 维度 | 滑动窗口 | Nginx + Redis | 防止单 IP 刷接口 |
常用限流算法
令牌桶(Token Bucket):
- 以固定速率往桶里放令牌(如 1000 个/秒)
- 每个请求消耗一个令牌,桶空则拒绝
- 允许突发流量(桶满时可以瞬间消耗)
漏桶(Leaky Bucket):
- 请求进入队列,以固定速率处理
- 队列满则丢弃新请求
- 严格限制输出速率,不允许突发
滑动窗口:
- 统计最近 N 秒内的请求数
- 比固定窗口更精确,无边界突刺问题
Nginx 层限流配置
# 按 IP 限流:每秒最多 100 个请求
limit_req_zone $binary_remote_addr zone=ip_limit:10m rate=100r/s;
# 按用户 ID 限流(从 Cookie 中提取)
limit_req_zone $cookie_user_id zone=user_limit:10m rate=10r/s;
location /seckill/buy {
limit_req zone=ip_limit burst=20 nodelay;
limit_req zone=user_limit burst=5;
proxy_pass http://backend;
}
预热方案
活动开始前的预热步骤
T-24h:运营配置秒杀活动(商品、库存、时间)
T-1h:系统预加载活动数据到 Redis
- 库存:SET seckill:inventory:{itemId} {count}
- 活动信息:HMSET seckill:activity:{activityId} ...
- 预生成令牌:LPUSH seckill:token:{activityId} {token1} {token2} ...
T-0:活动开始,直接从 Redis 读取数据
预生成令牌
思路:活动开始前,按库存数量预生成令牌,放入 Redis 队列。用户请求时从队列中弹出令牌,弹出成功才允许下单。
// 预热:生成 N 个令牌
void warmUp(long activityId, int inventory) {
String key = "seckill:token:" + activityId;
for (int i = 0; i < inventory; i++) {
redis.rpush(key, UUID.randomUUID().toString());
}
redis.expire(key, 3600); // 1小时过期
}
// 秒杀:弹出令牌
String token = redis.lpop("seckill:token:" + activityId);
if (token == null) {
return "库存不足";
}
// 凭 token 下单
优势:
- LPOP 是原子操作,天然防超卖
- 令牌数量 = 库存数量,精确控制
- 比 DECR 更灵活(可以做令牌绑定用户等逻辑)
本地缓存预热
// Web 服务层:活动开始前加载到本地缓存
@Scheduled(fixedDelay = 60000) // 每分钟刷新
void refreshLocalCache() {
List<Activity> activities = activityService.getActiveActivities();
for (Activity activity : activities) {
localCache.put("activity:" + activity.getId(), activity);
}
}
隔离策略
为什么要隔离
秒杀流量是普通流量的 100 倍以上,如果混在一起:
- 秒杀流量会拖垮普通商品的购买
- 秒杀系统的故障会影响整个电商平台
隔离层次
| 层次 | 隔离方式 | 说明 |
|---|---|---|
| 系统隔离 | 独立部署秒杀系统 | 独立的应用集群、独立的 DB、独立的缓存 |
| 域名隔离 | 独立域名(seckill.xxx.com) | CDN、Nginx 可按域名单独配置 |
| 数据隔离 | 秒杀商品数据独立存储 | 热点商品放独立 Redis 实例,不影响公共缓存 |
| 线程池隔离 | Hystrix/Sentinel 线程池隔离 | 秒杀接口使用独立线程池,故障不影响其他接口 |
降级与容灾
降级策略
graph TD
Normal["正常状态<br/>全功能"] -->|"Redis 故障"| Degrade1["降级 1:关闭 Redis 预扣<br/>直接走 DB(有超卖风险)"]
Normal -->|"DB 故障"| Degrade2["降级 2:关闭下单入口<br/>展示'系统繁忙'"]
Normal -->|"MQ 积压严重"| Degrade3["降级 3:同步下单模式<br/>绕过 MQ 直接写 DB"]
Normal -->|"全面故障"| Degrade4["兜底:静态页面<br/>'活动已结束'"]
style Degrade4 fill:#fcc,stroke:#c00
熔断机制
// Sentinel 限流 + 熔断
@SentinelResource(
value = "seckill",
blockHandler = "blockHandler", // 限流/熔断时的处理
fallback = "fallback" // 异常时的处理
)
public SeckillResult doSeckill(long userId, long itemId) {
// 正常秒杀逻辑
}
public SeckillResult blockHandler(long userId, long itemId, BlockException ex) {
return SeckillResult.fail("系统繁忙,请稍后重试");
}
public SeckillResult fallback(long userId, long itemId, Throwable t) {
return SeckillResult.fail("秒杀失败,请稍后重试");
}
兜底方案(Plan B)
缓存失效兜底:
- 本地缓存(Caffeine)+ 分布式缓存(Redis)双层
- Redis 故障时,本地缓存提供服务(TTL 更短,数据可能稍旧)
DB 故障兜底:
- 关闭下单入口,展示友好错误页
- 保留 Redis 中的库存数据,待 DB 恢复后补单
流量超预期兜底:
- 全局限流兜底(Nginx 层硬限制)
- 自动扩容(K8s HPA)
监控体系
关键监控指标
| 层级 | 指标 | 告警阈值 | 说明 |
|---|---|---|---|
| 流量 | QPS、TPS | 超过预期 150% | 防止流量超预期 |
| 性能 | TP99 响应时间 | > 500ms | 用户体验下限 |
| 成功率 | 下单成功率 | < 95% | 业务核心指标 |
| 库存 | Redis 库存剩余 | = 0 时告警 | 活动结束信号 |
| 队列 | MQ 消息堆积量 | > 10 万 | 消费者处理能力不足 |
| 数据库 | 慢 SQL 数量 | > 0 | 及时发现性能问题 |
| 错误率 | 5xx 错误率 | > 1% | 系统健康度 |
监控看板关键图表
- 流量漏斗图:各层拦截量,直观展示削峰效果
- 库存消耗曲线:库存随时间的消耗速率
- 响应时间分布:TP50/TP99/TP999
- MQ 消费延迟:消息从生产到消费的延迟
参考资料
- 《如何设计一个秒杀系统》— 许令波(极客时间,ID: 461702917)
- 《手把手带你搭建秒杀系统》— 志东(极客时间,ID: 1277675645)
- 《高并发系统设计 40 问》— 唐扬(极客时间)
- 《大型网站技术架构演进与性能优化》— 许令波
评论 (0)