基于 Redis + Lua + Stream 的高并发秒杀系统完整实现(黑马点评秒杀券抢购的最终实现)

前言:本文将从黑马点评中秒杀券抢购出发,逐步拆解其中方案的实现。涵盖 Lua 脚本原子性操作、Java 调用机制、Redis Stream 消息队列、Redisson 分布式锁以及数据库事务 等内容

其中Redission的详细解析可以去看我的上一篇博客:黑马点评中的分布式锁设计与实现(Redis + Redisson)

📚 目录(点击跳转对应章节)

一、为什么秒杀一定会出问题?
二、最终解决方案总览
三、Lua 脚本(系统的核心大脑)
四、Java 调用 Lua(同步校验资格)
五、Redis Stream 消费订单(异步落库)
六、Pending List(异常兜底)
七、分布式锁防止并发写库
八、数据库最终落库(事务)
九、总结


一、为什么秒杀一定会出问题?

假设不进行任何优化,采用最原始的秒杀逻辑:

  1. 查询数据库库存
  2. 判断库存是否大于 0
  3. 扣减库存
  4. 插入订单

在几千甚至上万 QPS 的并发场景下,这种逻辑会出现以下致命问题:

  • 超卖问题:多个线程同时读到 ,导致库存扣减成负数。
  • 重复下单:同一个用户瞬间发送多次请求,导致生成多条订单。
  • 数据库宕机:数据库无法承受高并发 IO,瞬间连接池耗尽。
  • 系统阻塞:串行化的业务逻辑导致响应延迟极高,用户体验极差。

因此,优化的核心原则只有一条:将“最频繁、最核心的判断”从数据库移至 Redis,数据库只负责最终的异步持久化。


二、最终解决方案总览

1. 架构核心思想

下单资格判断在 Redis 完成,真正的写库操作异步完成。

2. 技术选型

模块技术组件作用
并发控制Redis + Lua保证库存扣减与资格判断的原子性
防重复Redis Set利用 Set 集合的唯一性防止重复下单
削峰填谷Redis Stream替代传统的 List 或 Pub/Sub,提供持久化的消息队列
异步下单Java 线程池从队列中获取消息,异步执行耗时操作
并发写库Redisson分布式锁,防止异步线程间的并发安全问题
数据一致性Spring Transaction保证数据库操作的 ACID 特性

三、Lua 脚本(系统的核心大脑)

Lua 脚本在秒杀系统中承担了“交通指挥官”的角色,它必须一次性完成以下三件事且保证原子性:

  1. 判断库存是否充足。
  2. 判断用户是否重复下单。
  3. 执行扣库存与发送消息队列。

1. Lua 脚本完整代码 (seckill.lua)

-- =========================
-- 1. 参数列表
-- =========================
-- ARGV[1]:优惠券id
local voucherId = ARGV[1]
-- ARGV[2]:用户id
local userId = ARGV[2]
-- ARGV[3]:订单id
local orderId = ARGV[3]

-- =========================
-- 2. Redis Key 定义
-- =========================
-- 库存 key,例如 seckill:stock:1001
local stockKey = 'seckill:stock:' .. voucherId
-- 已下单用户 key,使用 Set 结构存储
local orderKey = 'seckill:order:' .. voucherId
-- 订单消息队列 Key
local streamKey = 'stream.orders'

-- =========================
-- 3. 业务逻辑
-- =========================

-- 3.1 查询库存
local stock = redis.call('get', stockKey)
if(stock == nil or tonumber(stock) <= 0) then
    -- 库存不足,返回 1
    return 1
end

-- 3.2 判断是否重复下单
if(redis.call('sismember', orderKey, userId) == 1) then
    -- Set 中已存在该用户,说明重复下单,返回 2
    return 2
end

-- 3.3 扣减库存
local result = redis.call('decr', stockKey)
if(tonumber(result) < 0) then
    -- 扣减后小于0,说明并发导致超卖,需要回滚(虽然 Lua 保证原子性,但加上此判断更严谨)
    redis.call('incr', stockKey)
    return 1
