外观
秒杀优惠券模块面经文档
项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · Lua · Kafka · 线程池 · 连接池(HikariCP)
1. 模块概览
秒杀场景的核心矛盾:瞬时超高并发 vs 数据库有限的写入能力。
普通/限购优惠券的下单流程直接操作数据库,在秒杀场景下会被瞬间流量压垮。秒杀模块通过以下三层优化解决这个矛盾:
| 层次 | 手段 | 解决的问题 |
|---|---|---|
| 第一层:流量前移 | Redis 预热 + Lua 脚本 | 将资格判断从 DB 前移到 Redis,绝大多数请求在内存中快速拒绝或接受 |
| 第二层:流量削峰 | Kafka 异步下单 | 将瞬时写入峰值拉平,DB 按自身速率消费消息 |
| 第三层:消费提速 | 线程池 + 连接池 | 提高消费者处理吞吐量,减少创建线程和连接的开销 |
2. 整体流程图
2.1 秒杀主流程
商家上架秒杀商品
│
▼
定时任务(活动前30分钟)
│── 将库存、开始/结束时间、限购数量写入 Redis(不设 TTL)
│── 设置活动结束后30分钟清理 Redis 数据的定时任务
│
▼
用户发起秒杀请求
│
▼
┌─────────────────────────────────────────────────────┐
│ Lua 脚本(Redis 原子执行) │
│ 1. Redis 中有无该商品信息? │
│ 2. 秒杀是否在时间窗口内? │
│ 3. 库存是否充足? │
│ 4. 用户是否超过限购数量? │
│ 全部通过 → 预扣库存 + 记录订单号到 Set │
└─────────────────────────────────────────────────────┘
│
├── 失败(返回错误码 1~5)→ 直接返回错误信息给用户
│
└── 成功(返回 0)
│
▼
发送消息到 Kafka(同步确认,确保消息不丢失)
│
▼
返回订单号给用户(此时 DB 中还没有订单)
│
▼
┌───────────────────────────────────────────────┐
│ Kafka 消费者(线程池处理) │
│ 1. 从队列取出订单消息 │
│ 2. 扣减 MySQL 库存 │
│ 3. 写入订单表 │
│ 4. 失败则重试,多次失败则回滚 Redis │
└───────────────────────────────────────────────┘2.2 Lua 脚本返回值说明
| 返回值 | 含义 |
|---|---|
| 0 | 秒杀成功,可以下单 |
| 1 | Redis 中缺少数据(未预热或已清除) |
| 2 | 秒杀尚未开始 |
| 3 | 秒杀已经结束 |
| 4 | 库存不足 |
| 5 | 超过用户最大限购数量 |
3. Redis 预热
3.1 预热的数据结构
seckill:stock:{voucherId} → String 库存数量
seckill:beginTime:{voucherId} → String 开始时间戳
seckill:endTime:{voucherId} → String 结束时间戳
seckill:limit:{voucherId} → String 限购数量
seckill:buyCount:{voucherId} → Hash field=userId, value=已购数量
seckill:order → Set 存放活动期间所有订单号3.2 为什么不设 TTL,要用定时任务手动清理?
若设置了 TTL,活动期间 key 自动过期会导致正在进行的秒杀校验失败。活动结束后由定时任务统一清理,可以精确控制清理时机(例如活动结束后 30 分钟,确保所有消息队列中的消息都已消费完毕),同时避免冷数据长期占用 Redis 内存。
3.3 为什么要预热而不是请求到来时再从 DB 读取?
秒杀的特点是在极短时间内涌入大量请求。若第一个请求触发从 DB 读取数据写入 Redis,其后的请求都等待,这个窗口期内会有大量请求直接打到 DB(类似缓存击穿),而且还会引入锁竞争。预热在活动开始前提前将数据就绪,秒杀开始的瞬间 Redis 中已经有完整数据,所有请求都能直接在 Redis 层得到处理。
4. Lua 脚本保证原子性
4.1 为什么必须用 Lua 脚本?
秒杀判断逻辑包含多步操作:
① 检查时间窗口
② 检查库存 (GET → 判断)
③ 检查限购 (HGET → 判断)
④ 预扣库存 (DECR)
⑤ 记录订单号 (SADD)
⑥ 更新用户购买数量 (HINCRBY)若用普通命令逐条执行,两个并发请求可能同时通过库存校验(库存=1),然后都执行 DECR,导致库存变为 -1(超卖)。Lua 脚本在 Redis 中是原子执行的(执行期间不接受其他命令),天然解决并发问题,且只需一次网络往返,降低延迟。
4.2 核心 Lua 脚本逻辑
lua
-- 参数:voucherId, userId, orderId, currentTime, buyNumber, maxLimit
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
local now = tonumber(ARGV[4])
local buyNum = tonumber(ARGV[5])
local maxLimit = tonumber(ARGV[6])
-- key 定义
local stockKey = "seckill:stock:" .. voucherId
local beginTimeKey = "seckill:beginTime:" .. voucherId
local endTimeKey = "seckill:endTime:" .. voucherId
local limitKey = "seckill:limit:" .. voucherId
local buyCountKey = "seckill:buyCount:" .. voucherId
local orderSetKey = "seckill:order"
-- 1. Redis 是否有该商品数据
if redis.call("EXISTS", stockKey) == 0 then return 1 end
-- 2. 检查时间窗口
local beginTime = tonumber(redis.call("GET", beginTimeKey))
local endTime = tonumber(redis.call("GET", endTimeKey))
if now < beginTime then return 2 end -- 未开始
if now > endTime then return 3 end -- 已结束
-- 3. 检查库存
local stock = tonumber(redis.call("GET", stockKey))
if stock < buyNum then return 4 end -- 库存不足
-- 4. 检查限购
local limitCount = tonumber(redis.call("GET", limitKey))
local boughtCount = tonumber(redis.call("HGET", buyCountKey, userId)) or 0
if boughtCount + buyNum > limitCount then return 5 end -- 超过限购
-- 5. 全部通过:预扣库存、记录订单、更新购买数量
redis.call("DECRBY", stockKey, buyNum)
redis.call("SADD", orderSetKey, orderId)
redis.call("HINCRBY", buyCountKey, userId, buyNum)
return 0 -- 成功4.3 Java 调用 Lua 脚本
java
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT, // 预加载的 Lua 脚本
Collections.emptyList(), // KEYS(本脚本不使用)
voucherId.toString(), // ARGV[1]
userId.toString(), // ARGV[2]
String.valueOf(orderId), // ARGV[3]
String.valueOf(currentTime), // ARGV[4]
String.valueOf(buyNumber), // ARGV[5]
String.valueOf(MAX_BUY_LIMIT) // ARGV[6]
);5. Kafka 异步下单
5.1 为什么用 Kafka 而不是直接写 DB?
秒杀峰值 QPS 可能达到数万,而 MySQL 单机写入 QPS 一般只有数千。将订单消息发入 Kafka,由消费者按 DB 实际处理能力异步消费,实现削峰填谷:
秒杀峰值: ██████████████████ 10000 QPS
↓ Kafka 缓冲
DB 写入: ████ ████ ████ 2000 QPS(匀速)5.2 消息发送:确保不丢失
java
Event event = new Event();
event.setTopic("createOrder");
event.setUserId(userId);
event.setEntityId(orderId);
event.setData(Map.of("voucherId", voucherId, "buyNumber", buyNumber));
kafkaTemplate.send("createOrder", JSON.toJSONString(event));发送失败时不应向用户返回下单成功,需要捕获异常并回滚 Redis 中的预扣库存。
5.3 消费者处理流程
KafkaOrderConsumer 通过单线程 ExecutorService 从 Kafka 消费者组 g1 的消费者 c1 读取消息,并处理 Kafka offset 中的失败消息:
KafkaOrderConsumer(线程池处理)
│
▼
@KafkaListener(topics = "createOrder") 接收消息
│
├── 解析 Event JSON
│
├── 提交到 ExecutorService 线程池
│ │
│ ├── 成功 → 扣减 MySQL 库存 + 写订单表
│ │
│ └── 异常 → 发消息到 save-order-failed-topic 触发回滚
│
└── acknowledge() 确认消费5.4 消息顺序性保证
秒杀订单消息的顺序性要求:同一个用户对同一商品的操作必须按顺序处理。
Kafka 通过分区(Partition)保证顺序:同一用户的消息发往同一分区(以 userId 为 key),分区内消息有序消费。
6. 线程池异步处理订单
6.1 为什么要用线程池?
Kafka 消费者默认串行处理消息(一条处理完再处理下一条),在消息堆积场景下消费速度远跟不上生产速度。引入线程池后,消费者取到消息即提交给线程池并发处理,吞吐量大幅提升。
| 维度 | 不用线程池 | 用线程池 |
|---|---|---|
| 处理方式 | 串行 | 并发 |
| 线程创建 | 每次 new Thread(开销大) | 线程复用(开销低) |
| 并发控制 | 无限制,可能压垮 DB | 通过最大线程数限制并发 |
| DB 连接 | 频繁建立/释放 | 配合连接池复用连接 |
6.2 线程池四个核心参数
java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // corePoolSize:核心线程数,常驻
20, // maximumPoolSize:最大线程数,控制并发上限
60, TimeUnit.SECONDS, // keepAliveTime:非核心线程空闲超时时间
new LinkedBlockingQueue<>(100), // 任务队列容量
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);线程池工作流程:
新任务到来
│
├── 核心线程数未满 → 创建核心线程处理
│
├── 核心线程已满,队列未满 → 放入队列等待
│
├── 队列已满,最大线程数未满 → 创建非核心线程处理
│
└── 队列已满,最大线程数已满 → 触发拒绝策略拒绝策略选择:秒杀场景下选 CallerRunsPolicy(由调用者线程执行任务),而不是 AbortPolicy(抛异常丢弃),防止订单消息被丢弃。
6.3 连接池(HikariCP)
yaml
spring:
datasource:
hikari:
maximum-pool-size: 10 # 最大连接数
minimum-idle: 5 # 最小空闲连接
connection-timeout: 30000 # 获取连接超时时间(ms)
idle-timeout: 600000 # 连接空闲超时时间(ms)线程池控制并发线程数,连接池控制 DB 连接数,两者配合:最大线程数 ≤ 最大连接数,避免线程等待连接。
7. 支付流程中的数据一致性
7.1 问题:用户支付时,订单可能还在 Stream 队列里,DB 中没有记录
方案一(本项目采用):Redis 中在整个活动期间缓存所有已下单的订单号(seckill:order Set)。
用户支付请求
│
├── 查 Redis:订单号是否在 seckill:order Set 中?
│ ├── 不在 Redis → 说明不是秒杀订单 → 直接查 DB 走普通支付流程
│ └── 在 Redis → 说明是秒杀订单
│ │
│ └── 查 DB 是否有订单
│ ├── DB 有 → 正常支付
│ └── DB 没有 → 订单还在队列中,等待后重试方案二:Redis 中只缓存尚未写入 DB 的订单号。消费者写入 DB 成功后,从 Redis 删除该订单号。支付时若 Redis 中存在则等待,不存在则直接查 DB。
两种方案对比:
- 方案一实现更简单,但 Redis 中数据量更大(整个活动期间所有订单)
- 方案二 Redis 数据量小,但需要消费者在写 DB 后额外删除 Redis,增加一步操作且存在短暂不一致窗口
7.2 Redis 与 MySQL 数据一致性保证
Kafka 消费者写 DB 失败的处理链路(基于 Kafka offset 机制):
处理消息异常 → 不 ACK,消息留在 Kafka offset
│
▼
KafkaOrderConsumer 检查 Kafka offset(poll ... 0)
│
├── 重试成功 → ACK 确认 → 消息从 Kafka offset 移除
│
└── 多次重试仍失败 → 记录日志,人工介入
│
▼
执行回滚 Lua 脚本(原子操作):
① 恢复库存:INCRBY seckill:stock:{id} buyNumber
② 删除订单:SREM seckill:order orderId
③ 恢复限购:HINCRBY seckill:buyCount:{id} userId -buyNumber8. 方案对比
8.1 三种下单方案整体性能对比
| 维度 | 普通(乐观锁) | 限购(分布式锁) | 秒杀(Redis+Kafka) |
|---|---|---|---|
| 核心瓶颈 | DB 写入 | DB 写入 | Redis 内存操作 |
| 并发能力 | 中 | 低~中 | 高 |
| 响应时间 | 中(需等 DB) | 中~高(有锁等待) | 低(Redis 毫秒级) |
| 实现复杂度 | 低 | 中 | 高 |
| 数据一致性 | 强 | 强 | 最终一致 |
| 适用并发量 | < 1000 QPS | < 5000 QPS | 万级 QPS |
8.2 为什么选 Kafka 而不是 Kafka / RabbitMQ
| 维度 | Kafka | Kafka | RabbitMQ |
|---|---|---|---|
| 额外中间件 | ❌ 复用已有 Redis | ✅ 需部署 | ✅ 需部署 |
| 吞吐量 | 中(万/s,够用) | 极高(百万/s) | 中(万/s) |
| 消息持久化 | ✅(磁盘) | ✅ 磁盘持久化 | ✅ |
| 消费者组 | ✅ poll | ✅ Consumer Group | ✅ |
| 运维复杂度 | 低 | 高(ZK/KRaft) | 中 |
| 适用场景 | 轻量级异步 | 高吞吐、日志流 | 复杂路由 |
秒杀场景选 Kafka 的核心原因:项目已依赖 Redis,Lua 脚本预扣库存后直接 Kafka 发送 到同一实例的 Stream,无需引入额外中间件,部署运维简单,吞吐量满足业务需求。
9. 极端场景与容灾
场景一:Redis 宕机,秒杀流量直接打到 DB
Redis 预热数据全部丢失,Lua 脚本返回「Redis 缺少数据」(返回码 1),所有请求无法通过资格校验。此时需要降级处理:
- 短期降级:关闭秒杀,给用户提示「活动暂时不可用」
- 根本方案:Redis 高可用部署(哨兵模式 / 集群模式),主节点宕机后自动切换
场景二:Kafka Kafka 发送 失败,消息无法写入
Lua 脚本预扣库存成功后,Kafka 发送 写入 Stream 失败(如 Redis 内存满或连接中断):
- 立即回滚 Redis(用 Lua 脚本恢复库存、删除订单号、恢复限购计数)
- 向用户返回下单失败
- 兜底方案:将订单信息先保存到服务器本地日志,Redis 恢复后重新发送
场景三:消费者处理消息堆积
- 增加消费者组中的消费者数量(当前仅 c1 单消费者,可扩展为多消费者并行消费)
- 优化消费逻辑(批量写入替代单条写入,减少 DB IO 次数)
- 检查是否有消费阻塞(如死锁、超时未处理)
- 通过 Kafka consumer lag 监控 Stream 长度,设置告警阈值
场景四:消费者服务器崩溃,已取到但未处理的消息丢失吗?
Kafka 的 Kafka offset 机制保证消息不丢失。消费者通过 poll 取到消息后,消息进入该消费者的 Kafka offset;只有在处理完成并执行 ACK 后,消息才从 Kafka offset 移除。若消费者崩溃,未 ACK 的消息仍留在 Kafka offset 中,消费者重启后会重新读取并处理这些消息(需保证消费逻辑幂等性)。
场景五:同一用户重复收到消息(Kafka offset 重试导致重复消费)
消费者处理逻辑必须幂等:写入订单前先检查订单号是否已存在,若已存在则跳过。Kafka 的 Kafka offset 重试机制意味着消息可能被处理多次(at-least-once 语义),因此幂等性是必须的。
10. 面试题清单
10.1 基础实现层
| 题目 | 考察点 |
|---|---|
| 秒杀模块的整体设计思路是什么,为什么这么设计? | 削峰填谷设计思维 |
| 什么是 Redis 预热?为什么要预热,不能在请求来了再加载吗? | 预热必要性 |
| Lua 脚本在秒杀中解决了什么问题?为什么不用普通 Redis 命令? | 原子性,并发安全 |
| 为什么用 Kafka 异步下单,直接写 DB 不行吗? | 削峰填谷,系统解耦 |
| 线程池在这里的作用是什么?四个核心参数怎么配置? | 线程池原理与使用 |
10.2 方案对比层
| 题目 | 考察点 |
|---|---|
| 用户支付时订单还没写入 DB,怎么处理?两种方案分别是什么? | 数据一致性设计 |
| Redis 预扣库存成功,但写 DB 失败,如何保证数据一致性? | 最终一致性,回滚机制 |
| Kafka 消息堆积了怎么解决? | 消息队列运维 |
| 线程池的四种拒绝策略分别是什么,秒杀场景下选哪个,为什么? | 拒绝策略与业务匹配 |
10.3 原理深挖层
| 题目 | 考察点 |
|---|---|
| Lua 脚本在 Redis 中为什么是原子的?Redis 单线程模型解释一下 | Redis 执行模型 |
| Kafka 如何保证消息顺序性?消费者组怎么工作? | Kafka 消费者组,消息 ID |
| Kafka 的消息持久化机制是什么?宕机后会丢失吗? | 磁盘 持久化 |
| 线程池的工作流程是什么?核心线程、最大线程、队列三者的关系? | 线程池源码级理解 |
10.4 极端场景层
| 题目 | 考察点 |
|---|---|
| Redis 在秒杀中途宕机,如何处理? | 降级,高可用 |
| Kafka Kafka 发送 失败,已预扣库存但消息没写入,怎么办? | 回滚机制 |
| 消费者崩溃,正在处理的消息会丢失吗? | Kafka offset 机制,幂等消费 |
| 同一条消息被消费了两次怎么办? | 幂等性设计 |
11. 一句话总结(面试开场白模板)
秒杀模块的核心设计是三层优化:第一层把资格判断从 MySQL 前移到 Redis,用 Lua 脚本原子性地完成时间校验、库存校验、限购校验和预扣库存,大量不满足条件的请求在内存层就被拦截;第二层用 Kafka 做异步下单,Lua 脚本预扣库存后直接 Kafka 发送 到同一 Redis 实例的 createOrder,无需额外中间件,KafkaOrderConsumer 通过Kafka 消费者 按 MySQL 实际能力匀速写入订单;第三层在消费者侧用单线程 ExecutorService 处理消息,并配合 HikariCP 连接池减少建立连接的开销。在数据一致性上,Redis 预扣库存但 DB 写入失败时,消费者通过 Kafka offset 机制自动重试,多次失败后记录日志人工介入,用 Lua 脚本原子性地回滚 Redis 中的库存、订单号和限购计数,保证最终一致性。