秒杀异步下单业务逻辑梳理

11 阅读7分钟

秒杀异步下单源码

1. 这段代码一句话总结

这套代码实现的是:

用户抢券时先在 Redis 中完成资格校验,抢购成功后把订单放进 Redis Stream,后台线程异步创建订单,并通过 Redisson + 数据库乐观锁保证一人一单和防止超卖。


2. 整体流程图

用户发起秒杀请求
        |
        v
seckillVoucher(voucherId)
        |
        |-- 1. 获取 userId
        |-- 2. 生成 orderId
        |-- 3. 执行 Lua 脚本
        |       |- 判断库存
        |       |- 判断是否重复下单
        |       |- 扣减 Redis 库存
        |       |- 记录已下单用户
        |       |- 写入 Stream 消息队列
        |
        |-- 4. 返回 orderId
        v
前台请求结束(快速返回)

====================================
后台异步线程一直运行
====================================

VoucherOrderHandler.run()
        |
        |-- 1. 从 stream.orders 读取消息
        |-- 2. 转成 VoucherOrder
        |-- 3. handleVoucherOrder(voucherOrder)
        |-- 4. 成功后 ACK
        |
        |-- 如果异常
                |
                v
          handlePendingList()
                |
                |-- 从 pending-list 读取未确认消息
                |-- 再次处理
                |-- 成功后 ACK

3. 类的作用

@Service
public class VoucherOrderServiceImpl

这是秒杀订单业务实现类,负责两件事:

作用 1:处理用户秒杀请求

就是 seckillVoucher() 方法。

作用 2:处理后台异步订单创建

就是 VoucherOrderHandler 线程。


4. 成员变量笔记


4.1 seckillVoucherService

@Resource
private ISeckillVoucherService seckillVoucherService;

作用:

操作秒杀券库存表。

主要用于数据库扣库存。


4.2 redisIdWorker

@Resource
private RedisIdWorker redisIdWorker;

作用:

生成全局唯一订单 id。


4.3 stringRedisTemplate

@Resource
private StringRedisTemplate stringRedisTemplate;

作用:

操作 Redis,包括执行 Lua、读取 Stream、确认 ACK。


4.4 redissonClient

@Resource
private RedissonClient redissonClient;

作用:

创建 Redisson 分布式锁,保证集群下一人一单。


5. 线程池与初始化


5.1 单线程线程池

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

作用:

启动一个后台线程专门处理订单消息。


5.2 初始化启动线程

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

含义:

当前 Bean 初始化完成后,立即启动异步订单处理任务。


6. 前台秒杀入口:seckillVoucher()

这是用户点击秒杀时真正调用的方法。


6.1 获取当前用户

Long userId = UserHolder.getUser().getId();

作用:

获取当前登录用户 id。


6.2 生成订单 id

long orderId = redisIdWorker.nextId("order");

作用:

提前生成订单 id,后面 Lua 和队列里都要用。


6.3 执行 Lua 脚本

Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(),
        voucherId.toString(), userId.toString(), String.valueOf(orderId)
);

Lua 脚本的作用:

  • 判断库存是否足够
  • 判断用户是否重复下单
  • 扣减 Redis 库存
  • 记录用户已下单
  • 把订单消息写入 stream.orders

返回值约定

  • 0:成功
  • 1:库存不足
  • 2:不能重复下单

6.4 判断资格

if (r != 0) {
    return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}

含义:

Redis 判断不通过,直接返回失败。


6.5 获取代理对象

proxy = (IVoucherOrderService) AopContext.currentProxy();

目的:

让后续调用事务方法时走 Spring AOP 代理。

不过你这段代码后面没真正用到 proxy,这是一个注意点。


6.6 返回订单 id

return Result.ok(orderId);

注意:

这里返回成功,不代表数据库订单已经创建完成。
只代表 Redis 抢购资格校验通过,订单已经进入异步处理流程。


7. 后台异步线程:VoucherOrderHandler

这个内部类是消息消费者。


7.1 队列名

private final String queueName = "stream.orders";

说明 Redis Stream 名字是:

stream.orders

7.2 主循环

while (true) {

表示:

一直监听消息队列,不停消费订单消息。


8. 正常消费消息流程


8.1 读取新消息

List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
    Consumer.from("g1", "c1"),
    StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
    StreamOffset.create(queueName, ReadOffset.lastConsumed())
);

等价于:

XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >

含义:

  • 消费者组:g1
  • 消费者:c1
  • 一次读取 1 条
  • 最多阻塞 2 秒
  • 读取新消息

8.2 没有消息就继续下一轮

if (list == null || list.isEmpty()) {
    continue;
}

8.3 解析消息

MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);

作用:

把 Stream 中的消息转成 VoucherOrder 订单对象。


8.4 处理订单

handleVoucherOrder(voucherOrder);

8.5 ACK 确认

stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());

作用:

告诉 Redis:这条消息消费成功,可以从 pending-list 删除。


9. 异常补偿:handlePendingList()

这是 Redis Stream 可靠消费的关键。


9.1 为什么需要 pending-list

如果消费者拿到消息后:

  • 还没处理完
  • 或者处理过程中报错
  • 没来得及 ACK

那么这条消息会留在 pending-list 中。

所以要有补偿机制把它重新处理。


9.2 读取 pending-list 消息

