【黑马点评】异步秒杀优化:从 Redisson 到 Lua + 阻塞队列
·
1. 背景与瓶颈分析
在上一阶段,我们利用 Redisson 实现了基于 Redis 的分布式锁,成功解决了集群环境下的“超卖”和“一人一单”安全问题。
然而,随着压测并发量的提升,同步模式(Synchronous Mode) 的性能瓶颈逐渐暴露:
- 数据库是最大短板:所有请求无论成功与否,都需要经过“加锁 -> 查库 -> 减库存 -> 落库 -> 解锁”的完整流程。MySQL 单机 TPS 有限,直接限制了系统的吞吐量上限。
- 响应时间长:用户必须等待服务器处理完所有数据库事务才能收到响应,在高并发下会导致大量线程阻塞。
优化目标:将“业务校验”与“数据库落地”解耦,实现异步秒杀,将 QPS 提升一个数量级。
2. 解决方案:异步秒杀三剑客
为了突破数据库瓶颈,我们对架构进行了重构,引入了以下核心组件:
- Redis + Lua 脚本(前置拦截): 将“库存校验”和“一人一单校验”前置到 Redis 内存中原子执行。只有校验通过的请求才有资格进入后续流程。
- BlockingQueue(流量削峰): 在 JVM 内存中维护一个阻塞队列。主线程验证通过后,将订单信息写入队列并立刻返回“排队成功”,不再等待数据库操作。
- 独立线程池(异步落库): 后台启动独立的单线程,匀速消费队列中的订单,慢慢写入数据库,避免瞬间流量打崩 MySQL。
3. 核心代码深度拆解
3.1 核心脚本:Lua 守门员
我们编写了 seckill.lua,利用 Redis 的原子性,在内存中极速完成资格判断。
Lua
local voucherId = ARGV[1]
local userId = ARGV[2]
-- 1. 判断库存
local stockKey = 'seckill:stock:' .. voucherId
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 库存不足
end
-- 2. 判断一人一单
local orderKey = 'seckill:order:' .. voucherId
if (redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 重复下单
end
-- 3. 扣库存,占位(记录用户ID)
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
return 0 -- 资格校验通过
3.2 主线程:极速响应
seckillVoucher 方法现在的职责非常单一:校验 + 入队。
关键技术点:
- Lua 执行:完全不查数据库,性能极高。
- Proxy 传递:这是面试中的大坑。后续子线程需要调用事务方法,但子线程拿不到主线程的
ThreadLocal(即AopContext)。因此,必须在主线程里先把proxy存下来。
Java
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
// ⚠️ 定义成员变量保存代理对象,解决跨线程事务失效问题
private IVoucherOrderService proxy;
// 阻塞队列 (JVM内存队列)
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
Long orderId = redisIdWorker.nextId("order");
// 1. 执行 Lua 脚本 (Redis层校验)
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
// 2. 判断结果
int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 3. 封装订单信息
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 4. 放入阻塞队列
orderTasks.add(voucherOrder);
// 🌟 核心:获取当前事务代理对象,赋值给成员变量
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 5. 秒级返回
return Result.ok(orderId);
}
}
3.3 后台线程:异步处理
利用 @PostConstruct 在应用启动时开启消费者线程。
关键技术点:
- Redisson 兜底锁:虽然 Lua 已经做过校验,但为了保证数据库层的绝对数据一致性(防止 Redis 和 DB 数据偶发不一致),我们在写入前再次加锁。
- Proxy 调用:直接使用主线程传递过来的
proxy变量调用事务方法。
Java
@PostConstruct
private void init() {
// 项目一启动,就让后台线程跑起来,时刻盯着队列看有没有单子
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
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();
// 3. 加锁 (Redisson) - 兜底方案
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
boolean isLock = redisLock.tryLock();
if (!isLock) {
log.error("不允许重复下单"); // 理论上 Lua 拦截了,这里很难触发
return;
}
try {
// 🌟 核心:使用成员变量 proxy 触发事务
proxy.createVoucherOrder(voucherOrder);
} finally {
redisLock.unlock();
}
}
}
4. 热点问题剖析
Q1: 为什么需要在成员变量里存 proxy?
A: 这是一个关于 Spring AOP 和线程模型的问题。
- 现象:如果在子线程
VoucherOrderHandler中直接调用createVoucherOrder,事务会失效。如果调用AopContext.currentProxy(),会抛出异常。 - 原因:Spring 的事务是基于 AOP 代理实现的,而
AopContext底层使用的是ThreadLocal来存储当前的代理对象。ThreadLocal是线程隔离的,主线程的数据,子线程看不见。 - 解决:我们在主线程(请求线程)中拿到代理对象,将其赋值给 Service 的成员变量
proxy,这样子线程就能共享访问这个代理对象,从而保证事务生效。
Q2: 既然 Lua 已经校验过了,为什么 Handler 里还要加 Redisson 锁?
A: 这是一种**“防御性编程”**。
- 虽然 Lua 脚本保证了 Redis 操作的原子性,但 Redis 数据和 MySQL 数据在极端的并发或故障恢复场景下可能存在短暂的不一致。
- 在 Handler 中再次加锁,是为了给数据库操作加上最后一道保险,确保“一人一单”在数据库层面也是绝对安全的。
Q3: 使用 ArrayBlockingQueue 有什么风险?
A:
- 内存限制:JVM 内存有限,如果瞬间涌入海量订单,可能会导致内存溢出 (OOM)。
- 数据丢失:这是内存队列,如果服务器宕机或重启,队列中未处理的订单会全部丢失,导致用户显示“抢购成功”但实际没货。
- 后续演进:使用 Redis Stream 消息队列代替 JVM 内存队列,实现消息的持久化和可靠消费。
5. 总结
通过本次优化,我们将秒杀流程从 同步阻塞 演进为 异步解耦:Lua 脚本检验(Redis) - 内存队列(Buffer) - 异步线程写库(DB)。
这实现了 性能质变(移除了 MySQL 事务阻塞点,利用 Redis 纯内存操作) 和 削峰填谷(利用队列缓冲,保护了数据库安全)。
但是当前架构使用了 JVM 内存队列(ArrayBlockingQueue),存在 数据安全性(JVM 内存没有持久化能力)和 内存溢出风险。
下一步,为了解决数据丢失的问题,需要引入分布式消息队列,使用 Redis Stream 代替 JVM 内存队列,实现 消息持久化、ACK 确认机制、消费者组模式。这就是高并发秒杀系统的终极形态。
更多推荐




所有评论(0)