day36 秒杀实现与悲观锁解决超卖

day36 秒杀实现与悲观锁解决超卖

一、秒杀功能概述

1.1 需求背景

秒杀是电商系统中的经典高并发场景:限时、限量、低价,大量用户在同一时刻抢购少数商品,核心挑战是防止超卖和保证系统稳定。

本项目(hm-dianping)中实现了优惠券秒杀功能,秒杀券是一种特殊的优惠券,有独立的库存和抢购时间限制。

1.2 数据模型

秒杀券采用两张表的设计:

表名 作用 核心字段
tb_voucher 优惠券主表,普通券和秒杀券都存于此,用字段区分类型 id、title、type、value 等通用信息
tb_seckill_voucher 秒杀券专属表,存秒杀特有信息 voucher_id(关联优惠券id)、stock(库存)、begin_time、end_time

对应实体类:Voucher 和 SeckillVoucher,其中 SeckillVoucher 的主键是 voucher_id,与主表一对一关联。

秒杀订单表为 tb_voucher_order,对应实体 VoucherOrder,包含用户 id、券 id、支付方式、订单状态等字段。


二、秒杀下单基础实现

2.1 下单流程

秒杀下单的核心校验和操作步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
前端请求(POST /voucher-order/seckill/{id})
↓
Controller 接收 voucherId
↓
Service 查询秒杀券信息
↓
判断秒杀是否开始(beginTime)
↓
判断秒杀是否结束(endTime)
↓
判断库存是否充足
↓
扣减库存(stock = stock - 1)
↓
创建秒杀订单
↓
返回订单 id

2.2 核心代码

秒杀下单入口位于 VoucherOrderServiceImpl,接口路径为 POST /voucher-order/seckill/{id}。

秒杀业务逻辑方法 doSeckillVoucher:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public Result doSeckillVoucher(Long voucherId) {

// 1. 查询秒杀券信息
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher == null) {
return Result.fail("秒杀优惠券不存在");
}

// 2. 判断秒杀是否开始
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀未开始");
}
// 3. 判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已结束");
}

// 4. 判断库存是否足够
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}

// 5. 扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}

// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);

// 7. 返回订单id
return Result.ok(orderId);
}

代码要点:

  • 使用 RedisIdWorker 生成分布式订单 id
  • 用户 id 从 UserHolder(ThreadLocal)中获取
  • 库存扣减使用数据库层面的 stock=stock-1 原子操作
  • .gt("stock", 0) 是数据库原子条件更新,只有 stock > 0 时才执行更新,防止库存扣减到负数

2.3 库存扣减的原子条件更新

1
2
3
4
5
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 关键:只有 stock > 0 才更新
.update();

这行代码的 SQL 等价于:

1
2
3
UPDATE tb_seckill_voucher
SET stock = stock - 1
WHERE voucher_id = ? AND stock > 0;

数据库的单行 UPDATE 本身是原子的,加上 stock > 0 的条件,可以从根本上杜绝超卖。这是一种数据库原子条件更新,利用 UPDATE 语句本身的原子性防止库存扣减到负数。

即使加了悲观锁,也建议保留 .gt("stock", 0) 作为双重保险。


三、超卖问题与悲观锁

3.1 超卖问题分析

现象:卖出的商品数量超过了实际库存。

根本原因:

1
2
3
4
5
线程1:查询库存 → 判断充足 → (还没扣减)
线程2:查询库存 → 判断充足 → (还没扣减)
线程3:查询库存 → 判断充足 → (还没扣减)
...
多个线程同时判断库存充足,然后各自扣减 → 最终超卖

在高并发场景下,”查询库存 → 判断 → 扣减” 这三步不是原子操作,多个线程可以同时进入”库存充足”的判断分支,导致超卖。

3.2 悲观锁思路

悲观锁:认为线程安全问题一定会发生,因此在操作之前先加锁,让线程串行执行。

常见的悲观锁实现:

  • JVM 层面:synchronized、ReentrantLock
  • 分布式层面:Redis 分布式锁、数据库行锁等

特点:

  • 保证线程安全,不会出现超卖
  • 串行执行,吞吐量低,性能受影响
  • 实现简单,容易理解

3.3 正确实现:synchronized + 编程式事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder>
implements IVoucherOrderService {

@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private TransactionTemplate transactionTemplate;

@Override
public Result seckillVoucher(Long voucherId) {
// 类级别锁,所有秒杀请求串行执行
synchronized (VoucherOrderServiceImpl.class) {
// 编程式事务:确保事务在锁内部提交
return transactionTemplate.execute(status -> doSeckillVoucher(voucherId));
}
}

public Result doSeckillVoucher(Long voucherId) {
// ... 秒杀业务逻辑(查询、校验、扣库存、建订单)
}
}

关键要点:

  1. 锁的范围要包住整个事务:synchronized 在 transactionTemplate.execute() 外面,确保事务提交后才释放锁
  2. 使用类级别锁:VoucherOrderServiceImpl.class 作为锁对象,同一 JVM 内所有秒杀请求共享同一把锁
  3. 事务在锁内部提交:只有事务提交(库存扣减落库)后,下一个线程才能进入并查询到最新库存