List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
        Consumer.from("g1", "c1"),
        StreamReadOptions.empty().count(1),
        StreamOffset.create(queueName, ReadOffset.from("0"))
);

等价于:

XREADGROUP GROUP g1 c1 COUNT 1 STREAMS stream.orders 0

这里的 "0" 表示:

从 pending-list 中读取未确认消息。


9.3 如果没有 pending 消息

if (list == null || list.isEmpty()) {
    break;
}

说明异常遗留消息已经处理完了。


9.4 再次处理并 ACK

逻辑和正常消费一样:

  • 解析消息
  • 处理订单
  • 成功 ACK

9.5 如果再次异常

Thread.sleep(20);

作用:

稍微休眠一下,避免疯狂重试导致 CPU 空转。


10. 真正创建订单前:handleVoucherOrder()

这个方法负责:

对同一个用户加分布式锁,然后再执行创建订单。


10.1 这里有个重点问题

你代码里写的是:

Long userId = voucherOrder.getId();

这里很可能是错的。

正确应该是:

Long userId = voucherOrder.getUserId();

因为一人一单应该按 用户 id 加锁,不应该按订单 id 加锁。


10.2 创建 Redisson 锁

RLock lock = redissonClient.getLock("lock:order:" + userId);

锁 key 类似:

lock:order:5

作用:

保证同一个用户在集群环境下不能并发创建多个订单。


10.3 尝试获取锁

boolean isLock = lock.tryLock();
  • 成功:继续下单
  • 失败:说明重复请求或并发冲突

10.4 获取不到锁

if(!isLock){
    log.error("不允许重复下单");
    return;
}

10.5 获取锁后创建订单

createVoucherOrder(voucherOrder);

10.6 finally 释放锁

lock.unlock();

11. 数据库建单逻辑:createVoucherOrder()

@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder)

这个方法是真正的数据库订单落库逻辑。


11.1 一人一单数据库兜底校验

int count = query().eq("user_id", userId)
    .eq("voucher_id", voucherOrder.getVoucherId())
    .count();
if (count > 0) {
    log.error("用户已经购买过一次!");
    return;
}

作用:

即使 Redis 前面已经校验过,这里仍然再查一次,防止重复订单。


11.2 乐观锁扣减库存

boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")
        .eq("voucher_id", voucherOrder.getVoucherId())
        .gt("stock", 0)
        .update();

SQL 思想:

update seckill_voucher
set stock = stock - 1
where voucher_id = ? and stock > 0

作用:

防止数据库层面超卖。


11.3 保存订单

save(voucherOrder);

12. 这套代码的完整时序图

用户请求秒杀
   |
   v
seckillVoucher()
   |
   |-- 获取 userId
   |-- 生成 orderId
   |-- 执行 Lua
   |     |- 判断库存
   |     |- 判断是否重复下单
   |     |- 扣 Redis 库存
   |     |- 写入 stream.orders
   |
   |-- 返回 orderId
   v
用户收到抢购结果

=================================
后台线程开始消费
=================================

VoucherOrderHandler.run()
   |
   |-- 从 stream.orders 读消息
   |-- 转成 VoucherOrder
   |-- handleVoucherOrder()
           |
           |-- Redisson 按 userId 加锁
           |-- createVoucherOrder()
                   |
                   |-- 查数据库订单是否存在
                   |-- 乐观锁扣减数据库库存
                   |-- 保存订单
           |-- 释放锁
   |
   |-- ACK 确认消息
   |
   |-- 如果异常
           |
           v
      handlePendingList()
           |
           |-- 从 pending-list 中取未确认消息
           |-- 重试处理
           |-- 成功后 ACK

13. 这段代码的亮点总结

亮点 1:Lua 原子校验

把高并发资格判断放到 Redis 中完成。

亮点 2:异步下单

前台线程不直接操作数据库,性能更高。

亮点 3:Stream 消息可靠性

支持 ACK 和 pending-list 补偿。

亮点 4:Redisson 分布式锁

解决集群环境下一人一单问题。

亮点 5:数据库乐观锁兜底

最终还是靠数据库防止超卖。


14. 你复习时最该记的两个 bug 点

bug 1:这里 userId 写错了

Long userId = voucherOrder.getId();

应该改为:

Long userId = voucherOrder.getUserId();

bug 2:事务代理对象没有真正用上

你获取了:

proxy = (IVoucherOrderService) AopContext.currentProxy();

但后面调用的是:

createVoucherOrder(voucherOrder);

不是:

proxy.createVoucherOrder(voucherOrder);

这样 @Transactional 可能不生效。


15. 面试简版答案

这套秒杀异步下单方案是基于 Redis + Lua + Stream + Redisson + MySQL 实现的。用户发起秒杀时,先生成订单 id,然后执行 Lua 脚本,在 Redis 中原子完成库存校验、一人一单判断、扣减 Redis 库存以及写入 Stream 消息,前台线程快速返回。后台单线程消费者从 Redis Stream 的消费者组中读取订单消息,解析后先通过 Redisson 按用户维度加分布式锁,再在事务中完成数据库订单重复校验、乐观锁扣减库存和保存订单。消费成功后通过 ACK 确认消息;如果处理失败,则从 pending-list 读取未确认消息并补偿处理,从而兼顾高并发性能、数据一致性和消息可靠性。