一条泥憨鱼头像
【Redis | 黑马点评】秒杀优化源码 | 万字解读封面图

【Redis | 黑马点评】秒杀优化源码 | 万字解读

黑马点评·秒杀优化源码解读

目录


0. 业务问题:为什么要优化秒杀

0.1 秒杀的业务规则

秒杀优惠券的下单必须满足:

  1. 活动在开始时间之后、结束时间之前;
  2. 库存不能超卖;
  3. 一个用户对同一张券只能下一单。

0.2 优化前的同步流程

旧实现还保留在 VoucherOrderServiceImpl.java:149-189 的注释里。它的每一次请求都要:

请求线程
  ├─ 查数据库 tb_seckill_voucher(getById)
  ├─ 校验开始/结束时间
  ├─ 校验库存
  ├─ 获取 Redisson 用户锁
  ├─ 开启事务
  │    ├─ 查 tb_voucher_order 是否已下单
  │    ├─ update tb_seckill_voucher 扣库存
  │    └─ insert tb_voucher_order
  └─ 提交事务、释放锁、返回

问题在于:所有校验和写库都压在请求线程上。秒杀的特点是瞬时并发极高、库存极少,绝大多数请求注定失败,但它们仍然会:

  • 访问数据库;
  • 竞争同一张券的行锁;
  • 争抢分布式锁;
  • 占用 Tomcat 工作线程。

数据库和锁会成为瓶颈,吞吐量上不去。

0.3 优化的目标

把秒杀拆成两个阶段:

阶段执行者做什么目标
资格判断请求线程 + Redis判断有没有库存、是否重复下单,并原子扣减快,尽量不碰数据库
下单落库后台消费者线程真正写订单、扣数据库库存慢一点没关系,串行处理

核心思想:先快速判断“有没有资格”,有资格才进入后续的下单流程。


1. 优化后的整体结构

MySQL 消费者线程 ArrayBlockingQueue Redis VoucherOrderServiceImpl 客户端 MySQL 消费者线程 ArrayBlockingQueue Redis VoucherOrderServiceImpl 客户端 alt [返回 1] [返回 2] [返回 0] POST /voucher-order/seckill/{id} UserHolder 取 userId 执行 seckill.lua(库存 + 一人一单 + 扣减) 库存不足 不能重复下单 RedisIdWorker 生成订单号 orderTasks.add(voucherOrder) 立即返回 orderId take() 阻塞获取 Redisson 用户锁 tryLock() 事务:查重、扣库存、insert unlock()

这里有两条时间线:

  • 请求线程:只做 Redis 操作和入队,速度快;
  • 消费者线程:在后台串行地做数据库事务,速度慢但不阻塞用户。

2. 基于 Redis 完成秒杀资格判断

2.1 Redis 里存了什么

key类型含义
seckill:stock:{voucherId}String该券的剩余库存
seckill:order:{voucherId}Set已经抢到该券的用户 id 集合

库存 key 的常量定义在 RedisConstants.java:20:

public static final String SECKILL_STOCK_KEY = "seckill:stock:";

订单集合 key 没有常量,seckill.lua:16 直接拼接:

local orderKey="seckill:order:"..voucherId

2.2 库存是怎么进入 Redis 的

不是在 Lua 里初始化,而是在新增秒杀券时写入的。VoucherServiceImpl.java:48-60:

@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    seckillVoucher.setVoucherId(voucher.getId());
    seckillVoucher.setStock(voucher.getStock());
    seckillVoucher.setBeginTime(voucher.getBeginTime());
    seckillVoucher.setEndTime(voucher.getEndTime());
    seckillVoucherService.save(seckillVoucher);
    // 保存秒杀库存到 redis 中
    stringRedisTemplate.opsForValue()
            .set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}

要点:

  • 数据库和 Redis 都保存了库存;
  • seckill:order:{voucherId} 不需要提前创建,第一次 SADD 时会自动创建;
  • 如果这个 key 不存在(比如 Redis 被清空、券是在这段代码之前加的),Lua 里的库存判断必须能处理 nil,否则会报错。

