
秒杀场景下存在的问题
1、超卖问题
问题描述:在库存为100的情况下,使用jmeter并发抢购200单,后端逻辑:
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 4、库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5、扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足!");
}抢购结果:库存-3,订单103,出现了超卖问题
产生原因:在库存为1的情况下,有多个线程查询到了还有库存,所以执行了扣减操作。流程如下如所示:

解决方案:悲观锁(synchronized、Lock)性能太低。乐观锁默认不加锁,只有在更新时,才判断有没有别的线程修改了。
乐观锁后端代码:
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
// where stock = ? and voucher_id = ?
.eq("voucher_id", voucherId).eq("stock", voucher.getStock())
.update();抢购结果:200个线程买100的库存,库存剩余72,只卖出去28单
产生的问题:线程竞争激烈导致CAS操作不成功,原因如下:

当库存剩下100的时候,多个线程都查到是100了,但是只要有一个线程执行了更新操作,其余线程的CAS操作将失败。因为stock变为99,但是其余线程预期stock为100。
最佳解决方案:只要执行扣减库存时,库存大于0即可
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
// where stock = ? and voucher_id = ?
.eq("voucher_id", voucherId).gt("stock", 0)
.update();2、一人一单问题
问题描述:秒杀券应该每个人限购一单
解决方案:在扣减库存之前先查询,该用户是否已经有订单了
// 查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
// 用户已经购买过
return Result.fail("您已经购买过一次了!");
}
// 扣减库存操作
...
// 生成该用户的订单
...但是高并发的场景下,会有多个线程查询到,用户还没有订单。因此会有多个线程成功绕过上述检查,都去做扣减库存和生成订单的操作。因此这一系列操作需要加锁。
....
// 只有在 createVoucherOrder(voucherId) 执行完了,事务才会提交,这样在查询用户是否已经购买了,才是有效的
return createVoucherOrder(voucherId);
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 一人一单
Long userId = UserHolder.getUser().getId();
// TODO Long 的 toString() 底层是使用 new 的方式创建的,因此即使值相同,userId.toString() 也是完全不同的对象
// TODO 因此必须使用 String.intern()
// TODO 这样一来,不同的用户就不会被锁住了,锁定范围变小了,性能会提升
synchronized (userId.toString().intern()) {
// 查询订单
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
// 用户已经购买过
return Result.fail("您已经购买过一次了!");
}
// 5、扣减库存
...
// 6、创建订单
...
// 7、返回订单id
return Result.ok(orderId);
} <-- 锁释放位置
}在对代码块加锁的时候,应该选择锁住用户id对象。这样一来,不同的用户就不会被锁住了,锁定范围变小了,性能会提升。
注意:由于Long 的 toString() 底层是使用 new 的方式创建的,因此即使值相同,userId.toString() 也是完全不同的对象。因此必须使用 String.intern()
如果这样加锁的话,由于在事务未提交时就释放了锁,所以很有可能在这个时候有别的线程重新获取到了锁,并且进入了synchronized代码块。由于事务还未提交,用户还是没有订单,所以又执行了购买操作。因此应该使用以下方式:
Long userId = UserHolder.getUser().getId();
// 当 createVoucherOrder(voucherId) 执行完了,事务就提交了,
// 这样再查询用户是否已经购买才是有效的
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
...
}但是以上的写法,事务是失效的!因为:事务要想生效,是因为Spring的事务是通过代理当前的类实现的,会重写被@Transactional标注的方法。自调用会导致绕过了代理,拿到的并不是代理对象,所以出现事务失效。
因此想要拿到代理对象,需要
synchronized (userId.toString().intern()) {
// 是当前被接口的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}但是会找不到createVoucherOrder(Long id)这个函数,因为是在实现类中写的,接口中没有定义,所以只要在接口中定义一下即可。
但是想让这个“拿到代理对象”的操作生效,还需要如下配置:
1️⃣maven添加aspectj依赖:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>2️⃣启动类上添加注解暴露代理对象:

默认是false,这里需要给true
注意:调用该函数的seckillVoucher(Long voucherId)的事务标记@Transactional需要去掉:
[-] @Transactional
public Result seckillVoucher(Long voucherId) {
...
}3、分布式并发问题
当后端采用集群模式时(即有多个 springboot 或者说 Tomcat 在运行),基于单个 Tomcat 的 synchronized 监视器锁显然是失效的(或者说单个 JVM ),因此又出现了并发安全问题。