end

-- 3.4 记录用户下单
redis.call('sadd', orderKey, userId)

-- 3.5 发送订单消息到 Stream
-- XADD stream.orders * userId 1001 voucherId 1 id 2002
local ok, err = pcall(
    redis.call,
    'xadd',
    streamKey,
    '*',
    'userId', userId,
    'voucherId', voucherId,
    'id', orderId
)

if not ok then
    -- 如果消息发送失败,必须回滚之前的库存和 Set 记录
    redis.call('incr', stockKey)
    redis.call('srem', orderKey, userId)
    return -1
end

-- 成功,返回 0
return 0

核心知识点深度解析

  1. 原子性保证:Redis 将整个 Lua 脚本作为一个命令执行,执行期间不会插入其他客户端的命令。这完全避免了“查询库存”和“扣减库存”之间的并发间隙(Race Condition)。
  2. redis.call 与 pcall:
  • redis.call:如果执行报错,脚本会立即停止并抛出异常。
  • pcall:以保护模式调用,如果报错会捕获异常并返回错误信息,适合用于非核心流程(如发消息失败后的回滚逻辑)。
  1. Redis Stream 的 XADD:
  • *:代表由 Redis 自动生成消息 ID(时间戳-序列号)。
  • Stream 相比 List 的优势在于它支持消费者组(Consumer Group)模式,支持消息持久化和 ACK 确认机制。

ACK 确认机制:消费者处理完消息后需要显式确认,未确认的消息可以重新消费,保证消息不丢失。
其本质含义是:确认对方的消息已经被成功接收。


四、Java 调用 Lua(同步校验资格)

这一步是前端请求的直接入口,必须保证极高的响应速度。

1. Lua 脚本加载

为了避免每次请求都传输很长的 Lua 脚本字符串,我们选择在类加载时初始化脚本。

private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    // 指定脚本路径
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    // 指定返回值类型
    SECKILL_SCRIPT.setResultType(Long.class);
}

2. 秒杀接口完整代码

@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
    // 1. 获取当前登录用户id
    Long userId = UserHolder.getUser().getId();

    // 2. 提前生成全局唯一的订单id(这是后续落库的关键)
    Long orderId = redisIdWorker.nextId("order");

    // 3. 执行 Lua 脚本
    // execute(脚本对象, Key列表, 参数列表...)
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(), // 这里没有 KEYS 参数,全部通过 ARGV 传入
            voucherId.toString(),
            userId.toString(),
            orderId.toString()
    );

    // 4. 结果判断
    int r = result.intValue();
    if (r != 0) {
        // 映射 Lua 返回的错误码
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }

    // 5. 返回订单id
    // 注意:此时数据库并没有真正的订单数据,但对用户而言秒杀已经成功
    return Result.ok(orderId);
}

核心知识点深度解析

  1. 为什么没有 Keys?:stringRedisTemplate.execute 的第二个参数是 List<K> keys。在 Lua 脚本中,我们通过字符串拼接生成了 Key,而不是由 Java 传递,所以这里传空列表。在集群模式下,建议显式传递 Key 以确保计算 Slot(Slot是一个 16 位的数字,用于将 Key 映射到 Redis 节点) 正确,单机模式下此写法无误。
  2. ID 生成策略:redisIdWorker 是一个基于 Redis INCR(自增) 和时间戳位运算的全局 ID 生成器。此处必须提前生成 ID 传给 Lua,因为 Lua 脚本中不建议执行耗时的 ID 生成逻辑,且需要保证 ID 的唯一性。
  3. 异步化的本质:代码执行到第 5 步直接返回,完全没有涉及数据库的 INSERT 操作。这就是吞吐量提升的关键——将 I/O 密集型操作剥离。

五、Redis Stream 消费订单(异步落库)

消息发出了,现在需要后台线程慢慢地将其写入数据库。

1. 消费线程启动

使用线程池管理单线程的消费者,避免资源浪费。

