3.springboot事务-4种隔离级别

老马 老马 | 160 | 2022-08-26

在SpringBoot中设置隔离级别的方式

@Transactional(isolation = Isolation.DEFAULT)

SpringBoot中隔离级别说明

首先说明一点,那就是spring中定义这些隔离级别都是对应数据库的隔离级别的。最终也都是依赖于数据库来实现事务的。

public enum Isolation {
    DEFAULT(TransactionDefinition.ISOLATION_DEFAULT),
    READ_UNCOMMITTED(TransactionDefinition.ISOLATION_READ_UNCOMMITTED),
    READ_COMMITTED(TransactionDefinition.ISOLATION_READ_COMMITTED),
    REPEATABLE_READ(TransactionDefinition.ISOLATION_REPEATABLE_READ),
    SERIALIZABLE(TransactionDefinition.ISOLATION_SERIALIZABLE);
}
枚举值说明
DEFAULT这是默认值,表示使用底层数据库的默认隔离级别。对大部分数据库而言,通常这值就是READ_COMMITTED。mysql5.7默认是REPEATABLE-READ
READ_UNCOMMITTED该隔离级别表示一个事务可以读取另一个事务修改但还没有提交的数据。该级别不能防止脏读和不可重复读,因此很少使用该隔离级别。
READ_COMMITTED该隔离级别表示一个事务只能读取另一个事务已经提交的数据。该级别可以防止脏读,这也是大多数情况下的推荐值。但会出现不可重复读及幻读问题。
REPEATABLE_READ该隔离级别表示一个事务在整个过程中可以多次重复执行某个查询,并且每次返回的记录都相同。即使在多次查询之间有新增的数据满足该查询,这些新增的记录也会被忽略。这种隔离级别可以防止脏读、不可重复读,但是不能防止第二类丢失更新和幻读。
SERIALIZABLE所有的事务依次逐个执行,这样事务之间就完全不可能产生干扰,也就是说,该级别可以防止脏读、不可重复读以及幻读。但是这将严重影响程序的性能。通常情况下也不会用到该级别。

需要理解的几个概念

1. 脏读(Dirty Read)

A事务执行过程中,B事务读取了A事务的修改。但是由于某些原因,A事务可能没有完成提交,发生RollBack操作,

则B事务所读取的数据就会是不正确的。这个未提交数据就是脏读(Dirty Read)。脏读产生的流程如下:

image-20220826170520683

READ_UNCOMMITTED这种隔离级别就容易产生脏读数据。下面通过程序来模拟脏读。

  • 库存表(stock_info)

    image-20220826170642132
  • 更改库存数量的Mapper

    //减少库存数量
    @Update("update stock_info set count = count - #{reduceCount} where id = #{id}")
    void reduceStockCount(Long id, Integer reduceCount);
    
  • 实现更改库存数量和查询功能的service

    //更改库存数量
    @SneakyThrows
    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public void updateStockCount(Long id, int stock) {
        this.stockInfoMapper.reduceStockCount(id, stock);
        //这里睡了1秒,是为了给查询留够时间
        Thread.sleep(1000);
        int i = 1 / 0;
    }
    
    //查询库存数量
    @SneakyThrows
    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public Integer getStockCountById(Long id) {
        //这里睡了500毫秒是为了等库存已经更新了,但是还没有回滚。
        Thread.sleep(500);
        Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKey(id);
        StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
        return stock.getCount();
    }
    
  • 测试类

    @SneakyThrows
    @Test
    public void test() {
        //更新库存的线程
        new Thread(() -> {
            log.info("库存-10开始");
            try {
                stockInfoService.updateStockCount(1L, 10);
            } catch (Exception e) {
                log.info("库存-10异常,事务回滚");
            }
        }).start();
        //查询库存的线程
        new Thread(() -> {
            log.info("查询库存:{}", stockInfoService.getStockCountById(1L));
    
        }).start();
        //阻塞主线程,防止退出
        System.in.read();
    }
    
  • 执行结果

    image-20220826170914330
  • 把service隔离级别设置为:READ_COMMITTED可解决这个问题

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void updateStockCount(Long id, int stock) {
        ......
    }
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public Integer getStockCountById(Long id) {
        ......
    }
    

    再次运行,不会出现脏读了。

    image-20220826171105360

2.不可重复读(Nonrepeatable Read)

B事务读取了两次数据,在这两次的读取过程中A事务修改了数据,B事务的这两次读取出来的数据不一样。

B事务这种读取的结果,即为不可重复读(Nonrepeatable Read)。不可重复读的产生的流程如下:

image-20220826171214963

不可重复读有一种特殊情况,两个事务更新同一条数据资源,后完成的事务会造成先完成的事务更新丢失。

