我悟了!Mysql事务隔离级别其实是这样!

问题描述

​ 最近几天在忙项目,有个项目是将业务收集到的数据变动,异步同步到一张数据表中。在测试的过程时,收到QA的反馈,说有订单的数据同步时好时坏。我怀着疑惑的表情打开了那段代码,它的逻辑大概是这样的:

在这里插入图片描述

如果用简单的代码实现的话,会是这样的:

    public void updateAndQuery(Example example, int diff){
        List<ProductPO> productPOS = productMapper.selectByExample(example);
        ProductPO productPO =  productPOS.get(0);
        System.out.println("一次查询内容" + JSONObject.toJSONString(productPO));

        //二次插入并查询
        Integer oldNumber = productPO.getNumber();
        productPO.setNumber(oldNumber + diff);

        //首先先更新,再进行查询
        System.out.println("更新内容:"+ JSONObject.toJSONString(productPO));
        productMapper.updateByPrimaryKey(productPO);

        //异步执行
        CompletableFuture.runAsync(() -> {
            Example example1 = new Example(ProductPO.class);
            example1.createCriteria().andEqualTo("skuId", productPO.getSkuId());
            List<ProductPO> select = productMapper.selectByExample(example1);
            System.out.println("二次查询结果:"+JSONObject.toJSONString(select));
            if (oldNumber != select.get(0).getNumber() - diff) {
                throw new NrsBusinessException("查询出错");
            }
        });
    }

起初我左看看,右看看,也没有想到这个是什么原因造成的。直到我看到了这个…

@Transactional(rollbackFor = Exception.class)

事务执行原理

​ 在了解的问题原因前,我们需要了解事务是如何实现的。首先假设现在我们要设计一个mysql事务,最简单的方案其实是这样:每执行一次SQL,写一次数据库。大致流程图如下所示:

在这里插入图片描述

​ 但是这个方案好么?显然不是。因为我们如果采用这样的方式,存在两个显著的问题:

  • 数据库写入为磁盘读写,速度很慢。
  • 数据库存在锁机制,难支持高并发。

对于问题一,了解到存储器读写速度如下所示(图源网络):

在这里插入图片描述

​ 可以看到内存的存储速度是纳秒级别(10-9次方),而硬盘的存储速度是毫秒级别(10-3次方)。由此,为加快读写速度,可以将修改的内容写入内存,而后再异步写入磁盘

​ 同时,由于内存本身并没对不同线程做锁控制机制,可以支持多个线程同时访问。对于高并发的问题也能更好支持。由此,上述的实现方案就改为了下面的流程:

在这里插入图片描述

事务隔离级别

​ 在修改为优先写内存后续再异步同步的情况后,又带来了新的问题:在一个事务尚未确认提交时,新事务从缓存中应该读取什么数据呢?

在这里插入图片描述

​ 对于这种不同事务间数据读取的策略就被称为事务隔离级别。根据读取策略的不同,事务隔离级别被划分为四种:读未提交、读已经提交、可重复读、序列化

读未提交(Read Uncommitted)

​ 读未提交的策略比较简单,即默认读取内存中的内容,而不必管这个数据是否已经写入到了数据。但是这个策略会带来一些问题:

在这里插入图片描述

存在问题:

​ 如图所示,若设置为读未提交,那么此时事务可能读到尚未提交的数据,即脏读。因此会造成数据A在前一时刻尚且可以读取到,但想二次更新的时候,mysql数据库却因为回滚导致数据A被回退了。这种错误会导致系统的无法正常运行,是不可容忍的。

读已提交(Read Committed)

​ 既然读未提交的事务带来的错误是不可容忍的,那么我只读已提交的数据就可以避免读到脏数据了呀!那么应如何实现只读已提交数据呢?对问题进行分析,要获取到最新已提交的数据,必然要将数据的版本关系体现出来。为此,InnoDB设计了一个版本链的概念。对每行记录会新增两个隐藏列:trx_id、roll_pointer

在这里插入图片描述

  1. trx_id:用于保存每次对该记录进行修改的事务id。
  2. roll_pointer:存储一个指针,指向这条数据记录上一个版本的地址,可以通过它获取到该记录上一个版本的数据信息。

由此一来,就可以通过最新记录(可能未提交)进行回溯,直到找到已提交的记录

​ 当然,仅有版本链的概念明显不够,我们还无法判断哪个数据是已提交的。为此InnoDB又新增了一个ReadView的解决方案,ReadView保存了一个写入了但未提交的事务ID列表。依据这个列表,我们就可以判断哪些事务还未写入。

在这里插入图片描述

