秒杀场景下存在的问题

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 ),因此又出现了并发安全问题。

 

This article was updated on