黑马点评 · 秒杀优化模块整理
这一模块解决的问题一句话就能说清:
旧流程每个请求要在 MySQL 上做 4 次往返(查券、查订单、改库存、插订单),还要抢一次 Redis 锁;优化后请求线程 0 次数据库,只剩一次 Redis 的 Lua 调用,那 4 次库操作里有 3 次被挪到后台线程,而且只有 Lua 判定有资格的请求才会入队。
整个方案可以拆成两半:
| 做什么 | 在哪做 | 快慢 | |
|---|---|---|---|
| 同步部分 | 判断库存、判断一人一单、抢资格 | Redis(一个 Lua 脚本) | 毫秒级 |
| 异步部分 | 扣库存、写订单 | 后台常驻线程 → MySQL | 用户不用等 |
这个模块代码确实绕,绕在四个地方:Lua 脚本和 Java 之间的返回值约定、阻塞队列 + 常驻线程的模型、异步线程里为什么还要加锁和二次校验、proxy 为什么要存成字段。文档按顺序把这四个都拆开讲。
一、学习链路与代码落点

七行对应 PPT 的推进顺序:先看旧流程的瓶颈,再讲优化思路(判断挪 Redis、下单异步化),然后是 Lua 脚本、库存预热、入队、异步下单,最后是阻塞队列的两个缺陷(引出下一课的消息队列)。
代码分布在三个地方:
resources/seckill.lua—— 库存和一人一单的原子判断VoucherOrderServiceImpl—— 主流程 + 阻塞队列 + 常驻消费者线程VoucherServiceImpl.addSeckillVoucher—— 新增秒杀券时把库存预热到 Redis
二、优化前:瓶颈在哪

原来的流程(左图)是:查优惠券 → 判断时间 → 判断库存 → 加锁 → 校验一人一单 → 扣库存 → 创建订单。其中 4 步访问 MySQL,1 步访问 Redis(加锁),2 步是纯内存判断。
下面把这两件事分开看:哪些代码真的打了数据库,哪些没打。
2.1 证据一:旧流程的代码
VoucherOrderServiceImpl.java 第 163~214 行那段注释掉的 seckillVoucher,就是优化前的版本:
| 行号 | 代码 | 实际打到哪 |
|---|---|---|
| 166 | SeckillVoucher voucher = seckillVoucherService.getById(voucherId); | MySQL:SELECT(查券的库存和起止时间) |
| 169-179 | voucher.getBeginTime().isAfter(now) / isBefore(now) | 内存判断 |
| 182 | if (voucher.getStock() < 1) | 内存判断,用的是第 166 行查出来的快照值 |
| 187 | Long userId = UserHolder.getUser().getId(); | ThreadLocal,不查库(登录拦截器早已从 Redis 放进去) |
| 197 | boolean isLock = lock.tryLock(); | Redis(Redisson 抢锁) |
| 207 | return proxy.createVoucherOrder(voucherId); | 进入下一段,见 2.2 |
这张表澄清两个容易误解的点:时间校验和库存校验是内存判断(数据来自第 166 行那一次查询),加锁本来就是 Redis。旧流程真正重的地方,是每个请求都要把券查出来、再走一遍完整的下单流程。
2.2 证据二:那三段数据库操作现在还在,只是搬去了异步线程
旧版的 createVoucherOrder(Long voucherId) 被新版替换成了 createVoucherOrder(VoucherOrder),但里面的数据库调用一个没少:
| 行号 | 代码 | 等价 SQL |
|---|---|---|
| 224 | query().eq("user_id", userId).eq("voucher_id", ...).count() | SELECT COUNT(*) FROM tb_voucher_order WHERE user_id=? AND voucher_id=? |
| 232-235 | seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id",..).gt("stock",0).update() | UPDATE tb_seckill_voucher SET stock=stock-1 WHERE voucher_id=? AND stock>0 |
| 239 | save(voucherOrder) | INSERT INTO tb_voucher_order (...) VALUES (...) |
加上 2.1 里那次 getById,旧流程每个请求的数据库开销就是:
1 次 SELECT 查秒杀券(库存、起止时间)
1 次 SELECT COUNT 一人一单校验
1 次 UPDATE 扣减库存
1 次 INSERT 写入订单
= 4 次 MySQL 往返
前三次是所有走到这一步的请求都要付的,INSERT 只有扣库存成功的那部分才付。
这些 ServiceImpl 方法不是”内存里算一算”——你项目之前跑起来时,日志里打印过同类调用生成的 SQL,例如 query().eq(...).count() 生成的就是 SELECT COUNT(*) FROM tb_shop WHERE (type_id = ?)。
问题有三个:
- 数据库往返太多:一个请求 4 次 MySQL。
- 还要抢分布式锁:同一用户的请求被锁串行化,吞吐量被锁死。
- 绝大多数请求是白跑的:秒杀的特点是”瞬间高并发、成功极少”,库存只有 100 件,可能来 10 万个请求,剩下 99900 个请求把数据库连接和 CPU 全耗掉了,最后只换来一句”库存不足”。
核心洞察:那些注定失败的请求,根本不该走到数据库。
对照一下:优化后新版的
seckillVoucher(第 121~158 行)方法体里,getById/query()/update()/save()一个都没有,只剩stringRedisTemplate.execute(...)和redisIdWorker.nextId(...)两次 Redis 调用,可以说”请求线程只碰 Redis”是有代码依据的。
三、优化思路:把「判断」和「写库」拆开
右图就是优化后的结构,分成两段:
第一段(同步、要快):请求线程执行一个 Lua 脚本,在 Redis 里一次性完成”库存够不够 + 这个人下过单没有”的判断。
- 通过 → 生成订单 id,把订单丢进阻塞队列,立刻返回
- 不通过 → 直接返回失败
这一段只访问 Redis,不碰数据库,所以极快。
第二段(异步、可以慢):后台有一个常驻线程,不停从队列里取订单,加锁 + 开事务,真正去数据库扣库存、写订单。
用户拿到的是”抢购成功的订单 id”,而订单真正落库是稍后由后台线程完成的。数据库的操作次数并没有减少,但它不再挡在用户面前,而且只有真正抢到资格的那一小部分请求才会走到这里。
四、Lua 脚本:把三个动作合成一次原子操作