private static final ExecutorService SECKILL_ORDER_EXECUTOR =
        Executors.newSingleThreadExecutor();

@PostConstruct
public void init() {
    // 项目启动时立即开始监听消息队列
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

2. 消费者完整代码

private class VoucherOrderHandler implements Runnable {

    @Override
    public void run() {
        while (true) {
            try {
                // 1. 读取消息队列中的未消费消息
                // 对应命令:XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
                List<MapRecord<String, Object, Object>> list =
                        stringRedisTemplate.opsForStream().read(
                                Consumer.from("g1", "c1"), // 消费者组 g1,消费者 c1
                                StreamReadOptions.empty()
                                        .count(1) // 每次取 1 条
                                        .block(Duration.ofSeconds(2)), // 阻塞 2 秒
                                StreamOffset.create(
                                        "stream.orders",
                                        ReadOffset.lastConsumed() // 这里的 > 代表读取最新未消费的消息
                                )
                        );

                // 2. 判断是否有消息
                if (list == null || list.isEmpty()) {
                    // 如果没有消息,继续下一次循环
                    continue;
                }

                // 3. 解析消息
                MapRecord<String, Object, Object> record = list.get(0);
                Map<Object, Object> value = record.getValue();
                
                // 将 Map 转换为对象
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(
                        value,
                        new VoucherOrder(),
                        true
                );

                // 4. 执行真正的下单逻辑(写入数据库)
                handleVoucherOrder(voucherOrder);

                // 5. 手动确认消息(ACK)
                // 对应命令:XACK stream.orders g1 <message-id>
                stringRedisTemplate.opsForStream().acknowledge(
                        "stream.orders",
                        "g1",
                        record.getId()
                );

            } catch (Exception e) {
                log.error("处理订单异常", e);
                // 如果发生异常(如数据库挂了),消息没有 ACK,会进入 Pending List
                // 需要专门的处理逻辑去 Pending List 捞取
                handlePendingList();
            }
        }
    }
}

核心知识点深度解析

  1. XREADGROUP:这是 Stream 专用的读取命令。
  • Group:消费者组模式,保证一条消息只被组内一个消费者消费,适合集群环境。
  • Block:阻塞读取。如果队列为空,线程挂起等待,避免 CPU 空转。
  • ReadOffset.lastConsumed():对应 Redis 中的 > 符号,表示只读取组内“从未被传递给消费者”的新消息。
  1. ACK 机制:Stream 不同于 List,消息被读取后不会消失,而是进入“Pending(待处理)”状态。必须显式执行 acknowledge,Redis 才会认为该消息处理完毕。这是保证数据不丢失的关键。

六、Pending List(异常兜底)

如果消费者在处理业务时崩溃了,或者抛出了异常,代码进入 catch 块,此时消息没有 ACK,它会一直停留在 Pending List 中。我们需要启动一个兜底流程来处理这些“半途而废”的消息。

private void handlePendingList() {
    while (true) {
        try {
            // 1. 读取 Pending List 中的消息
            // 对应命令:XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream.orders 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" 代表读取 Pending List 中的所有消息
                            )
                    );

            if (list == null || list.isEmpty()) {
                // 如果 Pending List 为空,说明所有异常消息都处理完了,退出循环
                break;
            }

            // 2. 解析消息
            MapRecord<String, Object, Object> record = list.get(0);
            VoucherOrder order = BeanUtil.fillBeanWithMap(
                    record.getValue(),
                    new VoucherOrder(),
                    true
            );

            // 3. 再次尝试执行下单
            createVoucherOrder(order);

            // 4. 成功后 ACK
            stringRedisTemplate.opsForStream().acknowledge(
                    "stream.orders",
                    "g1",
                    record.getId()
            );

        } catch (Exception e) {
            log.error("处理 Pending List 订单异常", e);
            // 这里通常建议休眠片刻再重试,防止死循环刷日志
            try { Thread.sleep(20); } catch (InterruptedException ex) { ex.printStackTrace(); }
        }
    }
}