这种情况就是大名鼎鼎的第二类丢失更新。主流的数据库已经默认屏蔽了第一类丢失更新问题,但我们

编程的时候仍需要特别注意第二类丢失更新。这里解释下第一类和第二类丢失的概念:

  • 第一类丢失更新:A、B两个事务更新同一条记录,A事务完成;B事务异常回滚,

​ 造成A事务完成的更新也同时丢失。这个问题现代关系型数据库已经不会发生。

  • 第二类丢失更新:两个事务更新同一条记录,后完成的事务会造成先完成的事务更新丢失。这个在实际编程中需要特别注意!

这种第二类丢失更新产生的流程如下:

image-20220826171347344

通过上面的介绍你可能知道了第二类丢失更新是什么了,并且还会嗯嗯的点点头。但是实际这个问题的严重性

可能远比你想的更严重!来看下图这个情况:

image-20220826171428073

举钱的例子太吓人,还是拿咱们库存的大白兔奶糖来模拟不可重复读。

  • 库存表(stock_info)、更改库存数量的Mapper都不变

  • 实现更改库存数量和查询功能的service

    //查询库存数量
    @SneakyThrows
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void testNonrepeatableRead_read() {
        Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKey(1L);
        StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
        log.info("第1次读取大白兔奶糖库存数量:{}", stock.getCount());
        Thread.sleep(1000);
        //换一个通过id查询的方式,避免mybatis的缓存
        Example example = new Example();
        Example.Criteria<StockInfo> criteria = example.createCriteria();
        criteria.andEqualTo(StockInfo::getId, 1L);
        Optional<StockInfo> stockInfo2 = this.stockInfoMapper.selectOneByExample(example);
        StockInfo stock2 = stockInfo2.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
        log.info("第2次读取大白兔奶糖库存数量:{}", stock2.getCount());
    }
    
    //更新库存数量
    @SneakyThrows
    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void testNonrepeatableRead_write() {
        //在第一次和第二次查询库存数量之间执行更新操作
        Thread.sleep(500);
        this.stockInfoMapper.reduceStockCount(1L, 10);
    }
    
  • 测试类

    @SneakyThrows
    @Test
    public void testNonrepeatableRead() {
        //查询库存的线程
        Thread readThread = new Thread(() -> {
            stockInfoService.testNonrepeatableRead_read();
        });
        //更新库存的线程
        Thread writeThread =new Thread(() -> {
             stockInfoService.testNonrepeatableRead_write();
        });
        readThread.start();
        writeThread.start();
        readThread.join();
        writeThread.join();
    }
    
  • 运行结果

    image-20220826171650326
  • 把service隔离级别设置为:REPEATABLE_READ可解决这个问题

    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void testNonrepeatableRead_read() {
        ......
    }
    
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void testNonrepeatableRead_write() {
        ......
    }
    

    再次运行程序,解决了不可重复读的问题。想想其实这个问题应该是不用解决,因为其他线程已经提交了事务。再次获得值不同也是合理的。但是毕竟这种现象也是事务之间没有隔离所造成的,但我们对于这种问题,似乎可以忽略。

    image-20220826171934878

3.幻读(Phantom Read)

B事务读取了两次数据,在这两次的读取过程中A事务添加了数据,B事务的这两次读取出来的集合不一样。

幻读产生的流程如下:

image-20220826173432614

这个看着和不可重复读差不多,都是在读线程的两次读中间,由于写线程变更数据造成的。但是不可重复读

强调的是对一条数据的更改;而幻读强调的是数据集合的增减变化。

目前能处理幻读的方式有两种:

  • 事务隔离级别设置为:Isolation.SERIALIZABLE
  • 锁定整个表:select * from stock for update;

由于我们实在际开发中,实际上对于幻读没有特别的要求,所以基本也不太考虑这个问题。

第二类丢失更新演示

由于此问题在实际工作中非常容易遇到。而且通过设置REPEATABLE_READ也是无法解决这个丢失更新的问题的。在此特别介绍和演示下相关内容。先用程序模拟下更新丢失的情况。

1.读线程和写线程的service

这里读线程在第二次读取数据后,进行了更新操作。从而覆盖了写线程的更新结果。

//查询库存数量
@SneakyThrows
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void testNonrepeatableRead_read2() {
    Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKey(1L);
    StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
    log.info("读线程第1次读取大白兔奶糖库存数量:{}", stock.getCount());
    Thread.sleep(1000);
    //换一个通过id查询的方式,避免mybatis的缓存
    Example example = new Example();
    Example.Criteria<StockInfo> criteria = example.createCriteria();
    criteria.andEqualTo(StockInfo::getId, 1L);
    Optional<StockInfo> stockInfo2 = this.stockInfoMapper.selectOneByExample(example);
    StockInfo stock2 = stockInfo2.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
    log.info("读线程第2次读取大白兔奶糖库存数量:{}", stock2.getCount());
    stock2.setCount(stock2.getCount() - 10);
    this.stockInfoMapper.updateByPrimaryKey(stock2);
    log.info("读线程更新大白兔奶糖库存数量:{}", stock2.getCount());
}

