Skip to content

秒杀优惠券模块面经文档

项目:黑马点评(类 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秒杀成功,可以下单
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      存放活动期间所有订单号

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 -buyNumber

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 中的库存、订单号和限购计数,保证最终一致性。