高并发情况下的“超卖”(脏数据/并发安全)问题

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService iSeckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = iSeckillVoucherService.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("库存不足!");
        }
        // 5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .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);
    }
}

以上列代码为例,正常情况下,理应时数据库中库存(stock字段)到达0就不会再减少了,但是在高并发的情况下,stock有可能出现负数的情况,这在业务中是极大的问题。(脏数据,“超卖”问题)。

为什么高并发下会出现“超卖”?

观察下面的时序图,正常情况下,线程执行是按顺序的,而高并发的情况下的线程不可能都是按顺序的。


"超卖"问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:

  • 悲观锁

悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。

例如Synchronized、Lock都属于悲观锁。

悲观锁最明显的缺点就是牺牲了一定的性能。

  • 乐观锁

乐观锁认为线程安全不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。

如果没有修改则认为是安全的,自己才更新数据。

如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。

乐观锁不像悲观锁不分三七二十一全部加锁串行,而是添加一些判断去保证数据的有效性,所以乐观锁的性能要优于悲观锁。但是关键就在于,如何判断有没有其它线程对数据做了修改。

乐观锁

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见方式有:

  • 版本号法

版本号法基于每个共享资源都有一个关联的版本号。当用户读取资源时,会将版本号一并读取,并在后续操作中使用。当用户要对资源进行更新时,系统会比较用户所持有的版本号与当前资源的版本号是否一致。

如果版本号一致,说明资源未被其他用户修改,用户可以执行更新操作,并将资源的版本号加一。这样可以保证资源的一致性,避免数据不一致情况的发生。

如果版本号不一致,说明资源已经被其他用户修改,用户无法执行更新操作。此时用户可以选择放弃更新、重新读取最新的资源并重新操作,或者根据业务需要采取其他策略。

版本号法的优点是实现简单,适用于轻度并发的场景。然而,它可能会导致大量冲突和重试操作,特别是在高并发访问下,因为每个更新操作都需要进行版本号的比较和更新。

  • CAS法(Compare and Set)

其实是版本号法的简化版。只不过版本号法用版本来作为是否修改的标识,CAS法直接用将要修改的值来判断。

  • 时间戳法

时间戳法(Timestamp-based)是一种基于时间戳的乐观锁实现方式。在每个共享资源中,维护一个时间戳字段。当用户读取资源时,会读取资源的时间戳,并在后续操作中使用。当用户要对资源进行更新时,系统会比较用户所持有的时间戳与当前资源的时间戳。

如果用户的时间戳比资源的时间戳更新,说明用户读取资源后,资源已经被其他用户修改过,用户无法执行更新操作。此时用户可以选择放弃更新、重新读取最新的资源并重新操作,或者根据业务需要采取其他策略。

  • 哈希值法

哈希值法(Hash-based)是一种基于哈希值的乐观锁实现方式。在每个共享资源中,维护一个哈希值字段,用于标识资源的状态。当用户读取资源时,会读取资源的哈希值,并在后续操作中使用。当用户要对资源进行更新时,系统会比较用户所持有的哈希值与当前资源的哈希值。

如果用户的哈希值与资源的哈希值不一致,说明用户读取资源后,资源已经被其他用户修改过,用户无法执行更新操作。此时用户可以选择放弃更新、重新读取最新的资源并重新操作,或者根据业务需要采取其他策略。


CAS法优化代码

在乐观锁的CAS法中,定义上一旦判断数据在当前线程的过程中发生了修改就不允许修改,仔细想想,这样是否显得过于小心?如果判断一旦版本号不相等就失败,那当前代码中,只要求不“超卖”就好了,所以我们可以稍微优化一下乐观锁,在第5步扣减库存时,将.eq("stock", voucher.getStock())更改为.gt("stock", voucher.getStock())。

@Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = iSeckillVoucherService.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("库存不足!");
        }
        // 5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", voucher.getStock()) //where id = ? and 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);
    }

篇外

上面我们用乐观锁应对了高并发情况下数据修改的问题,那高并发情况下数据的插入问题呢?

悲观锁应对高并发情况下插入脏数据的情况

上面的“超卖”问题,实际上是解决了更新脏数据的问题,那插入脏数据怎么解决呢?难道也每次查一遍数据库看有没有插入该条数据吗?

以抢购优惠券问题延伸的“限购”问题为例,悲观锁解决方案:

思考一

首先我们可以考虑到将业务逻辑抽取出来封装为一个方法,并为方法添加synchronized关键字,乍一看好像就完成悲观锁了,但是仔细想想,对方法加悲观锁,实际上是将所有的线程串行到一起来了,这样会大大损耗性能,而且我们需要对所有线程加锁吗?

实际上是不需要的,我们只需要对每一个用户加一把对应的锁(注意这里不同于对所有线程加锁,对所有线程加锁实际上是要求所有抢购的用户都串行执行,而对每一个用户加对应的锁是要求某一个用户对应的所有线程串行执行)。

@Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = iSeckillVoucherService.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("库存不足!");
        }
        // 5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", voucher.getStock()) //where id = ? and stock > 0,这里不是判断版本号相等,而另外做了优化,判断相等会使得高并发情况下失败率大大增加
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }

        return createVoucherOrder(voucherId);
    }

    private synchronized Result createVoucherOrder(Long voucherId) {
        // 一人一单
        Long userId = UserHolder.getUser().getId();
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            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);
    }

思考二

继续优化,取消方法上加锁,对每一个用户加对应的锁:

@Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = iSeckillVoucherService.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("库存不足!");
        }
        // 5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", voucher.getStock()) //where id = ? and stock > 0,这里不是判断版本号相等,而另外做了优化,判断相等会使得高并发情况下失败率大大增加
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }

        return createVoucherOrder(voucherId);
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 一人一单
        Long userId = UserHolder.getUser().getId();

        // 针对用户进行加锁
        synchronized (userId.toString()) {
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if (count > 0) {
                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);
        }
    }

在上面这段代码中,createVoucherOrder方法似乎已经满足我们的要求,只针对用户进行加锁了,但要注意的是,加锁的唯一标识是用户id,而这里由于userId是long型所以传入的是userId.toString(),但是阅读toString(long)的源码可知,每次toString返回的都是一个新的String对象,即就算是一个用户多个线程访问,都会被锁认为是不同的用户在执行!

 所以还是不行!

思考三

继续优化:

synchronized (userId.toString())

修改为

synchronized (userId.toString().intern()) 

intern返回字符串的规范表示,即去字符串常量池中寻找值相同的字符串并返回该字符串引用。 

但是这样真正结束了吗?

我们来看现在的方法,由于synchronized所包裹的代码块在执行后就会释放锁,而代码块中的数据库更新操作只有当事务提交后(即@Transactional声明的方法完成后)才会执行,这样就会出现一个问题:

可能锁释放后其它线程加入继续查询订单是否存在,由于数据库更新操作需要时间,就有可能依然查询到订单不存在,继而继续提交数据库更新操作。

这样一看,似乎锁的范围又太小了?

    @Transactional // <---spring提供的事务
    public Result createVoucherOrder(Long voucherId) { // <-- 事务开始
        // 一人一单
        Long userId = UserHolder.getUser().getId();

        synchronized (userId.toString().intern()) { // <--- 获取锁
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if (count > 0) {
                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);
        } // <--- 释放锁
    }// <--- 事务结束,spring只有当事务结束后才会执行数据库更新操作

思考四

需要重新将锁的范围扩大到方法。

public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = iSeckillVoucherService.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("库存不足!");
        }
        // 5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", voucher.getStock()) //where id = ? and stock > 0,这里不是判断版本号相等,而另外做了优化,判断相等会使得高并发情况下失败率大大增加
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }

        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) { // <--- !!!在这里上锁!!!
            return createVoucherOrder(voucherId);
        }
    }

    @Transactional // <---spring提供的事务
    public Result createVoucherOrder(Long voucherId) { // <-- 事务开始
        // 一人一单
        Long userId = UserHolder.getUser().getId();
        
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            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);
    }// <--- 事务结束,spring只有当事务结束后才会执行数据库更新操作

这样就既解决了思考三中的问题,又使得上锁的目标一定是单个用户而不是全部用户。

但其实这里还有个问题:

事务失效

事务,实际上是由Spring通过代理的方式拿到代理对象去执行方法,但是这里return createVoucherOrder实际上是目标对象而非代理对象。

 解决方案:

引入aspectjweaver的依赖

        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
        </dependency>

在启动类上添加@EnableAspectJAutoProxy注解,属性exposeProxy设置为true,表示暴露代理对象。

@MapperScan("com.hmdp.mapper")
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true) //暴露代理对象
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

修改代码

最终代码:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService iSeckillVoucherService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        // 1.查询优惠券
        SeckillVoucher voucher = iSeckillVoucherService.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("库存不足!");
        }
        // 5.扣减库存
        boolean success = iSeckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId)
                .gt("stock", voucher.getStock()) //where id = ? and stock > 0,这里不是判断版本号相等,而另外做了优化,判断相等会使得高并发情况下失败率大大增加
                .update();
        if (!success) {
            return Result.fail("库存不足!");
        }

        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) { // <--- !!!在这里上锁!!!
            //获取和事务有关的代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
    }

    @Transactional // <---spring提供的事务
    public Result createVoucherOrder(Long voucherId) { // <-- 事务开始
        // 一人一单
        Long userId = UserHolder.getUser().getId();

        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            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);
    }// <--- 事务结束,spring只有当事务结束后才会执行数据库更新操作
}

以上是单体服务时使用悲观锁解决“一人一单问题”,但是synchronized只针对一个JVM有效,在分布式的集群环境下,不同的服务器有不同的JVM,这样悲观锁又失效了。

分布式锁由此引出,详见

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值