重要限制:synchronized 是 JVM 进程内锁,仅限单机单实例有效。 集群或多实例部署时,每台服务器有独立的 JVM 和各自的 VoucherOrderServiceImpl.class 对象,锁互不感知,会直接失效导致超卖。生产环境集群必须使用 Redis 分布式锁等跨进程方案替代。此外,类级别锁会把所有秒杀请求全部串行化,吞吐量极差,不适合真实高并发场景。本方案仅适合单机学习演示。

为什么不能用 @Transactional + synchronized?见下一节的错误示范分析。


四、两种常见错误写法

4.1 错误一:锁释放早于事务提交

1
2
3
4
5
6
7
8
9
10
11
12
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
synchronized (VoucherOrderServiceImpl.class) {
// 查询秒杀信息
// 判断时间
// 判断库存
// 更新库存
}
// 创建订单
return Result.ok(orderId);
}

问题分析:

1
2
3
4
5
6
synchronized 代码块结束 → 释放锁
↓(此时事务还未提交!)
@Transactional 方法结束 → Spring 提交事务
↓
其他线程在锁释放后、事务提交前的窗口期内
查询到的仍是未扣减的旧库存 → 超卖

@Transactional 的事务提交发生在方法返回后、由 Spring AOP 代理完成,而 synchronized 在方法内部就结束了。锁释放早于事务提交,中间存在窗口期,其他线程可以读到旧数据。

4.2 错误二:同类调用导致 @Transactional 失效

1
2
3
4
5
6
7
8
9
10
11
@Override
public Result seckillVoucher(Long voucherId) {
synchronized (VoucherOrderServiceImpl.class) {
return doSeckillVoucher(voucherId); // this.doSeckillVoucher()
}
}

@Transactional
public Result doSeckillVoucher(Long voucherId) {
// ... 业务逻辑
}

问题分析:

@Transactional 基于 Spring 动态代理实现。外部类注入的是代理对象,调用代理对象的方法时,代理会在方法前后开启/提交事务。

但在同一个类内部,seckillVoucher 调用 doSeckillVoucher 实际上是 this.doSeckillVoucher(),this 是原始对象,不是代理对象,因此 @Transactional 注解不会生效,事务不会被正确管理。


五、编程式事务 TransactionTemplate

5.1 声明式事务 vs 编程式事务

维度 声明式事务 @Transactional 编程式事务 TransactionTemplate
使用方式 注解标注,简洁优雅 代码中手动编写事务边界
灵活性 受限于方法级别,细粒度控制难 可精确控制事务范围
适用场景 大多数常规业务 需要精细控制事务、或同类调用场景
侵入性 低,业务代码无感知 高,业务代码与事务代码混合

5.2 底层原理

编程式事务的底层是 PlatformTransactionManager,手动控制事务的开启、提交和回滚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Autowired
private PlatformTransactionManager transactionManager;

public Result someMethod() {
TransactionStatus status = transactionManager.getTransaction(
new DefaultTransactionDefinition());
try {
// 业务逻辑
Result result = doBusiness();
transactionManager.commit(status);
return result;
} catch (Exception e) {
transactionManager.rollback(status);
throw e;
}
}

Spring 将上述样板代码封装为 TransactionTemplate,使用更简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Autowired
private TransactionTemplate transactionTemplate;

// 有返回值
Result result = transactionTemplate.execute(status -> {
// 业务逻辑
return Result.ok(data);
});

// 无返回值
transactionTemplate.executeWithoutResult(status -> {
// 业务逻辑
});
  • execute():有返回值,适用于需要返回结果的场景
  • executeWithoutResult():无返回值,适用于纯操作型业务

5.3 依赖说明

只要项目中引入了 spring-boot-starter-jdbc 或 mybatis-plus-boot-starter,就已经包含了 spring-tx 和事务相关的类(PlatformTransactionManager、TransactionTemplate 等),不需要额外添加依赖。只需配置好数据库数据源,Spring Boot 会自动装配事务管理器。


今日总结

模块 核心要点
秒杀数据模型 普通券与秒杀券共用主表 + tb_seckill_voucher 专属表存库存和时间
下单流程 查询券 → 校验时间 → 校验库存 → 扣减库存 → 创建订单 → 返回订单id
超卖原因 “查询-判断-扣减”非原子,高并发下多线程同时判断库存充足导致超卖
悲观锁方案 synchronized 类级别锁 + TransactionTemplate 编程式事务,确保锁在事务提交后才释放;仅限单机,集群需 Redis 分布式锁
错误写法一 synchronized 在 @Transactional 方法内 → 锁释放早于事务提交 → 仍超卖
错误写法二 同类方法调用 @Transactional → this 不是代理对象 → 事务失效
库存兜底 .gt("stock", 0) 是数据库原子条件更新(非 CAS、非 MP 乐观锁),利用 UPDATE 原子性防止扣减到负数
编程式事务 TransactionTemplate 封装了手动事务管理,适合需要精细控制事务范围的场景