​ 以上图为例,由于此时trx_id=20、trx_id=40的事务均未提交,InnoDB会生成一个ReadView:{20,40}。由此可能出现三种情况的事务访问:

  • 若预期访问事务ID=10的记录,由于其小于最小的事务Id20,证明事务已提交,允许访问。
  • 若预期访问事务ID=30的记录,由于其介于最大最小的事务ID之间,就需要逐一判断ReadView中是否包含事务ID=30的记录
  • 若预期访问事务ID=50的记录,由于其大于ReadView最大的事务Id,必然是在生成ReadView后生成的,也必然没有提交,不允许访问。

结合版本链和ReadView,基本就可以实现只读取已经提交的内容。

存在问题:

​ 由于ReadView是每次查询才新生成的,因此不免存在以下情况:

在这里插入图片描述

​ 在事务中首先读了一次数据A,期间事务发生了提交,导致二次查询出来的数据A同第一次出现了差异。由此难免让人发问:“两次相同的条件,查询到的结果却不一致,我是出现了幻觉了嘛?” 因此,这种情况也被形象称做:幻读

​ 幻读同脏读不同,幻读造成的问题是会破坏数据一致性。假设我们有一张表 user(id, name, age),已经有两条数据 (1, “Jack”, 20), (2, “Tom”, 18),同时我们执行以下流程:

在这里插入图片描述

​ 三个事务执行完成后,主库数据库内的数据应该是:(1, “Jack”, 10), (2, “Jack”, 18),(3, “Jack”, 18)。然而,此时binlog内的写入的SQL语句却是:

//事务二
update user set name = "Jack" where id = 2
update user set age = "40" where id = 2

//事务三
insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/

//事务一
update user set name = "Tom" where name = "Jack"

​ 那么此时,从库收到了主库同步的binLog数据,并按照顺序执行。得到的结果却是:(1, “Jack”, 10), (2, “Jack”, 10),(3, “Jack”, 10)。不难发现,数据行2和3发生了主从不一致,这个是无法容忍的。

可重复读(Repeatable Read)

​ 要解决幻读,主要是解决两个问题:1、确保一次事务内看到的数据一致;2、确保生成的binLog数据顺序正确。

​ 对于问题1,其实相对比较简单。同一次事务内看到的数据不一致是由于每次ReadView都实时生成(也被称为实时读)。因此,只要确保同一次事务内只生成一次ReadView(也被称为快照读),就可以避免多次查询会出现不一致数据的情况。

​ 然而,仅保持自己看不到是不够的,如果无法解决binLog的SQL写入顺序问题,数据不一致的问题就无法得到解决。那其实对上述现象进行分析,导致SQL写入顺序混乱的原因,其实是因为违背了事务一对于"where name = “Jack” 的原子性。即事务操作期间还有别的符合条件数据能被修改

​ 那么,很朴素的一个思想就是,只要对这些都符合条件的数据都加锁不就可以了嘛?为此,mysql提出了间隙锁的概念。假设当前我们的数据对name字段配置了一个索引,那么此时事务一运行的时候,我们需要将其索引临近的一行及其间隙都锁上,不允许其余事务进行更新插入的操作。由此一来,索引被锁上,没法插入新的数据,也就不会出现SQL语句混乱的情况了。

在这里插入图片描述

​ 那么这个时候肯定有人会说:“你没索引的字段咋办啊?”,对于没有索引的字段,mysql会做全表的扫描。由此一来,相当于会把整张表的数据都给锁上。从而避免无索引的情况出现数据不一致的问题。

序列化(Serializable)

​ 对于可序列化来说,实现就相对粗暴些。本着“爷才不考虑那么多,直接将表锁了,肯定不会有问题”的思想出发进行设计:

1、首先针对每次事务读操作的时候加表级共享锁,确保多个事务可以读。

2、事务写操作的时候则加表级别的排它锁,只允许自己事务操作。

这些锁都维持到事务结束再释放,从而完美避免了上述问题的出现。然而,粗暴的方法一般性能都不太好,在高并发的情况下,常常只有一个线程可以操作数据,因此不建议使用。

总结

​ 介绍了这么多有关事务隔离的内容,我们终于可以回归到我们的问题上来了。那么其实对于开头提到的问题,原因就是在异步线程中,会新开一个事务,这两个事务是并行的。由于mysql默认的事务隔离级别是可重复读,会导致事务A异步的情况下,数据可能未提交,事务B执行较快而获取到了旧数据,造成了同步数据错误的问题。

在这里插入图片描述

​ 知道了问题,那么解决方案就比较简单了,可以不通过异步的方式发送,而是采用kafka消息的机制。这样就给事务A留足了事务提交的时间,从而确保数据的准确同步。

参考文献

深入浅出Mybatis系列(五)Mybatis事务篇

从因到果看懂事务隔离级别的实现原理

innodb存储引擎中一条sql写入的详细流程

MySQL的两阶段提交(数据一致性)

MySQL是如何实现读已提交和可重复读的——MVCC原理

幻读为什么会被 MySQL 单独拎出来解决?

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值