本章核心的算法逻辑与代码实现主要围绕 异步秒杀优化 方案展开,其核心在于利用 Redis + Lua脚本 实现原子化的库存与一人一单校验,并通过 内存阻塞队列 + 异步线程 将数据库持久化操作与前端响应解耦。以下是基于博客内容进行的深度拆解。
一、 核心算法逻辑:基于 Redis 的预减库存与异步下单
整个优化方案的核心逻辑可以概括为"快速校验,异步落库"。其核心思想是将耗时短、确定性高的判断逻辑前移至内存数据库 Redis 中执行,从而在毫秒级内响应用户请求,而将复杂的数据库事务操作(如创建订单、扣减库存)交由后台线程异步处理。
该流程包含两个关键阶段,其架构演进对比如下:
| 处理阶段 | 同步方案(优化前) | 异步方案(优化后) | 核心优化点 |
|---|---|---|---|
| 请求入口 | Nginx -> Tomcat | Nginx -> Tomcat | 无变化 |
| 核心校验 | 在 Tomcat 中串行执行: 1. 查询优惠券 2. 查库存 3. 查订单 4. 一人一单校验 5. 扣库存 6. 创建订单 | 在 Redis 中原子化执行 Lua 脚本: 1. 判断库存 (stock:vid:) 2. 判断用户是否下单 (order:vid: ) 3. 扣减库存并记录用户 | 校验逻辑前移、原子化操作 |
| 响应时机 | 所有数据库操作完成后,才返回结果给用户 | Lua 脚本执行成功后(约毫秒级),立即返回订单ID给用户 | 响应速度提升数个数量级 |
| 数据持久化 | 同步在主线程中完成 | 异步:将订单信息放入内存阻塞队列,由独立线程池消费并写入数据库 | 请求处理与数据落库解耦 |
| 技术栈 | MySQL 为主 | Redis (Lua) + 内存队列 + MySQL | 引入缓存与消息队列思想 |
二、 核心代码函数深度拆解
实现此逻辑的核心代码集中在 VoucherOrderServiceImpl 类中,主要包含三个部分:Lua脚本、秒杀入口函数、异步订单处理器。
1. Lua 脚本:seckill.lua (原子化校验的核心)
这是整个方案的技术基石,它保证了在高并发场景下,库存检查和用户下单记录的判断与修改是原子性的,避免了超卖和一人多单的问题 。
lua
-- 参数列表
local voucherId=ARGV[1] -- 优惠券id
local userId=ARGV[2] -- 用户id
-- 构造缓存数据Key
local stockKey ='seckill:stock:' .. voucherId -- 库存key
local orderKey ='seckill:order:' .. voucherId -- 下单用户集合key
-- 1. 判断库存是否充足
if tonumber(redis.call('get', stockKey)) <= 0 then
return 1 -- 库存不足
end
-- 2. 判断用户是否已下单 (SISMEMBER 命令检查Set中是否存在该用户)
if redis.call('sismember', orderKey, userId) == 1 then
return 2 -- 用户已下单,禁止重复购买
end
-- 3. 执行扣减库存和记录用户操作
redis.call('incrby', stockKey, -1) -- 库存减1
redis.call('sadd', orderKey, userId) -- 将用户ID加入已下单集合
return 0 -- 成功,允许下单
逻辑拆解:
- 输入参数 :
voucherId(商品ID)和userId(用户ID),通过ARGV数组从Java传入。 - 键构造 :动态拼接出库存键(如
seckill:stock:14)和订单用户集合键(如seckill:order:14)。 - 三步原子操作 :
- 库存检查 :使用
get命令获取库存值。若小于等于0,直接返回1,流程终止。 - 用户去重 :使用
sismember命令检查用户ID是否存在于代表"已购买用户"的Set集合中。若存在,返回2,流程终止。 - 扣减与记录 :若前两步都通过,则顺序执行
incrby(库存-1)和sadd(将用户加入集合)。这两个命令在同一个Lua脚本中执行,具备原子性。
- 库存检查 :使用
- 返回值 :
0表示成功,1表示库存不足,2表示重复下单。这个返回值是后续Java逻辑分支判断的依据。
2. 秒杀入口函数:seckillVoucher(Long voucherId)
此函数是前端请求的入口,负责调用Lua脚本并处理其返回结果,是同步响应部分的关键。
java
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 获取用户ID(从ThreadLocal中)
Long userId = UserHolder.getUser().getId();
// 2. 执行Lua脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT, // 上一步加载的脚本对象
Collections.emptyList(), // Keys列表,本例为空
voucherId.toString(), // ARGV[1]
userId.toString() // ARGV[2]
);
// 3. 解析脚本执行结果
int r = result.intValue();
if (r != 0) {
// 3.1 脚本返回非0,代表失败
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 3.2 脚本返回0,代表有购买资格
// 4. 生成全局唯一订单ID(使用雪花算法等)
long orderId = redisIdWorker.nextId("order");
// 5. 构造订单对象(此时订单并未存入数据库)
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 6. 将订单对象放入内存阻塞队列
orderTasks.add(voucherOrder);
// 7. 获取当前Service的代理对象(用于后续异步方法的事务管理)
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 8. 立即返回成功响应和订单ID给前端
return Result.ok(orderId);
}
逻辑拆解:
- 步骤1-2:获取用户上下文并调用Redis执行Lua脚本。这是整个流程中唯一与外部存储进行网络IO的同步操作,但因其在内存中执行,速度极快。
- 步骤3:根据Lua脚本返回值进行分支判断。若失败,直接返回错误信息,流程结束。此处的错误(库存不足、重复购买)是在请求层面做出的最终判断,后续异步线程不会再处理这些无效请求。
- 步骤4-6 :脚本执行成功(返回0)后的处理。生成订单ID、封装订单对象,并将其放入一个预定义的
BlockingQueue<VoucherOrder> orderTasks中。此处的add操作是内存操作,速度极快,是同步流程的终点。 - 步骤7-8:获取代理对象(为异步线程调用事务方法做准备)并立即向用户返回成功响应。此时,用户的购买请求在数十毫秒内已得到确认,而实际的数据库写入操作尚未开始。
3. 异步订单处理器:VoucherOrderHandler 内部类
这是一个实现了 Runnable 接口的内部类,在服务启动时由单线程线程池执行,负责消费阻塞队列中的订单信息并完成最终的数据库持久化,是异步落库部分的核心。
java
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();
// 使用分布式锁(或乐观锁)确保用户级别操作的幂等性,防止极端情况下的重复消费
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = lock.tryLock();
if (!isLock) {
log.error("不允许重复下单");
return;
}
try {
// 通过获取的代理对象调用事务方法,确保数据库操作的ACID特性
proxy.createVoucherOrder(voucherOrder);
} finally {
lock.unlock();
}
}
}
// 在Service初始化后启动处理器线程
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
逻辑拆解:
- 常驻循环 :
while (true)确保线程持续运行,监听队列。 - 阻塞消费 :
orderTasks.take()是一个阻塞方法。当队列为空时,线程会挂起,不消耗CPU资源;当有新的订单对象被seckillVoucher方法放入队列时,线程被唤醒并取出订单。 - 异步处理 :
handleVoucherOrder方法执行实际的数据库操作(查询优惠券、二次校验库存与一人一单、扣减库存、创建订单)。这部分是原同步流程中耗时最长的部分,现在被转移到后台异步执行,与用户请求线程完全分离。 - 事务与锁 :
proxy.createVoucherOrder(voucherOrder):通过代理对象调用,是为了使@Transactional注解生效,保证数据库操作的原子性。RLock lock = redissonClient.getLock(...):虽然Lua脚本在Redis层面保证了原子性,但此处加锁是针对 "同一个用户" 的额外防护。目的是防止在极端高并发下,由于网络延迟或队列消费速度问题,导致同一个用户的多个订单对象被同时处理。这是一个防御性编程和幂等性保障。
java
@Transactional
public void creatVoucherOrder(VoucherOrder voucherOrder) {
//5.一人一单
Long userId = UserHolder.getUser().getId();
//5.1查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getUserId()).count();
//5.2判断是否存在
if (count > 0){
//用户购买过了
log.error("您已经购买过一次了");
return;
}
//6.扣减库存
boolean success = seckillService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId())
.gt("stock", 0).update(); // ✅ 核心:只要库存大于0就可以扣.update(); //乐观锁,cas法
//6.1库存不足
if (!success) {
// 扣减失败
log.error("库存不足!");
return;
}
save(voucherOrder);
}
三、困惑解答
-
异步线程里为什么还要加锁?
- 锁的作用只是:同一时间只让一个线程执行。
- 锁不负责"记住有没有执行过",只负责:同一时间,只能有一个线程进去。
- 线程1执行完 → 释放锁 → 线程2再来获取锁 → 能成功,这是正常的。
-
那线程2进来会不会重复下单?
- 不会!
- 因为数据库有兜底判断:
线程1执行完 → 数据库里已经有订单了。
线程2即使拿到锁,一查count > 0→ 直接 return,什么都不做!
-
三层防御闭环(秒杀核心)
- 第一层:Redis Lua → 绝对防重复入队
- 第二层:分布式锁 → 防同一时间并发执行
- 第三层:数据库幂等 → 绝对防重复创建订单
-
你担心的场景答案
- 线程1执行完,线程2再执行 → 绝对不会超卖!
- 因为数据库已经有订单,会被第三层拦住。
2. 为什么获取不到锁就视为"重复下单"?
- 锁 key 是
lock:order:userId,同一用户同一时间只能一把锁生效。 - 获取不到锁 = 已有线程在为该用户创建订单 = 当前请求属于重复执行 = 直接拒绝。
- 就算前一个线程执行完释放锁,后续线程拿到锁也没关系,数据库会兜底拦截。
一句话口诀:
Lua 防重复入队,锁防并发执行,数据库防重复落库。
3. gt("stock", 0) 与 count 的作用关系
-
gt("stock", 0)是乐观锁,作用是:防超卖- 保证库存 永远不会扣成负数
- 解决的是:所有人总共不能多抢 的问题
-
count > 0是数据库幂等判断,作用是:防重复下单- 保证同一个用户 永远不能重复购买
- 解决的是:同一个人不能重复抢 的问题
-
两者缺一不可,作用完全不同
- 没有乐观锁 :库存会扣成负数,导致 超卖
- 没有 count 判断 :同一个用户可以无限下单,导致 重复下单
-
最终总结
gt("stock", 0)防"大家多抢"count > 0防"自己重复抢"
四、 架构价值与潜在风险分析
- 性能提升:将响应时间从依赖多个数据库查询的数百/数千毫秒(如测试中的平均957ms),降低到依赖单次Redis调用(通常<1ms)的毫秒级,吞吐量大幅提升。
- 可靠性保障:Lua脚本的原子性操作是防止超卖和重复购买的技术核心,比在应用层使用锁或事务更为高效和可靠。
- 系统解耦 :前端响应与后端数据持久化通过内存队列解耦,提高了系统的可伸缩性和抗冲击能力。即使数据库暂时性能下降或短暂不可用,秒杀请求仍可被快速接受并排队。
潜在风险与优化方向:
- 内存队列积压 :
ArrayBlockingQueue容量有限(博客中设置为1024*1024)。在瞬时流量远超数据库处理能力时,队列可能被填满,导致新的秒杀请求失败。可考虑引入更强大的外部消息队列(如Kafka、RabbitMQ),具备持久化和更强大的堆积能力。 - 数据一致性延迟:用户下单后,数据库中的订单记录并非立即可查,存在短暂延迟。前端需设计"查询中"状态,并通过订单ID轮询后端直至成功。
- 故障恢复:内存队列中的数据在服务重启后会丢失。需要设计队列持久化或消费确认机制,确保消息不丢失。
- 脚本健壮性:Lua脚本未处理Redis命令执行失败(如网络中断)的异常情况,在实际生产环境中需要增强错误处理和重试机制。