黑马点评 · 秒杀优化模块整理

这一模块解决的问题一句话就能说清:

旧流程每个请求要在 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,就是优化前的版本:

行号代码实际打到哪
166SeckillVoucher voucher = seckillVoucherService.getById(voucherId);MySQL:SELECT(查券的库存和起止时间)
169-179voucher.getBeginTime().isAfter(now) / isBefore(now)内存判断
182if (voucher.getStock() < 1)内存判断,用的是第 166 行查出来的快照值
187Long userId = UserHolder.getUser().getId();ThreadLocal,不查库(登录拦截器早已从 Redis 放进去)
197boolean isLock = lock.tryLock();Redis(Redisson 抢锁)
207return proxy.createVoucherOrder(voucherId);进入下一段,见 2.2

这张表澄清两个容易误解的点:时间校验和库存校验是内存判断(数据来自第 166 行那一次查询),加锁本来就是 Redis。旧流程真正重的地方,是每个请求都要把券查出来、再走一遍完整的下单流程。

2.2 证据二:那三段数据库操作现在还在,只是搬去了异步线程

旧版的 createVoucherOrder(Long voucherId) 被新版替换成了 createVoucherOrder(VoucherOrder),但里面的数据库调用一个没少:

行号代码等价 SQL
224query().eq("user_id", userId).eq("voucher_id", ...).count()SELECT COUNT(*) FROM tb_voucher_order WHERE user_id=? AND voucher_id=?
232-235seckillVoucherService.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
239save(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 = ?)。

问题有三个:

  1. 数据库往返太多:一个请求 4 次 MySQL。
  2. 还要抢分布式锁:同一用户的请求被锁串行化,吞吐量被锁死。
  3. 绝大多数请求是白跑的:秒杀的特点是”瞬间高并发、成功极少”,库存只有 100 件,可能来 10 万个请求,剩下 99900 个请求把数据库连接和 CPU 全耗掉了,最后只换来一句”库存不足”。

核心洞察:那些注定失败的请求,根本不该走到数据库。

对照一下:优化后新版的 seckillVoucher(第 121~158 行)方法体里,getById / query() / update() / save() 一个都没有,只剩 stringRedisTemplate.execute(...) 和 redisIdWorker.nextId(...) 两次 Redis 调用,可以说”请求线程只碰 Redis”是有代码依据的。


三、优化思路:把「判断」和「写库」拆开

右图就是优化后的结构,分成两段:

第一段(同步、要快):请求线程执行一个 Lua 脚本,在 Redis 里一次性完成”库存够不够 + 这个人下过单没有”的判断。

  • 通过 → 生成订单 id,把订单丢进阻塞队列,立刻返回
  • 不通过 → 直接返回失败

这一段只访问 Redis,不碰数据库,所以极快。

第二段(异步、可以慢):后台有一个常驻线程,不停从队列里取订单,加锁 + 开事务,真正去数据库扣库存、写订单。

用户拿到的是”抢购成功的订单 id”,而订单真正落库是稍后由后台线程完成的。数据库的操作次数并没有减少,但它不再挡在用户面前,而且只有真正抢到资格的那一小部分请求才会走到这里。


四、Lua 脚本:把三个动作合成一次原子操作

seckill.lua 逻辑

seckill.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 脚本的三段逻辑

  1. tonumber(redis.call('get', stockKey)) <= 0 → 库存不足,返回 1
  2. redis.call('sismember', orderKey, userId) == 1 → 重复下单,返回 2
  3. 都不满足 → 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
IVoucherOrderServicecreateVoucherOrder 的参数从 Long voucherId 改成 VoucherOrder