如何设计一个能支撑百万级并发的商品秒杀系统?
设计一个支撑百万级并发(QPS)的秒杀系统,是互联网架构中极其经典且极具挑战的题目。其核心难点在于:在极短时间内,处理海量请求,同时保证数据一致性(不超卖)和系统高可用(不宕机)。
核心设计思想可以总结为:层层过滤,漏斗模型,极致缓存,异步削峰。
以下是分层架构设计的详细方案:
一、 核心设计原则
- 稳(高可用): 无论流量多大,系统不能崩,哪怕服务降级也要保证核心流程可用。
- 准(一致性): 绝对不能超卖(卖出 101 个商品,库存只有 100)。
- 快(高性能): 响应要快,链路要短,尽量减少对数据库的直接访问。
二、 整体架构流程(漏斗模型)
我们将流量视为洪水,通过不同层级的“大坝”将流量拦截,最终只有极少量的有效请求能到达数据库。
- 客户端层: 页面静态化 + 禁止重复点击。
- 网络层(CDN/边缘计算): 拦截静态资源请求。
- 网关层: 限流、黑名单、鉴权。
- 应用层: 本地缓存 + Redis 预减库存。
- 消息队列层: 异步削峰。
- 数据库层: 最终持久化。
三、 详细分层设计
1. 客户端/前端优化 (流量拦截的第一道防线)
- 页面静态化 (CDN): 秒杀页面(HTML/CSS/JS/图片)全部推送到 CDN 节点。用户访问页面不需要回源到后端服务器,只有点击“抢购”按钮时才发起 API 请求。
- 按钮控制:
- 置灰: 活动未开始前,按钮置灰不可点。
- 防抖: 点击一次后,按钮立即置灰几秒,防止用户疯狂连点(JS控制)。
- 动态 URL: 秒杀接口的 URL 不要是固定的(如
/seckill/product/123),否则会被脚本提前刷。采用两段式请求:- 先请求“获取秒杀令牌(Token)”接口(包含时间校验、风控校验)。
- 拿到 Token 后,拼接到 URL 中再去请求真正的秒杀接口。
2. 网关层 (Nginx / API Gateway)
- 限流 (Rate Limiting): 使用 Nginx 的
limit_req或网关(如 Sentinel、Hystrix)进行限流。例如,限制单 IP 每秒只能请求 5 次,超过直接返回“系统繁忙”。 - 负载均衡: LVS + Nginx 将流量均匀分发到后端服务集群。
- 安全风控: 识别机器人/脚本。如果发现某用户 ID 或 IP 行为异常(如 1 秒内发起 1000 次请求),直接加入黑名单或弹验证码拦截。
3. 服务层 (核心业务逻辑)
这是抗住百万并发的关键。
- 热点数据本地缓存 (Local Cache):
- 对于秒杀商品的信息(价格、库存状态),不要每次都查 Redis。
- 在应用服务器内存中(使用 Guava Cache 或 Caffeine)缓存商品详情。即使 Redis 挂了,服务也能读到数据。
- Redis 预减库存 (核心抗压):
- 原理: 数据库无法支撑百万 QPS,Redis 可以(单机 10w+,集群可达百万)。
- 预热: 秒杀开始前,将库存数量加载到 Redis 中。
- 原子性: 使用 Redis 的
Lua 脚本或DECR命令扣减库存。- Lua 脚本逻辑:
if (redis.get(stock) > 0) { redis.decr(stock); return true; } else { return false; }
- Lua 脚本逻辑:
- 结果: Redis 扣减成功,视为“抢单成功”;Redis 扣减失败(库存为0),直接返回“已抢光”,不再向下执行。
4. 异步削峰 (Message Queue)
Redis 扣库存成功后,并不代表订单创建完成。此时需要写数据库,但数据库写性能差。
- 消息队列 (Kafka / RocketMQ):
- Redis 扣减成功后,立即向 MQ 发送一条“创建订单”的消息。
- 服务层直接给用户返回“排队中”或“抢购成功,正在生成订单”。
- 削峰填谷:
- MQ 就像一个蓄水池,上游流量再大,下游的消费者(Consumer)可以按照数据库能承受的速度(如每秒处理 2000 单)慢慢拉取消息并写入数据库。
5. 数据库层 (数据持久化)
- 唯一索引防重: 消费者读取 MQ 消息写库时,在
order表中对user_id+product_id建唯一索引,防止同一个用户重复创建订单。 - 乐观锁扣库存(双重保险):
- 虽然 Redis 扣过了,但数据库最终落盘时需再次确认。
- SQL:
UPDATE stock_table SET count = count - 1 WHERE id = 123 AND count > 0; - 利用数据库行锁保证最终数据一致性。
四、 关键技术难点与解决方案
1. 如何解决“超卖”问题?
- Redis 层: 使用 Lua 脚本保证“检查库存”和“扣减库存”是原子操作。
- 数据库层: 使用
UPDATE ... WHERE count > 0的乐观锁机制。
2. 如何解决“少卖”问题(库存遗留)?
- 场景: 用户 Redis 抢到了,MQ 发了,但在支付环节超时未支付,或者消费者写库失败。
- 方案:
- 超时释放: 订单创建后设置 15 分钟有效期。如果未支付,触发定时任务或延迟队列,取消订单。
- 回补库存: 订单取消后,需要同时回补 数据库库存 和 Redis 库存。
3. 如何处理“热点 Key”问题?
- 场景: 几百万人同时访问同一个商品 ID(Redis 中的一个 Key),导致该 Redis 节点网卡打满或 CPU 飙升。
- 方案:
- 多级缓存: 也就是前面提到的“本地缓存”。请求先查 JVM 内存,有则返回,无则查 Redis。
- 热点备份: 极端情况下,将热点 Key 复制多份(如
stock_1,stock_2...),随机访问不同的 Key 分摊压力(实现复杂,需汇总库存)。
4. 接口隐藏与防刷
- 动态 Token: 只有在秒杀开始时刻,服务器才下发加密的 Token,前端拿到 Token 才能请求秒杀接口。
- 验证码: 在流量洪峰到来前,强制弹出图形验证码或滑块,拉长用户请求时间,防止机器脚本瞬间击穿。
五、 兜底方案 (Plan B)
设计再完美,也可能有意外。必须有熔断降级机制。
- 服务降级: 当系统负载达到阈值,关闭非核心服务(如评价、推荐、物流查询),保住“下单”和“支付”核心链路。
- 限流熔断: 如果 Redis 或 DB 挂了,网关层直接拦截所有请求,返回“由于太火爆,请稍后再试”,防止雪崩。
- 库存预案: 如果 Redis 库存和 DB 库存严重不一致,以 DB 为准,人工介入或脚本自动对账。
六、 总结图解
plaintext
用户请求 (100W QPS)
│
▼
[ CDN / 静态资源 ] <-- 拦截 90% 流量 (页面浏览)
│
▼
[ Nginx / 网关 ] <-- 限流、黑名单、鉴权
│
▼
[ 应用服务集群 ]
│ ├── 1. 查询本地缓存 (Guava)
│ ├── 2. 检查并扣减 Redis 库存 (Lua脚本) <-- 核心拦截,只放行库存数量的请求
│ └── 3. 发送消息到 MQ
│
▼
[ 消息队列 (Kafka) ] <-- 削峰,缓存写请求
│
▼
[ 订单消费者 ] <-- 慢速消费
│
▼
[ 数据库 (MySQL) ] <-- 最终落地,乐观锁更新
这个架构通过空间换时间(缓存)、异步处理(MQ)和多级拦截,能够有效地支撑百万级并发的秒杀场景。