1. 问题:为什么秒杀接口扛不住?
在一开始实现秒杀功能时,整个流程是同步串行的:
用户请求 → 查询优惠券 → 判断秒杀时间 → 判断库存 → 一人一单校验
│
┌─────────┴─────────┐
│ 扣减库存 (MySQL) │
│ 创建订单 (MySQL) │
└───────────────────┘
所有操作都在主线程中串行执行,高并发下问题非常明显:
| 瓶颈 | 影响 |
|---|---|
| 全部走 MySQL | 查库存、扣库存、创建订单、查一人一单,每个请求多次 DB 操作 |
| 响应时间长 | 用户必须等所有 DB 操作完成才能拿到结果 |
| Tomcat 线程耗尽 | 每个请求占一个线程,线程在等 DB 时处于阻塞状态 |
| 吞吐量低 | 串行同步模型,单机能处理的 QPS 非常有限 |
一句话总结:秒杀的核心矛盾是"高并发请求"和"慢速 DB 写入"之间的矛盾。
显然,我们需要把这个长链路拆开。首先想到的是:能不能把最频繁的"判断"操作从 MySQL 搬到 Redis?
2. 第一步:秒杀资格判断前置到 Redis
思路很直接 —— 把"库存"和"一人一单记录"在秒杀前预热到 Redis,然后用 Lua 脚本一次完成所有判断:
-- seckill.lua
-- KEYS[1]: 库存 key (seckill:stock:10)
-- KEYS[2]: 订单 set (seckill:order:10)
-- ARGV[1]: 用户 ID
-- ARGV[2]: 订单 ID (预先生成)
-- 1. 判断库存
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
return 1 -- 库存不足
end
-- 2. 判断一人一单
local isMember = redis.call('SISMEMBER', KEYS[2], ARGV[1])
if isMember == 1 then
return 2 -- 已购买过
end
-- 3. 扣库存 + 记录用户
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 0 -- 抢购成功
Java 调用:
public Result seckillVoucher(Long voucherId, Long userId) {
long orderId = redisIdWorker.nextId("order");
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Arrays.asList(
"seckill:stock:" + voucherId,
"seckill:order:" + voucherId
),
userId.toString(),
String.valueOf(orderId)
);
if (result == 1) return Result.fail("库存不足!");
if (result == 2) return Result.fail("每人限购一单!");
// TODO: 这里还是要创建订单,怎么办?
return Result.ok(orderId);
}
这一步的成果:资格校验从"多次 MySQL 查询"变成"一次 Redis Lua 调用",毫秒级完成。
但新问题来了:Lua 脚本成功后,订单还是要写入 MySQL。如果直接在接口里同步写库,之前省下来的时间又还回去了。我们需要一种方式,让"创建订单"这件事异步地、可靠地完成 —— 这就是消息队列登场的时刻。
3. 认识消息队列
消息队列的基本模型很简单:
生产者 ──→ [消息队列] ──→ 消费者
(暂存)
在我们的场景中:
- 生产者:秒杀接口,Lua 脚本成功后发一条"请创建订单"的消息
- 消费者:后台线程,从队列取消息、写 MySQL
- 消息队列:中间的缓冲区,负责削峰、解耦
用户请求
│
▼
Redis Lua 判断 ──→ 抢到 → 发消息到队列 → 立刻返回"抢购成功"
│ │
│ ▼
│ [消息队列缓冲]
│ │
│ ▼
│ 后台消费者慢慢写 MySQL
│
└── 没抢到 → 直接返回"库存不足"
三个核心作用:
| 作用 | 说明 |
|---|---|
| 异步 | 用户不用等 MySQL 写完,先拿到"抢购成功"的结果 |
| 解耦 | 秒杀判断和订单创建是两个独立模块,互不阻塞 |
| 削峰 | 瞬间涌入的请求先进入队列,消费者按自己的速度匀速处理 |
4. Redis 实现消息队列的三种方式
4.1 List 模拟队列
最简单的方式,用 Redis 的 List 数据结构:
生产者 LPUSH ──→ [ msg3, msg2, msg1 ] ──→ BRPOP 消费者
// 生产者
stringRedisTemplate.opsForList().leftPush("stream.orders", msg);
// 消费者:阻塞等待
String msg = stringRedisTemplate.opsForList()
.rightPop("stream.orders", 5, TimeUnit.SECONDS);
缺点很明显:不支持消息确认(消费者崩溃消息就丢了)、不持久化、不支持消费者组。
4.2 Pub/Sub 发布订阅
PUBLISH channel:order msg → 所有订阅者同时收到
致命缺陷:消息不存储。如果没有消费者在线,消息直接丢弃。秒杀场景下丟一条消息就是一个真金白银的订单 —— 绝对不行。
4.3 Stream(Redis 5.0+,生产推荐)
Stream 是 Redis 5.0 引入的真正意义上的消息队列,核心特性:
| 特性 | 说明 |
|---|---|
| 消息持久化 | 写入磁盘,重启不丢 |
| 消费者组 | 同组内负载均衡,一条消息只被一个消费者处理 |
| 消息确认 (XACK) | 处理完确认,崩溃了消息留在 pending 等待重新处理 |
| 消息回溯 | 按 ID 或时间范围重新读取历史消息 |
消费者组的消息确认机制是 Stream 的核心优势:
消费者 XREADGROUP 取消息 → 消息进入 pending 状态
├─ 处理成功 → XACK 确认 → 消息从 pending 移除
└─ 消费者崩溃 → 消息留在 pending → 其他消费者接手处理
↑
保证消息不丢失!
生产者代码:
public void sendOrderMessage(VoucherOrder order) {
Map<String, String> message = new HashMap<>();
message.put("data", JSON.toJSONString(order));
stringRedisTemplate.opsForStream().add("stream.orders", message);
}
消费者代码:
while (true) {
// 1. 读取分配给自己的消息(阻塞等待)
List<MapRecord<String, Object, Object>> records =
stringRedisTemplate.opsForStream().read(
Consumer.from(GROUP_NAME, CONSUMER_NAME),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create(STREAM_KEY, ReadOffset.lastConsumed())
);
if (records == null || records.isEmpty()) {
handlePendingMessages(); // 检查有无之前崩溃遗留的 pending 消息
continue;
}
for (MapRecord<String, Object, Object> record : records) {
try {
handleOrder(record); // 2. 创建订单 (MySQL)
acknowledge(record); // 3. XACK 确认
} catch (Exception e) {
// 不 XACK → 消息留在 pending → 下次重试
}
}
}
5. 三种方案对比
| 特性 | List | Pub/Sub | Stream |
|---|---|---|---|
| 消息持久化 | ⚠️ 依赖 RDB/AOF | ❌ | ✅ |
| 消息确认 (ACK) | ❌ | ❌ | ✅ |
| 消费者组 | ❌ | ❌ | ✅ |
| 消息回溯 | ❌ | ❌ | ✅ |
| 实现复杂度 | ⭐ 低 | ⭐ 低 | ⭐⭐⭐ 中 |
| 本场景推荐 | ❌ | ❌ | ✅ |
6. 优化后的完整流程
用户请求
│
▼
① Redis Lua 原子操作(毫秒级)
├─ 判断库存
├─ 判断一人一单
└─ 扣库存 + 记录用户
│
├─ 失败 → 返回"库存不足/已购买"
│
└─ 成功 → ② XADD 发送消息到 Stream
│
▼
③ 立即返回"抢购成功"给用户
[Redis Stream 持久化消息]
│
▼
④ 后台消费者 XREADGROUP
│
▼
⑤ 创建订单 (MySQL)
│
▼
⑥ XACK 确认
完整 Service 代码:
public Result seckillVoucher(Long voucherId, Long userId) {
long orderId = redisIdWorker.nextId("order");
// ① Lua 原子判断
Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
Arrays.asList("seckill:stock:" + voucherId, "seckill:order:" + voucherId),
userId.toString(), String.valueOf(orderId));
if (result != 0) {
return result == 1 ? Result.fail("库存不足") : Result.fail("已购买过");
}
// ② 发送到 Stream(异步下单)
VoucherOrder order = new VoucherOrder();
order.setId(orderId);
order.setUserId(userId);
order.setVoucherId(voucherId);
Map<String, String> msg = new HashMap<>();
msg.put("data", JSON.toJSONString(order));
stringRedisTemplate.opsForStream().add("stream.orders", msg);
// ③ 立即返回(不等待 MySQL)
return Result.ok(orderId);
}
7. 总结
秒杀优化的核心就是三步:
| 步骤 | 做什么 | 关键手段 |
|---|---|---|
| 1 | 判断加速 | Redis Lua 原子脚本替代 MySQL 查询 |
| 2 | 异步写入 | 消息队列分离"判断"和"写入" |
| 3 | 削峰保护 | Stream 缓冲 + 消费者匀速处理,保护 DB |
技术选型建议:中小项目不想引入额外中间件时,Redis Stream 完全够用。消息量大、需要严格事务保证时再考虑 RocketMQ 或 Kafka。