@SneakyThrows
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void testNonrepeatableRead_write2() {
    //在第一次和第二次查询库存数量之间执行更新操作
    Thread.sleep(500);
    Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKey(1L);
    StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
    log.info("写线程获得到的大白兔奶糖库存数量:{}", stock.getCount());
    stock.setCount(stock.getCount() - 10);
    try {
        this.stockInfoMapper.updateByPrimaryKeySelective(stock);
    }catch (Exception e) {
        log.info("写线程更新数据失败");
    }
    log.info("写线程更新后的大白兔奶糖库存数量:{}", stock.getCount());
}

如果REPEATABLE_READ在写线程的时候能抛出异常那么就能防止更新丢失了。但是我试验的结果是没有异常

2.测试类

@SneakyThrows
@Test
public void testNonrepeatableRead2() {
    //查询库存的线程
    Thread readThread = new Thread(() -> {
        stockInfoService.testNonrepeatableRead_read2();
    });
    //更新库存的线程
    Thread writeThread =new Thread(() -> {
        stockInfoService.testNonrepeatableRead_write2();
    });
    readThread.start();
    writeThread.start();
    readThread.join();
    writeThread.join();
}

3.执行结果

image-20220826172440195

从打印的结果来看,明显读线程把写线程的更新操作给覆盖了。

4.数据库值

image-20220826172607563

数据库开始值为100。从这里也可以看出的确少减了一次10。

防止第二类丢失更新

从思路上可以分为悲观锁方案和乐观锁方案。从具体方法上来说大概有3种。

image-20220826172749643

1.SERIALIZABLE(相当于锁表)

该方式简单粗暴,直接锁表,这个虽然能解决问题,但是性能实在是堪忧,所以正常情况下是不会用的。但是如果在并发特别低的场景中,也算是一个解决方案。因为该方式实现非常的简单。

只要修改下我们读线程和写线程的service的事务批注即可。

@Transactional(isolation = Isolation.SERIALIZABLE)
public void testNonrepeatableRead_read2() {
    ......
}
@Transactional(isolation = Isolation.READ_COMMITTED)
public void testNonrepeatableRead_write2() {
}

再次运行程序,会发现写线程会抛出异常,从而防止了写线程的写入。当然如果做的更完善可以再写线程出现异常的时候,捕获异常然后循环尝试插入。运行信息如下:

image-20220826172905026

2.行锁方式

通过select for update的行锁机制来做。

#StockInfoMapper增加一个新方法
//通过主键查询库存信息,行锁
@Select("select * from stock_info t where t.id = #{id} for update")
Optional<StockInfo> selectByPrimaryKeyForUpdate(Long id);


#StockInfoServiceImpl增加两个新的测试方法
//查询库存数量
@SneakyThrows
@Transactional
public void testNonrepeatableRead_read3() {
    Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKeyForUpdate(1L);
    StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
    log.info("读线程读取大白兔奶糖库存数量:{}", stock.getCount());
    //模拟其他业务逻辑
    Thread.sleep(1000);
    //库存数量-10
    stock.setCount(stock.getCount() - 10);
    this.stockInfoMapper.updateByPrimaryKey(stock);
    log.info("读线程更新大白兔奶糖库存数量:{}", stock.getCount());
}

@SneakyThrows
@Transactional
public void testNonrepeatableRead_write3() {
    //在查询库存信息和更新库存数量之间执行更新操作
    Thread.sleep(500);
    Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKeyForUpdate(1L);
    StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
    log.info("写线程获得到的大白兔奶糖库存数量:{}", stock.getCount());
    stock.setCount(stock.getCount() - 10);
    try {
        this.stockInfoMapper.updateByPrimaryKeySelective(stock);
    }catch (Exception e) {
        log.info("写线程更新数据失败:{}", e.getMessage());
    }
    log.info("写线程更新后的大白兔奶糖库存数量:{}", stock.getCount());
}


#测试类
@SneakyThrows
@Test
public void testNonrepeatableRead3() {
    //查询库存的线程
    Thread readThread = new Thread(() -> {
        stockInfoService.testNonrepeatableRead_read3();
    });
    //更新库存的线程
    Thread writeThread =new Thread(() -> {
        stockInfoService.testNonrepeatableRead_write3();
    });
    readThread.start();
    Thread.sleep(100);
    writeThread.start();
    readThread.join();
    writeThread.join();
}

