电商秒杀系统设计
一、业务背景与核心痛点
秒杀:大促场景短时间涌入几十万并发请求,争抢有限商品库存。
业务硬性约束
- 不能超卖:实际下单数量 ≤ 商品真实库存
- 不能少卖:库存不能无故丢失
- 高并发防护:保护 MySQL 不被打崩,不能拖垮整个电商主站
- 限购:同一用户只允许抢购 1 次
- 防黄牛爬虫
- 超时未支付自动释放库存
- 故障降级:部分组件故障,业务尽可能可用
四大技术难题
- 瞬时大流量直接打数据库,数据库 CPU/IO 打满雪崩
- 并发竞态导致超卖(先 select 查询库存,再 update 扣减)
- 95% 以上请求都是无效请求,大量无效 IO
- Redis‑MQ‑MySQL 多组件,出现库存数据不一致
面试记忆点:秒杀本质矛盾:高并发读、极少有效写,绝大多数请求无效;核心原则:层层拦截,无效请求在上层直接拒绝,尽量不要落到数据库。
二、整体架构分层设计(可直接渲染)

各层职责拆解
-
CDN 静态层 秒杀页面全部静态化,页面资源托管 CDN。用户打开页面不走后端 Java 服务,减少后端压力。
-
Nginx + Gateway 网关层
- IP 限流、拉黑恶意黄牛 IP
- 令牌桶全局限流,超过阈值直接返回 “活动火爆,请稍后重试”
- 校验请求合法性,拦截爬虫非法调用
- 秒杀业务服务层
- 校验登录、活动时间是否开始结束
- Redis 执行 Lua 脚本预扣库存、校验用户限购
- 没有抢到:直接返回抢购失败;抢到资格:发送 MQ,前端返回排队中
- RocketMQ 消息队列(削峰核心)
不会同步写数据库。只有拿到资格的请求进入 MQ,把瞬时洪峰流量变成平缓流量交给消费者。
- 消费者服务 + MySQL 消费者消费消息,执行真实库存扣减、生成订单;MySQL 是唯一真相源,Redis 仅做前置过滤。
三、核心技术选型
表格
| 组件 | 选型 | 作用 |
|---|---|---|
| 网关 | Nginx + SpringCloud Gateway | 流量入口、限流、黑名单、路由 |
| 缓存 | Redis‑Cluster + Lua 脚本 | 库存预热、预扣库存、用户限购原子校验 |
| 本地缓存 | Caffeine | 缓解 Redis 热点 Key 压力 |
| 消息队列 | RocketMQ | 异步削峰;延迟消息实现超时未支付回库;死信队列处理异常消息 |
| 数据库 | MySQL InnoDB | 存储真实库存、秒杀订单;依靠行锁防止超卖 |
| 限流算法 | 令牌桶算法 | 网关、应用双层限流 |
| 分布式锁 | Redisson | 可选;库存扣减优先使用 Lua 脚本 + MySQL 原子 SQL |
四、核心模块实现详解
4.1 超卖问题:错误代码(面试必踩坑)
❌错误写法:先查询、后更新,并发场景出现超卖
java
运行
// 禁止使用!并发竞态会超卖
Integer stock = mapper.selectStock(goodsId);
if(stock > 0){
mapper.updateStock(goodsId);
}
问题:select 和 update 是两次 SQL,中间时间窗口,多个线程读到同一个库存,出现超卖。
4.2 MySQL 解决超卖:原子 update(生产首选)
利用 InnoDB 行锁,单条 SQL 原子完成判断 + 扣减,不需要先查询。
sql
update seckill_goods
set stock = stock - 1
where goods_id = #{goodsId} and stock > 0;
返回受影响行数 = 1:扣库存成功;返回 0:库存不足。
4.3 Redis 预扣库存(Lua 脚本,保证 Redis 端原子)
活动预热阶段,把秒杀商品库存加载进 Redis: SET seckill:stock:10001 200
Lua 脚本,同时完成:库存判断扣减 + 用户限购校验,多条命令服务端原子执行,避免网络往返带来并发问题。
lua
-- KEYS[1]:库存key;KEYS[2]:已抢购用户集合key;ARGV[1]:userId
local stock = redis.call('get',KEYS[1])
if tonumber(stock) <= 0 then
return 0
end
-- 判断用户是否已经抢购
local exist = redis.call('sismember',KEYS[2],ARGV[1])
if exist == 1 then
return 2
end
redis.call('decr',KEYS[1])
redis.call('sadd',KEYS[2],ARGV[1])
return 1
返回码定义:
0:库存不足1:预扣成功,拿到抢购资格2:用户重复抢购,限购拦截
⚠️重点:Redis 预扣成功≠下单成功,仅代表拿到资格;最终以 MySQL 扣减结果为准。如果 DB 扣减失败,必须回补 Redis 库存。
4.4 MQ 异步完整流程