2.3 Lua 脚本逐行解读(当前真实代码)

src/main/resources/seckill.lua:

--参数列表
--1.1,优惠券id
local voucherId=ARGV[1]
--1.2,用户id
local userId=ARGV[2]

--2.数据key
--2.1库存key
local stockKey="seckill:stock:"..voucherId
--2.1订单key
local orderKey="seckill:order:"..voucherId

--3.脚本业务
--判断库存是否充足
if(tonumber(redis.call('get',stockKey)<=0)) then
    return 1
end
--判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId)==1) then
    return 2
end
--扣库存 incrby stockKey -1
redis.call('incyby',stockKey,-1)
--下单(保存用户),sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0

脚本按顺序做四件事:

  1. 读库存;
  2. 判断库存是否充足;
  3. 判断用户是否已经在 seckill:order:{voucherId} 集合里;
  4. 扣库存并把用户加入集合。
⚠️ 当前脚本有两处会导致执行失败的问题

问题 1:tonumber 的括号位置错了(seckill.lua:20)

if(tonumber(redis.call('get',stockKey)<=0)) then

实际解析结果是:

tonumber( redis.call('get', stockKey) <= 0 )

redis.call('get', stockKey) 返回的是字符串(例如 "100"),Lua 用 <= 比较字符串和数字会直接报错:

attempt to compare string with number

如果 key 不存在,redis.call('get') 返回 false,则报:

attempt to compare boolean with number

所以这个分支一执行,脚本就会抛错,Spring Data Redis 会把异常包装成 RedisSystemException,最终被 WebExceptionAdvice.java:12 捕获,接口返回 "服务器异常"。

正确写法应该是:

local stock = redis.call('get', stockKey)
if (not stock or tonumber(stock) <= 0) then
    return 1
end

问题 2:命令名拼写错误(seckill.lua:28)

redis.call('incyby',stockKey,-1)

Redis 没有 incyby 这个命令,正确是 incrby。即使修好了问题 1,扣库存这一步仍然会报:

Unknown Redis command called from script

问题 3(边界):库存 key 不存在

当前写法没有处理 stock 为 nil/false 的情况。即使把括号改对,tonumber(false) 返回 nil,nil <= 0 仍然会报错。生产代码应该显式把“库存 key 不存在”当作库存不足。

修正后的脚本(建议,未应用到项目)
-- 参数
local voucherId = ARGV[1]
local userId = ARGV[2]

-- key
local stockKey = "seckill:stock:" .. voucherId
local orderKey = "seckill:order:" .. voucherId

-- 1. 判断库存是否充足(key 不存在也视为库存不足)
local stock = redis.call('get', stockKey)
if (not stock or tonumber(stock) <= 0) then
    return 1
end

-- 2. 判断用户是否已经下单
if (redis.call('sismember', orderKey, userId) == 1) then
    return 2
end

-- 3. 扣库存、记录用户
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
return 0

注意:这里仍然通过 ARGV 拼接 key。单机 Redis 没问题;Redis Cluster 要求脚本访问的 key 必须显式放在 KEYS 里,否则会被判定为跨槽访问。当前 Java 侧传的是 Collections.emptyList(),属于单机写法。

2.4 Java 侧如何调用脚本

VoucherOrderServiceImpl.java:58-63 先加载脚本:

private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}

seckillVoucher 中执行(VoucherOrderServiceImpl.java:115-130):

@Override
public Result seckillVoucher(Long voucherId) {
    // 获取用户
    Long userId = UserHolder.getUser().getId();
    // 执行 lua 脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),              // KEYS:当前脚本没有使用
            voucherId.toString(), userId.toString() // ARGV[1], ARGV[2]
    );
    // 判断结果是否为 0
    int r = result.intValue();
    if (r != 0) {
        return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    ...
}

参数对应关系:

Java 参数Lua 变量含义
Collections.emptyList()KEYS当前脚本不使用 KEYS
voucherId.toString()ARGV[1]优惠券 id
userId.toString()ARGV[2]用户 id

返回值约定:

返回值含义Java 处理
0有购买资格,Redis 库存已扣减、用户已记录继续生成订单并入队
1库存不足Result.fail("库存不足")
2用户重复下单Result.fail("不能重复下单")

2.5 为什么必须用 Lua

如果不用脚本,而是在 Java 里分四步调用:

get stock
sismember order
decr stock
sadd order

在并发下会出现两类问题:

超卖:

库存 = 1
线程A: get → 1
线程B: get → 1
线程A: decr → 0
线程B: decr → -1     // 库存变成负数,两个人都认为自己有资格

重复下单:

用户U 同时发两个请求
线程A: sismember → 0
线程B: sismember → 0
线程A: sadd
线程B: sadd            // 集合里只有一个 U,但两个请求都通过了

Lua 脚本在 Redis 中作为一个整体执行,脚本运行期间不会插入其他客户端的命令,所以“读库存 → 判断 → 扣减 → 记录用户”是不可分割的。这和第 5 节分布式锁用 Lua 解锁是同一个原理。

2.6 一个行为变化:不再校验活动时间

旧实现(VoucherOrderServiceImpl.java:149-189 的注释)会校验:

if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { ... }
if (voucher.getEndTime().isBefore(LocalDateTime.now())) { ... }

新实现只执行 Lua,没有查询数据库,也没有校验开始/结束时间。也就是说:

  • 活动开始前、结束后,只要 Redis 里还有库存,请求仍然可能通过资格判断;
  • 这是当前的实现事实,不是推测。

如果业务仍然要求时间窗口,需要在 Lua 里增加时间判断,或者在进入 Redis 前做一次轻量校验。


3. 基于阻塞队列实现异步下单

Redis 只完成了“资格判断 + 预扣库存”,真正的订单还没有写入数据库。接下来要把下单动作交给后台线程。

3.1 队列和消费者的初始化

VoucherOrderServiceImpl.java:65-71:

private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
private static final ExecutorService SECKILL_ORDER_EXECUTOR =
        Executors.newSingleThreadExecutor();

@PostConstruct
private void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

三个组成部分:

组件作用
ArrayBlockingQueue<VoucherOrder>有界阻塞队列,存放待落库的订单
newSingleThreadExecutor()单线程线程池,保证同一个 JVM 内顺序处理
@PostConstruct init()Bean 初始化完成后启动消费者

@PostConstruct 是 JSR-250 注解,Spring 在完成依赖注入之后、Bean 投入使用之前调用它。所以应用启动完成后,消费者线程已经在 take() 上阻塞等待了。

3.2 生产者:请求线程把订单放进队列

VoucherOrderServiceImpl.java:131-146:

// 为 0,有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
// 订单 id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户 id
voucherOrder.setUserId(userId);
// 代金券 id
voucherOrder.setVoucherId(voucherId);
// 放入阻塞队列
orderTasks.add(voucherOrder);
// 获取代理对象(事务)
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 返回订单 id
return Result.ok(orderId);

关键点:

  1. 订单号在入队前生成。RedisIdWorker.nextId("order") 使用“时间戳 + Redis 自增序列”生成全局唯一 id;
  2. tb_voucher_order 的主键是 @TableId(type = IdType.INPUT)(见 VoucherOrder.java),数据库不会自动生成 id,所以必须由应用生成;
  3. 订单号已经通过 Result.ok(orderId) 返回给前端,后台落库必须使用同一个 id,所以传递的是整个 VoucherOrder 对象;
  4. orderTasks.add() 是非阻塞的:队列没满就立即成功;队列满时抛 IllegalStateException。

3.3 消费者:后台线程取订单

VoucherOrderServiceImpl.java:73-89:

