外观
03 - 秒杀系统
知识图谱
┌──────────────────── 秒杀全链路 ────────────────────┐
│ │
│ HTTP POST /voucher-order/seckill/{id} │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ VoucherOrderServiceImpl │ │
│ │ .seckillVoucher() │ │
│ │ │ │
│ │ 1. RedisIdWorker.nextId() │ │
│ │ 2. 获取当前时间戳 │ │
│ │ 3. 执行 seckill.lua ──────┐ │ │
│ │ 4. 判断返回码 │ │ │
│ └────────┬───────────────────┘ │ │
│ │ │ │
│ ┌──────┴──────┐ ┌──────┴──────┐ │
│ │ 返回码 ≠ 0 │ │ seckill.lua │ │
│ │ → 失败信息 │ │ (Redis原子) │ │
│ └─────────────┘ │ │ │
│ │ 校验时间窗口 │ │
│ ┌──────────────┐ │ 校验库存 │ │
│ │ 返回码 = 0 │ │ 校验限购 │ │
│ │ → 发Kafka │ │ 扣减库存 │ │
│ └──────┬───────┘ │ 记录购买数 │ │
│ │ │ 添加订单ID │ │
│ ▼ └──────────────┘ │
│ Kafka: createOrder │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ KafkaOrderConsumer │ │
│ │ → ExecutorService(10线程) │ │
│ │ │ │
│ │ 1. 构建 VoucherOrder │ │
│ │ 2. Redisson锁: lock:order:{uid}│ │
│ │ 3. DB乐观锁扣库存 │ │
│ │ 4. 保存订单到MySQL │ │
│ │ 5. 失败 → Kafka: save-order- │ │
│ │ failed-topic │ │
│ └──────────┬──────────────────────┘ │
│ │(失败时) │
│ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ KafkaOrderConsumer │ │
│ │ handleSaveOrderFailed() │ │
│ │ → 执行 rollback.lua │ │
│ │ → 恢复Redis库存/购买数/订单 │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘seckill.lua 完整解析
文件: src/main/resources/seckill.lua
参数
| 序号 | ARGV | 含义 | 来源 |
|---|---|---|---|
| 1 | voucherId | 优惠券ID | 路径参数 |
| 2 | userId | 用户ID | ThreadLocal (UserHolder) |
| 3 | orderId | 订单ID | RedisIdWorker.nextId("order") |
| 4 | currentTime | 当前时间戳(ms) | LocalDateTime → EpochMilli |
| 5 | requestQuantity | 购买数量 | 请求参数 (默认1) |
| 6 | maxLimit | 最大限购值 | MAX_BUY_LIMIT = 100 |
Redis Key 结构
seckill:stock:{voucherId} → Hash {id, stock, beginTime, endTime}
seckill:order → Set {orderId1, orderId2, ...}
seckill:buyCount:{voucherId} → Hash {userId:123 → 2, userId:456 → 1}执行流程 & 返回码
lua
-- 1. 获取时间窗口
beginTime = HGET seckill:stock:{vid} beginTime
endTime = HGET seckill:stock:{vid} endTime
→ 缺少数据 → return 1
-- 2. 校验时间
currentTime < beginTime → return 2 (秒杀尚未开始)
currentTime > endTime → return 3 (秒杀已经结束)
-- 3. 校验库存
stock = HGET seckill:stock:{vid} stock
stock < requestQuantity → return 4 (库存不足)
-- 4. 校验限购
currentBuyCount = HGET seckill:buyCount:{vid} userId:{uid}
(currentBuyCount + requestQuantity) > maxLimit → return 5 (超过限购)
-- 5. 执行扣减 (三个写操作, 原子执行)
HINCRBY seckill:stock:{vid} stock -requestQuantity -- 扣库存
HINCRBY seckill:buyCount:{vid} userId:{uid} +requestQuantity -- 记录购买数
SADD seckill:order orderId -- 记录订单ID
return 0 (成功)rollback.lua 解析
文件: src/main/resources/rollback.lua
lua
local orderId = tonumber(ARGV[1])
local voucherId = tonumber(ARGV[2])
local buyNumber = tonumber(ARGV[3])
local userId = tonumber(ARGV[4])
local stockKey = "seckill:stock:" .. voucherId
local orderSetKey = "seckill:order"
local buyCountKey = "seckill:buyCount:" .. voucherId
-- 检查订单是否存在
if redis.call("SISMEMBER", orderSetKey, orderId) == 1 then
redis.call("INCRBY", stockKey, buyNumber) -- ⚠️ BUG: String命令操作Hash
redis.call("SREM", orderSetKey, orderId)
local currentBuyCount = tonumber(redis.call("HGET", buyCountKey, userId))
-- ⚠️ BUG: 缺少 "userId:" 前缀
if currentBuyCount then
redis.call("HSET", buyCountKey, userId, currentBuyCount - buyNumber)
end
return 1
else
return 0
endBug 分析
| Bug | seckill.lua 用法 | rollback.lua 用法 | 后果 |
|---|---|---|---|
| stockKey 操作 | HINCRBY stockKey "stock" -qty (Hash) | INCRBY stockKey buyNumber (String) | WRONGTYPE 运行时异常 |
| buyCount 字段名 | userId:{userId} (如 userId:123) | userId (如 123) | HGET 返回 nil,购买计数不回滚 |
正确的 rollback.lua 应该是:
lua
redis.call("HINCRBY", stockKey, "stock", buyNumber) -- Hash 操作
local currentBuyCount = tonumber(redis.call("HGET", buyCountKey, "userId:" .. userId))
if currentBuyCount then
redis.call("HINCRBY", buyCountKey, "userId:" .. userId, -buyNumber)
end四层并发控制
┌─────────────────────────────────────────────────────┐
│ 第1层: Redis Lua 原子性 (seckill.lua) │
│ 所有校验+扣减在一个 Lua 脚本中原子执行 │
│ → 防止超卖(两个用户同时看到 stock=1 都买成功) │
├─────────────────────────────────────────────────────┤
│ 第2层: Kafka 消息队列 │
│ 将瞬时高并发转为队列串行消费 │
│ → 削峰填谷,保护数据库 │
├─────────────────────────────────────────────────────┤
│ 第3层: Redisson 分布式锁 (lock:order:{userId}) │
│ 同一用户的订单创建串行化 │
│ → 防止重复下单(Kafka 可能重复投递) │
├─────────────────────────────────────────────────────┤
│ 第4层: DB 乐观锁 (WHERE stock > buyNumber) │
│ 最后一道防线,数据库层面防超卖 │
│ → 即使前面所有层都失效,DB 也不会超卖 │
└─────────────────────────────────────────────────────┘三种券对比
| 维度 | CommonVoucher | LimitVoucher | SeckillVoucher |
|---|---|---|---|
| 库存扣减 | DB 乐观锁 | Redisson锁 + DB乐观锁 | Lua原子 + Redisson锁 + DB乐观锁 |
| 时间校验 | 无 | 无 | Lua 中校验 beginTime/endTime |
| 限购检查 | 无 | Java 层查询订单总量 | Lua 中检查 buyCount Hash |
| 持久化 | 同步写 DB | 同步写 DB | 异步 Kafka → DB |
| 事务 | 无 @Transactional | 有 @Transactional | 无 @Transactional (⚠️) |
| 适用场景 | 普通领券 | 限购活动 | 高并发秒杀 |
面试 Q&A
Q1: 秒杀系统的核心思路是什么?
核心思路:异步解耦 + 原子预扣减
- 用 Redis Lua 脚本做原子的"资格校验 + 库存预扣减",这步极快(微秒级)
- 校验通过后,发消息到 Kafka,立即返回订单 ID 给用户
- Kafka 消费者异步将订单写入 MySQL
这样把读写分离了:快路径(Redis)处理秒杀请求,慢路径(DB)处理持久化。
追问:为什么不直接在 Java 层做库存校验?
Java 层的"先查后扣"不是原子操作:
// 非原子操作 int stock = redis.get("stock"); // T1 和 T2 都读到 stock=1 if (stock >= 1) { redis.decrBy("stock", 1); // T1 扣减: stock=0 // T2 扣减: stock=-1 → 超卖! }Lua 脚本保证校验和扣减在同一个原子操作中完成。
再追问:Lua 脚本会阻塞 Redis 吗?
会。Redis 单线程模型下,Lua 脚本执行期间其他命令排队等待。
但 seckill.lua 只有几个 HGET/HINCRBY/SADD 操作,执行时间在微秒级别,对 Redis 性能影响极小。如果 Lua 脚本中有复杂循环或大量 key 操作,才需要担心阻塞问题。
Redis 有
lua-time-limit(默认5秒)配置,超时后其他客户端可以用 SCRIPT KILL 终止。
Q2: 为什么用 Kafka 而不是直接写 DB?
三个原因:
削峰填谷:秒杀瞬间可能有 10 万请求通过 Lua 校验,如果直接写 DB,MySQL 扛不住。Kafka 作为缓冲,消费者按照 DB 承受能力匀速消费。
解耦:秒杀接口只关注"是否有资格下单"(毫秒级),不关注"订单保存到哪"。后续可以扩展消费者做短信通知、积分发放等。
可靠性:Kafka 消息持久化,即使消费者暂时宕机,消息不丢。恢复后继续消费。
追问:Kafka 消息丢了怎么办?
Kafka 消息丢失有三个环节:
环节 丢失场景 项目现状 改进 生产者 发送失败 retries=10 加回调确认 Broker 宕机 单节点 多副本 replication 消费者 处理完未提交 手动ACK但未调用 修复 acknowledge() 当前项目配置了
ack-mode: manual(手动确认),但消费者代码中并未调用acknowledgment.acknowledge(),存在消息重复消费的风险。
Q3: 秒杀成功后用户怎么支付?
payment()方法的逻辑:
- 先检查 Redis Set
seckill:order是否包含此 orderId- 如果是秒杀订单但 DB 中还没有 →
Thread.sleep(1000)后递归重试- 如果 DB 中找到了 → 进入付款流程(TODO)
追问:sleep + 递归重试有什么问题?
三个问题:
- 无退出条件:如果 Kafka 消费者持续失败,
payment()会无限递归 → StackOverflow- 阻塞线程:sleep 占用 Tomcat 线程,高并发下线程池耗尽
- 用户体验差:用户等待 1 秒+ 才能知道结果
更好的方案:立即返回"订单处理中",前端轮询或 WebSocket 推送。
Q4: createVoucherOrder 没有 @Transactional,有什么影响?
严重问题:
java// 步骤1: 扣减DB库存 → 成功 ✓ seckillVoucherService.update() .setSql("stock = stock - " + buyNumber) .eq("voucher_id", voucherId) .gt("stock", buyNumber) .update(); // 步骤2: 保存订单 → 失败 ✗ save(voucherOrder); // 抛异常步骤 1 成功但步骤 2 失败时:
- DB 库存已经扣了,但没有对应的订单 → 库存永久丢失
- catch 块会发 Kafka 消息触发 rollback.lua → 但 rollback.lua 只恢复 Redis,不恢复 DB
应该加
@Transactional保证两步要么都成功要么都回滚。
Q5: seckill.lua 是幂等的吗?
不是幂等的。
如果同一个请求(相同的 orderId、userId、buyNumber)被执行两次:
HINCRBY stock -requestQuantity→ 库存被扣两次HINCRBY buyCount +requestQuantity→ 购买计数加两次SADD orderKey orderId→ Set 天然幂等,不会重复添加要实现幂等,应该在扣减前检查 orderId 是否已在 seckill:order 中:
luaif redis.call('SISMEMBER', orderKey, orderId) == 1 then return 0 -- 已处理过,直接返回成功 end
踩坑点
| 踩坑点 | 说明 | 面试官考察意图 |
|---|---|---|
| rollback.lua 两个 Bug | INCRBY 操作 Hash;userId 前缀不一致 | 代码审查能力 |
| seckill.lua 非幂等 | 重复执行会重复扣库存 | 幂等性设计 |
| 无 @Transactional | DB扣库存和保存订单不在一个事务中 | 事务边界 |
| payment 递归重试 | 无退出条件,可能 StackOverflow | 重试策略 |
| Kafka ACK 未调用 | 配了手动ACK但没用,可能重复消费 | 消息可靠性 |
| 时间戳来源 | currentTime 由客户端(Java)提供,不是 Redis TIME | 时间信任问题 |
加分回答
- 主动指出 rollback.lua 的两个 Bug,展示代码审查能力
- 分析秒杀链路中每一层的失败场景和恢复策略
- 提到可以用 Redis Stream 替代 Kafka 做轻量级消息队列(减少外部依赖)
- 提到秒杀预热(VoucherServiceImpl.addSeckillVoucher 初始化 Redis Hash)
- 讨论"令牌桶 + 秒杀"的组合方案:先用令牌桶限流,再用 Lua 扣减
关联文档
- 02-分布式锁 — Redisson 锁在消费端的使用
- 04-分布式ID生成 — RedisIdWorker 生成订单 ID
- 10-Kafka异步架构 — Kafka 配置与消费者设计
- 11-已知问题与优化方向 — rollback Bug、幂等性、事务问题