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

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 单 需处理消息积压

三级演进,每次都在解决上一级的具体问题:

  1. 原始 → 阻塞队列:把"快判断"和"慢落库"拆开,响应从 3.4 秒变成毫秒级
  2. 阻塞队列 → 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",而是三个判断:

  1. 先问"用户真正要什么" 。用户要的是"抢没抢到",不是"数据何时落库"。想清这一点,"同步改异步"才成立。技术方案的起点是需求边界,不是技术本身。
  2. 异步必然带来"丢"的风险,要主动去测。阻塞队列版看起来一切正常,是压测把它戳穿的。
  3. 测试环境测不出异步链路 。@SpringBootTest 容器会关闭,测到的"丢单"是环境造成的假象。异步必须用常驻进程验证。

我是正在准备 Java 后端秋招的应届生,把项目里踩过的坑按"改坏---定位---修好"的过程记下来。如果这篇对你有用,欢迎关注,后面会持续更。

相关推荐
努力努力再努力wz1 小时前
【Lua 入门系列】Lua 函数深入理解:函数对象、多返回值、可变参数、闭包、递归与尾调用优化
开发语言·lua
没有气的汽水4 小时前
一次生产 Redis 内存打满的排查、清理和复盘
redis
hey you~5 小时前
云呼叫中心坐席自动分配实战:ACD 策略、Redis+Lua 实现与排查清单
数据库·redis·lua·云呼叫中心·acd·坐席分配·坐席状态机
我不会起名字3227 小时前
Redis pipeline 用了反而更慢:3 类误用与正确的批量姿势
redis·pipeline·lua·rtt·批量
Tsuki_tl7 小时前
【总结】Redis五大数据类型应用场景及基本命令
数据库·redis·后端·缓存·缓存中间件·redis命令·redis 五大数据类型
代码丰1 天前
大 JSON 放进 Redis 之后,如何用 Stream 解耦耗时任务?
redis·后端·系统架构
枫子有风1 天前
卡码网盘项目深挖
java·redis·jwt·es·pgvector
福大大架构师每日一题1 天前
redis 8.10.3正式发布:聚焦集群总线超大消息传播场景的安全修复,8.10.3 Latest更新解读
数据库·redis·安全
江屿风1 天前
【Plain Language Large Model】【从麦当劳点餐到Redis接入:MCP如何让AI工具集成变简单】流食般投喂
人工智能·redis·笔记·语言模型·llm·mcp