private class VoucherOrderHandler implements Runnable {
    @Override
    public void run() {
        while (true) {
            try {
                // 获取队列中的订单信息
                VoucherOrder voucherOrder = orderTasks.take();
                // 创建订单
                handleVoucherOrder(voucherOrder);
            } catch (InterruptedException e) {
                log.error("处理订单异常", e);
            }
        }
    }
}
  • take() 是阻塞方法:队列为空时线程挂起,不消耗 CPU;
  • 单线程执行器保证同一个实例内订单是串行处理的;
  • while(true) 让它持续消费。

3.4 处理订单:分布式锁 + 事务落库

VoucherOrderServiceImpl.java:91-111:

private void handleVoucherOrder(VoucherOrder voucherOrder) {
    // 获取用户
    Long userId = voucherOrder.getUserId();
    // 创建锁对象
    RLock lock = redissonClient.getLock("order:" + userId);
    // 获取锁
    boolean isLock = lock.tryLock();
    // 判断是否获取锁成功
    if (!isLock) {
        log.error("不允许重复下单!");
        return;
    }
    try {
        proxy.createVoucherOrder(voucherOrder);
    } finally {
        // 释放锁
        lock.unlock();
    }
}

为什么 Lua 已经判过重复下单,这里还要加锁?

  • Lua 的 SISMEMBER 保护的是 Redis 这一层的判断;
  • 数据库里的“查询是否已下单 → 插入订单”是两步操作,不同实例的消费者线程可能同时执行;
  • 这把锁按用户粒度串行化数据库写流程,是第二道保险;
  • 锁在消费者线程里,不会拖慢请求线程的响应速度。

3.5 真正落库:createVoucherOrder

VoucherOrderServiceImpl.java:192-218:

@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
    // 一人一单
    Long userId = voucherOrder.getUserId();
    Long voucherId = voucherOrder.getVoucherId();

    // 查询订单
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    if (count > 0) {
        log.error("用户已经购买过一次!");
        return;
    }
    // 扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock=stock-1")
            .eq("voucher_id", voucherId).gt("stock", 0)
            .update();
    if (!success) {
        log.error("库存不足!");
        return;
    }
    // 保存订单(id、userId、voucherId 已在 seckillVoucher 中生成)
    save(voucherOrder);
}

这一层做了三件事:

  1. 数据库查重:防止 Redis 数据丢失、消息重复等情况下重复下单;
  2. 条件扣库存:where stock > 0,数据库层面的最后一道防超卖;
  3. 保存订单:使用上游已经生成好的 id。

@Transactional 保证这三步在同一个事务里。方法通过 proxy.createVoucherOrder(...) 调用,走的是 Spring 代理,事务才会生效;如果直接 this.createVoucherOrder(...),事务不会开启。

3.6 订单号的生成

RedisIdWorker.nextId:

long timestamp = nowSecond - BEGIN_TIMESTAMP;
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return timestamp << COUNT_BITS | count;
  • 高 32 位是相对时间戳(秒);
  • 低 32 位是当天该业务的自增序列(Redis INCR);
  • 用 << 和 | 拼成一个 long。

这样得到的 id 全局唯一、趋势递增,而且不依赖数据库自增。


4. 涉及的关键技术原理

4.1 Redis Lua 的原子性

  • Redis 命令执行是单线程的;
  • 整个 Lua 脚本作为一条命令执行,期间不会插入其他客户端的命令;
  • 因此脚本内部的多次读写对外是一个整体。

注意:Lua 保证“不被交错执行”,不保证回滚。脚本中途报错,前面执行过的写命令不会撤销。所以脚本要短小、可预期。

4.2 阻塞队列 ArrayBlockingQueue

  • 基于数组的有界队列;
  • 线程安全;
  • add:队列满时抛 IllegalStateException;
  • put:队列满时阻塞等待;
  • take:队列空时阻塞等待;
  • 当前代码用的是 add + take,即“生产不等待、消费等待”。

4.3 单线程消费者

Executors.newSingleThreadExecutor() 内部只有一个工作线程,队列中的任务按提交顺序执行。在这里,消费任务本身是一个 while(true) 循环,所以整个 JVM 内只有一个线程在顺序处理订单。