这里注意读线程和写线程都要用selectByPrimaryKeyForUpdate进行查询。方法结束,spring容器会自动释放行锁。

执行结果:

image-20220826173049843

3.乐观锁方式

执行更新SQL语句时,条件中增加对数据版本的判断,也可以是某个字段值的判断。然后通过影响行数来判断更新是否成功。影响行数为0,表明更新失败,说明这条记录已经被其他事务修改过了。这时可以选择抛出异常或者是重新尝试继续修改。

#StockInfoMapper增加一个新方法
//更新库存数量通过id和count
@Update("update stock_info set count=count-#{reduceCount} where id=#{id} and count=#{count}")
int reduceStockCountByVersion(Long id, Integer count, Integer reduceCount);

#StockInfoServiceImpl增加两个新的测试方法
public void testNonrepeatableRead_read4() {
    Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKey(1L);
    StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
    log.info("读线程读取大白兔奶糖库存数量:{}", stock.getCount());
    //乐观锁更新数据
    int i = this.stockInfoMapper.reduceStockCountByVersion(1L, stock.getCount(), 10);
    if(i == 1) {
        //更新成功
        log.info("读线程成功更新库存数量");
        return;
    }else {
        //更新失败,尝试从新更新
        log.info("读线程更新失败,重新更新");
        testNonrepeatableRead_read4();
    }
}

public void testNonrepeatableRead_write4() {
    //在查询库存信息和更新库存数量之间执行更新操作
    Optional<StockInfo> stockInfo = this.stockInfoMapper.selectByPrimaryKey(1L);
    StockInfo stock = stockInfo.orElseThrow(() -> new RuntimeException("查询库存信息失败"));
    log.info("写线程获得到的大白兔奶糖库存数量:{}", stock.getCount());
    //乐观锁更新数据
    int i = this.stockInfoMapper.reduceStockCountByVersion(1L, stock.getCount(), 10);
    if(i == 1) {
        //更新成功
        log.info("写线程成功更新库存数量");
        return;
    }else {
        //更新失败,尝试从新更新
        log.info("写线程更新失败,重新更新");
        testNonrepeatableRead_write4();
    }
}

#测试类
@SneakyThrows
@Test
public void testNonrepeatableRead4() {
    //查询库存的线程
    Thread readThread = new Thread(() -> {
        stockInfoService.testNonrepeatableRead_read4();
    });
    //更新库存的线程
    Thread writeThread =new Thread(() -> {
        stockInfoService.testNonrepeatableRead_write4();
    });
    readThread.start();
    writeThread.start();
    readThread.join();
    writeThread.join();
}

这里可以看到,采用这种乐观锁的方式,在代码中可以完全不用事务或者锁了,因为所有的锁机制都交由

数据库来保证,我们只要判断受影响的行数是不是0来判断是否更新成功。当然用这种方式,代码的复杂度

也提高了很多。我这里仅仅是演示的代码,采用回调的方式。如果在特别高的并发下,有可能出现栈溢出的。

执行效果如下:

image-20220826173255801

总结

READ_UNCOMMITTED < READ_COMMITTED < REPEATABLE_READ < SERIALIZABLE

隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大,鱼和熊掌不可兼得啊。对于多数应用程序,可以优先考虑把数据库系统的隔离级别设为Read Committed,它能够避免脏读取,而且具有较好的并发性能。尽管它会导致不可重复读、幻读这些并发问题,在可能出现这类问题的个别场合,可以由应用程序采用悲观锁或乐观锁来控制。

名词解释解决方式
脏读B事务读取了A事务的修改事务隔离级别设置为:READ_COMMITTED
不可重复读B事务在读取两次数据之间A事务修改了数据,致使B事务两次读取的不一致。事务隔离级别设置为:REPEATABLE_READ
第二类丢失更新B事务把A事务的更新覆盖了悲观锁:selelct for update乐观锁:update + 版本号
幻读B事务在读取两次数据集合之间A事务添加或者删除数据,致使数据集合不一致锁表:select * from stock for update事务隔离级别设置为:SERIALIZABLE

补充

查看数据库的隔离级别:

  • mysql

    select @@tx_isolation;
    
  • oracle

    SELECT DISTINCT name,
                    isdefault,
                    VALUE,
                    DECODE (VALUE, 'serializable', SID, NULL) SID
    FROM V$SES_OPTIMIZER_ENV
    WHERE LOWER (name) LIKE '%isolation%'
    ORDER BY name
    
文章标签: Mysql
推荐指数:

真诚点赞 诚不我欺~

3.springboot事务-4种隔离级别

点赞 收藏 评论