实战篇 30. 秒杀优化 - 基于 Stream 实现异步秒杀学习文档

4 阅读4分钟

太棒了!终于来到了秒杀优化篇章的最高潮。前面我们一步步排雷,从同步阻塞,到 JVM 本地队列的 OOM 风险,再到对比各种消息队列的优劣,现在我们将使用 Redis Stream 的消费者组模式,完成一个真正具备工业级高可用标准、能在秋招面试中作为核心亮点的异步秒杀架构

掌握了这一套完整的 Java 后端落地方案,应对复杂高并发场景下的数据一致性和削峰填谷问题就有了十足的底气。


📚 实战篇 30. 秒杀优化 - 基于 Stream 实现异步秒杀学习文档

一、 架构升级:从 BlockingQueue 到 Redis Stream

回顾之前的方案,我们在 Java 内存中维护了一个 BlockingQueue。现在我们要把它彻底“外包”给 Redis Stream。

整体业务流转的改变:

  1. Lua 脚本的职责增加: 以前 Lua 只负责查库存和防重复,然后 Java 负责把订单塞进队列。现在,为了保证极简和原子性,向 Stream 发送消息(XADD)的动作也直接写进 Lua 脚本里
  2. Tomcat 主线程彻底解放: 主线程只需要调用一次 Lua 脚本,拿到返回值就可以直接给前端返回订单号了,真正的“毫秒级”响应。
  3. 独立消费者组: 后台依然有线程池,但这次它们不是监听本地队列,而是利用 XREADGROUP 从 Redis Stream 中竞争消费订单消息,且自带 ACK 确认和 PEL 宕机重试机制。

二、 核心改造 1:修改 Lua 脚本 (增加 XADD)

我们需要修改 seckill.lua 文件,让它在校验通过后,直接把订单数据写进 Stream 中。

Lua

-- 1. 接收参数
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3] -- 新增:现在要在 Lua 里发消息,必须由外部提前把全局唯一订单号传进来

-- 2. 拼接 Key
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId

-- 3. 校验逻辑 (保持不变)
if (tonumber(redis.call('get', stockKey)) <= 0) then return 1 end
if (redis.call('sismember', orderKey, userId) == 1) then return 2 end

-- 4. 扣减库存,记录一人一单
redis.call('decr', stockKey)
redis.call('sadd', orderKey, userId)

-- 5. 【核心新增】:发送消息到 Stream 队列中
-- 相当于执行命令:XADD stream.orders * userId <userId> voucherId <voucherId> id <orderId>
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)

-- 6. 成功返回 0
return 0

三、 核心改造 2:主线程逻辑 (极速放行)

VoucherOrderServiceImpl 中,改造我们的 seckillVoucher 方法。现在它变得极其清爽:

Java

@Override
public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order"); // 提前生成订单号,传给 Lua

    // 1. 执行 Lua 脚本 (现在传入三个参数)
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(), userId.toString(), String.valueOf(orderId)
    );

    int r = result.intValue();
    // 2. 判断结果
    if (r != 0) {
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }

    // 3. 获取代理对象 (依然要在主线程获取,为后台线程写库做准备)
    proxy = (IVoucherOrderService) AopContext.currentProxy();

    // 4. 直接返回订单号!
    // 此时消息已经躺在 Redis Stream 里了,不用再往 BlockingQueue 里塞了。
    return Result.ok(orderId);
}

四、 核心改造 3:消费者线程池与监听逻辑

这是整个架构中最体现企业级开发严谨性的地方:如何监听 Stream 并处理异常积压消息。

1. 初始化消费者组:

我们通常需要手动在 Redis 客户端执行一行命令,或者在项目启动时的 @PostConstruct 中用 Java 代码去自动创建 Stream 和消费者组:

XGROUP CREATE stream.orders g1 0 MKSTREAM

2. 消费者死循环逻辑:

替换掉以前的 orderTasks.take(),换成上节课我们推演出的完美 Stream 监听模板。

Java

private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                // 1. 读取 Stream 中的新消息 (XREADGROUP ... >)
                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()) // 这里的 lastConsumed 就是 >
                );
                
                if (list == null || list.isEmpty()) {
                    continue; // 没拿到新消息,继续循环
                }
                
                // 2. 解析消息并执行写库
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                
                // 3. 执行核心写库业务 (利用上面保存的 proxy 执行)
                handleVoucherOrder(voucherOrder);
                
                // 4. 成功后,必须向 Redis 发送 ACK 确认!
                stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
                
            } catch (Exception e) {
                log.error("处理订单异常,准备进入 PEL 处理逻辑", e);
                // 发生异常,走兜底逻辑,处理 PEL 中的积压消息
                handlePendingList(); 
            }
        }
    }
}

3. PEL 兜底处理逻辑 (handlePendingList):

Java

private void handlePendingList() {
    while (true) {
        try {
            // 读取 PEL 中的积压消息 (XREADGROUP ... 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")) // 这里的 0 代表读取未确认的历史消息
            );
            
            if (list == null || list.isEmpty()) {
                // PEL 空了,说明异常消息都消化完了,跳出内循环,回外层接着读新消息
                break; 
            }
            
            // 重新解析、写库、并发送 ACK... (逻辑同上)
            MapRecord<String, Object, Object> record = list.get(0);
            Map<Object, Object> value = record.getValue();
            VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
            
            handleVoucherOrder(voucherOrder);
            stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
            
        } catch (Exception e) {
            log.error("处理 PEL 订单异常,休眠后重试", e);
            try { Thread.sleep(20); } catch (InterruptedException ex) { ex.printStackTrace(); }
        }
    }
}

五、 实战总结与进阶探索

依靠 Redis 原生的 Stream 结构,结合企业级的并发控制,我们彻底消灭了本地 OOM 风险和宕机丢消息的风险,将系统的吞吐量拉到了极其夸张的程度。这种严密的数据一致性处理逻辑(正常消费 + PEL兜底)是高并发系统设计的核心精髓,也是大厂甚至国企核心研发部门非常看重的工程思维。