优点:

  • 实现简单;
  • 订单落库顺序可控;
  • 不需要处理并发写同一张券的数据库竞争(单实例内)。

局限:

  • 吞吐量受单线程限制;
  • 一个异常如果没有捕获,整个消费循环会停止。

4.4 @PostConstruct

Spring 在 Bean 实例化、依赖注入完成后调用被 @PostConstruct 标记的方法。这里用它启动消费者,保证应用启动后队列马上有人消费。

4.5 Spring 代理与 @Transactional

createVoucherOrder 上的事务注解只有通过代理调用才生效。AopContext.currentProxy() 依赖启动类上的:

@EnableAspectJAutoProxy(exposeProxy = true)

(HmDianPingApplication.java:8)。没有这个配置,currentProxy() 会抛 IllegalStateException。


5. 异常、边界与已知问题

以下按“代码事实”和“可选改进”区分。

5.1 已确认的问题

(1)seckill.lua 当前无法正常执行
  • 位置:seckill.lua:20
  • 原因:<= 0 被写进了 redis.call 的结果比较里,实际变成“字符串 <= 数字”,Lua 直接报错;
  • 影响:所有秒杀请求都会走异常分支,接口返回“服务器异常”;
  • 位置:seckill.lua:28
  • 原因:incyby 拼写错误,正确是 incrby;
  • 影响:即使修好第一处,扣库存仍然失败。
(2)时间窗口校验丢失
  • 位置:VoucherOrderServiceImpl.java:115-147
  • 事实:新实现只执行 Lua,没有查询 begin_time / end_time;
  • 影响:活动开始前、结束后仍可能抢到券;
  • 对比:旧实现(注释代码 :149-189)有校验。
(3)消费者只捕获 InterruptedException
  • 位置:VoucherOrderServiceImpl.java:83
  • 事实:handleVoucherOrder 内部的 RedisConnectionException、数据库异常、NullPointerException 等运行时异常不会被捕获;
  • 后果:异常从 run() 抛出。由于线程是用 submit() 提交的,异常被 Future 吞掉,不会打印完整堆栈,消费循环结束,队列只进不出;
  • 建议:在 while 循环里 catch (Exception e),记录日志并继续消费。
(4)proxy 字段的赋值顺序有竞态
  • 位置:VoucherOrderServiceImpl.java:141-144
  • 事实:先 orderTasks.add(voucherOrder),后 proxy = AopContext.currentProxy();
  • 后果:消费者可能在 proxy 赋值前就取到订单,第一次请求时 proxy 为 null,handleVoucherOrder 里抛 NullPointerException,再叠加问题(3),消费者直接停掉;
  • 另外:proxy 是普通实例字段,没有 volatile,跨线程可见性也没有保证;
  • 建议:先给 proxy 赋值,再入队。ArrayBlockingQueue 的内部锁会为入队前的写操作提供 happens-before 关系。
(5)队列满时 Redis 已扣库存
  • 位置:VoucherOrderServiceImpl.java:141
  • 事实:ArrayBlockingQueue.add 在队列满时抛 IllegalStateException;
  • 后果:Lua 已经扣了库存、把用户加入了集合,但订单没有入队,请求返回“服务器异常”,库存和资格都白白消耗;
  • 说明:队列容量是 1024×1024,实际很难满,但这是有界队列的固有行为;
  • 可选:改用 put() 阻塞,或捕获异常后做补偿(回滚 Redis 库存和集合)。
(6)进程崩溃会丢订单
  • 位置:整个阻塞队列方案
  • 事实:ArrayBlockingQueue 在 JVM 内存里,不是持久化队列;
  • 后果:应用重启/崩溃时,队列中未处理的订单全部丢失;但 Redis 的库存已经扣减、用户已经记录,数据库里却没有订单;
  • 影响:该用户无法再次下单,库存也少了一份;
  • 这是“内存队列”方案的固有限制。课程后续会换成 Redis Stream + 消费者组来解决持久化与确认(ACK)问题。