核心知识点深度解析

  1. ReadOffset.from(“0”):这里的 0 是关键。它告诉 Redis,不要读新消息,而是去读当前消费者名下那些“已读取但未 ACK的消息。
  2. 死信队列:如果一条消息在 Pending List 中被反复重试仍然失败(例如数据本身有问题),在完善的系统中,应该设置重试次数上限,超过后移入死信数据库人工处理,防止阻塞后续消息。

七、分布式锁防止并发写库

虽然我们用单线程消费 Stream,但在多实例部署(集群)下,可能有多个节点的消费者同时在运行。为了绝对安全,在写数据库前加分布式锁。

private void handleVoucherOrder(VoucherOrder voucherOrder) {
    Long userId = voucherOrder.getUserId();
    // 使用 Redisson 获取分布式锁
    // 锁的粒度是用户 ID,意味着同一用户同一时间只能处理一个订单,不影响其他用户
    RLock lock = redissonClient.getLock("lock:order:" + userId);

    // 尝试获取锁
    if (!lock.tryLock()) {
        // 获取失败,说明该用户的订单正在被处理,或者是重复消息
        log.error("不允许重复下单");
        return;
    }

    try {
        // 获取代理对象执行事务方法,防止事务失效
        proxy.createVoucherOrder(voucherOrder);
    } finally {
        lock.unlock();
    }
}

核心知识点深度解析

  1. 锁的粒度:lock:order: + userId。我们不锁商品(太慢),而是锁用户。因为秒杀的规则是“一人一单”,只需保证同一用户不会并发写入即可。
  2. Redisson:相比原生的 setnx,Redisson 提供了看门狗(WatchDog)机制自动续期,以及可重入锁的特性,大大增加了锁的稳定性。

八、数据库最终落库(事务)

这是最后一道防线,真正将数据写入 MySQL。

@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    
    // 1. 再次校验(双重检查锁模式)
    // 虽然 Redis 已经挡住了大部分请求,但为了数据绝对安全,数据库层仍需校验
    Long userId = voucherOrder.getUserId();
    Long count = query()
            .eq("user_id", userId)
            .eq("voucher_id", voucherOrder.getVoucherId())
            .count();
    
    if (count > 0) {
        log.error("用户已经购买过一次");
        return;
    }

    // 2. 扣库存(乐观锁 CAS)
    // update tb_seckill_voucher set stock = stock - 1 where id = ? and stock > 0
    boolean success = seckillVoucherService.update()
            .setSql("stock = stock - 1")
            .eq("voucher_id", voucherOrder.getVoucherId())
            .gt("stock", 0) // 关键:利用数据库行锁确保库存不为负
            .update();

    if (!success) {
        log.error("库存扣减失败");
        return;
    }

    // 3. 保存订单
    save(voucherOrder);
}

核心知识点深度解析

  1. 双重校验:在分布式系统中,Redis 和 MySQL 之间存在时间差。虽然 Redis 拦截了,但如果 Redis 数据因极端情况丢失,数据库层面的唯一性校验(count > 0)和数据库的唯一索引是最终保障。
  2. 乐观锁 CAS:.gt("stock", 0) 被翻译为 SQL WHERE stock > 0。利用数据库 Update 语句自带的行级锁特性,确保在并发扣减时,不会出现库存为负的情况。

九、总结

我们将整个流程梳理一遍:

  1. 用户发起请求:流量首先打到 Java Web 层。
  2. Redis 快速拦截:Lua 脚本原子性地判断库存、校验重复下单。
  3. 消息异步解耦:如果资格校验通过,直接将订单信息写入 Redis Stream,并立即返回前端“排队中”。
  4. 异步消费:后台线程池从 Stream 中读取消息。
  5. 分布式锁:为了防止集群下多线程并发安全问题,使用 Redisson 加锁。
  6. 数据库落库:执行最终的数据库事务,完成数据持久化。

通过这套组合拳,我们将数据库的写压力转化为了 Redis 的内存操作,将同步等待转化为了异步处理,从而实现了高性能的秒杀系统。

Logo

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

更多推荐