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


    问题描述

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

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

    1. public void updateAndQuery(Example example, int diff){
    2. List<ProductPO> productPOS = productMapper.selectByExample(example);
    3. ProductPO productPO = productPOS.get(0);
    4. System.out.println("一次查询内容" + JSONObject.toJSONString(productPO));
    5. //二次插入并查询
    6. Integer oldNumber = productPO.getNumber();
    7. productPO.setNumber(oldNumber + diff);
    8. //首先先更新,再进行查询
    9. System.out.println("更新内容:"+ JSONObject.toJSONString(productPO));
    10. productMapper.updateByPrimaryKey(productPO);
    11. //异步执行
    12. CompletableFuture.runAsync(() -> {
    13. Example example1 = new Example(ProductPO.class);
    14. example1.createCriteria().andEqualTo("skuId", productPO.getSkuId());
    15. List<ProductPO> select = productMapper.selectByExample(example1);
    16. System.out.println("二次查询结果:"+JSONObject.toJSONString(select));
    17. if (oldNumber != select.get(0).getNumber() - diff) {
    18. throw new NrsBusinessException("查询出错");
    19. }
    20. });
    21. }
    22. 复制代码

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

    1. @Transactional(rollbackFor = Exception.class)
    2. 复制代码

    事务执行原理

    ​ 在了解的问题原因前,我们需要了解事务是如何实现的。首先假设现在我们要设计一个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语句却是:

    1. //事务二
    2. update user set name = "Jack" where id = 2
    3. update user set age = "40" where id = 2
    4. //事务三
    5. insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/
    6. //事务一
    7. update user set name = "Tom" where name = "Jack"
    8. 复制代码

    ​ 那么此时,从库收到了主库同步的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留足了事务提交的时间,从而确保数据的准确同步。

  • 相关阅读:
    电力工作记录仪、智能安全帽、智能布控球助力智能电网建设
    腾讯云HAI域AI作画
    [0CTF/TCTF 2022] real_magic_dlog
    LINUX之ftp服务-2
    Linux之并发竞争管理
    网关Gateway的介绍与使用
    【MySQL数据库】一约束
    unity工程参照以及评价(B)
    对main方法中“String[] args“数组的理解
    Github搜索技巧
  • 原文地址:https://blog.csdn.net/BASK2311/article/details/128013421