(7)Redis 与数据库的双写不一致
  • 位置:seckillVoucher 扣 Redis 库存;createVoucherOrder 扣数据库库存
  • 事实:两边的扣减不是同一个事务;
  • 可能的后果:
    • Redis 扣了,数据库扣失败(例如数据库库存为 0)→ 订单丢弃,Redis 库存少一份;
    • Redis 扣了,进程崩溃 → 同上;
  • 当前没有任何补偿机制。
(8)返回订单号时订单还没落库
  • 位置:VoucherOrderServiceImpl.java:146
  • 事实:Result.ok(orderId) 在入队后立即返回;
  • 后果:用户立刻查询订单,可能查不到;如果后台落库失败,前端已经拿到了“成功”的订单号;
  • 这是异步化的必然代价,属于最终一致性模型。

5.2 设计权衡

选择好处代价
Redis 预扣库存请求线程不碰数据库,吞吐高Redis 与 DB 可能不一致
Lua 原子判断不超卖、不重复脚本要自己保证正确性,调试更难
阻塞队列实现简单、无需额外中间件内存态、不持久、单实例
单线程消费者顺序处理、实现简单吞吐受限、异常会停掉消费
先返回订单号响应快前端看到的是“受理成功”,不是“落库成功”

5.3 可选改进方向

  • 修复 seckill.lua 的两处错误和 nil 边界;
  • 在 Lua 或入口补回活动时间校验;
  • 消费者循环捕获 Exception,避免线程静默退出;
  • 把 proxy 的赋值移到入队之前,或者改为在消费者里通过 applicationContext.getBean 获取代理;
  • 改用持久化消息队列(Redis Stream、RabbitMQ、Kafka)替代内存队列;
  • 增加 Redis 与数据库的对账/补偿任务;
  • Redis Cluster 场景下把 key 放进 KEYS,避免跨槽问题。

6. 优化前后对比

维度优化前(同步)优化后(Redis + 队列)
请求线程做什么查库、校验、加锁、事务写库执行 Lua、生成 id、入队
数据库压力每个请求都访问只有抢到资格的请求才访问
响应时间取决于数据库和锁主要是 Redis 往返
并发能力受数据库和锁限制大幅提升
超卖防护分布式锁 + SQL 条件更新Lua 原子扣减 + SQL 条件更新
一人一单分布式锁 + 数据库查重Redis Set + 分布式锁 + 数据库查重
一致性同步,返回即落库最终一致,返回时可能尚未落库
可靠性进程崩溃只影响当前请求队列内未处理订单会丢失
实现复杂度较低较高,需要处理补偿和异常

7. 总结

  • 资格判断前移:把“库存是否充足、用户是否重复下单、扣减库存、记录用户”放进一个 Lua 脚本,在 Redis 中原子完成,请求线程不再访问数据库。
  • 异步下单:抢到资格的请求只把订单对象放入 ArrayBlockingQueue,由单线程消费者在后台完成数据库查重、扣库存、插入订单。
  • 订单号前置生成:RedisIdWorker 在入队前生成全局唯一 id,并立即返回给前端,后台落库使用同一个 id。
  • 两道防线:Redis 的 Lua 是第一道,数据库的条件更新 + 分布式锁是第二道。
  • 当前实现的硬伤:seckill.lua 有两处语法/命令错误,按现状秒杀请求会直接异常;消费者异常处理、proxy 赋值顺序、队列持久化也存在明显风险。

