秒杀系统设计
秒杀系统是典型的高并发、高负载场景,核心挑战是在极短时间内处理大量用户请求,保证不超卖、系统不崩溃、用户体验可接受。下面从架构、流程、关键技术、数据一致性、高可用等方面展开设计。
一、需求与挑战
业务目标
- 例如:限量商品(如 1000 部手机)在指定时间开抢,预期数十万甚至上百万用户同时参与。
- 系统需要在秒级内完成:校验资格 → 预扣库存 → 生成订单 → 异步处理后续(支付等)。
核心挑战
- 瞬时高并发:QPS 可能从几十瞬间飙升至数万甚至数十万。
- 热点商品:库存单一,数据库热点行成为瓶颈。
- 超卖:并发扣减库存时,必须保证最终库存不为负数。
- 恶意刷单:自动化脚本、黄牛等攻击手段。
- 系统稳定性:防止缓存击穿、数据库雪崩、服务熔断。
二、总体架构
text
┌─────────┐ ┌─────────┐ ┌───────────────────┐
│ 前端 │────▶│ CDN │────▶│ 负载均衡 (Nginx) │
└─────────┘ └─────────┘ └───────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────┐ ┌───────────┐
│ Web 应用集群 │ │ 限流组件 │ │ 风控服务 │
└───────────────┘ └───────────┘ └───────────┘
│ │ │
└───────────────┼───────────────┘
▼
┌───────────────────────┐
│ Redis 集群(缓存) │
│ - 库存预热 │
│ - 限流计数器 │
│ - 用户资格/黑名单 │
└───────────────────────┘
│
▼
┌───────────────────────┐
│ 消息队列 (RocketMQ) │
│ - 削峰填谷 │
│ - 异步下单 │
└───────────────────────┘
│
▼
┌───────────────────────┐
│ 数据库主从 │
│ - 订单表(分库分表) │
│ - 库存变更记录 │
└───────────────────────┘
三、核心流程
1. 秒杀前(准备阶段)
- 库存预热:将商品库存从数据库加载到 Redis(如
SET seckill:stock:{id} 1000)。 - 页面静态化:商品详情页、秒杀页全静态,通过 CDN 加速,减少后端压力。
- 限流规则配置:如单用户访问频率、总请求阈值等。
- 预置令牌/资格:可提前通过活动资格校验(如积分、会员等级)生成 token 存入 Redis,避免开抢时大量 DB 查询。
2. 用户请求到达
-
前端限流:按钮置灰、验证码(图形或滑块)防止机器刷单。
-
CDN/负载均衡:分发静态资源,动态请求转发至应用集群。
-
统一入口网关(Nginx/Spring Cloud Gateway):
- 进行全局限流(如基于 IP 的令牌桶)。
- 校验用户登录态,并将请求路由到后端。
3. 应用层处理
应用层无状态,水平扩展,核心逻辑如下:
java
// 伪代码
@PostMapping("/seckill")
public Result seckill(Long userId, Long productId) {
// 1. 用户限流(Redis incr + 过期时间)
if (!rateLimiter.tryAcquire(userId, 1, 10)) {
return Result.error("请求太频繁");
}
// 2. 资格校验(是否已秒杀过、是否满足活动条件)
if (hasOrder(userId, productId)) {
return Result.error("已参与过");
}
// 3. Redis 预扣库存(Lua 脚本原子操作)
Long stock = redisTemplate.execute(SCRIPT,
Collections.singletonList("seckill:stock:" + productId),
Collections.singletonList("1"));
if (stock == null || stock < 0) {
return Result.error("库存不足");
}
// 4. 发送消息到队列,异步创建订单
sendToQueue(buildSeckillMessage(userId, productId));
return Result.success("抢购中,请稍后查看订单");
}
库存扣减 Lua 脚本
lua
local key = KEYS[1]
local decr = tonumber(ARGV[1])
local stock = redis.call('get', key)
if not stock then
return -1
end
stock = tonumber(stock)
if stock >= decr then
redis.call('decrby', key, decr)
return stock - decr
else
return -1
end
返回剩余库存(>=0 成功,-1 失败)。
4. 异步下单(消息队列)
-
消息体包含用户ID、商品ID、秒杀时间戳等。
-
消费端处理:
- 再次校验库存(最终以数据库为准,可使用乐观锁)。
- 插入订单,更新数据库库存。
- 如果数据库扣减失败(如并发超卖),则触发补偿:回滚 Redis 库存(通过消息重试或落库失败补偿)。
-
消息消费失败可重试,超过次数后进入死信队列人工介入。
5. 客户端轮询结果
- 前端定时请求查询订单状态(例如 3 秒一次,最多 10 次)。
- 后台根据 Redis 或数据库返回“排队中/成功/失败”。
四、关键技术细节
1. 防止超卖
- Redis 预扣库存:利用单线程原子性减少数据库压力,但不绝对保证最终一致性。
- 数据库乐观锁:更新库存时使用
UPDATE ... SET stock = stock - 1 WHERE product_id = ? AND stock > 0,通过受影响行数判断是否成功。 - 最终一致性:以数据库为准,若数据库扣减失败,需通过补偿机制恢复 Redis 库存(可发送补偿消息或记录失败日志)。
2. 限流与防刷
- 令牌桶/漏桶:在网关或应用层使用
Guava RateLimiter或Sentinel限制单 IP/单用户的 QPS。 - 滑动窗口:使用 Redis 的 ZSet 记录用户请求时间戳,实现更精确的限流。
- 验证码:开抢前要求输入图形验证码或拖动验证,增加机器成本。
- 风控服务:识别异常 IP、设备指纹、用户行为,拦截黑产。
3. 缓存与热点
- 库存数据全量在 Redis,避免读取数据库。
- 使用 Redis Cluster 分散热点,避免单节点压力。
- 本地缓存(如 Caffeine):将商品基础信息(价格、活动规则)缓存到应用内存,减少 Redis 访问。
4. 削峰填谷
- 消息队列将瞬时请求异步化,拉平高峰,保护下游数据库。
- 队列长度需监控,防止堆积过多导致消费者崩溃;可设置最大积压告警。
5. 服务降级与熔断
- 熔断:若数据库或 Redis 响应缓慢,通过 Hystrix/Sentinel 快速失败,返回友好提示。
- 降级:关闭非核心功能(如推荐、评论),优先保障秒杀主流程。
6. 数据库设计
-
订单表按用户 ID 或时间分库分表(ShardingSphere)。
-
库存表可设计为:
sql
CREATE TABLE seckill_stock ( id BIGINT PRIMARY KEY, product_id BIGINT NOT NULL, stock INT NOT NULL, version INT DEFAULT 0, -- 乐观锁 ... );更新时使用
update ... set stock = stock - 1, version = version + 1 where product_id = ? and stock > 0 and version = ?。
五、高可用与扩展
部署架构
- 多机房多活:流量分发至不同数据中心,单机房故障时快速切换。
- 无状态应用:Web 应用水平扩展,通过 K8s 自动伸缩。
- Redis Cluster:主从+哨兵,自动故障转移。
- 消息队列集群:RocketMQ 主从同步,保证消息不丢失。
- 数据库:读写分离,主库写,从库读;分库分表后每个库可独立主从。
监控与告警
- 指标:QPS、响应时间、库存剩余、队列堆积、数据库连接数、Redis 命中率。
- 链路追踪:SkyWalking 定位慢请求。
- 告警:如库存 Redis 值异常、队列积压超过阈值、错误率飙升时,及时通知值班人员。
六、整体时序图(简化)
text
用户 ──▶ CDN ──▶ Nginx ──▶ 应用
│
▼
Redis(库存扣减)
│
▼
成功 → 发送消息 → RocketMQ → 消费者 → 数据库
│ │
用户 ◀─────────────────── 轮询结果 ◀───────────────────────┘
七、扩展思考
1. 是否可以用数据库直接扣减?
可以,但需要配合乐观锁+排队。但纯数据库处理数十万 QPS 很难,必须引入缓存层。
2. 万一 Redis 挂了怎么办?
- 主从切换,但切换期间有短暂不可用。
- 可结合本地缓存+降级方案,比如直接放行少量请求(减少损失),或关闭秒杀入口。
3. 如何应对流量突增?
- 通过自动扩缩容(K8s HPA)增加实例数。
- 提前压测,确定单机上限,设置合理的实例数量。
- 启用限流,超出预估的请求直接返回“繁忙”。
4. 用户体验如何优化?
- 秒杀按钮倒计时同步(服务器时间)。
- 返回“排队中”而不是“失败”,减少用户刷新。
- 提供结果通知(如短信、站内信)增强体验。
八、总结
秒杀系统的核心设计思路是:层层过滤,逐级削峰,最终一致性。
- 前端层:限流、静态化、CDN。
- 接入层:负载均衡、网关限流、风控。
- 应用层:无状态、本地缓存、服务降级。
- 缓存层:Redis 原子操作、库存预热。
- 异步层:消息队列削峰,异步落库。
- 存储层:分库分表、乐观锁保证最终一致性。
在具体实现时,根据业务规模和资金投入,可适当简化或增强某些环节,但上述原则基本适用。