4.1 Redis 里的两个 key
seckill:stock:{voucherId} string "100" 剩余库存
seckill:order:{voucherId} set {1,2,3,5,7} 已经下过单的用户 id
一人一单为什么用 Set 而不是 List?因为 SISMEMBER 是 O(1),判断极快;Set 本身还会去重,同一个用户重复 SADD 也只会有一条记录。
4.2 脚本的三段逻辑
tonumber(redis.call('get', stockKey)) <= 0→ 库存不足,返回 1redis.call('sismember', orderKey, userId) == 1→ 重复下单,返回 2- 都不满足 →
incrby stockKey -1扣库存,sadd orderKey userId记录用户,返回 0
返回 1 和 2 的分支里脚本直接结束,Redis 里什么都不改。只有返回 0 的那一次才真正扣了库存、记了用户。
4.3 为什么必须用 Lua
因为这三个动作(判断库存、判断用户、扣减并记录)必须是一次不可分割的执行。
如果分成三条命令从 Java 发过去,「判断库存通过」和「扣库存」之间就会被别的请求插进来——两个线程都判断通过,然后都扣一次,照样超卖。而 Redis 执行 Lua 脚本是单线程、不可中断的,脚本里的多条命令要么全做完、要么一条都不做。
这就是”把并发问题从数据库搬到 Redis,再用 Lua 把判断和修改压成一步”。
4.4 脚本和 Java 之间的”协议”
返回值就是两边约定的暗号,Java 侧照着翻译:

int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}这里有个容易踩的点:stringRedisTemplate.execute(script, keys, args...) 的第二个参数是 KEYS 列表,脚本里我们没用 KEYS,所以传 Collections.emptyList();voucherId 和 userId 是参数,按顺序对应脚本里的 ARGV[1] 和 ARGV[2]。
五、库存要先预热进 Redis
Lua 脚本读的是 seckill:stock:{id},如果 Redis 里没有这个 key,tonumber(nil) 会报错、脚本直接失败。所以新增秒杀券的时候要顺手把库存写进 Redis:

这也是线上活动前的常规操作:活动开始前把库存、用户白名单之类的数据刷进 Redis(缓存预热)。
六、阻塞队列 + 常驻线程
抢到资格的订单需要一个”中转站”,等后台线程来取——这就是阻塞队列。