8. 理解检查题

  1. 业务与一致性:优化后的 seckillVoucher 为什么可以在订单还没写入数据库时就返回订单号?这样做的收益是什么,代价是什么?

  2. Lua 原子性:如果把 seckill.lua 里的四步拆成 Java 里的四次 Redis 调用,请构造一个并发场景,说明什么情况下会超卖、什么情况下会重复下单。

  3. Redis 数据模型:seckill:stock:{voucherId} 和 seckill:order:{voucherId} 分别是什么类型、存什么内容?库存 key 是在哪里初始化的?如果这个 key 不存在,当前脚本会发生什么?

  4. 异步链路:请完整描述一个 VoucherOrder 从 Lua 返回 0 到最终写入数据库的路径。订单号为什么必须在入队前生成?如果应用在入队后、落库前崩溃,会发生什么?

  5. 锁的作用:Lua 已经用 SISMEMBER 判断了重复下单,为什么消费者里的 handleVoucherOrder 还要再加一把 Redisson 分布式锁?这把锁保护的是哪一步?


9. 理解检查题参考答案与逐题反馈

第 1 题:业务与一致性

标准答案

可以提前返回,是因为返回订单号只代表“资格判断通过、订单已受理”,不代表“订单已经落库”。

优化后的请求线程只做了三件事:执行 Lua 扣 Redis 库存、生成订单号、把订单对象放入阻塞队列。真正的数据库写入由后台消费者完成。因此请求线程不需要等待数据库事务提交。

收益:

  • 响应时间从“数据库 + 分布式锁 + 事务”缩短为“Redis 往返 + 入队”;
  • 数据库只处理真正抢到资格的少量请求,压力大幅下降;
  • 即使数据库慢,也不会阻塞大量秒杀请求。

代价:

  • 最终一致性:返回时订单可能还没写进数据库,用户立刻查询可能查不到;
  • 失败不可见:如果后台落库失败、进程崩溃、队列丢单,前端已经拿到了订单号,但订单可能不存在;
  • 需要补偿机制:Redis 扣了库存、数据库没订单,需要额外手段对账或回滚。
常见误区
  • ❌ “返回订单号就说明下单成功了。”——这里只是“有资格、已受理”。
  • ❌ “异步化没有代价,只是把代码换个地方执行。”——一致性、可靠性、可观测性都变复杂了。
  • ❌ “数据库最终一定会写成功。”——进程崩溃、队列丢失、事务异常都可能让订单永远不出现。

第 2 题:Lua 原子性

标准答案

如果拆成四次独立调用:

1. GET seckill:stock:{voucherId}
2. SISMEMBER seckill:order:{voucherId} userId
3. DECR seckill:stock:{voucherId}
4. SADD seckill:order:{voucherId} userId

超卖场景:

库存 = 1
线程A: GET → "1"
线程B: GET → "1"
线程A: DECR → 0
线程B: DECR → -1

两个请求都认为自己有资格,库存变成负数,最终可能生成两张订单(数据库的 stock > 0 条件更新可能挡住第二张,但 Redis 这一层已经超卖了)。

重复下单场景:

用户 U 同时发两个请求
线程A: SISMEMBER → 0
线程B: SISMEMBER → 0
线程A: SADD
线程B: SADD

两次判断都在对方写入之前完成,两个请求都通过。

Lua 把“读库存、判断、扣减、记录用户”放在一个脚本里,Redis 执行脚本期间不会插入其他命令,所以这两个交错都不会发生。

常见误区
  • ❌ “Redis 是单线程的,所以四条命令天然原子。”——单条命令原子,多条命令之间会被其他客户端插队。
  • ❌ “Lua 像一个事务,出错会回滚。”——Lua 保证不被交错执行,不保证回滚。
  • ❌ “用 WATCH/MULTI 也一样。”——WATCH 是乐观锁,需要客户端重试;Lua 可以在服务端直接做条件分支。

第 3 题:Redis 数据模型

标准答案
  • seckill:stock:{voucherId}:String 类型,值是剩余库存的字符串,例如 "100";
  • seckill:order:{voucherId}:Set 类型,成员是已经抢到该券的用户 id。

库存 key 在新增秒杀券时初始化(VoucherServiceImpl.java:58):

stringRedisTemplate.opsForValue()
        .set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());

订单集合不需要提前创建,第一次 SADD 时 Redis 会自动创建。

