当前位置: 首页 > 科技观察

所有相同条件的MySQLSelect语句,为什么读出的内容不一样?

时间:2023-03-17 18:11:55 科技观察

假设当前数据库中存在下表。用户表数据库的原始状态是老规矩,在InnoDB引擎的可重复读隔离级别下,默认还是会出现下面的内容。它们都是精选结果,但有所不同。可以看到,线程1也读取了age>=3的数据。1条数据是第一次读取,这是原始状态。之后线程2也把id=2的age字段改成了3,此时线程1读取了两次,一次读取的结果还是原来的,而另一次读取的结果是两次,不同的是是否添加或不更新。为什么相同条件下读取的数据不同?重复阅读不是要求每次都看同样的内容吗?要回答这个问题。我需要从盘古如何创造世界的话题开始。打扰一下。我发脾气了。然后开始说事务是如何回滚的。事务回滚是如何实现的?当我们执行一个事务时,一般遵循begin格式;操作1;操作2;操作3;xxxxx....提交;在提交事务之前会执行各种操作,其中可以包含各种逻辑。只要是在执行逻辑,就有可能报错。回想一下事务的ACID里面有个A,原子性,整个事务是一个整体,要么一起成功,要么一起失败。如果ACID失效,就需要让执行到一半的事务有能力回到事务未执行前的状态,这就是回滚。执行事务的代码类似于以下内容。开始;尝试:操作1;操作2;操作3;xxxxx....提交;除了异常:回滚;如果执行rollback可以回到事务执行前的状态,那就意味着mysql需要知道事务执行前某些行的数据是什么样子的。数据库是怎么做到的?这是关于undolog,记录了一行数据,以及事务执行前的情况。比如id=1的那一行数据,如果name字段从“小白”更新为“小白调试”,就会增加一个undolog来记录之前的数据。undolog会记录之前的数据。由于可能有很多并发执行的事务,所以可能会有很多undolog。事务的id(trx_id)字段被添加到日志中,以指示哪个事务产生了undolog。同时将它们以链表的形式组织起来,在undolog中加入一个指针(roll_pointer)指向之前的undolog,从而形成版本链。Undologversionchain有了这个versionchain,当一个事务执行到一半失败的时候,会直接回滚。这时候就可以按照这个版本链,回到执行事务之前的状态。什么是当前读取和快照读取?有了上面的undologversionchain,我们可以在表头看到最新的数据,以及之后的所有旧数据版本。不管是最新的还是旧的数据版本,我们都称之为数据快照。当前读取,读取的是版本链的头部,也就是最新的数据。快照读取是指读取版本链中的其中一个快照。当然,如果快照刚好是表头,那么快照读取的结果和当前读取的结果是一样的。当前读取和快照读取我们平时执行的普通select语句,比如下面的,就是快照读取。从phone_no=2的用户中选择*;而特殊的select语句,比如sharemode加锁,select后forupdate,都属于当前读。另外,insert、update、delete操作都是写操作。既然是写,肯定是在写最新的数据,所以才会触发当前的读。那么问题来了。当前读取读取的是版本链的头部,那么当当前读取执行时,是否有可能其他事务恰好生成更新的快照来替换当前头部,成为新的头部?不是这次吗?读取的不是最新的数据吗?答案是否定的,不管是select...forupdate这样的(特殊)读操作,还是insert、update这样的写操作,都会锁定这一行数据。undologsnapshots的产生也是在写操作的情况下产生的,在执行写操作之前也必须先获取锁。因此写操作需要阻塞等待当前读操作完成,只有拿到锁后才能更新版本链。读视图数据库中可以并发执行很多事务,每个事务都会分配一个事务ID,是递增的,越新的事务ID越大。对于数据表中一行数据的undolog版本链,每条undolog也有一个transactionid(trx_id),也就是创建undolog的transactionid。并不是所有的事务都会产生undolog,也就是说一行数据的undolog版本链中只有一些事务id。但是所有的交易都可能访问到这行数据对应的版本链。而且,虽然版本链上有很多undolog快照,但并不是所有的undolog都可以读取。毕竟有些undo日志还没有被创建它们的事务提交,随时可能失败回滚。现在问题来了,现在有一个事务通过快照读取来读取undolog版本链,它能读取哪些快照呢?它应该读取哪个快照?这里需要引入读视图的概念。它就像一个有上限和下限的滑动窗口。整个数据库有那么多事务,这些事务又分为committed和uncommitted。未提交的意思是这些事务还在进行中,也就是所谓的活跃事务。所有活动交易的id形成m_ids。而最小的事务id就是读视图的下边界,称为min_trx_id。在读视图生成的那一刻,将所有事务中最大的事务id加1,即读视图的上边界,称为max_trx_id。概念太多,有点乱?没关系,继续往下看,后面会有例子。事务可以读取哪些快照?有了这些基本信息,我们先来看看读视图中一个事务可以读到哪些快照。记住一个大前提:一个事务只能读取自己产生的undolog数据(事务不能提提交),或者其他事务已经提交的数据。既然事务(假设叫事务A)有了读视图,那么不管我们看哪个undolog版本链,都可以把读视图放在版本链上。版本链分为几个部分。readview版本链快照的trx_id=的max_trx_id阅读视图。max_trx_id是在事务A创建读视图的那一刻产生的,它比当时数据库已知的所有事务id都大。所以如果undolog版本链上的一个快照包含一个大于max_trx_id的trx_id,说明这个快照超出了事务A的“理解范围”,不应该被读取。读取视图的min_trx_id<=版本链快照的trx_id<读取视图的max_trx_id。如果版本链快照的trx_id恰好是事务A的id,那么它恰好是自己生成的undolog快照,无论commit与否都可以读取。如果版本链快照的trx_id刚好在活跃事务m_ids中,那么这些事务数据还没有提交,事务A无法读取到。除了以上两种情况,其余都是committed的事务数据,可以放心读取。事务将读取哪个快照上面提到,事务有机会在读取视图的可见范围内读取N个以上的快照。但是快照版本那么多,事务具体会读取哪个快照呢?事务会从header开始遍历undolog版本链,它会根据每个undolog中的trx_id和自己读视图的上下边界进行判断。第一个小于max_trx_id的快照发生。如果snapshot是自己生成的,提交与否无所谓,决定读取。如果snapshot是别人生成的,已经提交了,那也没关系,我决定读一读。比如下图中,undolog1刚好小于max_trx_id,事务已经commit了,那么读一下。什么是readview和undo版本链MVCC?和上面的一样,它维护了一个多快照的undolog版本链,事务根据自己的readview决定读取哪个undologsnapshot。理想情况下,每个事务读取自己的一个快照快照,然后在这个快照上做自己的逻辑,只有在写数据的时候,才操作最新的行数据,这样读写分离,相比没有单行数据快照,可以更好的解决读写冲突,所以数据库并发性能也更好。其实这就是面试中经常被问到的MVCC,全称Multi-VersionConcurrencyControl,即多版本并发控制。MVCC的四种隔离级别是如何实现的?之前写的一篇文章最后留了一个问题,四个隔离级别是怎么实现的。了解了undolog版本链和MVCC之后,我们再回过头来看看这个问题。四层隔离级别是读未提交,每次读取最新的数据,不管数据行所在的事务是否提交。实现也很简单,每次只需要读取undolog版本链的链表头(最新快照)即可。与readuncommitted不同,readcommitted和repeatableread隔离级别是基于MVCC的readview实现的。反之,MVCC只会出现在这两个隔离级别。读取提交的隔离级别,每执行一次正常的select,就会重新生成一个新的readview,然后拿最新的readview逐条遍历一行数据的版本链,找到第一个合适的数据。这样每次都能读取到其他交易最新提交的数据。可重复读隔离级别下的事务只会在第一次执行普通select时生成读视图,无论后续执行多少次普通select都会重用读视图。这样每次阅读都可以保持在同一个标??准下阅读,读到的数据也会是一样的。序列化的目的是让并发事务看起来像单线程执行。实现也非常简单。它与读未提交隔离级别相同。序列化隔离域下的事务只读取undolog链的链表头,即最新版本的快照,即使是普通的select,也会对最新版本的快照加读锁链。这样其他事务如果要写,就得等待读锁被释放。所有对这行数据进行操作的事务都老老实实阻塞等待锁,一个一个处理,效果上和单线程处理是一样的。我们再看一下文章开头的例子。下面我们利用上面提到的概念,回到文章开头的那个例子来梳理一下。用户表数据库的原始状态我们假设数据库中的前三条数据都是由trx_id=1的事务insert产生的。所以数据表一开始是这个样子的。每行数据只有一个快照。注意快照中,trx_id填充的是创建它们的事务id,也就是刚才提到的事务1。roll_pointer应该指向插入生成的撤销日志。为简单起见,这里写成null(事务提交后可以清空insertundolog)。下面这张用户表数据库的原始trx信息图还是文章开头的图。放在这里是为了方便大家,不用翻页阅读。都是select的结果但是在线程1上启动一个事务时是不同的。我们假设它的事务trx_id=2,普通select的第一次执行是快照读,会在repeatableread隔离级别生成读视图。在当前数据库中,只有一个活动事务,所以m_ids=[2]。m_ids中最小的id,即min_trx_id=2。max_trx_id是当前最大的数据库事务id(只有它自己,所以也是2),加1,即max_trx_id=3。此时事务1的readview就是线程1的事务,拿这个readview去读数据库表。因为这三条数据的trx_id=1小于min_trx_id=2,都属于可见范围,所以可以读取这三条数据的所有快照,最后得到满足条件的一条数据(age>=3)返回。此时,事务2假设其事务trx_id=3,执行更新操作,生成新的undolog快照。用户表数据库被添加到撤销日志中。这时线程1第二次执行普通select,也就是snapshotread。因为是可重复读,所以会复用之前读过的视图,再次执行一次读操作。这里,重点关注id=2的那一行的数据,从version列表的头部开始遍历。第一个snapshottrx_id=3>=readview的max_trx_id=3,所以不可读。遍历下一个快照trx_id=1=3。但是线程1第三次读取,执行了selectforupdate,变成了当前读取。它直接读取undolog版本链中最新的行快照,所以可以读取到id=2,age=3,所以最终返回的结果满足age>=3的数据有2条。总的来说,由于快照读取和当前读取的数据读取规则不同,我们看到的结果是不一样的。看到这里大家应该明白了,所谓的可重复读肯定是每次都读到相同的数据,这里的“读”指的是快照读。如果下次面试官问你,在repeatableread隔离级别下,每次读取的数据都是一样的吗?你应该知道怎么回答吧?总结一下通过undolog回滚事务的作用,从而实现事务Atomicity的原子性。多个事务产生的undolog形成一个版本链。读取快照时,事务根据读视图决定读取哪个快照。当前读时事务直接读取最新的快照版本。MySQL的InnoDB引擎通过MVCC提高了读写并发性。