Skip to content

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含义来源
1voucherId优惠券ID路径参数
2userId用户IDThreadLocal (UserHolder)
3orderId订单IDRedisIdWorker.nextId("order")
4currentTime当前时间戳(ms)LocalDateTime → EpochMilli
5requestQuantity购买数量请求参数 (默认1)
6maxLimit最大限购值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
end

Bug 分析

Bugseckill.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 也不会超卖               │
└─────────────────────────────────────────────────────┘

三种券对比

维度CommonVoucherLimitVoucherSeckillVoucher
库存扣减DB 乐观锁Redisson锁 + DB乐观锁Lua原子 + Redisson锁 + DB乐观锁
时间校验Lua 中校验 beginTime/endTime
限购检查Java 层查询订单总量Lua 中检查 buyCount Hash
持久化同步写 DB同步写 DB异步 Kafka → DB
事务无 @Transactional有 @Transactional无 @Transactional (⚠️)
适用场景普通领券限购活动高并发秒杀

面试 Q&A

Q1: 秒杀系统的核心思路是什么?

核心思路:异步解耦 + 原子预扣减

  1. 用 Redis Lua 脚本做原子的"资格校验 + 库存预扣减",这步极快(微秒级)
  2. 校验通过后,发消息到 Kafka,立即返回订单 ID 给用户
  3. Kafka 消费者异步将订单写入 MySQL

这样把读写分离了:快路径(Redis)处理秒杀请求,慢路径(DB)处理持久化。

追问:为什么不直接在 Java 层做库存校验?

Java 层的"先查后扣"不是原子操作:

// 非原子操作
int stock = redis.get("stock");      // T1 和 T2 都读到 stock=1
if (stock >= 1) &#123;
    redis.decrBy("stock", 1);        // T1 扣减: stock=0
                                     // T2 扣减: stock=-1 → 超卖!
&#125;

Lua 脚本保证校验和扣减在同一个原子操作中完成。

再追问:Lua 脚本会阻塞 Redis 吗?

会。Redis 单线程模型下,Lua 脚本执行期间其他命令排队等待。

但 seckill.lua 只有几个 HGET/HINCRBY/SADD 操作,执行时间在微秒级别,对 Redis 性能影响极小。如果 Lua 脚本中有复杂循环或大量 key 操作,才需要担心阻塞问题。

Redis 有 lua-time-limit(默认5秒)配置,超时后其他客户端可以用 SCRIPT KILL 终止。

Q2: 为什么用 Kafka 而不是直接写 DB?

三个原因:

  1. 削峰填谷:秒杀瞬间可能有 10 万请求通过 Lua 校验,如果直接写 DB,MySQL 扛不住。Kafka 作为缓冲,消费者按照 DB 承受能力匀速消费。

  2. 解耦:秒杀接口只关注"是否有资格下单"(毫秒级),不关注"订单保存到哪"。后续可以扩展消费者做短信通知、积分发放等。

  3. 可靠性:Kafka 消息持久化,即使消费者暂时宕机,消息不丢。恢复后继续消费。

追问:Kafka 消息丢了怎么办?

Kafka 消息丢失有三个环节:

环节丢失场景项目现状改进
生产者发送失败retries=10加回调确认
Broker宕机单节点多副本 replication
消费者处理完未提交手动ACK但未调用修复 acknowledge()

当前项目配置了 ack-mode: manual(手动确认),但消费者代码中并未调用 acknowledgment.acknowledge(),存在消息重复消费的风险。

Q3: 秒杀成功后用户怎么支付?

payment() 方法的逻辑:

  1. 先检查 Redis Set seckill:order 是否包含此 orderId
  2. 如果是秒杀订单但 DB 中还没有 → Thread.sleep(1000) 后递归重试
  3. 如果 DB 中找到了 → 进入付款流程(TODO)

追问:sleep + 递归重试有什么问题?

三个问题:

  1. 无退出条件:如果 Kafka 消费者持续失败,payment() 会无限递归 → StackOverflow
  2. 阻塞线程:sleep 占用 Tomcat 线程,高并发下线程池耗尽
  3. 用户体验差:用户等待 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 中:

lua
if redis.call('SISMEMBER', orderKey, orderId) == 1 then
    return 0  -- 已处理过,直接返回成功
end

踩坑点

踩坑点说明面试官考察意图
rollback.lua 两个 BugINCRBY 操作 Hash;userId 前缀不一致代码审查能力
seckill.lua 非幂等重复执行会重复扣库存幂等性设计
无 @TransactionalDB扣库存和保存订单不在一个事务中事务边界
payment 递归重试无退出条件,可能 StackOverflow重试策略
Kafka ACK 未调用配了手动ACK但没用,可能重复消费消息可靠性
时间戳来源currentTime 由客户端(Java)提供,不是 Redis TIME时间信任问题

加分回答

  • 主动指出 rollback.lua 的两个 Bug,展示代码审查能力
  • 分析秒杀链路中每一层的失败场景和恢复策略
  • 提到可以用 Redis Stream 替代 Kafka 做轻量级消息队列(减少外部依赖)
  • 提到秒杀预热(VoucherServiceImpl.addSeckillVoucher 初始化 Redis Hash)
  • 讨论"令牌桶 + 秒杀"的组合方案:先用令牌桶限流,再用 Lua 扣减

关联文档