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 | 前端请求(POST /voucher-order/seckill/{id}) |
2.2 核心代码
秒杀下单入口位于 VoucherOrderServiceImpl,接口路径为 POST /voucher-order/seckill/{id}。
秒杀业务逻辑方法 doSeckillVoucher:
1 | public Result doSeckillVoucher(Long voucherId) { |
代码要点:
- 使用
RedisIdWorker生成分布式订单 id - 用户 id 从
UserHolder(ThreadLocal)中获取 - 库存扣减使用数据库层面的
stock=stock-1原子操作 .gt("stock", 0)是数据库原子条件更新,只有 stock > 0 时才执行更新,防止库存扣减到负数
2.3 库存扣减的原子条件更新
1 | boolean success = seckillVoucherService.update() |
这行代码的 SQL 等价于:
1 | UPDATE tb_seckill_voucher |
数据库的单行 UPDATE 本身是原子的,加上 stock > 0 的条件,可以从根本上杜绝超卖。这是一种数据库原子条件更新,利用 UPDATE 语句本身的原子性防止库存扣减到负数。
即使加了悲观锁,也建议保留
.gt("stock", 0)作为双重保险。
三、超卖问题与悲观锁
3.1 超卖问题分析
现象:卖出的商品数量超过了实际库存。
根本原因:
1 | 线程1:查询库存 → 判断充足 → (还没扣减) |
在高并发场景下,”查询库存 → 判断 → 扣减” 这三步不是原子操作,多个线程可以同时进入”库存充足”的判断分支,导致超卖。
3.2 悲观锁思路
悲观锁:认为线程安全问题一定会发生,因此在操作之前先加锁,让线程串行执行。
常见的悲观锁实现:
- JVM 层面:
synchronized、ReentrantLock - 分布式层面:Redis 分布式锁、数据库行锁等
特点:
- 保证线程安全,不会出现超卖
- 串行执行,吞吐量低,性能受影响
- 实现简单,容易理解
3.3 正确实现:synchronized + 编程式事务
1 |
|
关键要点:
- 锁的范围要包住整个事务:
synchronized在transactionTemplate.execute()外面,确保事务提交后才释放锁 - 使用类级别锁:
VoucherOrderServiceImpl.class作为锁对象,同一 JVM 内所有秒杀请求共享同一把锁 - 事务在锁内部提交:只有事务提交(库存扣减落库)后,下一个线程才能进入并查询到最新库存
重要限制:
synchronized是 JVM 进程内锁,仅限单机单实例有效。 集群或多实例部署时,每台服务器有独立的 JVM 和各自的VoucherOrderServiceImpl.class对象,锁互不感知,会直接失效导致超卖。生产环境集群必须使用 Redis 分布式锁等跨进程方案替代。此外,类级别锁会把所有秒杀请求全部串行化,吞吐量极差,不适合真实高并发场景。本方案仅适合单机学习演示。
为什么不能用
@Transactional+synchronized?见下一节的错误示范分析。
四、两种常见错误写法
4.1 错误一:锁释放早于事务提交
1 |
|
问题分析:
1 | synchronized 代码块结束 → 释放锁 |
@Transactional 的事务提交发生在方法返回后、由 Spring AOP 代理完成,而 synchronized 在方法内部就结束了。锁释放早于事务提交,中间存在窗口期,其他线程可以读到旧数据。
4.2 错误二:同类调用导致 @Transactional 失效
1 |
|
问题分析:
@Transactional 基于 Spring 动态代理实现。外部类注入的是代理对象,调用代理对象的方法时,代理会在方法前后开启/提交事务。
但在同一个类内部,seckillVoucher 调用 doSeckillVoucher 实际上是 this.doSeckillVoucher(),this 是原始对象,不是代理对象,因此 @Transactional 注解不会生效,事务不会被正确管理。
五、编程式事务 TransactionTemplate
5.1 声明式事务 vs 编程式事务
| 维度 | 声明式事务 @Transactional |
编程式事务 TransactionTemplate |
|---|---|---|
| 使用方式 | 注解标注,简洁优雅 | 代码中手动编写事务边界 |
| 灵活性 | 受限于方法级别,细粒度控制难 | 可精确控制事务范围 |
| 适用场景 | 大多数常规业务 | 需要精细控制事务、或同类调用场景 |
| 侵入性 | 低,业务代码无感知 | 高,业务代码与事务代码混合 |
5.2 底层原理
编程式事务的底层是 PlatformTransactionManager,手动控制事务的开启、提交和回滚:
1 |
|
Spring 将上述样板代码封装为 TransactionTemplate,使用更简洁:
1 |
|
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 封装了手动事务管理,适合需要精细控制事务范围的场景 |