云计算百科
云计算领域专业知识百科平台

秒杀优化三连:Lua + 阻塞队列 + Redis Stream,响应从 3.4 秒到毫秒级

秒杀优化三连: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 步:

— 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,扣库存和发消息在同一个原子操作里,要么都成要么都不成。

约定的返回值

// 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 里,接下来要真正写数据库。第一个版本用阻塞队列:

// 初始化:单线程池 + 阻塞队列
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 列表"是关键——它解决了"消息取出来但处理失败怎么办"这个问题。阻塞队列版里,如果落库抛异常,那条订单就没了。

消费端代码:

/** 启动后台消费线程 */
@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" 从头读。

真正处理消息的循环:

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 列表:

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 可能丢数据。(主从切换、内存淘汰都可能)

所以真正落库时,数据库要再查一次:

@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 后端秋招的应届生,把项目里踩过的坑按"改坏—定位—修好"的过程记下来。如果这篇对你有用,欢迎关注,后面会持续更。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 秒杀优化三连:Lua + 阻塞队列 + Redis Stream,响应从 3.4 秒到毫秒级
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!