面试知识库

秒杀优惠券模块面经文档#

项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · Lua · Kafka · 线程池 · 连接池(HikariCP)


1. 模块概览#

秒杀场景的核心矛盾:瞬时超高并发 vs 数据库有限的写入能力

普通/限购优惠券的下单流程直接操作数据库,在秒杀场景下会被瞬间流量压垮。秒杀模块通过以下三层优化解决这个矛盾:

层次手段解决的问题
第一层:流量前移Redis 预热 + Lua 脚本将资格判断从 DB 前移到 Redis,绝大多数请求在内存中快速拒绝或接受
第二层:流量削峰Kafka 异步下单将瞬时写入峰值拉平,DB 按自身速率消费消息
第三层:消费提速线程池 + 连接池提高消费者处理吞吐量,减少创建线程和连接的开销

2. 整体流程图#

2.1 秒杀主流程#

2.2 Lua 脚本返回值说明#

返回值含义
0秒杀成功,可以下单
1Redis 中缺少数据(未预热或已清除)
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      存放活动期间所有订单号
plaintext

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)
plaintext

若用普通命令逐条执行,两个并发请求可能同时通过库存校验(库存=1),然后都执行 DECR,导致库存变为 -1(超卖)。Lua 脚本在 Redis 中是原子执行的(执行期间不接受其他命令),天然解决并发问题,且只需一次网络往返,降低延迟。

4.2 核心 Lua 脚本逻辑#

4.3 Java 调用 Lua 脚本#

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]
);
java

5. Kafka 异步下单#

5.1 为什么用 Kafka 而不是直接写 DB?#

秒杀峰值 QPS 可能达到数万,而 MySQL 单机写入 QPS 一般只有数千。将订单消息发入 Kafka,由消费者按 DB 实际处理能力异步消费,实现削峰填谷

秒杀峰值: ██████████████████ 10000 QPS
                      ↓ Kafka 缓冲
DB 写入:   ████ ████ ████    2000 QPS(匀速)
plaintext

5.2 消息发送:确保不丢失#

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));
java

发送失败时不应向用户返回下单成功,需要捕获异常并回滚 Redis 中的预扣库存。

5.3 消费者处理流程#

KafkaOrderConsumer 通过单线程 ExecutorService 从 Kafka 消费者组 g1 的消费者 c1 读取消息,并处理 Kafka offset 中的失败消息:

KafkaOrderConsumer(线程池处理)


@KafkaListener(topics = "createOrder") 接收消息

    ├── 解析 Event JSON

    ├── 提交到 ExecutorService 线程池
    │       │
    │       ├── 成功 → 扣减 MySQL 库存 + 写订单表
    │       │
    │       └── 异常 → 发消息到 save-order-failed-topic 触发回滚

    └── acknowledge() 确认消费
plaintext

5.4 消息顺序性保证#

秒杀订单消息的顺序性要求:同一个用户对同一商品的操作必须按顺序处理。

Kafka 通过分区(Partition)保证顺序:同一用户的消息发往同一分区(以 userId 为 key),分区内消息有序消费。


6. 线程池异步处理订单#

6.1 为什么要用线程池?#

Kafka 消费者默认串行处理消息(一条处理完再处理下一条),在消息堆积场景下消费速度远跟不上生产速度。引入线程池后,消费者取到消息即提交给线程池并发处理,吞吐量大幅提升。

维度不用线程池用线程池
处理方式串行并发
线程创建每次 new Thread(开销大)线程复用(开销低)
并发控制无限制,可能压垮 DB通过最大线程数限制并发
DB 连接频繁建立/释放配合连接池复用连接

6.2 线程池四个核心参数#

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    5,                        // corePoolSize:核心线程数,常驻
    20,                       // maximumPoolSize:最大线程数,控制并发上限
    60, TimeUnit.SECONDS,     // keepAliveTime:非核心线程空闲超时时间
    new LinkedBlockingQueue<>(100),  // 任务队列容量
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
);
java

线程池工作流程:

新任务到来

    ├── 核心线程数未满 → 创建核心线程处理

    ├── 核心线程已满,队列未满 → 放入队列等待

    ├── 队列已满,最大线程数未满 → 创建非核心线程处理

    └── 队列已满,最大线程数已满 → 触发拒绝策略
plaintext

拒绝策略选择:秒杀场景下选 CallerRunsPolicy(由调用者线程执行任务),而不是 AbortPolicy(抛异常丢弃),防止订单消息被丢弃。

6.3 连接池(HikariCP)#

spring:
  datasource:
    hikari:
      maximum-pool-size: 10     # 最大连接数
      minimum-idle: 5           # 最小空闲连接
      connection-timeout: 30000 # 获取连接超时时间(ms)
      idle-timeout: 600000      # 连接空闲超时时间(ms)
yaml

线程池控制并发线程数,连接池控制 DB 连接数,两者配合:最大线程数 ≤ 最大连接数,避免线程等待连接。


7. 支付流程中的数据一致性#

7.1 问题:用户支付时,订单可能还在 Stream 队列里,DB 中没有记录#

方案一(本项目采用):Redis 中在整个活动期间缓存所有已下单的订单号(seckill:order Set)。

用户支付请求

    ├── 查 Redis:订单号是否在 seckill:order Set 中?
    │       ├── 不在 Redis → 说明不是秒杀订单 → 直接查 DB 走普通支付流程
    │       └── 在 Redis   → 说明是秒杀订单
    │               │
    │               └── 查 DB 是否有订单
    │                       ├── DB 有 → 正常支付
    │                       └── DB 没有 → 订单还在队列中,等待后重试
plaintext

方案二:Redis 中只缓存尚未写入 DB 的订单号。消费者写入 DB 成功后,从 Redis 删除该订单号。支付时若 Redis 中存在则等待,不存在则直接查 DB。

两种方案对比:

  • 方案一实现更简单,但 Redis 中数据量更大(整个活动期间所有订单)
  • 方案二 Redis 数据量小,但需要消费者在写 DB 后额外删除 Redis,增加一步操作且存在短暂不一致窗口

7.2 Redis 与 MySQL 数据一致性保证#


8. 方案对比#

8.1 三种下单方案整体性能对比#

维度普通(乐观锁)限购(分布式锁)秒杀(Redis+Kafka)
核心瓶颈DB 写入DB 写入Redis 内存操作
并发能力低~中
响应时间中(需等 DB)中~高(有锁等待)低(Redis 毫秒级)
实现复杂度
数据一致性最终一致
适用并发量< 1000 QPS< 5000 QPS万级 QPS

8.2 为什么选 Kafka 而不是 Kafka / RabbitMQ#

维度KafkaKafkaRabbitMQ
额外中间件❌ 复用已有 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 恢复后重新发送

场景三:消费者处理消息堆积#

  1. 增加消费者组中的消费者数量(当前仅 c1 单消费者,可扩展为多消费者并行消费)
  2. 优化消费逻辑(批量写入替代单条写入,减少 DB IO 次数)
  3. 检查是否有消费阻塞(如死锁、超时未处理)
  4. 通过 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 中的库存、订单号和限购计数,保证最终一致性。