秒杀异步下单源码
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 读取未确认消息并补偿处理,从而兼顾高并发性能、数据一致性和消息可靠性。