三个关键设计:
① 用 BlockingQueue,队列空时 take() 会阻塞。 消费者线程写的是死循环,但没有订单时它会挂起等,不消耗 CPU。如果换成 poll(),队列空会立刻返回 null,你就得自己写 sleep,空转起来白白烧 CPU。
② 用单线程线程池。 下单这件事本身要抢锁、要串行,多开几个消费者只会互相等锁;单线程还能保证同一时刻只有一个消费者在写库。线程池保证的是后台运行
③ 用 @PostConstruct 启动。 这个消费者是个”项目一启动就得一直跑着”的后台任务,所以放在 @PostConstruct 里提交给线程池,跟着 Spring 容器的生命周期走。
再注意循环里的 try-catch:异常必须在循环内部捕获,否则一条订单处理失败,整个消费者线程就退出了——后面所有订单再也没人处理。捕获之后打个日志,继续处理下一条。
七、异步线程里到底做了什么

handleVoucherOrder 里做了三件事:加锁 → 走代理调用事务方法 → 释放锁。
7.1 为什么异步线程里还要再加一次锁?
因为 Redis 那层是”预检”,不是”最终事实”。异步线程和请求线程不是同一个线程,中间还隔着队列,可能出现异常路径(重复投递、多节点、重试)。加锁是为了保证同一个用户在数据库这一层也是串行的——这是第二道防线,不是多余。
7.2 proxy 为什么要存成一个字段?
这是这段代码最绕的地方。createVoucherOrder 上有 @Transactional,事务要靠 AOP 代理才生效(和上一章”事务自我调用”讲的是同一件事)。而 AopContext.currentProxy() 依赖 ThreadLocal,只能在走过代理的那个线程里调用。
- 请求线程
seckillVoucher():走过代理,可以拿到 proxy - 后台线程
handleVoucherOrder():它没有走过代理,直接调AopContext.currentProxy()会抛异常
所以课程的做法是:在请求线程里提前把代理对象取出来,存到成员变量 proxy 里,后台线程直接用这个字段。
7.3 数据库侧还要再校验一次

createVoucherOrder 里又把一人一单和库存查了一遍,逻辑和第一节的老代码几乎一样。这不是重复劳动,而是兜底:
| 层次 | 作用 | 出问题时的后果 |
|---|---|---|
| Redis + Lua | 预检 + 拦截绝大多数无效请求 | 判断快,但数据在内存里,可能丢 |
| 数据库 + 锁 + 事务 | 最终一致性的保证 | 慢,但可靠 |
两处校验的写法要注意:判断不通过时必须 return。如果只写 log.error(...) 不返回,代码会继续往下扣库存、存订单,一人一单就白校验了。
八、这个方案的两个硬伤

PPT 126 页专门问了”基于阻塞队列的异步秒杀存在哪些问题”,答案是两个:
① 内存限制。 ArrayBlockingQueue 在 JVM 堆里,容量是有限的(代码里写 1024 × 1024,看着很大,但一台机器的内存总有限);服务重启、宕机,队列里还没写库的订单全部消失。
② 数据安全。 这个队列没有持久化,也没有”消费成功确认”机制,更没有失败重试和补偿。任务丢了就是丢了——用户看到”抢购成功”,但订单永远不会有。
这两个问题正是下一课要解决的:把 JVM 内部的阻塞队列换成 Redis 的消息队列。
| 方案 | 持久化 | 消费确认 | 适用 |
|---|---|---|---|
| JVM 阻塞队列(现在) | 无 | 无 | 单体、可容忍丢任务 |
| Redis List | 有 | 无(读完就删) | 简单队列 |
| Redis PubSub | 无 | 无 | 实时广播,不要求可靠 |
| Redis Stream | 有 | 有(ACK + 消费者组) | 可靠的消息队列 |
九、代码
| 文件 | 这一模块的内容 |
|---|---|
resources/seckill.lua | 库存 + 一人一单的原子判断,返回 0 / 1 / 2 |
VoucherOrderServiceImpl.seckillVoucher() | 执行脚本 → 有资格就封装订单 → 入队 → 立即返回 |
VoucherOrderServiceImpl(字段) | orderTasks 阻塞队列、SECKILL_ORDER_EXECUTOR 单线程池、proxy 代理对象 |
VoucherOrderServiceImpl.init() | @PostConstruct,启动时把消费者线程跑起来 |
VoucherOrderHandler | 死循环 take() → handleVoucherOrder(),异常在循环内捕获 |
VoucherOrderServiceImpl.handleVoucherOrder() | 加锁 → 代理调用 → 释放锁 |
VoucherOrderServiceImpl.createVoucherOrder() | 数据库兜底:一人一单 + CAS 扣库存 + 保存订单 |
VoucherServiceImpl.addSeckillVoucher() | 新增秒杀券时把库存预热进 Redis |
IVoucherOrderService | createVoucherOrder 的参数从 Long voucherId 改成 VoucherOrder |