如果库存 key 不存在:

  • 按当前脚本:redis.call('get', ...) 返回 false,随后 false <= 0 会报“attempt to compare boolean with number”,脚本异常;
  • 按修正后的脚本:not stock 成立,返回 1,即“库存不足”。

所以部署或清库之后,必须确保库存 key 已经初始化。

常见误区
  • ❌ “库存 key 是 Lua 脚本自动创建的。”——脚本只读和扣减,不会初始化。
  • ❌ “订单 Set 需要提前建好。”——第一次 SADD 自动创建。
  • ❌ “每次请求都会查数据库库存。”——新实现只读 Redis,数据库库存只在异步落库时扣减。

第 4 题:异步链路

标准答案

完整路径:

1. seckillVoucher 执行 seckill.lua,返回 0
2. 从 UserHolder 取出 userId
3. redisIdWorker.nextId("order") 生成订单号
4. new VoucherOrder,设置 id、userId、voucherId
5. orderTasks.add(voucherOrder)
6. proxy = AopContext.currentProxy()
7. 返回 Result.ok(orderId)
8. 消费者线程 orderTasks.take() 取出订单
9. handleVoucherOrder:redissonClient.getLock("order:" + userId).tryLock()
10. proxy.createVoucherOrder(voucherOrder)
11. @Transactional 中:查询是否重复 → 条件扣库存 → save
12. finally 中 unlock()

订单号必须在入队前生成,原因有两个:

  • tb_voucher_order.id 是 IdType.INPUT,数据库不会自动生成主键;
  • 订单号要立即返回给前端,后台落库必须使用同一个 id,不能重新生成。

如果应用在入队后、落库前崩溃:

  • 队列在 JVM 内存中,未处理的订单丢失;
  • Redis 库存已经扣减、用户已经加入 seckill:order:{voucherId};
  • 数据库中没有对应订单;
  • 该用户无法再次抢同一张券,库存也少了一份,形成数据不一致;
  • 当前代码没有补偿机制。
常见误区
  • ❌ “阻塞队列在 Redis 里,不会丢。”——ArrayBlockingQueue 在 JVM 内存里。
  • ❌ “订单号可以落库时再由数据库生成。”——实体主键是 IdType.INPUT,且前端已经拿到 id。
  • ❌ “崩溃只影响响应,不影响数据。”——Redis 已经扣减,数据库没有订单。
  • ❌ “消费者只有一个线程,所以不会有并发问题。”——只在一个 JVM 内成立,多实例仍然有并发。

第 5 题:锁的作用

标准答案

Lua 的 SISMEMBER 保护的是 Redis 层的重复下单判断,但它不能替代数据库层的锁,原因有三点:

  1. 数据库的“查重 + 插入”不是原子操作:count(...) 和 save(...) 是两条 SQL,中间可能被其他线程插入;
  2. 多实例部署:每个应用实例都有自己的消费者线程和阻塞队列,不同实例的消费者可能同时处理同一个用户的订单;
  3. 第二道保险:如果 Redis 数据丢失、被清空,或者消息被重复投递,数据库查重仍需要被串行化。

handleVoucherOrder 里的 redissonClient.getLock("order:" + userId) 按用户粒度串行化“数据库查重 → 扣库存 → 插入订单”这一整段,保证同一用户的订单不会并发写入。

它放在消费者线程里,而不是请求线程里,所以不会增加秒杀请求的响应时间。

常见误区
  • ❌ “Lua 已经判重了,这把锁是多余的。”——Lua 保护 Redis,锁保护数据库写入,层次不同。
  • ❌ “单线程消费者不需要锁。”——只在单个 JVM 内成立;多实例、消息重复、Redis 数据异常时仍需要。
  • ❌ “锁是为了防止超卖。”——超卖主要由数据库的 where stock > 0 和 Lua 扣减保证;这把锁的主要目标是同一用户的重复下单。
  • ❌ “锁应该加在请求线程里。”——那样会拖慢响应;放在消费者里才是异步化的意义。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2503_94545876/article/details/168765513

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--