- MQ 消息体:
userId、goodsId、requestId(唯一幂等ID) - 消费者必须做幂等:防止 MQ 重试造成重复下单;依靠 requestId 判重。
- 数据库扣库存失败场景:Redis 和 MySQL 库存不一致,代码执行回补 Redis。
4.5 超时未支付,自动归还库存
秒杀订单 15 分钟未支付自动关闭、归还库存。
不建议定时任务轮表,压力大;优先使用 RocketMQ延迟消息。
- 创建订单同时发送 15 分钟延迟消息;
- 消费者收到消息,查询订单状态;
- 如果状态 = 未支付:关闭订单,MySQL 库存 + 1,同步 Redis 库存 + 1。
4.6 热点 Key 问题
秒杀商品的 Redis key 被海量请求访问,单 Redis 实例 CPU 打满。 解决方案:
- Caffeine 本地缓存,短时间缓存库存状态,减少访问 Redis;
- 库存 key 拆分:一份逻辑库存拆分为多个物理 key,请求随机访问其中一个 key;
- Redis 集群合理分片,避免热点落在单节点。
4.7 防黄牛防刷
- 网关层 IP 限流、黑名单;
- 强制登录,未登录请求直接拦截;
- 秒杀按钮点击弹出验证码,打散瞬时流量;
- 请求签名校验,防止爬虫直接调用后端接口;
注意:前端所有校验仅防普通用户,黄牛可以绕过;所有校验后端必须二次校验,绝不信任前端。
4.8 Redis 与 MySQL 库存不一致如何处理
产生场景:Redis 预扣成功,服务宕机,MQ 消息丢失,没有执行 DB 扣减,Redis 库存少扣。 处理方案:
- MQ 消费失败业务代码立刻回补 Redis;
- 定时校正任务:以 MySQL 真实库存为准,定时同步校正 Redis 缓存库存。
4.9 MQ 可靠性保障
- 生产者开启确认机制,保证消息不丢失;
- 消费者手动 ACK;消费失败有限重试;多次失败消息进入死信队列,人工排查。
五、数据库核心表结构
sql
-- 秒杀商品表
CREATE TABLE seckill_goods (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
goods_id BIGINT COMMENT '商品ID',
stock INT COMMENT '真实库存',
start_time DATETIME COMMENT '秒杀开始时间',
end_time DATETIME COMMENT '秒杀结束时间',
create_time DATETIME
);
-- 秒杀订单表
CREATE TABLE seckill_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(64) COMMENT '订单号',
request_id VARCHAR(64) COMMENT '幂等唯一ID',
user_id BIGINT,
goods_id BIGINT,
status TINYINT COMMENT '0未支付 1已支付 2已关闭',
pay_time DATETIME,
create_time DATETIME
);
-- 建立索引
CREATE INDEX idx_goods_user ON seckill_order(goods_id,user_id);
CREATE INDEX idx_request_id ON seckill_order(request_id);
大促量级大,秒杀订单可以做分表,避免单表数据量过大。
六、高频面试题 + 标准答案
Q1:秒杀系统主要面临哪些问题,如何解决?
答:
- 瞬时高并发压垮数据库:分层削峰。CDN 静态页面、网关限流;Redis Lua 预扣拦截绝大多数无效请求;MQ 异步削峰,数据库只处理少量有效下单请求。
- 超卖:根源是先查询再更新的竞态条件。MySQL 使用单条原子 update
update set stock=stock‑1 where goods_id=xx and stock>0,依靠 InnoDB 行锁;Redis 层 Lua 脚本做预扣,MySQL 作为最终兜底。 - 重复抢购:Redis 集合记录已抢购用户,Lua 脚本原子校验,限制一人一单。
- Redis 与 MySQL 库存不一致:DB 扣减失败立刻回补 Redis;定时校正任务以 MySQL 为准修复 Redis。
- 订单超时未归还库存:RocketMQ 延迟消息实现自动关闭订单,恢复库存。
- 黄牛刷接口:网关 IP 限流黑名单、登录校验、验证码打散流量;后端全部校验,不依赖前端。
Q2:为什么不能直接把库存全部放在 Redis,去掉 MySQL?
答: Redis 是内存缓存,存在 RDB/AOF 丢失数据风险,不能保证持久化事务。Redis 只适合做前置过滤,真实库存必须落 MySQL。Redis 预扣成功只是拿到抢购资格,最终是否下单成功以 MySQL 执行结果为准。
Q3:为什么用 Lua 脚本,不用 Redisson 分布式锁扣 Redis 库存?
答: 分布式锁可以实现,但是高并发性能差,大量请求抢锁阻塞。Lua 脚本在 Redis 服务端原子执行,无锁竞争,吞吐更高。
注意:Lua 脚本解决 Redis 预扣;数据库层依旧使用原子 SQL 兜底,不能只靠 Redis。
Q4:乐观锁与悲观锁在秒杀场景如何选型?
答:
- 悲观锁(原子 update 行锁):简单可靠,不会超卖;存在锁等待;适合秒杀业务。不推荐 select for update,多一次查询 SQL,增加锁等待时间。
- 乐观锁(version 版本号):无阻塞;高并发场景大量版本冲突,大量更新返回 0,吞吐量差,不适合秒杀。
秒杀优先选用原子 update 行锁。
Q5:MQ 重复消费如何处理?
答: MQ 重试会产生重复消费,必须实现幂等。消息携带全局唯一requestId,消费的时候优先查询数据库是否已经处理过该 requestId;已经处理直接跳过,保证不会重复生成订单。
Q6:热点 Key 怎么解决?
答: 海量请求打同一个 Redis key,造成单节点 CPU100%。方案:
- Caffeine 本地缓存缓存库存状态,减少 Redis 访问;
- 库存 key 拆分,逻辑库存分散多个物理 key;
- 合理规划 Redis 分片,热点 key 打散到不同实例。
Q7:Redis 宕机,秒杀系统如何降级?
答: 关闭 Redis 预扣逻辑;依靠网关限流保护,请求直接访问 MySQL。系统吞吐量下降,但是业务可用,不能直接整体不可用。
Q8:口述一次完整秒杀请求流程(面试背诵)
答:
- 用户访问页面,静态资源由 CDN 返回;
- 请求到达 Nginx 和 Gateway 网关,执行 IP 限流、黑名单拦截;
- 请求转发到秒杀服务,校验登录状态;
- 执行 Redis Lua 脚本,校验用户是否抢购过、预扣 Redis 库存;
- Lua 返回库存不足 / 重复抢购,直接返回抢购失败;
- 预扣成功,发送 RocketMQ 消息,返回前端排队中,前端轮询查询订单状态;
- MQ 消费者消费消息,做幂等校验,开启数据库事务;
- 执行 MySQL 原子 update 扣库存;影响行数等于 1 则生成订单提交事务;扣减失败回补 Redis 库存;
- 下单成功发送 15 分钟延迟消息;超时未支付自动关闭订单、归还 MySQL 和 Redis 库存。
七、项目经验面试话术(直接复制口述)
在项目中我负责电商秒杀模块设计,核心解决瞬时大流量带来数据库压力、超卖、库存一致性问题。整体采用分层削峰架构,页面静态化托管 CDN,网关实现 IP 限流防刷。后端通过 Redis 集群做库存预热,Lua 脚本原子完成预扣库存和限购校验,拦截大部分无效请求。拿到资格的请求发送 RocketMQ 实现异步削峰,消费者异步消费。真实库存以 MySQL 为准,原子 update 行锁防止超卖。 针对 MQ 重复消费做业务幂等;订单超时使用延迟消息自动释放库存;定时校正任务处理 Redis 与 MySQL 库存不一致。针对 Redis 热点 key,引入 Caffeine 本地缓存缓解压力。同时实现降级策略,Redis 故障可以降级直连 MySQL,保障业务可用。
八、生产环境踩坑(面试加分点)
- ❌不要先 select 库存,再 update,并发直接超卖;
- ❌不能把 Redis 当做唯一数据源,MySQL 必须兜底;
- ❌Redis 预扣成功,DB 扣失败,忘记回补 Redis 库存,导致少卖;
- ❌只做前端按钮置灰、验证码,后端不做二次校验;
- ❌MQ 消费不做幂等,重试出现重复下单;
- ❌消息无限重试,失败消息没有进入死信队列,堆积脏数据。
更多推荐




所有评论(0)