黑马点评·秒杀优化源码解读
目录
- 0. 业务问题:为什么要优化秒杀
- 1. 优化后的整体结构
- 2. 基于 Redis 完成秒杀资格判断
- 3. 基于阻塞队列实现异步下单
- 4. 涉及的关键技术原理
- 5. 异常、边界与已知问题
- 6. 优化前后对比
- 7. 总结
- 8. 理解检查题
- 9. 理解检查题参考答案与逐题反馈
- 附录:源码索引
0. 业务问题:为什么要优化秒杀
0.1 秒杀的业务规则
秒杀优惠券的下单必须满足:
- 活动在开始时间之后、结束时间之前;
- 库存不能超卖;
- 一个用户对同一张券只能下一单。
0.2 优化前的同步流程
旧实现还保留在 VoucherOrderServiceImpl.java:149-189 的注释里。它的每一次请求都要:
请求线程
├─ 查数据库 tb_seckill_voucher(getById)
├─ 校验开始/结束时间
├─ 校验库存
├─ 获取 Redisson 用户锁
├─ 开启事务
│ ├─ 查 tb_voucher_order 是否已下单
│ ├─ update tb_seckill_voucher 扣库存
│ └─ insert tb_voucher_order
└─ 提交事务、释放锁、返回
问题在于:所有校验和写库都压在请求线程上。秒杀的特点是瞬时并发极高、库存极少,绝大多数请求注定失败,但它们仍然会:
- 访问数据库;
- 竞争同一张券的行锁;
- 争抢分布式锁;
- 占用 Tomcat 工作线程。
数据库和锁会成为瓶颈,吞吐量上不去。
0.3 优化的目标
把秒杀拆成两个阶段:
| 阶段 | 执行者 | 做什么 | 目标 |
|---|---|---|---|
| 资格判断 | 请求线程 + Redis | 判断有没有库存、是否重复下单,并原子扣减 | 快,尽量不碰数据库 |
| 下单落库 | 后台消费者线程 | 真正写订单、扣数据库库存 | 慢一点没关系,串行处理 |
核心思想:先快速判断“有没有资格”,有资格才进入后续的下单流程。
1. 优化后的整体结构
这里有两条时间线:
- 请求线程:只做 Redis 操作和入队,速度快;
- 消费者线程:在后台串行地做数据库事务,速度慢但不阻塞用户。
2. 基于 Redis 完成秒杀资格判断
2.1 Redis 里存了什么
| key | 类型 | 含义 |
|---|---|---|
seckill:stock:{voucherId} | String | 该券的剩余库存 |
seckill:order:{voucherId} | Set | 已经抢到该券的用户 id 集合 |
库存 key 的常量定义在 RedisConstants.java:20:
public static final String SECKILL_STOCK_KEY = "seckill:stock:";
订单集合 key 没有常量,seckill.lua:16 直接拼接:
local orderKey="seckill:order:"..voucherId
2.2 库存是怎么进入 Redis 的
不是在 Lua 里初始化,而是在新增秒杀券时写入的。VoucherServiceImpl.java:48-60:
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 保存秒杀库存到 redis 中
stringRedisTemplate.opsForValue()
.set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
要点:
- 数据库和 Redis 都保存了库存;
seckill:order:{voucherId}不需要提前创建,第一次SADD时会自动创建;- 如果这个 key 不存在(比如 Redis 被清空、券是在这段代码之前加的),Lua 里的库存判断必须能处理
nil,否则会报错。
2.3 Lua 脚本逐行解读(当前真实代码)
src/main/resources/seckill.lua:
--参数列表
--1.1,优惠券id
local voucherId=ARGV[1]
--1.2,用户id
local userId=ARGV[2]
--2.数据key
--2.1库存key
local stockKey="seckill:stock:"..voucherId
--2.1订单key
local orderKey="seckill:order:"..voucherId
--3.脚本业务
--判断库存是否充足
if(tonumber(redis.call('get',stockKey)<=0)) then
return 1
end
--判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId)==1) then
return 2
end
--扣库存 incrby stockKey -1
redis.call('incyby',stockKey,-1)
--下单(保存用户),sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0
脚本按顺序做四件事:
- 读库存;
- 判断库存是否充足;
- 判断用户是否已经在
seckill:order:{voucherId}集合里; - 扣库存并把用户加入集合。
⚠️ 当前脚本有两处会导致执行失败的问题
问题 1:tonumber 的括号位置错了(seckill.lua:20)
if(tonumber(redis.call('get',stockKey)<=0)) then
实际解析结果是:
tonumber( redis.call('get', stockKey) <= 0 )
redis.call('get', stockKey) 返回的是字符串(例如 "100"),Lua 用 <= 比较字符串和数字会直接报错:
attempt to compare string with number
如果 key 不存在,redis.call('get') 返回 false,则报:
attempt to compare boolean with number
所以这个分支一执行,脚本就会抛错,Spring Data Redis 会把异常包装成 RedisSystemException,最终被 WebExceptionAdvice.java:12 捕获,接口返回 "服务器异常"。
正确写法应该是:
local stock = redis.call('get', stockKey)
if (not stock or tonumber(stock) <= 0) then
return 1
end
问题 2:命令名拼写错误(seckill.lua:28)
redis.call('incyby',stockKey,-1)
Redis 没有 incyby 这个命令,正确是 incrby。即使修好了问题 1,扣库存这一步仍然会报:
Unknown Redis command called from script
问题 3(边界):库存 key 不存在
当前写法没有处理 stock 为 nil/false 的情况。即使把括号改对,tonumber(false) 返回 nil,nil <= 0 仍然会报错。生产代码应该显式把“库存 key 不存在”当作库存不足。
修正后的脚本(建议,未应用到项目)
-- 参数
local voucherId = ARGV[1]
local userId = ARGV[2]
-- key
local stockKey = "seckill:stock:" .. voucherId
local orderKey = "seckill:order:" .. voucherId
-- 1. 判断库存是否充足(key 不存在也视为库存不足)
local stock = redis.call('get', stockKey)
if (not stock or tonumber(stock) <= 0) then
return 1
end
-- 2. 判断用户是否已经下单
if (redis.call('sismember', orderKey, userId) == 1) then
return 2
end
-- 3. 扣库存、记录用户
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)
return 0
注意:这里仍然通过
ARGV拼接 key。单机 Redis 没问题;Redis Cluster 要求脚本访问的 key 必须显式放在KEYS里,否则会被判定为跨槽访问。当前 Java 侧传的是Collections.emptyList(),属于单机写法。
2.4 Java 侧如何调用脚本
VoucherOrderServiceImpl.java:58-63 先加载脚本:
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
seckillVoucher 中执行(VoucherOrderServiceImpl.java:115-130):
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户
Long userId = UserHolder.getUser().getId();
// 执行 lua 脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // KEYS:当前脚本没有使用
voucherId.toString(), userId.toString() // ARGV[1], ARGV[2]
);
// 判断结果是否为 0
int r = result.intValue();
if (r != 0) {
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
...
}
参数对应关系:
| Java 参数 | Lua 变量 | 含义 |
|---|---|---|
Collections.emptyList() | KEYS | 当前脚本不使用 KEYS |
voucherId.toString() | ARGV[1] | 优惠券 id |
userId.toString() | ARGV[2] | 用户 id |
返回值约定:
| 返回值 | 含义 | Java 处理 |
|---|---|---|
0 | 有购买资格,Redis 库存已扣减、用户已记录 | 继续生成订单并入队 |
1 | 库存不足 | Result.fail("库存不足") |
2 | 用户重复下单 | Result.fail("不能重复下单") |
2.5 为什么必须用 Lua
如果不用脚本,而是在 Java 里分四步调用:
get stock
sismember order
decr stock
sadd order
在并发下会出现两类问题:
超卖:
库存 = 1
线程A: get → 1
线程B: get → 1
线程A: decr → 0
线程B: decr → -1 // 库存变成负数,两个人都认为自己有资格
重复下单:
用户U 同时发两个请求
线程A: sismember → 0
线程B: sismember → 0
线程A: sadd
线程B: sadd // 集合里只有一个 U,但两个请求都通过了
Lua 脚本在 Redis 中作为一个整体执行,脚本运行期间不会插入其他客户端的命令,所以“读库存 → 判断 → 扣减 → 记录用户”是不可分割的。这和第 5 节分布式锁用 Lua 解锁是同一个原理。
2.6 一个行为变化:不再校验活动时间
旧实现(VoucherOrderServiceImpl.java:149-189 的注释)会校验:
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { ... }
if (voucher.getEndTime().isBefore(LocalDateTime.now())) { ... }
新实现只执行 Lua,没有查询数据库,也没有校验开始/结束时间。也就是说:
- 活动开始前、结束后,只要 Redis 里还有库存,请求仍然可能通过资格判断;
- 这是当前的实现事实,不是推测。
如果业务仍然要求时间窗口,需要在 Lua 里增加时间判断,或者在进入 Redis 前做一次轻量校验。
3. 基于阻塞队列实现异步下单
Redis 只完成了“资格判断 + 预扣库存”,真正的订单还没有写入数据库。接下来要把下单动作交给后台线程。
3.1 队列和消费者的初始化
VoucherOrderServiceImpl.java:65-71:
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
private static final ExecutorService SECKILL_ORDER_EXECUTOR =
Executors.newSingleThreadExecutor();
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
三个组成部分:
| 组件 | 作用 |
|---|---|
ArrayBlockingQueue<VoucherOrder> | 有界阻塞队列,存放待落库的订单 |
newSingleThreadExecutor() | 单线程线程池,保证同一个 JVM 内顺序处理 |
@PostConstruct init() | Bean 初始化完成后启动消费者 |
@PostConstruct 是 JSR-250 注解,Spring 在完成依赖注入之后、Bean 投入使用之前调用它。所以应用启动完成后,消费者线程已经在 take() 上阻塞等待了。
3.2 生产者:请求线程把订单放进队列
VoucherOrderServiceImpl.java:131-146:
// 为 0,有购买资格,把下单信息保存到阻塞队列
VoucherOrder voucherOrder = new VoucherOrder();
// 订单 id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户 id
voucherOrder.setUserId(userId);
// 代金券 id
voucherOrder.setVoucherId(voucherId);
// 放入阻塞队列
orderTasks.add(voucherOrder);
// 获取代理对象(事务)
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 返回订单 id
return Result.ok(orderId);
关键点:
- 订单号在入队前生成。
RedisIdWorker.nextId("order")使用“时间戳 + Redis 自增序列”生成全局唯一 id; tb_voucher_order的主键是@TableId(type = IdType.INPUT)(见VoucherOrder.java),数据库不会自动生成 id,所以必须由应用生成;- 订单号已经通过
Result.ok(orderId)返回给前端,后台落库必须使用同一个 id,所以传递的是整个VoucherOrder对象; orderTasks.add()是非阻塞的:队列没满就立即成功;队列满时抛IllegalStateException。
3.3 消费者:后台线程取订单
VoucherOrderServiceImpl.java:73-89:
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 创建订单
handleVoucherOrder(voucherOrder);
} catch (InterruptedException e) {
log.error("处理订单异常", e);
}
}
}
}
take()是阻塞方法:队列为空时线程挂起,不消耗 CPU;- 单线程执行器保证同一个实例内订单是串行处理的;
while(true)让它持续消费。
3.4 处理订单:分布式锁 + 事务落库
VoucherOrderServiceImpl.java:91-111:
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 获取用户
Long userId = voucherOrder.getUserId();
// 创建锁对象
RLock lock = redissonClient.getLock("order:" + userId);
// 获取锁
boolean isLock = lock.tryLock();
// 判断是否获取锁成功
if (!isLock) {
log.error("不允许重复下单!");
return;
}
try {
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
lock.unlock();
}
}
为什么 Lua 已经判过重复下单,这里还要加锁?
- Lua 的
SISMEMBER保护的是 Redis 这一层的判断; - 数据库里的“查询是否已下单 → 插入订单”是两步操作,不同实例的消费者线程可能同时执行;
- 这把锁按用户粒度串行化数据库写流程,是第二道保险;
- 锁在消费者线程里,不会拖慢请求线程的响应速度。
3.5 真正落库:createVoucherOrder
VoucherOrderServiceImpl.java:192-218:
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
// 一人一单
Long userId = voucherOrder.getUserId();
Long voucherId = voucherOrder.getVoucherId();
// 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
log.error("用户已经购买过一次!");
return;
}
// 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!success) {
log.error("库存不足!");
return;
}
// 保存订单(id、userId、voucherId 已在 seckillVoucher 中生成)
save(voucherOrder);
}
这一层做了三件事:
- 数据库查重:防止 Redis 数据丢失、消息重复等情况下重复下单;
- 条件扣库存:
where stock > 0,数据库层面的最后一道防超卖; - 保存订单:使用上游已经生成好的 id。
@Transactional 保证这三步在同一个事务里。方法通过 proxy.createVoucherOrder(...) 调用,走的是 Spring 代理,事务才会生效;如果直接 this.createVoucherOrder(...),事务不会开启。
3.6 订单号的生成
RedisIdWorker.nextId:
long timestamp = nowSecond - BEGIN_TIMESTAMP;
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return timestamp << COUNT_BITS | count;
- 高 32 位是相对时间戳(秒);
- 低 32 位是当天该业务的自增序列(Redis
INCR); - 用
<<和|拼成一个long。
这样得到的 id 全局唯一、趋势递增,而且不依赖数据库自增。
4. 涉及的关键技术原理
4.1 Redis Lua 的原子性
- Redis 命令执行是单线程的;
- 整个 Lua 脚本作为一条命令执行,期间不会插入其他客户端的命令;
- 因此脚本内部的多次读写对外是一个整体。
注意:Lua 保证“不被交错执行”,不保证回滚。脚本中途报错,前面执行过的写命令不会撤销。所以脚本要短小、可预期。
4.2 阻塞队列 ArrayBlockingQueue
- 基于数组的有界队列;
- 线程安全;
add:队列满时抛IllegalStateException;put:队列满时阻塞等待;take:队列空时阻塞等待;- 当前代码用的是
add+take,即“生产不等待、消费等待”。
4.3 单线程消费者
Executors.newSingleThreadExecutor() 内部只有一个工作线程,队列中的任务按提交顺序执行。在这里,消费任务本身是一个 while(true) 循环,所以整个 JVM 内只有一个线程在顺序处理订单。
优点:
- 实现简单;
- 订单落库顺序可控;
- 不需要处理并发写同一张券的数据库竞争(单实例内)。
局限:
- 吞吐量受单线程限制;
- 一个异常如果没有捕获,整个消费循环会停止。
4.4 @PostConstruct
Spring 在 Bean 实例化、依赖注入完成后调用被 @PostConstruct 标记的方法。这里用它启动消费者,保证应用启动后队列马上有人消费。
4.5 Spring 代理与 @Transactional
createVoucherOrder 上的事务注解只有通过代理调用才生效。AopContext.currentProxy() 依赖启动类上的:
@EnableAspectJAutoProxy(exposeProxy = true)
(HmDianPingApplication.java:8)。没有这个配置,currentProxy() 会抛 IllegalStateException。
5. 异常、边界与已知问题
以下按“代码事实”和“可选改进”区分。
5.1 已确认的问题
(1)seckill.lua 当前无法正常执行
- 位置:
seckill.lua:20 - 原因:
<= 0被写进了redis.call的结果比较里,实际变成“字符串 <= 数字”,Lua 直接报错; - 影响:所有秒杀请求都会走异常分支,接口返回“服务器异常”;
- 位置:
seckill.lua:28 - 原因:
incyby拼写错误,正确是incrby; - 影响:即使修好第一处,扣库存仍然失败。
(2)时间窗口校验丢失
- 位置:
VoucherOrderServiceImpl.java:115-147 - 事实:新实现只执行 Lua,没有查询
begin_time/end_time; - 影响:活动开始前、结束后仍可能抢到券;
- 对比:旧实现(注释代码
:149-189)有校验。
(3)消费者只捕获 InterruptedException
- 位置:
VoucherOrderServiceImpl.java:83 - 事实:
handleVoucherOrder内部的RedisConnectionException、数据库异常、NullPointerException等运行时异常不会被捕获; - 后果:异常从
run()抛出。由于线程是用submit()提交的,异常被Future吞掉,不会打印完整堆栈,消费循环结束,队列只进不出; - 建议:在
while循环里catch (Exception e),记录日志并继续消费。
(4)proxy 字段的赋值顺序有竞态
- 位置:
VoucherOrderServiceImpl.java:141-144 - 事实:先
orderTasks.add(voucherOrder),后proxy = AopContext.currentProxy(); - 后果:消费者可能在
proxy赋值前就取到订单,第一次请求时proxy为null,handleVoucherOrder里抛NullPointerException,再叠加问题(3),消费者直接停掉; - 另外:
proxy是普通实例字段,没有volatile,跨线程可见性也没有保证; - 建议:先给
proxy赋值,再入队。ArrayBlockingQueue的内部锁会为入队前的写操作提供 happens-before 关系。
(5)队列满时 Redis 已扣库存
- 位置:
VoucherOrderServiceImpl.java:141 - 事实:
ArrayBlockingQueue.add在队列满时抛IllegalStateException; - 后果:Lua 已经扣了库存、把用户加入了集合,但订单没有入队,请求返回“服务器异常”,库存和资格都白白消耗;
- 说明:队列容量是 1024×1024,实际很难满,但这是有界队列的固有行为;
- 可选:改用
put()阻塞,或捕获异常后做补偿(回滚 Redis 库存和集合)。
(6)进程崩溃会丢订单
- 位置:整个阻塞队列方案
- 事实:
ArrayBlockingQueue在 JVM 内存里,不是持久化队列; - 后果:应用重启/崩溃时,队列中未处理的订单全部丢失;但 Redis 的库存已经扣减、用户已经记录,数据库里却没有订单;
- 影响:该用户无法再次下单,库存也少了一份;
- 这是“内存队列”方案的固有限制。课程后续会换成 Redis Stream + 消费者组来解决持久化与确认(ACK)问题。
(7)Redis 与数据库的双写不一致
- 位置:
seckillVoucher扣 Redis 库存;createVoucherOrder扣数据库库存 - 事实:两边的扣减不是同一个事务;
- 可能的后果:
- Redis 扣了,数据库扣失败(例如数据库库存为 0)→ 订单丢弃,Redis 库存少一份;
- Redis 扣了,进程崩溃 → 同上;
- 当前没有任何补偿机制。
(8)返回订单号时订单还没落库
- 位置:
VoucherOrderServiceImpl.java:146 - 事实:
Result.ok(orderId)在入队后立即返回; - 后果:用户立刻查询订单,可能查不到;如果后台落库失败,前端已经拿到了“成功”的订单号;
- 这是异步化的必然代价,属于最终一致性模型。
5.2 设计权衡
| 选择 | 好处 | 代价 |
|---|---|---|
| Redis 预扣库存 | 请求线程不碰数据库,吞吐高 | Redis 与 DB 可能不一致 |
| Lua 原子判断 | 不超卖、不重复 | 脚本要自己保证正确性,调试更难 |
| 阻塞队列 | 实现简单、无需额外中间件 | 内存态、不持久、单实例 |
| 单线程消费者 | 顺序处理、实现简单 | 吞吐受限、异常会停掉消费 |
| 先返回订单号 | 响应快 | 前端看到的是“受理成功”,不是“落库成功” |
5.3 可选改进方向
- 修复
seckill.lua的两处错误和nil边界; - 在 Lua 或入口补回活动时间校验;
- 消费者循环捕获
Exception,避免线程静默退出; - 把
proxy的赋值移到入队之前,或者改为在消费者里通过applicationContext.getBean获取代理; - 改用持久化消息队列(Redis Stream、RabbitMQ、Kafka)替代内存队列;
- 增加 Redis 与数据库的对账/补偿任务;
- Redis Cluster 场景下把 key 放进
KEYS,避免跨槽问题。
6. 优化前后对比
| 维度 | 优化前(同步) | 优化后(Redis + 队列) |
|---|---|---|
| 请求线程做什么 | 查库、校验、加锁、事务写库 | 执行 Lua、生成 id、入队 |
| 数据库压力 | 每个请求都访问 | 只有抢到资格的请求才访问 |
| 响应时间 | 取决于数据库和锁 | 主要是 Redis 往返 |
| 并发能力 | 受数据库和锁限制 | 大幅提升 |
| 超卖防护 | 分布式锁 + SQL 条件更新 | Lua 原子扣减 + SQL 条件更新 |
| 一人一单 | 分布式锁 + 数据库查重 | Redis Set + 分布式锁 + 数据库查重 |
| 一致性 | 同步,返回即落库 | 最终一致,返回时可能尚未落库 |
| 可靠性 | 进程崩溃只影响当前请求 | 队列内未处理订单会丢失 |
| 实现复杂度 | 较低 | 较高,需要处理补偿和异常 |
7. 总结
- 资格判断前移:把“库存是否充足、用户是否重复下单、扣减库存、记录用户”放进一个 Lua 脚本,在 Redis 中原子完成,请求线程不再访问数据库。
- 异步下单:抢到资格的请求只把订单对象放入
ArrayBlockingQueue,由单线程消费者在后台完成数据库查重、扣库存、插入订单。 - 订单号前置生成:
RedisIdWorker在入队前生成全局唯一 id,并立即返回给前端,后台落库使用同一个 id。 - 两道防线:Redis 的 Lua 是第一道,数据库的条件更新 + 分布式锁是第二道。
- 当前实现的硬伤:
seckill.lua有两处语法/命令错误,按现状秒杀请求会直接异常;消费者异常处理、proxy赋值顺序、队列持久化也存在明显风险。
8. 理解检查题
-
业务与一致性:优化后的
seckillVoucher为什么可以在订单还没写入数据库时就返回订单号?这样做的收益是什么,代价是什么? -
Lua 原子性:如果把
seckill.lua里的四步拆成 Java 里的四次 Redis 调用,请构造一个并发场景,说明什么情况下会超卖、什么情况下会重复下单。 -
Redis 数据模型:
seckill:stock:{voucherId}和seckill:order:{voucherId}分别是什么类型、存什么内容?库存 key 是在哪里初始化的?如果这个 key 不存在,当前脚本会发生什么? -
异步链路:请完整描述一个
VoucherOrder从 Lua 返回 0 到最终写入数据库的路径。订单号为什么必须在入队前生成?如果应用在入队后、落库前崩溃,会发生什么? -
锁的作用:Lua 已经用
SISMEMBER判断了重复下单,为什么消费者里的handleVoucherOrder还要再加一把 Redisson 分布式锁?这把锁保护的是哪一步?
9. 理解检查题参考答案与逐题反馈
第 1 题:业务与一致性
标准答案
可以提前返回,是因为返回订单号只代表“资格判断通过、订单已受理”,不代表“订单已经落库”。
优化后的请求线程只做了三件事:执行 Lua 扣 Redis 库存、生成订单号、把订单对象放入阻塞队列。真正的数据库写入由后台消费者完成。因此请求线程不需要等待数据库事务提交。
收益:
- 响应时间从“数据库 + 分布式锁 + 事务”缩短为“Redis 往返 + 入队”;
- 数据库只处理真正抢到资格的少量请求,压力大幅下降;
- 即使数据库慢,也不会阻塞大量秒杀请求。
代价:
- 最终一致性:返回时订单可能还没写进数据库,用户立刻查询可能查不到;
- 失败不可见:如果后台落库失败、进程崩溃、队列丢单,前端已经拿到了订单号,但订单可能不存在;
- 需要补偿机制:Redis 扣了库存、数据库没订单,需要额外手段对账或回滚。
常见误区
- ❌ “返回订单号就说明下单成功了。”——这里只是“有资格、已受理”。
- ❌ “异步化没有代价,只是把代码换个地方执行。”——一致性、可靠性、可观测性都变复杂了。
- ❌ “数据库最终一定会写成功。”——进程崩溃、队列丢失、事务异常都可能让订单永远不出现。
第 2 题:Lua 原子性
标准答案
如果拆成四次独立调用:
1. GET seckill:stock:{voucherId}
2. SISMEMBER seckill:order:{voucherId} userId
3. DECR seckill:stock:{voucherId}
4. SADD seckill:order:{voucherId} userId
超卖场景:
库存 = 1
线程A: GET → "1"
线程B: GET → "1"
线程A: DECR → 0
线程B: DECR → -1
两个请求都认为自己有资格,库存变成负数,最终可能生成两张订单(数据库的 stock > 0 条件更新可能挡住第二张,但 Redis 这一层已经超卖了)。
重复下单场景:
用户 U 同时发两个请求
线程A: SISMEMBER → 0
线程B: SISMEMBER → 0
线程A: SADD
线程B: SADD
两次判断都在对方写入之前完成,两个请求都通过。
Lua 把“读库存、判断、扣减、记录用户”放在一个脚本里,Redis 执行脚本期间不会插入其他命令,所以这两个交错都不会发生。
常见误区
- ❌ “Redis 是单线程的,所以四条命令天然原子。”——单条命令原子,多条命令之间会被其他客户端插队。
- ❌ “Lua 像一个事务,出错会回滚。”——Lua 保证不被交错执行,不保证回滚。
- ❌ “用 WATCH/MULTI 也一样。”——WATCH 是乐观锁,需要客户端重试;Lua 可以在服务端直接做条件分支。
第 3 题:Redis 数据模型
标准答案
seckill:stock:{voucherId}:String 类型,值是剩余库存的字符串,例如"100";seckill:order:{voucherId}:Set 类型,成员是已经抢到该券的用户 id。
库存 key 在新增秒杀券时初始化(VoucherServiceImpl.java:58):
stringRedisTemplate.opsForValue()
.set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
订单集合不需要提前创建,第一次 SADD 时 Redis 会自动创建。
如果库存 key 不存在:
- 按当前脚本:
redis.call('get', ...)返回false,随后false <= 0会报“attempt to compare boolean with number”,脚本异常; - 按修正后的脚本:
not stock成立,返回1,即“库存不足”。
所以部署或清库之后,必须确保库存 key 已经初始化。
常见误区
- ❌ “库存 key 是 Lua 脚本自动创建的。”——脚本只读和扣减,不会初始化。
- ❌ “订单 Set 需要提前建好。”——第一次
SADD自动创建。 - ❌ “每次请求都会查数据库库存。”——新实现只读 Redis,数据库库存只在异步落库时扣减。
第 4 题:异步链路
标准答案
完整路径:
1. seckillVoucher 执行 seckill.lua,返回 0
2. 从 UserHolder 取出 userId
3. redisIdWorker.nextId("order") 生成订单号
4. new VoucherOrder,设置 id、userId、voucherId
5. orderTasks.add(voucherOrder)
6. proxy = AopContext.currentProxy()
7. 返回 Result.ok(orderId)
8. 消费者线程 orderTasks.take() 取出订单
9. handleVoucherOrder:redissonClient.getLock("order:" + userId).tryLock()
10. proxy.createVoucherOrder(voucherOrder)
11. @Transactional 中:查询是否重复 → 条件扣库存 → save
12. finally 中 unlock()
订单号必须在入队前生成,原因有两个:
tb_voucher_order.id是IdType.INPUT,数据库不会自动生成主键;- 订单号要立即返回给前端,后台落库必须使用同一个 id,不能重新生成。
如果应用在入队后、落库前崩溃:
- 队列在 JVM 内存中,未处理的订单丢失;
- Redis 库存已经扣减、用户已经加入
seckill:order:{voucherId}; - 数据库中没有对应订单;
- 该用户无法再次抢同一张券,库存也少了一份,形成数据不一致;
- 当前代码没有补偿机制。
常见误区
- ❌ “阻塞队列在 Redis 里,不会丢。”——
ArrayBlockingQueue在 JVM 内存里。 - ❌ “订单号可以落库时再由数据库生成。”——实体主键是
IdType.INPUT,且前端已经拿到 id。 - ❌ “崩溃只影响响应,不影响数据。”——Redis 已经扣减,数据库没有订单。
- ❌ “消费者只有一个线程,所以不会有并发问题。”——只在一个 JVM 内成立,多实例仍然有并发。
第 5 题:锁的作用
标准答案
Lua 的 SISMEMBER 保护的是 Redis 层的重复下单判断,但它不能替代数据库层的锁,原因有三点:
- 数据库的“查重 + 插入”不是原子操作:
count(...)和save(...)是两条 SQL,中间可能被其他线程插入; - 多实例部署:每个应用实例都有自己的消费者线程和阻塞队列,不同实例的消费者可能同时处理同一个用户的订单;
- 第二道保险:如果 Redis 数据丢失、被清空,或者消息被重复投递,数据库查重仍需要被串行化。
handleVoucherOrder 里的 redissonClient.getLock("order:" + userId) 按用户粒度串行化“数据库查重 → 扣库存 → 插入订单”这一整段,保证同一用户的订单不会并发写入。
它放在消费者线程里,而不是请求线程里,所以不会增加秒杀请求的响应时间。
常见误区
- ❌ “Lua 已经判重了,这把锁是多余的。”——Lua 保护 Redis,锁保护数据库写入,层次不同。
- ❌ “单线程消费者不需要锁。”——只在单个 JVM 内成立;多实例、消息重复、Redis 数据异常时仍需要。
- ❌ “锁是为了防止超卖。”——超卖主要由数据库的
where stock > 0和 Lua 扣减保证;这把锁的主要目标是同一用户的重复下单。 - ❌ “锁应该加在请求线程里。”——那样会拖慢响应;放在消费者里才是异步化的意义。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2503_94545876/article/details/168765513




