本文目录导读:

- 核心思想:流量漏斗 + 层层过滤 + 最终一致性
- 第一部分:分布式限流(保护系统入口)
- 第二部分:防超卖(核心库存保护)
- 第三部分:流量削峰与最终一致性(保证不超卖的关键落地)
- 第四部分:完整流程总结(伪代码)
- 第五部分:关键点检查清单
这是一个非常经典且高难度的分布式系统设计问题。秒杀系统的核心挑战在于:短时间内海量并发请求(读和写)冲击单一热点资源(库存),导致数据库崩溃、库存超卖、用户响应缓慢。
针对“分布式限流”和“防超卖”这两个关键点,以下是经过生产验证的完整解决方案架构。
核心思想:流量漏斗 + 层层过滤 + 最终一致性
把请求想象成水流,从外到内逐层削减,最终只有极少量的请求能触达数据库。
graph TD
subgraph 客户端层
A[用户请求] --> B[CDN/浏览器静态化]
end
subgraph 接入层 (限流与防刷)
B --> C[Nginx/LVS]
C --> D{API网关/负载均衡}
D -- 令牌桶/漏桶 --> E[Nginx Lua限流模块]
end
subgraph 应用与缓存层 (核心过滤与库存扣减)
E --> F[Redis Cluster]
F -- 库存预扣/本地标记 -->
G[秒杀服务集群]
G -- 抢到资格 -->
H[消息队列 MQ]
end
subgraph 数据持久层 (异步落库)
H -- 异步/串行消费 -->
I[数据库 DB 事务]
I --> J[订单/库存持久化]
end
style F fill:#f9f,stroke:#333,stroke-width:2px
style H fill:#bbf,stroke:#333,stroke-width:2px
第一部分:分布式限流(保护系统入口)
目标是挡住大部分无效或过量的请求,防止系统被冲垮。
前端与网关限流(第一道防线)
- 动静分离:秒杀页面静态化(HTML/CSS/JS)、CDN加速,用户点击按钮前,页面静态部分不消耗服务器资源。
- 按钮防抖:点击后立即置灰,防止用户频繁点击。
- Nginx/Lua级别限流(最外层,性能最高):
- IP限流:同一IP每秒最多允许N次请求。
- URL限流:针对“/seckill/do”接口做全局限流。
- 连接数限制:限制Nginx到后端服务的最大连接数。
应用层分布式限流(第二道防线)
-
令牌桶算法(推荐):
-
原理:以固定速率向桶中放入令牌,请求必须拿到令牌才能通过,允许突发流量,但总体速率可控。
-
实现:使用 Redis + Lua脚本(原子性)实现分布式令牌桶,定义一个key,hash结构存储
last_refresh_time和tokens。 -
代码示例(Lua脚本简化逻辑):
-- 获取令牌逻辑 local key = KEYS[1] local rate = tonumber(ARGV[1]) -- 每秒速率,1000 local burst = tonumber(ARGV[2])-- 桶容量 local now = redis.call('TIME')[1] local token_info = redis.call('HMGET', key, 'last_time', 'tokens') local last_time = tonumber(token_info[1]) or now local tokens = tonumber(token_info[2]) or burst -- 根据时间差计算应添加的令牌 local delta = math.max(0, now - last_time) tokens = math.min(burst, tokens + delta * rate) if tokens >= 1 then redis.call('HMSET', key, 'last_time', now, 'tokens', tokens - 1) return 1 -- 限流通过 else return 0 -- 限流拒绝 end
-
第二部分:防超卖(核心库存保护)
核心原则:将库存扣减操作提前到内存/缓存层,且必须保证原子性。
Redis 原子扣减库存(最重要的一步)
- 为什么不用数据库直接扣?:数据库行锁会导致性能瓶颈,无法支撑高并发。
- 操作方式:使用 Redis 的
DECR命令或 Lua脚本 进行库存扣减。- Lua脚本 保证整个操作(查库存、判断、扣减)的原子性,避免并发问题。
- 预判库存:先读库存,如果小于等于0,直接返回“已售罄”,避免无效的
DECR操作。-- Redis Lua 扣减库存脚本 -- KEYS[1]: 商品库存的Key (stock:1001) -- ARGV[1]: 购买数量 (通常为1) local stock = redis.call('GET', KEYS[1]) if not stock or tonumber(stock) <= 0 then return 0 -- 库存不足 end if tonumber(stock) - tonumber(ARGV[1]) < 0 then return 0 -- 库存不足 end return redis.call('DECRBY', KEYS[1], ARGV[1]) -- 返回剩余库存
本地缓存标记(为 Redis 减负)
- 问题:高并发下,即使使用Redis,大量请求查询“商品是否售罄”也会给Redis带来压力。
- 解决方案:
- 当从Redis中读取到某个商品的库存为0(或已被标记售罄)时,将状态写入秒杀服务本地内存(如Caffeine、Guava Cache)。
- 后续请求直接检查本地缓存,若已售罄,不再请求Redis,直接返回失败。
- 注意:需要设置一个较短的过期时间(如10秒),并配合一个后台任务定期检查Redis库存,防止库存恢复(如订单取消)后本地缓存未更新。
“一人一单”严格限制(结合用户维度)
- 问题:黄牛用多个账号/IP刷单。
- 技术实现:
- 基于Redis Set:使用商品ID+用户ID作为key,维护一个已购买成功的用户ID集合。
- 基于布隆过滤器:在秒杀开始前,将历史购买记录加载到布隆过滤器中,快速拦截重复购买。
第三部分:流量削峰与最终一致性(保证不超卖的关键落地)
异步下单(消息队列MQ)
- 为什么需要:扣减库存后,不能直接写入数据库,如果直接写入,高并发写入会导致数据库死锁或Connection池耗尽。
- 流程:
- Redis扣减库存成功。
- 将“用户ID、商品ID、数量、秒杀资格Token”封装成消息,发送到 MQ(如 RocketMQ/Kafka)。
- 立即返回给用户“正在排队中...”或“秒杀成功,请等待订单生成”。
- 后端服务异步消费MQ消息,单线程/串行化地处理数据库写操作。
- 防重复消费:数据库订单表中建立唯一索引(用户ID + 商品ID + 活动ID),确保同一用户不会生成两个订单。
数据库层兜底(乐观锁)
- 即使异步落库,最后一步写入数据库时仍需防御。
- 更新SQL:
UPDATE stock SET version = version + 1, num = num - 1 WHERE id = ? AND version = ? and num > 0 - 如果更新影响行数为0,说明库存被其他线程更新了,该订单作废(回滚Redis库存)。
第四部分:完整流程总结(伪代码)
// 1. 入口:接入层限流 (令牌桶)
if (!rateLimiter.tryAcquire()) {
return "请求过于频繁,请稍后重试";
}
// 2. 业务层:检查用户黑名单,一人一单,防刷 (布隆过滤器)
if (bloomFilter.mightContain(userId + productId)) {
return "您已经参与过该活动";
}
// 3. 本地缓存检查售罄 (JVM内存)
if (localCache.isSoldOut(productId)) {
return "商品已售罄";
}
// 4. Redis Lua 脚本原子扣减库存
Long stockRemaining = redisTemplate.execute(seckillLuaScript, stockKey, 1);
if (stockRemaining == null || stockRemaining < 0) {
// 扣减失败,标记本地缓存售罄
localCache.markSoldOut(productId, 10, TimeUnit.SECONDS);
return "商品已被抢光";
}
// 5. 扣减成功,发送MQ消息,异步落单 (加购标记,防重复)
orderMessage = new OrderMessage(userId, productId, token);
mqProducer.send(orderMessage);
// 6. 返回成功给用户
return "恭喜你,秒杀成功!正在处理订单...";
// --- 异步消费者 ---
@RocketMQMessageListener(topic = "seckill_order")
public void onMessage(OrderMessage msg) {
try {
// 7. 数据库防重复插入 (唯一索引)
// INSERT INTO order (user_id, product_id,...) VALUES (?,?,...)
// 如果唯一索引冲突,直接 return (幂等)
// 8. 数据库乐观锁扣减最终库存
int rows = stockMapper.updateStockByVersion(productId, version);
if (rows == 0) {
// 乐观锁失败,库存被改 (理论上不应该发生,或极少发生)
// 补偿:修改订单状态为“失败”,回滚Redis库存
redisTemplate.opsForValue().increment(stockKey, 1);
return;
}
// 9. 创建订单成功
} catch (Exception e) {
// 10. 重试/死信队列处理
}
}
第五部分:关键点检查清单
| 环节 | 技术手段 | 目标 |
|---|---|---|
| 前端 | 按钮置灰、静态化、CDN | 减少无效请求 |
| 网关 | Nginx Lua 限流(IP/URL) | 防刷、流量整形 |
| 应用层 | 分布式令牌桶(Redis+Lua) | 控制总并发数 |
| 库存层 | Redis Lua 原子扣减 | 严格防止超卖 |
| 状态缓存 | 本地缓存(Caffeine)标记售罄 | 减轻Redis压力 |
| 一人一单 | Redis Set / 布隆过滤器 | 防黄牛重复购买 |
| 削峰 | 消息队列(RocketMQ/Kafka) | 异步落库,保护DB |
| 最终一致 | 数据库乐观锁 + 唯一索引 | 兜底,防重复 |
通过以上“层层过滤、异步削峰、原子扣减”的组合拳,就能构建一个稳定、不超卖的分布式秒杀系统。