1. 背景与瓶颈分析

在上一阶段,我们利用 Redisson 实现了基于 Redis 的分布式锁,成功解决了集群环境下的“超卖”和“一人一单”安全问题。

然而,随着压测并发量的提升,同步模式(Synchronous Mode) 的性能瓶颈逐渐暴露:

  1. 数据库是最大短板:所有请求无论成功与否,都需要经过“加锁 -> 查库 -> 减库存 -> 落库 -> 解锁”的完整流程。MySQL 单机 TPS 有限,直接限制了系统的吞吐量上限。
  2. 响应时间长:用户必须等待服务器处理完所有数据库事务才能收到响应,在高并发下会导致大量线程阻塞。

优化目标:将“业务校验”与“数据库落地”解耦,实现异步秒杀,将 QPS 提升一个数量级。


2. 解决方案:异步秒杀三剑客

为了突破数据库瓶颈,我们对架构进行了重构,引入了以下核心组件:

  1. Redis + Lua 脚本(前置拦截): 将“库存校验”和“一人一单校验”前置到 Redis 内存中原子执行。只有校验通过的请求才有资格进入后续流程。
  2. BlockingQueue(流量削峰): 在 JVM 内存中维护一个阻塞队列。主线程验证通过后,将订单信息写入队列并立刻返回“排队成功”,不再等待数据库操作。
  3. 独立线程池(异步落库): 后台启动独立的单线程,匀速消费队列中的订单,慢慢写入数据库,避免瞬间流量打崩 MySQL。

3. 核心代码深度拆解

3.1 核心脚本:Lua 守门员

我们编写了 seckill.lua,利用 Redis 的原子性,在内存中极速完成资格判断。

Lua

local voucherId = ARGV[1]
local userId = ARGV[2]

-- 1. 判断库存
local stockKey = 'seckill:stock:' .. voucherId
if (tonumber(redis.call('get', stockKey)) <= 0) then
    return 1 -- 库存不足
end

-- 2. 判断一人一单
local orderKey = 'seckill:order:' .. voucherId
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2 -- 重复下单
end

-- 3. 扣库存,占位(记录用户ID)
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
return 0 -- 资格校验通过

3.2 主线程:极速响应

seckillVoucher 方法现在的职责非常单一:校验 + 入队

关键技术点:

  • Lua 执行:完全不查数据库,性能极高。
  • Proxy 传递:这是面试中的大坑。后续子线程需要调用事务方法,但子线程拿不到主线程的 ThreadLocal(即 AopContext)。因此,必须在主线程里先把 proxy 存下来。

Java

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    // ⚠️ 定义成员变量保存代理对象,解决跨线程事务失效问题
    private IVoucherOrderService proxy;

    // 阻塞队列 (JVM内存队列)
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

    @Override
    public Result seckillVoucher(Long voucherId) {
        Long userId = UserHolder.getUser().getId();
        Long orderId = redisIdWorker.nextId("order");

        // 1. 执行 Lua 脚本 (Redis层校验)
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(), String.valueOf(orderId)
        );

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

        // 3. 封装订单信息
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);

        // 4. 放入阻塞队列
        orderTasks.add(voucherOrder);

        // 🌟 核心:获取当前事务代理对象,赋值给成员变量
        proxy = (IVoucherOrderService) AopContext.currentProxy();

        // 5. 秒级返回
        return Result.ok(orderId);
    }
}

3.3 后台线程:异步处理

利用 @PostConstruct 在应用启动时开启消费者线程。

关键技术点:

  • Redisson 兜底锁:虽然 Lua 已经做过校验,但为了保证数据库层的绝对数据一致性(防止 Redis 和 DB 数据偶发不一致),我们在写入前再次加锁。
  • Proxy 调用:直接使用主线程传递过来的 proxy 变量调用事务方法。

Java

@PostConstruct
    private void init() {
        // 项目一启动,就让后台线程跑起来,时刻盯着队列看有没有单子
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }


private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                // 1. 阻塞获取队列中的订单
                VoucherOrder voucherOrder = orderTasks.take();
                // 2. 处理订单
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
                log.error("订单处理异常", e);
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        // 3. 加锁 (Redisson) - 兜底方案
        RLock redisLock = redissonClient.getLock("lock:order:" + userId);
        boolean isLock = redisLock.tryLock();

        if (!isLock) {
            log.error("不允许重复下单"); // 理论上 Lua 拦截了,这里很难触发
            return;
        }
        try {
            // 🌟 核心:使用成员变量 proxy 触发事务
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            redisLock.unlock();
        }
    }
}

4. 热点问题剖析

Q1: 为什么需要在成员变量里存 proxy

A: 这是一个关于 Spring AOP 和线程模型的问题。

  • 现象:如果在子线程 VoucherOrderHandler 中直接调用 createVoucherOrder,事务会失效。如果调用 AopContext.currentProxy(),会抛出异常。
  • 原因:Spring 的事务是基于 AOP 代理实现的,而 AopContext 底层使用的是 ThreadLocal 来存储当前的代理对象。ThreadLocal 是线程隔离的,主线程的数据,子线程看不见
  • 解决:我们在主线程(请求线程)中拿到代理对象,将其赋值给 Service 的成员变量 proxy,这样子线程就能共享访问这个代理对象,从而保证事务生效。

Q2: 既然 Lua 已经校验过了,为什么 Handler 里还要加 Redisson 锁?

A: 这是一种**“防御性编程”**。

  • 虽然 Lua 脚本保证了 Redis 操作的原子性,但 Redis 数据和 MySQL 数据在极端的并发或故障恢复场景下可能存在短暂的不一致。
  • 在 Handler 中再次加锁,是为了给数据库操作加上最后一道保险,确保“一人一单”在数据库层面也是绝对安全的。

Q3: 使用 ArrayBlockingQueue 有什么风险?

A:

  1. 内存限制:JVM 内存有限,如果瞬间涌入海量订单,可能会导致内存溢出 (OOM)。
  2. 数据丢失:这是内存队列,如果服务器宕机或重启,队列中未处理的订单会全部丢失,导致用户显示“抢购成功”但实际没货。
  • 后续演进:使用 Redis Stream 消息队列代替 JVM 内存队列,实现消息的持久化和可靠消费。

5. 总结

通过本次优化,我们将秒杀流程从 同步阻塞 演进为 异步解耦:Lua 脚本检验(Redis) - 内存队列(Buffer) - 异步线程写库(DB)。

这实现了 性能质变(移除了 MySQL 事务阻塞点,利用 Redis 纯内存操作) 和 削峰填谷(利用队列缓冲,保护了数据库安全)。

但是当前架构使用了 JVM 内存队列(ArrayBlockingQueue),存在 数据安全性(JVM 内存没有持久化能力)和 内存溢出风险

下一步,为了解决数据丢失的问题,需要引入分布式消息队列,使用 Redis Stream 代替 JVM 内存队列,实现 消息持久化、ACK 确认机制、消费者组模式。这就是高并发秒杀系统的终极形态。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