秒杀优化三连:Lua + 阻塞队列 + Redis Stream,响应从 3.4 秒到毫秒级
这是《黑马点评项目复盘》第 4 篇。
上一篇解决"卖超",这一篇解决"太慢"。慢到什么程度?200 并发抢 100 库存,耗时 3.4 秒。
优化后:资格判断在 Redis 里一次原子完成,落库交给后台线程。用户只等"能不能抢到",不等"数据库写完没有"。
一、先算清楚慢在哪
原来的下单流程,6 个步骤全打 MySQL,而且是单线程串行:
1. 查询优惠券 → 2. 判库存 → 3. 查订单 → 4. 校验一人一单
→ 5. 扣库存 → 6. 创建订单
每一步都是一次数据库往返。200 并发抢 100 库存,耗时 3.4 秒。
注意这 3.4 秒是怎么来的:不是"慢了 3.4 秒",而是200 个请求排队走完这 6 步总共花 3.4 秒。用户体感是"点了之后要转很久"。
秒杀场景最怕这个------流量是脉冲式的,几秒钟涌进来,数据库连接池瞬间被打满,后面的请求全在排队,越排越久。
二、为什么"多线程并行"是错的答案
第一反应肯定是:既然单线程慢,那多线程并行跑不就行了?
讲义明确否定了这个思路,理由两条,第二条才是关键:
| 理由 | 说明 |
|---|---|
| ① 线程池会被耗尽 | 每个请求开 N 个线程 → 高并发下瞬间打满,反而更糟 |
| ② 时效性是伪需求 ⭐ | 用户只关心"能不能抢到 ",不关心"数据库那条记录是不是此刻就写好了" |
第 ② 条是一个观念转变,我觉得值一整段:
之前我一直默认"接口必须把活干完才能返回"。但秒杀这里,用户真正要的只有一件事:告诉我抢没抢到。
至于订单记录什么时候落库------1 秒后还是 3 秒后,用户根本感知不到。他只会拿着返回的订单 id 去"我的订单"里查,那时候早落完了。
所以正确的思路不是"让流程更快",而是"把流程拆成快和慢两段":
- 快的那段 :判断有没有资格(库存够不够、是不是重复下单)→ 必须在 Redis 里原子完成 → 立刻返回
- 慢的那段:真正扣库存、建订单 → 丢给后台线程慢慢做
三、第一步:Lua 做资格判断
资格判断要回答两个问题:还有库存吗?这个人买过吗?
数据放 Redis:
| 数据 | Redis 结构 | key |
|---|---|---|
| 库存 | String |
seckill:stock:{voucherId} |
| 已下单用户 | Set ⭐ |
seckill:order:{voucherId} |
为什么用 Set? 因为它天然去重,SISMEMBER 一句就能判断"这个用户下过没",完美匹配"一人一单"。用 List 还得自己遍历。
为什么必须用 Lua? 因为"查库存 → 查是否下单 → 扣库存 → 记用户"是 4 步操作,如果分开执行,中间一样有时间缝,等于把 Java 里的并发问题原封不动搬到了 Redis。
Lua 脚本在 Redis 里是单线程原子执行的,4 步并成 1 步:
lua
-- seckill 资格判断:库存 + 一人一单,全部原子
-- args: ARGV[1]=voucherId, ARGV[2]=userId, ARGV[3]=orderId
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]
-- 1. 拼 key:库存(String)、已下单用户(Set)
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
-- 2. 判断库存,不足返回 1
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1
end
-- 3. 判断是否重复下单,重复返回 2
if (redis.call('sismember', orderKey, userId) == 1) then
return 2
end
-- 4. 扣库存
redis.call('incrby', stockKey, -1)
-- 5. 记录该用户已下单
redis.call('sadd', orderKey, userId)
-- 6. 发消息到 Stream(第 7 章落库用,与扣库存同一原子操作)
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
-- 7. 返回 0 表示有资格
return 0
注意第 6 步:消息投递也写在 Lua 里。
这一点特别重要------如果"扣库存"成功、"发消息"失败,那库存白扣了,订单永远不会被创建。把 XADD 放进 Lua,扣库存和发消息在同一个原子操作里,要么都成要么都不成。
约定的返回值
java
// 0=成功,1=库存不足,2=重复下单
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString(),
String.valueOf(orderId)
);
int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
return Result.ok(orderId); // 立刻返回,不等落库
接口到这里就返回了。 一个 Lua 调用,一次网络往返,毫秒级。
实测:直接用 redis-cli 验证 Lua
写业务代码之前,我先用 redis-cli 手工跑了 4 个场景,确认返回值符合预期:
| 次序 | 场景 | 返回值 | 库存变化 | Set 变化 |
|---|---|---|---|---|
| 1 | 用户 1001 正常抢 | 0 ✅ | -1 | +1001 |
| 2 | 用户 1002 正常抢 | 0 ✅ | -1 | +1002 |
| 3 | 用户 1001 重复抢 | 2 ✅ | 不变 | 不变 |
| 4 | 库存 0 时再抢 | 1 ✅ | 不变 | 不变 |
先单独验证脚本逻辑,再接入业务------这个习惯帮我省了很多事。Lua 出错时 Redis 报错信息很不友好,混在 Java 调用里排查会很痛苦。
四、第二步:异步落库------先上阻塞队列
资格判断完,订单信息在 Redis 里,接下来要真正写数据库。第一个版本用阻塞队列:
java
// 初始化:单线程池 + 阻塞队列
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 入口:Lua 判断通过后,把订单丢进队列就返回
orderTasks.add(order);
return Result.ok(orderId);
// 后台线程:不停从队列取,落库
private class VoucherOrderHandler implements Runnable {
public void run() {
while (true) {
VoucherOrder order = orderTasks.take(); // 队列空就阻塞等
handleVoucherOrder(order);
}
}
}
为什么用单线程池? 因为落库是 IO 密集型,如果开多线程,多个线程同时抢数据库行锁,反而增加冲突。单线程串行落库,数据库压力最小。
跑起来确实快了很多。但阻塞队列有两个解决不了的问题:
| 问题 | 后果 |
|---|---|
| 内存限制 | 队列在 JVM 堆里,订单多了内存撑爆 → OOM |
| 数据安全 | JVM 一挂,队列里没消费完的订单全丢,且无法恢复 |
五、★ 我实测到了"丢单":95 单直接没了
第二个问题我没停留在"理论上会丢",而是真去测了。
用同样的压测(100 个订单),分别在两种环境跑:
| 环境 | 耗时 | 预期订单 | 实际订单 | 结果 |
|---|---|---|---|---|
测试环境 (@SpringBootTest) |
--- | 100 单 | 仅 5 单 ❌ | 丢 95 单 |
| 真实应用(jar 常驻运行) | 4.6 秒 | 100 单 | 100 单 ✅ | 5 秒内消化完,零超卖 |
为什么测试环境丢了 95 单?
我一开始以为是代码有 bug,查了很久。真相是------
测试方法跑完,Spring 容器就关闭了 → JVM 退出 → 后台消费线程被杀。
队列里还没落库的 95 个订单,随着 JVM 一起消失。
这是个很容易误判的坑:我差点因为测试环境的数字去改本来正确的代码。教训是------
异步逻辑不能用
@SpringBootTest验证完整流程。容器关闭会中断后台线程,你测到的不是"代码不工作",而是"测试环境活不到工作完成"。
要验证异步链路,必须用常驻进程(jar 跑起来)压。
换成 jar 常驻后:4.6 秒落完 100 单,零超卖。 数据对上了。
六、第三步:换成 Redis Stream(解决丢单)
丢单的根源是"队列在 JVM 内存里"。那就把它挪到 Redis------用 Stream。
Stream 是 Redis 专门为消息队列设计的数据结构,比 List 更适合这个场景:
| 特性 | 说明 |
|---|---|
| 持久化 | 消息在 Redis 里,JVM 挂了不丢 |
| 消费者组 | 多个消费者分工,处理过的会被记录 |
| pending 列表 | 消费了但没确认的消息留在 pending,可以重试 ⭐ |
"pending 列表"是关键------它解决了"消息取出来但处理失败怎么办"这个问题。阻塞队列版里,如果落库抛异常,那条订单就没了。
消费端代码:
java
/** 启动后台消费线程 */
@PostConstruct
private void init() {
// 1. 拿代理对象(getBean 返回的就是事务代理,无需 AopContext)
proxy = applicationContext.getBean(IVoucherOrderService.class);
// 2. 创建消费者组,从 "0" 开始消费(默认 $ 会跳过历史消息)
try {
stringRedisTemplate.opsForStream()
.createGroup("stream.orders", ReadOffset.from("0"), "g1");
} catch (Exception e) {
log.debug("消费者组 g1 已存在");
}
// 3. 提交后台消费线程
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
第 2 步那个 ReadOffset.from("0") 是个坑。 默认是 $,意思是"只消费从现在开始的新消息"------服务重启时 Stream 里积压的消息会被全部跳过 。必须用 "0" 从头读。
真正处理消息的循环:
java
public void run() {
while (true) {
try {
// 1. XREADGROUP:读 1 条,阻塞 2 秒,从 ">" 读新消息
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
if (list == null || list.isEmpty()) {
continue;
}
// 2. 解析消息为 VoucherOrder
MapRecord<String, Object, Object> record = list.get(0);
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
record.getValue(), new VoucherOrder(), true);
// 3. 处理订单
handleVoucherOrder(voucherOrder);
// 4. 确认消息(移出 pending)
stringRedisTemplate.opsForStream().acknowledge("g1", record);
} catch (Exception e) {
log.error("处理订单异常", e);
// 5. 出异常时处理 pending 列表
handlePendingList();
}
}
}
ReadOffset.lastConsumed() 对应 >,意思是"只读还没投递给这个消费者的新消息"。
第 4 步的 acknowledge 不能漏。 不 ACK 的话,消息会一直停在 pending 列表里,虽然不会丢,但会被重复处理。
异常时处理的正是 pending 列表:
java
private void handlePendingList() {
while (true) {
try {
// 从 pending 列表读(起始 ID 用 "0")
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
if (list == null || list.isEmpty()) {
break; // pending 空了就退出
}
MapRecord<String, Object, Object> record = list.get(0);
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
record.getValue(), new VoucherOrder(), true);
handleVoucherOrder(voucherOrder);
stringRedisTemplate.opsForStream().acknowledge("g1", record);
} catch (Exception e) {
log.error("处理 pending 订单异常", e);
try { Thread.sleep(20); } catch (InterruptedException ex) { }
}
}
}
七、落库那一步:一人一单要再兜一次
异步链路里有个容易被忽略的问题:Redis 可能丢数据。(主从切换、内存淘汰都可能)
所以真正落库时,数据库要再查一次:
java
@Override
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
// 1. 一人一单兜底:Redis 可能丢数据,数据库再查一次
Long userId = voucherOrder.getUserId();
int count = query().eq("user_id", userId)
.eq("voucher_id", voucherOrder.getVoucherId()).count();
if (count > 0) {
throw new RuntimeException("用户已经购买过一次!");
}
// 2. 扣库存:WHERE 带 stock > 0,由数据库在行锁内完成"判断+扣减",防超卖
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock", 0)
.update();
if (!success) {
throw new RuntimeException("库存不足!");
}
// 3. 保存订单
save(voucherOrder);
}
这三步在同一个 @Transactional 里,任何一步失败整体回滚。
注意这里 stock > 0 又出现了一次------Redis 判过一次,数据库还要再判一次 。这不是重复劳动,是纵深防御:Redis 那边是"挡住绝大多数请求",数据库这边是"最后兜底"。
八、三级火箭:三层优化对比
| 方案 | 资格判断 | 落库 | 200 并发耗时 | 问题 |
|---|---|---|---|---|
| 原始 | MySQL 6 步串行 | 同步 | 3.4 秒 | 数据库被打满 |
| 阻塞队列 | Lua(Redis 原子) | 异步单线程 | 毫秒级响应 | JVM 挂 = 丢单(实测丢 95/100) |
| Redis Stream | Lua(Redis 原子) | 异步 + 持久化 + pending | 毫秒级响应,4.6 秒落完 100 单 | 需处理消息积压 |
三级演进,每次都在解决上一级的具体问题:
- 原始 → 阻塞队列:把"快判断"和"慢落库"拆开,响应从 3.4 秒变成毫秒级
- 阻塞队列 → Stream:因为实测丢单,把队列从 JVM 挪到 Redis,加持久化和 pending 重试
九、面试怎么答(可直接背)
问:秒杀怎么优化的?
"原来 6 个步骤全部打 MySQL、单线程串行,200 并发抢 100 库存要 3.4 秒。
优化的核心思路是把同步流程拆成两段 :资格判断放 Redis 用 Lua 脚本原子执行 ,落库交给异步线程 。因为用户只关心'抢没抢到',不关心订单什么时候真的写进库------时效性是伪需求。
资格判断的数据结构:库存用
String,已下单用户用Set(SISMEMBER天然适合一人一单)。Lua 里一次性完成'判库存 → 判重复 → 扣库存 → 记用户 → 发消息',返回 0/1/2 三种状态。异步落库第一版用阻塞队列 ,但队列在 JVM 内存里,JVM 一挂就丢单 ------这个我实测过,测试环境 100 单丢了 95 单。所以换成 Redis Stream ,消息持久化,配合消费者组和 pending 列表,处理失败还能重试。
最终:毫秒级响应,4.6 秒落完 100 单,零超卖。"
如果被追问"消息发失败了怎么办":
"
XADD直接写在 Lua 脚本里,和扣库存是同一个原子操作。要么都成功,要么都不成功,不会出现'库存扣了但消息没发'。"
如果被追问"Stream 的坑":
"两个:一是创建消费者组时
ReadOffset默认是$,会跳过重启前积压的消息 ,必须用'0'从头读;二是处理完必须 ACK,否则消息一直停在 pending 列表里被重复处理。"
如果被追问"异步了怎么保证不超卖":
"两道防线:Redis 的 Lua 已经原子扣过库存,挡住绝大部分;数据库那条
UPDATE ... WHERE stock > 0再兜一次。Redis 可能丢数据,数据库这道才是最后保证。"
十、小结
这一章最大的收获不是"会用 Stream",而是三个判断:
- 先问"用户真正要什么" 。用户要的是"抢没抢到",不是"数据何时落库"。想清这一点,"同步改异步"才成立。技术方案的起点是需求边界,不是技术本身。
- 异步必然带来"丢"的风险,要主动去测。阻塞队列版看起来一切正常,是压测把它戳穿的。
- 测试环境测不出异步链路 。
@SpringBootTest容器会关闭,测到的"丢单"是环境造成的假象。异步必须用常驻进程验证。
我是正在准备 Java 后端秋招的应届生,把项目里踩过的坑按"改坏---定位---修好"的过程记下来。如果这篇对你有用,欢迎关注,后面会持续更。