秒杀系统全解析:高并发场景下的架构抉择
秒杀系统全解析:高并发场景下的架构抉择
秒杀系统全解析:高并发场景下的架构抉择是系统设计的核心,它决定了系统的可扩展性、可靠性和可维护性。
本文介绍了秒杀系统全解析:高并发场景下的架构抉择的设计原则和实践经验,帮助你提升架构设计能力。
| 挑战 | 说明 |
|---|---|
| 瞬时高并发 | 平时 QPS 100,秒杀时 QPS 10万+ |
| 库存有限 | 100 件商品 10 万人抢 |
| 不能超卖 | 卖出数量不能超过库存 |
| 不能少卖 | 库存没卖完但系统说卖完了 |
| 防黄牛 | 一个人不能买多次 |
| 高可用 | 不能挂,挂了就是事故 |
1.2 设计原则
- 层层过滤:每一层尽量拦截无效请求,让尽量少的请求打到数据库
- 缓存为王:能缓存绝不查库
- 异步削峰:用消息队列把同步请求变异步
- 限流兜底:超过系统承载能力的请求直接拒绝
二、整体架构设计
用户 → CDN → 负载均衡(Nginx) → 网关(限流/鉴权) → 应用层(秒杀服务)
↓
Redis(预扣减库存)
↓
MQ(异步下单)
↓
数据库(最终扣减)2.1 架构分层说明
| 层级 | 职责 | 技术选型 |
|---|---|---|
| CDN | 静态资源加速 | CDN |
| 负载均衡 | 流量分发 | Nginx / LVS |
| 网关 | 限流、鉴权、路由 | Spring Cloud Gateway |
| 应用层 | 业务逻辑 | Spring Boot |
| 缓存层 | 库存预扣减 | Redis Cluster |
| 消息队列 | 异步削峰 | RocketMQ / Kafka |
| 数据库 | 最终一致性 | MySQL |
三、详细设计
3.1 前端层:把请求挡在门外
页面静态化 + CDN:秒杀页面纯静态,放 CDN。
按钮防抖:点击后置灰 5 秒,防止连点。
// 前端防抖
let isClicked = false;
async function seckill() {
if (isClicked) return;
isClicked = true;
try {
const res = await fetch('/api/seckill/execute', {
method: 'POST',
body: JSON.stringify({ skuId: 123 })
});
const data = await res.json();
alert(data.message);
} finally {
setTimeout(() => isClicked = false, 5000); // 5秒冷却
}
}验证码/答题:增加提交成本,错开请求时间。
3.2 网关层:限流 + 鉴权
限流——令牌桶算法
@Component
public class RateLimitFilter implements GlobalFilter {
// 每秒生成 1000 个令牌
private final RateLimiter rateLimiter = RateLimiter.create(1000);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
if (!rateLimiter.tryAcquire()) {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
}IP 黑名单
// 同一 IP 1 秒内请求超过 10 次,拉黑 5 分钟
@Component
public class IpBlacklistFilter implements GlobalFilter {
@Autowired
private StringRedisTemplate redis;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
String key = "rate:ip:" + ip;
Long count = redis.opsForValue().increment(key);
if (count == 1) {
redis.expire(key, 1, TimeUnit.SECONDS);
}
if (count > 10) {
redis.opsForValue().set("blacklist:ip:" + ip, "1", 5, TimeUnit.MINUTES);
}
if ("1".equals(redis.opsForValue().get("blacklist:ip:" + ip))) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
}3.3 应用层:核心秒杀逻辑
方案一:Redis 预扣减 + 异步下单(推荐)
@Service
public class SeckillService {
@Autowired
private StringRedisTemplate redis;
@Autowired
private RocketMQTemplate mqTemplate;
/**
* 秒杀入口
*/
public SeckillResult execute(Long skuId, Long userId) {
// 1. 校验活动时间
if (!checkActivityTime(skuId)) {
return SeckillResult.fail("活动未开始或已结束");
}
// 2. 防重复购买(Redis Set)
String userKey = "seckill:user:" + skuId;
Boolean isNew = redis.opsForSet().add(userKey, userId.toString()) == 1;
if (!isNew) {
return SeckillResult.fail("您已参与过该商品秒杀");
}
// 3. 预扣减库存(Lua 脚本保证原子性)
String stockKey = "seckill:stock:" + skuId;
Long stock = redis.opsForValue().decrement(stockKey);
if (stock < 0) {
// 库存不足,回滚用户标记
redis.opsForSet().remove(userKey, userId.toString());
redis.opsForValue().increment(stockKey); // 恢复库存
return SeckillResult.fail("手慢了,商品已售罄");
}
// 4. 发送 MQ 异步创建订单
SeckillMessage message = new SeckillMessage(skuId, userId,
"ORD" + System.currentTimeMillis() + userId);
mqTemplate.asyncSend("seckill-order-topic", message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
// 消息发送成功
}
@Override
public void onException(Throwable e) {
// 发送失败,回滚库存和用户标记
redis.opsForValue().increment(stockKey);
redis.opsForSet().remove(userKey, userId.toString());
log.error("MQ发送失败, skuId={}, userId={}", skuId, userId, e);
}
});
// 5. 返回排队中,前端轮询结果
return SeckillResult排队中("正在排队中,请稍候...");
}
}Lua 脚本保证库存扣减原子性
-- seckill.lua
-- KEYS[1]: 库存key
-- KEYS[2]: 用户集合key
-- ARGV[1]: userId
-- 1. 判断是否已购买
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
return -1 -- 已购买
end
-- 2. 判断库存
local stock = tonumber(redis.call('get', KEYS[1]))
if stock == nil or stock <= 0 then
return 0 -- 库存不足
end
-- 3. 扣减库存 + 记录用户
redis.call('decr', KEYS[1])
redis.call('sadd', KEYS[2], ARGV[1])
return 1 -- 成功Java 调用:
@Service
public class SeckillService {
private final DefaultRedisScript<Long> seckillScript;
@PostConstruct
public void init() {
seckillScript = new DefaultRedisScript<>();
seckillScript.setLocation(new ClassPathResource("lua/seckill.lua"));
seckillScript.setResultType(Long.class);
}
public SeckillResult execute(Long skuId, Long userId) {
String stockKey = "seckill:stock:" + skuId;
String userKey = "seckill:user:" + skuId;
Long result = redis.execute(seckillScript,
Arrays.asList(stockKey, userKey),
userId.toString());
if (result == -1) return SeckillResult.fail("您已参与过秒杀");
if (result == 0) return SeckillResult.fail("商品已售罄");
// 发送 MQ 异步下单
mqTemplate.asyncSend("seckill-order-topic",
new SeckillMessage(skuId, userId),
new DefaultSendCallback());
return SeckillResult.queuing("正在排队中");
}
}3.4 消息队列:异步削峰
消费者——创建订单
@Component
@RocketMQMessageListener(topic = "seckill-order-topic",
consumerGroup = "seckill-consumer-group")
public class SeckillOrderConsumer implements RocketMQListener<SeckillMessage> {
@Autowired
private OrderService orderService;
@Override
@Transactional
public void onMessage(SeckillMessage message) {
try {
// 创建订单(数据库操作)
orderService.createOrder(message.getSkuId(), message.getUserId(),
message.getOrderNo());
// 更新秒杀状态为成功
redis.opsForValue().set(
"seckill:result:" + message.getUserId() + ":" + message.getSkuId(),
"SUCCESS", 30, TimeUnit.MINUTES
);
} catch (DuplicateKeyException e) {
// 重复消费,幂等处理
log.warn("重复消费: {}", message);
} catch (Exception e) {
// 创建订单失败,回滚 Redis 库存
redis.opsForValue().increment("seckill:stock:" + message.getSkuId());
redis.opsForSet().remove("seckill:user:" + message.getSkuId(),
message.getUserId().toString());
redis.opsForValue().set(
"seckill:result:" + message.getUserId() + ":" + message.getSkuId(),
"FAIL", 30, TimeUnit.MINUTES
);
log.error("订单创建失败", e);
}
}
}3.5 前端轮询结果
@GetMapping("/result")
public SeckillResult checkResult(Long skuId, Long userId) {
String result = redis.opsForValue().get(
"seckill:result:" + userId + ":" + skuId);
if ("SUCCESS".equals(result)) return SeckillResult.success("秒杀成功!");
if ("FAIL".equals(result)) return SeckillResult.fail("秒杀失败");
// 没有结果,还在排队
return SeckillResult.queuing("正在排队中...");
}前端轮询策略:
async function pollResult(skuId) {
let attempts = 0;
const maxAttempts = 30; // 最多轮询30次
const timer = setInterval(async () => {
attempts++;
const res = await fetch(`/api/seckill/result?skuId=${skuId}`);
const data = await res.json();
if (data.status !== 'QUEUING' || attempts >= maxAttempts) {
clearInterval(timer);
alert(data.message);
}
}, 2000); // 每2秒查一次
}四、库存方案对比
4.1 方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库行锁 | 简单可靠 | 性能差,TPS 低 | 低并发 |
| Redis 预扣减 | 高性能,原子操作 | 需要处理一致性 | 中高并发 ⭐ |
| Redis + Lua | 原子性最好 | 脚本调试难 | 高并发 ⭐⭐ |
| 分库分表 | 超高性能 | 架构复杂 | 超高并发 |
4.2 Redis 库存预热
活动开始前,把库存写入 Redis:
@Scheduled(cron = "0 0 9 * * ?") // 每天9点检查
public void preloadStock() {
List<SeckillActivity> activities = activityRepo.findTodayActivities();
for (SeckillActivity activity : activities) {
String stockKey = "seckill:stock:" + activity.getSkuId();
redis.opsForValue().set(stockKey, activity.getStock().toString());
String userKey = "seckill:user:" + activity.getSkuId();
redis.delete(userKey); // 清空上次记录
log.info("库存预热: skuId={}, stock={}", activity.getSkuId(), activity.getStock());
}
}4.3 数据库最终扣减
-- 使用乐观锁,version 机制
UPDATE seckill_stock
SET stock = stock - 1, version = version + 1
WHERE sku_id = #{skuId} AND stock > 0 AND version = #{version};
-- 或者直接用 stock 条件
UPDATE seckill_stock
SET stock = stock - 1
WHERE sku_id = #{skuId} AND stock > 0;五、防黄牛策略
5.1 多维度限制
@Service
public class AntiFraudService {
@Autowired
private StringRedisTemplate redis;
public boolean check(Long userId, Long skuId, String ip, String deviceFingerprint) {
// 1. 用户维度:同一用户只能买1次
String userKey = "seckill:user:" + skuId;
if (redis.opsForSet().isMember(userKey, userId.toString())) {
return false;
}
// 2. IP 维度:同一IP最多5次请求
String ipKey = "seckill:ip:" + skuId + ":" + ip;
Long ipCount = redis.opsForValue().increment(ipKey);
if (ipCount == 1) redis.expire(ipKey, 1, TimeUnit.HOURS);
if (ipCount > 5) return false;
// 3. 设备维度:同一设备最多3次
String deviceKey = "seckill:device:" + skuId + ":" + deviceFingerprint;
Long deviceCount = redis.opsForValue().increment(deviceKey);
if (deviceCount == 1) redis.expire(deviceKey, 1, TimeUnit.HOURS);
if (deviceCount > 3) return false;
return true;
}
}5.2 风控规则
- 非正常时间段请求(如凌晨3点大量请求)
- 请求频率异常(1秒内切换多个 IP)
- 账号注册时间过短(新号参与秒杀)
- 历史退单率高的用户
六、高可用保障
6.1 熔断降级
@Service
public class SeckillService {
@Autowired
private CircuitBreaker circuitBreaker;
public SeckillResult execute(Long skuId, Long userId) {
return circuitBreaker.executeSupplier(() -> {
// 正常秒杀逻辑
return doSeckill(skuId, userId);
}, () -> {
// 降级逻辑
return SeckillResult.fail("系统繁忙,请稍后重试");
});
}
}6.2 接口隔离
秒杀服务独立部署,不和其他服务共享资源,防止秒杀打挂影响全站。
6.3 监控告警
- Redis 库存监控(低于阈值告警)
- MQ 积压监控
- 接口 RT 监控
- 异常率监控
七、避坑指南
| 坑 | 说明 | 解决方案 |
|---|---|---|
| Redis 库存和 DB 不一致 | MQ 消费失败导致库存没扣 | 消费失败时回滚 Redis 库存 |
| 超卖 | 并发请求同时通过库存检查 | Lua 脚本原子操作 |
| 少卖 | MQ 消息丢失 | 消息持久化 + 消费幂等 + 定时对账 |
| 重复下单 | MQ 重复消费 | 消费端幂等(唯一索引 / Redis 去重) |
| 缓存击穿 | 热点 key 过期瞬间大量请求 | 互斥锁 / 永不过期 + 异步更新 |
| 缓存雪崩 | 大量 key 同时过期 | 过期时间加随机值 |
八、面试要点
Q1:秒杀系统怎么防止超卖?
答:核心是库存扣减的原子性。方案一:Redis + Lua 脚本,在 Lua 中判断库存并扣减,保证原子性。方案二:数据库乐观锁 UPDATE ... WHERE stock > 0。实际方案是 Redis 预扣减 + MQ 异步下单 + 数据库最终扣减三层保障。
Q2:为什么用 MQ 异步下单?
答:1)削峰填谷:瞬时高并发请求转为 MQ 的排队消费,保护数据库。2)解耦:秒杀服务和订单服务解耦。3)失败重试:MQ 消费失败可重试,保证最终一致性。
Q3:Redis 库存和数据库库存怎么保证一致?
答:Redis 预扣减成功后发送 MQ,消费者消费时在数据库中扣减。如果消费失败,回滚 Redis 库存。同时定时任务对账,发现 Redis 库存 > 数据库库存时以数据库为准。关键:Redis 是"预扣减",数据库才是最终账本。
Q4:秒杀系统怎么做限流?
答:多层限流:1)网关层令牌桶限流,保护整个系统。2)接口层单用户限流,防黄牛。3)Redis 库存本身就是最精确的限流——库存为 0 直接拒绝。
Q5:MQ 消费者怎么保证幂等?
答:1)数据库唯一索引(订单号唯一)。2)Redis 记录消费状态,消费前检查。3)乐观锁 version 机制。推荐用数据库唯一索引,简单可靠。
总结
秒杀系统的设计核心是四个字:层层过滤。
10万请求 → 前端防抖过滤6万 → 网关限流过滤2万 → Redis 库存过滤1.97万
→ MQ 削峰 → 数据库只处理300个订单每一层都把流量削减一部分,最终到数据库的请求量是可控的。技术要点:
- 前端:静态化 + 防抖 + 验证码
- 网关:限流 + 黑名单
- Redis:Lua 原子扣减 + 防重复
- MQ:异步下单削峰
- 数据库:乐观锁兜底
- 高可用:熔断 + 降级 + 监控
记住:没有完美的架构,只有适合场景的架构。QPS 1000 的秒杀用 Redis 就够了,QPS 10 万的秒杀才需要全套方案。根据实际流量选择合适的复杂度。