黑马点评 秒杀优惠券单体下一人一单超卖问题
今天在学习黑马程序员的黑马点评实战项目中关于优惠券秒杀一人一单的时候碰到了一些问题,现在总结下来:
核心代码:VoucherOrderServiceImpl.java
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
/**
* 抢购秒杀优惠券
*
* @param voucherId
* @return
*/
public Result seckillVoucher(Long voucherId) {
//1.查询优惠券信息
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
//2.判断秒杀是否已开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 尚未开始
return Result.fail("秒杀尚未开始");
}
//3.判断秒杀是否已结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 已结束
return Result.fail("秒杀已经结束");
}
//4.判断秒杀优惠券库存是否充足
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}
//获取用户ID
Long userId = UserHolder.getUser().getId();
//synchronized : 基于这个字符串对象加锁,同一用户的并发请求会串行执行。
synchronized (userId.toString().intern()) {
//直接调用类内部的createVoucherOrder方法,会导致事务注解@Transactional失效,因为Spring的事务是通过代理对象来管理的
//return this.createVoucherOrder(voucherId);
//获取当前的代理对象,使用代理对象调用第三方事务方法,防止事务失效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); //获取当前类的代理对象
return proxy.createVoucherOrder(voucherId);
}
}
/**
* 通过数据库查询确保“一人一单”
* @param voucherId
* @return
*/
@Transactional // 事务注解:保证订单创建和库存扣减的原子性,并且只有事务提交后,其他请求才能看到新订单和库存变化
public Result createVoucherOrder(Long voucherId) {
//5.一人一单
Long userId = UserHolder.getUser().getId();
//5.1查询数据库中是否已经存在该用户抢购该优惠券的订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//5.2判断是否存在
if (count > 0) {
//用户已经购买过了,返回失败信息
return Result.fail("用户已购买!");
}
//6.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") //set stock = stock - 1
.eq("voucher_id", voucherId).gt("stock", 0) //where id = ? and stock > 0 数据库层面的乐观锁,避免超卖
.update();
if (!success) {
//库存扣减失败
return Result.fail("库存不足");
}
//7.创建订单(在订单表tb_voucher_order插入一条数据)
VoucherOrder voucherOrder = new VoucherOrder();
//7.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
//7.2 用户id
voucherOrder.setUserId(userId);
//7.3 代金券id
voucherOrder.setVoucherId(voucherId);
//插入到订单信息表
save(voucherOrder);
//8.返回订单id(生成唯一订单id并保存)
return Result.ok(orderId);
}
}
这段代码的主要实现了一个优惠券秒杀的功能,重点处理了并发情况下的线程安全和事务处理。
1. 整体流程
-
查询优惠券信息:检查秒杀活动的开始和结束时间,以及库存是否充足。
-
用户加锁:对用户ID加锁,防止同一用户并发请求。
-
创建订单:在事务中检查用户是否已购买、扣减库存并生成订单。
2. 关键代码解析
2.1 锁的实现:synchronized (userId.toString().intern())
-
目的:确保同一用户的请求在同一时间只能有一个线程处理,防止重复下单。
-
原理:
-
userId.toString().intern()
将用户ID转换为字符串,并调用intern()
方法,确保所有相同用户ID的字符串指向常量池中的同一个对象。 -
synchronized
基于这个字符串对象加锁,同一用户的并发请求会串行执行。
-
2.2 代理对象:IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy()
-
目的:确保
@Transactional
注解生效。 -
原因:
-
Spring的事务管理基于AOP代理,直接调用内部方法(如
this.createVoucherOrder()
)会绕过代理,导致事务失效。 -
通过
AopContext.currentProxy()
获取当前类的代理对象,再调用方法,事务才能正确应用。
-
-
依赖:需在启动类添加
@EnableAspectJAutoProxy(exposeProxy = true)
暴露代理对象。
2.3 事务方法:createVoucherOrder
-
检查用户订单:通过数据库查询确保“一人一单”。
-
扣减库存:
-
使用条件更新:
eq("voucher_id", voucherId).gt("stock", 0)
。 -
数据库层面的乐观锁,避免超卖。
-
-
创建订单:生成唯一订单ID并保存。
3. 并发安全设计
-
用户维度锁:
-
防止同一用户并发请求导致多次下单。
-
锁的粒度较细(用户级别),不同用户的请求可并行处理。
-
-
数据库乐观锁:
-
扣减库存时检查
stock > 0
,确保库存不为负。
-
-
事务隔离:
-
@Transactional
保证订单创建和库存扣减的原子性。 -
事务提交后,其他请求才能看到新订单和库存变化。
-
4. 代码总结
步骤 | 操作 | 并发安全措施 |
---|---|---|
查询优惠券信息 | 检查时间、库存 | 无 |
用户加锁 | synchronized 锁 | 防止同一用户重复下单 |
创建订单(事务) | 检查用户订单、扣库存、插入订单 | 数据库乐观锁、事务隔离 |
5. 关键问答
Q1: 为什么要用 userId.toString().intern()
作为锁?
确保同一用户ID的字符串在不同请求中是同一个对象,锁生效。
Q2: 为什么需要代理对象调用 createVoucherOrder
?
Spring事务通过动态代理实现,直接调用内部方法会绕过代理,事务失效
Q3: 如何防止超卖?
数据库更新时检查stock > 0,结合应用层锁和数据库乐观锁。
6. 事务的核心特性
在秒杀场景中,事务确保以下关键操作要么全部成功,要么全部回滚:
-
检查用户是否已下单(
SELECT
)。 -
扣减库存(
UPDATE stock SET stock = stock - 1
)。 -
创建订单(
INSERT INTO voucher_order
)。
若缺少事务,可能出现:
-
库存扣减成功,但订单创建失败 → 数据不一致(用户付钱没拿到券)。
-
用户重复下单检查通过,但下单时库存不足 → 业务逻辑错误。
在上面的代码中,事务实现如下:
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 数据库操作:检查订单、扣库存、创建订单
}
-
作用:Spring通过AOP代理为该方法添加事务管理。
-
默认行为:
-
传播机制:
REQUIRED
(如果当前有事务则加入,否则新建)。 -
隔离级别:
ISOLATION_DEFAULT
(依赖数据库默认,通常为可重复读)。 -
回滚规则:遇到运行时异常(如
RuntimeException
)自动回滚。
-
6.1 为什么必须使用代理对象调用方法?
// 错误写法:直接调用this.createVoucherOrder(),事务失效!
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId); // 通过代理对象调用
-
根本原因:Spring的事务基于动态代理(JDK或CGLib),直接调用内部方法会绕过代理,导致
@Transactional
失效。 -
解决方案:通过
AopContext.currentProxy()
获取当前类的代理对象。
7. 事务与锁的协作流程
步骤 | 操作 | 事务与锁的作用 |
---|---|---|
1. 用户请求进入 | 执行seckillVoucher 方法 | 无 |
2. 获取用户锁 | synchronized(userKey) | 防止同一用户并发操作 |
3.调用代理方法 | proxy.createVoucherOrder() | 触发事务管理(AOP代理) |
4. 事务开始 | 自动开启事务 | 开启数据库事务 |
5. 检查用户订单 | SELECT COUNT(...) | 事务内读操作(可重复读保证一致性) |
6. 扣减库存 | UPDATE stock ... WHERE stock>0 | 事务内写操作(数据库乐观锁) |
7. 创建订单 | INSERT INTO voucher_order | 事务内写操作 |
8. 事务提交 | 自动提交 | 所有操作原子生效 |
9. 释放用户锁 | 退出synchronized 块 | 允许其他线程处理该用户请求 |
7.1 关键协作点
-
锁在事务外:先加锁,再开启事务。
-
原因:若锁在事务内,事务提交前释放锁,其他线程可能读到未提交的数据(如库存未扣减)。
-
当前代码正确性:锁包裹事务,确保事务提交后才释放锁。
-
-
事务隔离级别:
-
默认隔离级别(可重复读)防止脏读、不可重复读,确保事务内多次读取数据一致。
-
7.2 事务在秒杀场景的小总结
操作 | 无事务的风险 | 有事务的保障 |
---|---|---|
检查用户订单 | 可能通过检查,但下单时冲突 | 可重复读确保一致性 |
扣减库存 | 超卖(库存扣减到负数) | 数据库乐观锁(stock > 0 )阻止超卖 |
创建订单 | 订单丢失或重复 | 唯一键约束+原子提交保证数据完整 |
7.3 关键问答
Q1: 如果事务方法中抛出非RuntimeException,事务会回滚吗?
默认不会,需通过@Transactional(rollbackFor = Exception.class)配置。
Q2: 事务方法内调用其他事务方法,事务如何传递?
默认使用REQUIRED
传播机制,合并到同一事务中。
8. 串行执行 VS 并发执行
8.1串行执行
定义:任务按顺序依次执行,前一个任务完成后,后一个任务才能开始。
特点:
-
简单、安全,不会出现资源冲突。
-
效率低,无法充分利用多核CPU资源。
场景:单线程程序、需要严格顺序的操作(如转账步骤)。
示例:
// 单线程串行执行任务
public static void main(String[] args) {
task1(); // 任务1执行完毕
task2(); // 任务2开始执行
task3(); // 任务3开始执行
}
8.2 并发执行
定义:任务看似同时执行,实际通过时间片轮转或并行处理实现。
特点:
-
提高资源利用率,适合多核CPU。
-
可能引发线程安全问题(如数据竞争)。
场景:Web服务器处理多请求、批量数据处理。
示例:
// 多线程并发执行任务
public static void main(String[] args) {
new Thread(() -> task1()).start(); // 任务1开始执行
new Thread(() -> task2()).start(); // 任务2开始执行
new Thread(() -> task3()).start(); // 任务3开始执行
// 三个任务可能交替执行
}
8.3 代码中秒杀系统中的订单创建
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
-
锁的作用:
强制同一用户的订单创建操作串行执行(即使有多个并发请求)。
例如:用户A的3个请求会依次处理,但用户B的请求可以与用户A并发执行。 -
并发与串行的平衡:
-
并发:不同用户的请求可以并行处理(提高吞吐量)。
-
串行:同一用户的请求串行处理(避免重复下单)。
-
8.4 并发执行的风险和解决方案
8.4.1 超卖(库存扣减为负)
并发场景:
-
线程1和线程2同时查询库存为1。
-
两者都认为可以扣减,最终库存变为-1。
解决方案:
// 使用数据库乐观锁
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 保证库存 > 0 时才扣减
.update();
8.4.2 重复下单
并发场景:
-
同一用户的多个请求同时通过“一人一单”检查。
-
最终插入多条订单。
解决方案:
// 对用户ID加锁
synchronized (userId.toString().intern()) {
// 检查订单 + 创建订单(事务内)
}
9. Spring事务管理基于AOP的底层原理详解
9.1 AOP的核心概念
AOP(Aspect-Oriented Programming,面向切面编程)用于将横切关注点(如日志、事务、安全)与业务逻辑解耦。
关键术语:
术语 | 说明 | 事务管理中的对应示例 |
---|---|---|
切面(Aspect) | 横切多个类的模块化功能(如事务管理) | @Transactional 注解标记的方法 |
通知(Advice) | 切面在特定连接点执行的动作(如“在方法前后加事务”) | 事务的开启、提交、回滚操作 |
连接点(Join Point) | 程序执行的点(如方法调用) | createVoucherOrder() 方法的执行 |
切点(Pointcut) | 匹配连接点的表达式(定义哪些方法需要增强) | 通过@Transactional 注解匹配需要事务的方法 |
目标对象(Target) | 被代理的原始对象 | VoucherOrderServiceImpl 类实例 |
代理(Proxy) | 对目标对象增强后的对象(由AOP框架生成) | Spring生成的IVoucherOrderService 代理类 |
9.2 Spring事务管理的实现流程
9.2.1 事务管理本质是AOP的环绕通知
Spring通过AOP动态代理,在目标方法前后添加事务逻辑:
// 伪代码:事务的AOP环绕通知实现
public class TransactionAspect {
@Around("@annotation(transactional)")
public Object around(ProceedingJoinPoint pjp, Transactional transactional) {
Connection conn = DataSourceUtils.getConnection();
try {
conn.setAutoCommit(false); // 1.开启事务
Object result = pjp.proceed(); // 2.执行目标方法(如createVoucherOrder)
conn.commit(); // 3.提交事务
return result;
} catch (Exception e) {
conn.rollback(); // 4.回滚事务
throw e;
} finally {
DataSourceUtils.releaseConnection(conn);
}
}
}
9.2.2 动态代理的两种实现方式
Spring根据目标类型选择代理方式:
代理类型 | 条件 | 示例 |
---|---|---|
JDK动态代理 | 目标类实现了接口 | IVoucherOrderService 接口实现类 |
CGLib动态代理 | 目标类未实现接口 | 无接口的普通类 |
代码中为什么比用代理对象?
直接调用 this.createVoucherOrder() 会绕过代理,导致事务失效。
9.2.3 结合代码的完整AOP事务流程
以seckillVoucher
方法调用为例:
-
Spring容器启动:
-
扫描到
VoucherOrderServiceImpl
类,发现它实现了IVoucherOrderService
接口。 -
检测到
createVoucherOrder
方法有@Transactional
注解,为此类生成JDK动态代理对象。
-
-
方法调用过程:
// 用户调用IVoucherOrderService.seckillVoucher() IVoucherOrderService proxy = Spring容器中的代理对象; proxy.seckillVoucher(voucherId); // 代理对象内部执行: public Result seckillVoucher(Long voucherId) { // 1. 执行AOP前置逻辑(无事务) synchronized (userId.toString().intern()) { // 2. 调用原始对象的seckillVoucher方法 IVoucherOrderService target = new VoucherOrderServiceImpl(); target.seckillVoucher(voucherId); // 此时this指向原始对象 // 3. 通过AopContext获取当前代理对象 IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 4. 通过代理调用createVoucherOrder,触发事务 return proxy.createVoucherOrder(voucherId); } }
-
事务方法触发:
-
代理对象拦截
createVoucherOrder
方法的调用。 -
执行事务环绕通知:开启事务 → 执行方法 → 提交/回滚事务。
-
9.3 AOP与事务管理的关系
步骤 | AOP的作用 | 事务管理的体现 |
---|---|---|
代理对象生成 | 通过JDK/CGLib创建目标类的代理 | 为@Transactional 类生成代理 |
方法拦截 | 在目标方法执行前后插入通知 | 环绕通知中管理事务(开启、提交、回滚) |
异常处理 | 捕获方法抛出的异常 | 根据异常类型决定回滚或提交 |
自调用问题解决 | 通过暴露代理对象访问增强方法 | 使用AopContext.currentProxy() |
9.4 关键问答
Q1: 为什么事务注解要加在public方法上?
-
Spring的AOP默认基于代理,只能拦截public方法。
-
若注解加在非public方法(如
private
),代理无法增强该方法,事务失效。
Q2: Spring AOP和AspectJ有什么区别?
-
Spring AOP基于动态代理,仅支持方法级别的切面;AspectJ是完整的AOP框架,支持字段、构造器级别的切面。
Q3: 如何强制Spring使用CGLib代理?
-
在
@EnableAspectJAutoProxy
中设置proxyTargetClass=true
。
Q4: 事务方法中调用另一个事务方法,事务如何传递?
-
由
@Transactional(propagation=...)
决定,默认REQUIRED
(合并到同一事务)。
Q5: 事务失效的常见场景有哪些?
-
自调用、非public方法、异常被捕获未抛出、数据库引擎不支持事务等。