• 高并发使用JVM锁和MySQL锁解决数据不一致问题


    1. 引入并发问题

    1.1 项目搭建

    这里以一个最基础的库存问题引入:在高并发下下单会造成库存数据异常情况。

    1. 数据表:就一个最基础的库存表和一个基础的数据。
      在这里插入图片描述
      在这里插入图片描述
      2. 新建SpringBoot2.7.3项目并引入相关依赖:

          <dependencies>
              <dependency>
                  <groupId>org.springframework.bootgroupId>
                  <artifactId>spring-boot-starter-webartifactId>
              dependency>
      
              <dependency>
                  <groupId>mysqlgroupId>
                  <artifactId>mysql-connector-javaartifactId>
              dependency>
      
              <dependency>
                  <groupId>com.baomidougroupId>
                  <artifactId>mybatis-plus-boot-starterartifactId>
                  <version>3.5.1version>
              dependency>
      
              <dependency>
                  <groupId>org.projectlombokgroupId>
                  <artifactId>lombokartifactId>
                  <optional>trueoptional>
              dependency>
              <dependency>
                  <groupId>org.springframework.bootgroupId>
                  <artifactId>spring-boot-starter-testartifactId>
                  <scope>testscope>
              dependency>
          dependencies>
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      1. mapper
      public interface StockMapper extends BaseMapper<Stock> {
      }
      
      • 1
      • 2
      1. service
      @Service
      public class StockServiceImpl implements StockService {
      
          @Resource
          private StockMapper stockMapper;
      
          @Override
          public Integer deStock() {
              Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>()
                      .eq("product_code", "1001"));
              if (!Objects.isNull(stock)
                      && stock.getCount() > 0){
                  stock.setCount(stock.getCount() -1);
                  stockMapper.updateById(stock);
              }
              return stockMapper.selectById(stock.getId()).getCount();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
    2. controller

      @RestController
      @RequestMapping("/stock")
      public class StockController {
      
          @Resource
          private StockService stockService;
      
          @GetMapping
          public String deStock(){
              return "库存剩余:" + stockService.deStock();
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12

    1.2 使用Jmeter进行测试

    • 使用十个线程各发送十各请求,也就是共100各请求,这里设置数据中也刚好有100库存。
      在这里插入图片描述
      在这里插入图片描述
    • 请求之后可以发现:请求的数量与数据库中的库存剩余对应不上,这个值应该处于于0 - 90 之间,每个用户至少有一个请求会进去。
      在这里插入图片描述

    2. JVM锁解决

    这里直接不测试了,肯定可以解决。

    修改service减库存方法:

    1. synchronized

          @Override
          public synchronized Integer deStock() {
              Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>()
                      .eq("product_code", "1001"));
              if (!Objects.isNull(stock)
                      && stock.getCount() > 0){
                  stock.setCount(stock.getCount() -1);
                  stockMapper.updateById(stock);
              }
              return stockMapper.selectById(stock.getId()).getCount();
          }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
    2. ReentrantLock显示锁

          @Override
          public Integer deStock() {
              ReentrantLock reentrantLock = new ReentrantLock();
              try {
                  reentrantLock.lock();
                  Stock stock = stockMapper.selectOne(new QueryWrapper<Stock>()
                          .eq("product_code", "1001"));
                  if (!Objects.isNull(stock)
                          && stock.getCount() > 0){
                      stock.setCount(stock.getCount() -1);
                      stockMapper.updateById(stock);
                  }
                  return stockMapper.selectById(stock.getId()).getCount();
              }finally {
                  reentrantLock.unlock();
              }
          }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17

    JVM锁缺陷

    • 只有是单例stockService对象下才能保证锁进行成功,在多例情况下每个对象都拥有自己的锁,不需要等待其他线程释放锁就可以执行。

    • 在开启spring事务的情况下锁也可能(有一定几率)失效:由于spring的锁是使用AOP的方式来进行增强,如果同时A、B两个用户分别发送两个请求事务也会开启两个,但是由于只能一个用户(线程)才能够获得锁,完成之后才会释放锁;此时A用户的事务并没有提交,但是B用户已经可以获取当前方法的锁,那么就会造成一个数据不一致的问题,B获取的库存是A提交前的数量。如图:
      在这里插入图片描述
      当然可以通过设置Spring事务中的隔离级别为读未提交来解决,但这种隔离级别就完全不满足系统需求了。

    • 集群模式下:在不同的服务中的stockService肯定也不是一个相同的对象,存在与第一中失效方式相似的问题。

    总结来说:上诉缺陷除了能避免使用多例模式,其他两种在系统构建上是无法进行取代的,因此需要使用其他的方式来进行并发数据的处理。

    3. MySQL实现锁

    3.1 MySQL默认锁

    使用MySQL中自带的锁去解决:MySQL在执行更、删、改操作时会自动对当前语句加锁,也就是说我们只要能够使用一条sql来实现当前功能就可以避免数据问题。

    • 新增mapper接口方法:

       @Update("update db_stock set count = count - #{count} " +
                  "where product_code = #{productCode} and count >= #{count}")
          Integer deduct(@Param("productCode") String productCode, @Param("count") Integer count);
      
      
      • 1
      • 2
      • 3
      • 4
    • 修改service实现类方法:

          @Override
          public Integer deStock() {
              return stockMapper.deduct("1001", 1);
          }
      
      • 1
      • 2
      • 3
      • 4
    • 使用Jmeter来测试,可以发现不会出现数据异常问题。

    • 解决: 很明显的可以看出一条sql中携带的锁可以完美的解决上方JVM锁失效的问题,但是真的那么🐂🐎?

    • 发现问题:

      1. 一条sql无法实现在复杂情况下的操作,例如:如果一条商品code有多个仓库,就无法实现了。
      2. 同样的无法记录库存在进行下单前后的状态变化。
      3. 锁的粒度:通过分析可以看出,当前sql是一个表级锁,即当前sql在事务为提交之前,其他的io操作都无法执行。

    3.2 MySQL悲观锁

    • 使用select ... for update,可以对当前查询出的数据加上行级锁,即其他事务无法对已经查询出的数据进行修改删除等操作。
      但是想要改为行级锁就必须满足以下要求:

      1. 查询或者更新条件必须是索引字段;
      2. 查询或者更新条件必须是一个具体值(不能使索引失效);
    • 新增mapper中方法:

          @Select("select * from db_stock where product_code = #{productCode} for update")
          List<Stock> selectStockForUpdate(String productCode);
      
      • 1
      • 2
    • 改造service中方法:

          @Transactional
          @Override
          public Integer deStock() {
              List<Stock> stocks = stockMapper.selectStockForUpdate("1001");
              if (Objects.isNull(stocks) || stocks.isEmpty()){
                  return -1;
              }
      
              // 假设存在多仓库情况,默认扣减第一个仓库
              Stock stock = stocks.get(0);
              if (!Objects.isNull(stock) && stock.getCount() >= 1){
                  stock.setCount(stock.getCount() - 1);
              }
              return stockMapper.updateById(stock);
          }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
    • 使用Jmeter测试,可以看出悲观锁也可以实现数据异常的问题。

    • 优缺点

      1. 效率比JVM锁高,但是比一条sql低;
      2. 可能存在死锁问题:需要保证对多条数据加锁时顺序一致;
      3. 库存操作要统一:要么都select ... for update,要么都select,一个有锁一个没有锁指定会出现数据冲突问题。

    3.3 MySQL乐观锁

    乐观锁:默认对IO属性操作不加锁,在执行完毕对数据中的版本号或者其他属性进行判断,确定当前数据执行前后是否被其他的事务更改。也就是CAS思想。
    CAS:Compare and Swap,比较并交换,其实就是有用一个属性,在更新后判断当前属性是否有变化,有变化就放弃更改,无变化就更改。

    • 更改成功
      在这里插入图片描述
    • 放弃更改
      在这里插入图片描述
    • 修改数据库表:新增版本号字段。每次更改时将version进行加一。
      在这里插入图片描述

    • 修改service方法:

          @Override
          public Integer deStock() {
              List<Stock> stocks = stockMapper.selectList( 
                      new QueryWrapper<Stock>()
                              .eq("product_code", "1001"));
      
              if (Objects.isNull(stocks) || stocks.isEmpty()){
                  return -1;
              }
      
              // 假设存在多仓库情况,默认扣减第一个仓库
              Stock stock = stocks.get(0);
              Integer version = 0;
              if (!Objects.isNull(stock) && stock.getCount() >= 1){
                  version = stock.getVersion();
                  stock.setCount(stock.getCount() - 1);
                  stock.setVersion(version + 1);
              }
      
              QueryWrapper<Stock> queryWrapper = new QueryWrapper<>();
              queryWrapper
                      .eq("id", stock.getId())
                      .eq("version", version);
              int update = stockMapper.update(stock, queryWrapper);
      
              // 更新失败递归重试
              if (update == 0){
                  try {
                      // 避免一直重试导致栈内存溢出
                      Thread.sleep(20);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  return deStock();
              }
      
              return update;
          }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
    • 通过Jmeter今天压力测试,发现可以满足数据一致的问题。

    • 问题分析:

      1. 性能很低,并且会随着并发越来越高吞吐量会越来越低(因为线程越多修改失败的就越多,进行自旋的线程也就越多)。
      2. ABA问题:其实用来判断是否被修改的version字段可能已经被其他线程改过了,但是又改了回来,就导致当前线程无法判断出已经被修改,也就是仍然会任务当前可以进行更新。
      3. 在读写分离(主从)的情况下,可能回导致乐观锁不可靠:进行主从复制需要进行两次网络IO(读取主库、主库相应),两次本地IO(写入本地、从本地读出),在这个过程中可能有些数据已经被更改了。这就导致在进行查询时数据会有一定的延迟性,导致修改失败(或者不应该更改成功)。

    4. JVM和MySQL锁总结

    • 性能:一条sql锁>悲观锁>JVM锁>乐观锁。
    • 在业务场景允许的情况下肯定优先选择一条更新sql自带的默认锁啊。
    • 如果是多读少写,争抢不是很激烈的情况下优先选择乐观锁
    • 如果写入的并发量比较高,而且经常出现锁冲突,为了避免出现锁冲突而进行自旋的情况越来越多,优先选择悲观锁
  • 相关阅读:
    acwing1291题解(约数)
    rocksdb 中 db_bench 的使用方法
    【Leetcode HOT100】最大正方形 c++
    Jenkins 发测试邮件报错 553 Mail from must equal authorized user
    DAY-5 | 牛客网 - WY49 数对问题:以数学分析来破解暴力搜索的时间复杂度问题
    Ubuntu 22.04 安装Nvidia显卡驱动、CUDA、cudnn
    指针的使用注意
    TX Text Control .NET Server for ASP.NET 32.0 Crack
    2024全国水科技大会暨流域水环境治理与水生态修复论坛(六)
    为什么要选择华为 HCIE-Security 课程?
  • 原文地址:https://blog.csdn.net/weixin_44289860/article/details/126686376