乐趣区

关于后端:都是同样条件的mysql-select语句为什么读到的内容却不一样

假如以后数据库里有上面这张表。

老规矩,以下内容还是默认产生在 innodb 引擎的可反复读隔离级别下。

大家能够看到,线程 1,同样都是读 age >= 3 的数据。第一次读到 1 条数据,这个是原始状态。这之后线程 2 将 id= 2 的 age 字段也改成了 3。
线程 1 此时再读两次,一次读到的后果还是原来的 1 条,另一次读的后果却是 2 条,区别在于加没加 for update。
为什么同样条件下,都是读,读出来的数据却不一样呢?
可反复读不是要求每次读出来的内容要一样吗?

要答复这个问题。
我须要从盘古是怎么开天辟地这个话题开始聊起。

不好意思。
失态了。
那就从事务是怎么回滚的开始聊起吧。

事务的回滚是怎么实现的
咱们在执行事务的时候,个别都是上面这样的格局
begin;
操作 1;
操作 2;
操作 3;
xxxxx
….
commit;
复制代码
在提交事务之前,会执行各种操作,外面能够蕴含各种逻辑。
只有是执行逻辑,那就有可能会报错。
回忆下事务的 ACID 里有个 A,原子性,整个事务就是个整体,要么一起胜利,要么一起失败。

如果失败了的话,那就要让执行到一半的事务有能力回到没执行事务前的状态,这就是回滚。
执行事务的代码就相似写成上面这样。
begin;
try:

操作 1;

操作 2;
操作 3;
xxxxx
….
commit;
except Exception:

rollback;

复制代码
如果执行 rollback 能回到事务执行前的状态的话,那阐明 mysql 须要晓得某些行,执行事务前的数据长什么样子。
那数据库是怎么做到的呢?
这就要提到 undo 日志了,它记录了某一行数据,在执行事务前是怎么样的。
比方 id= 1 那行数据,name 字段从“ 小白 ” 更新成了 ” 小白 debug”,那就会新增一个 undo 日志,用于记录之前的数据。

因为同时并发执行的事务能够有很多,于是可能会有很多 undo 日志,日志里退出事务的 id(trx_id)字段,用于表明这是哪个事务下产生的 undo 日志。
同时将它们用链表的模式组织起来,在 undo 日志里退出一个指针(roll_pointer),指向上一个 undo 日志,于是就造成了一条版本链。

有了这个版本链,当某个事务执行到一半发现失败时,就间接回滚,这时候就能够顺着这个版本链,回到执行事务前的状态。

以后读和快照读是什么
有了下面的 undo 日志版本链之后,咱们能够看到最新的数据在表头,在这之后的都是一个个旧的数据版本。不论是最新的,还是旧的数据版本,咱们都叫它数据快照。
以后读,读的就是版本链的表头,也就是最新的数据。
快照读,读的就是版本链里的其中一个快照,当然如果这个快照正好就是表头,那此时快照读和以后读的后果一样。

咱们平时执行的一般 select 语句,比方上面这种,就是快照读。
select * from user where phone_no=2;
复制代码
而非凡的 select 语句,比方在 select 前面加上 lock in share mode 或 for update,都属于以后读。
除此之外 insert,update,delete 操作都属于写操作,既然写,那必然是写最新的数据,所以都会引发以后读。

那么问题来了。
以后读,读的是版本链的表头,那么执行以后读的时候,有没有可能恰好有其余事务,生成更加新的快照,代替以后表头,成为新的表头呢,那这时候岂不是读的不是最新数据了?
答案是不会,不论是 select … for update 这些(非凡的)读操作,还是 insert、update 这些写操作,都会对这行数据加锁。而生成 undo 日志快照,也是在写操作的状况下生成的,执行写操作前也须要取得锁。所以写操作须要阻塞期待以后读实现后,取得锁后能力更新版本链。

read view
数据库里能够同时并发执行十分多的事务, 每个事务都会被调配一个事务 ID, 这个 ID 是递增的,越新的事务,ID 越大。
而数据表里某行数据的 undo 日志版本链,每个 undo 日志下面也有一个事务 id (trx_id),它是创立这个 undo 日志的事务 id。
并不是所有事务都会生成 undo 日志,也就是说某行数据的 undo 日志版本链上只有局部事务的 id。然而,所有事务都有可能会拜访这行数据对应的版本链。而且版本链上尽管有很多 undo 日志快照,但也不是所有 undo 日志都能被读,毕竟有些 undo 日志,创立它们的事务还没提交呢,人家随时可能失败并回滚。
当初的问题就成了,当初有一个事务,通过快照读的形式去读 undo 日志版本链,那它能读哪些快照?并且它应该读哪个快照?
这里就要引入一个 read view 的概念。它就像是一个有高低边界的滑动窗口。
整个数据库里有那么多事务,这些事务分为曾经提交(commit)的,和没提交的。没提交的,意味着这些事务还在进行中,也就是所谓的沉闷事务。所有的沉闷事务的 id,组成 m_ids。而这其中最小的事务 id 就是 read view 的下边界,叫 min_trx_id。
产生 read view 的那一刻,所有事务里最大的事务 id,加个 1,就是这个 read view 的上边界,叫 max_trx_id。
概念太多,有点乱?没事的,持续往下看,前面会有例子的。

事务能读哪些快照
有了这些根底信息之后,咱们先看下事务在 read view 下,他能读哪些快照呢?
记住一个大前提:事务只能读到本人产生的 undo 日志数据(事务提不提交都行),或者是其余事务曾经提交实现的数据。
当初事务(假如就叫事务 A 吧)有了 read view 之后,不论看哪个 undo 日志版本链,咱们都能够把 read view 往版本链上一放。版本链就被分成了好几局部。

版本链快照的 trx_id < read view 的 min_trx_id
从下面的形容中,咱们能够晓得 read view 的 m_ids 来源于数据库所有沉闷事务的 id,而最小的 min_trx_id 就是 read view 的下边界,因为事务 id 是依据工夫递增的,所以如果版本链快照的 trx_id 比 min_trx_id 还要小,那这些必定都是非沉闷(曾经提交)的事务 id,这些快照都能被事务 A 读到。

版本链快照的 trx_id >= read view 的 max_trx_id
max_trx_id 是在事务 A 创立 read view 的那一刻产生的,它比那时候所有数据库已知的事务 id 都还要大。所以如果 undo 日志版本链上的某个快照上含有比 max_trx_id 还要大的 trx_id,那阐明这个快照曾经超出事务 A 的 ” 了解范畴了 ”,它不该被读到。

read view 的 min_trx_id <= 版本链快照的 trx_id < read view 的 max_trx_id

如果版本链快照的 trx_id 正好就是事务 A 的 id,那正好是它本人生成的 undo 日志快照,那不论有没有提交,都能读。
如果版本链快照的 trx_id 正好在沉闷事务 m_ids 中, 那这些事务数据都还没提交,所以事务 A 不能读到它们
除了下面两种状况外,剩下的都是曾经提交的事务数据,能够释怀读。

事务会读哪个快照
下面提到,事务在 read view 的可见范畴里,有机会能读到 N 多快照。但那么多快照版本,事务具体会读哪个快照呢?
事务会从表头开始遍历这个 undo 日志版本链,它会拿每个 undo 日志里的 trx_id 去跟本人的 read view 的高低边界去做判断。第一个呈现的小于 max_trx_id 的快照。

如果快照是本人产生,那提不提交都行,就决定是读它了。
如果快照是他人产生的,且曾经提交实现了,那也行,决定读它了。

比方下图,undo 日志 1 正好小于 max_trx_id,且事务曾经提交,那么就读它了。

MVCC 是什么
像下面这种,保护一个多快照的 undo 日志版本链,事务依据本人的 read view 去决定具体读那个 undo 日志快照,最现实的状况下是每个事务都读本人的一份快照,而后在这个快照上做本人的逻辑,只有在写数据的时候,才去操作最新的行数据,这样读和写就被离开了,比起单行数据没有快照的形式,它能更好的解决读写抵触,所以数据库并发性能也更好。其实这就是面试里常问的 MVCC,全称 Multi-Version Concurrency Control,即多版本并发管制。

四个隔离级别是怎么实现的
之前的写的一篇文章最初留了个问题,四个隔离级别是怎么实现的。
晓得了 undo 日志版本链和 MVCC 之后,咱们再回过头来看下这个问题。

读未提交,每次读到的都是最新的数据,也不论数据行所在的事务是否提交。实现也很简略,只须要每次都读 undo 日志版本链的链表头(最新的快照)就行了。
与读未提交不同,读提交和可反复读隔离级别都是基于 MVCC 的 read view 实现的,反过来说, MVCC 也只会呈现在这两个隔离级别里。
读已提交隔离级别,每次执行一般 select,都会从新生成一个新的 read view,而后拿着这个最新的 read view 到某行数据的版本链上挨个遍历,找到第一个适合的数据。这样就能做到每次都读到其余事务最新已提交的数据。
可反复读隔离级别下的事务只会在第一次执行一般 select 时生成 read view,后续不论执行几次一般 select,都会复用这个 read view。这样就能放弃每次读的时候都是在同一规范下进行读取,那读到的数据也会是一样的。
串行化目标就是让并发事务看起来就像单线程执行一样,那实现也很简略,和读未提交隔离级别一样,串行化隔离界别下事务只读 undo 日志链的链表头,也就是最新版本的快照,并且就算是一般 select,也会在版本链的最新快照上退出读锁。这样其余事务想写,也得等这个读锁开释掉才行。所有对这行数据进行操作的事务,都老老实实地阻塞期待加锁,一个接一个进行解决,从成果上看就跟单线程解决一样。

再看文章结尾的例子
咱们用下面提到的概念,从新回到文章结尾的例子,梳理一遍。

咱们假如数据库一开始的三条数据,都是由 trx_id= 1 的事务 insert 生成的。
于是数据表一开始长上面这样。每行数据只有一个快照。留神快照里,trx_id 填的是创立它们的事务 id,也就是刚刚提到的事务 1。roll_pointer 本来应该指向 insert 产生的 undo 日志,为了简化,这里写为 null(insert undo 日志在事务提交后能够被清理掉)。

上面这个图,还是文章结尾的图,这里放进去是为了不便大家,不必划回去看了。

在线程 1 启动事务,咱们假如它的事务 trx_id=2,第一次执行一般 select,是快照读,在可反复读隔离级别,会生成一个 read view。以后这个数据库,沉闷事务只有它一个,那 m_ids =[2]。m_ids 里最小的 id,也就是 min_trx_id=2。max_trx_id 是以后最大数据库事务 id(只有它本人,所以也是 2),加个 1,也就是 max_trx_id=3

此时线程 1 的事务,拿着这个 read view 去读数据库表。
因为这三条数据的 trx_id= 1 都小于 min_trx_id=2,都属于可见范畴,因而能读到这三条数据的所有快照,最初返回符合条件(age>=3)的数据,有 1 条。

这时候事务 2,假如它的事务 trx_id=3,执行更新操作,生成新的 undo 日志快照。

此时线程 1 第二次执行一般 select,还是快照读,因为是可反复读,会复用之前的 read view,再执行一次读操作,这里重点关注 id= 2 的那行数据,从版本链表头开始遍历,第一个快照 trx_id=3 >= read view 的 max_trx_id=3,因而不可读,遍历下一个快照 trx_id=1 < min_trx_id=2,可读。于是 id= 2 的那行数据,还是拿到 age=2,而不是更新后的 age=3,因而快照读后果还是只有 1 条数据合乎 age>=3。
然而线程 1 第三次读,执行 select for update,就成了以后读了,间接读 undo 日志版本链里最新的那行快照,于是能读到 id=2,age=3,所以最终后果返回合乎 age>= 3 的数据有 2 条。
总的来说就是,因为快照读和以后读,读数据的规定不同,咱们看到了不一样的后果。

看到这里,大家应该了解了,所谓的可反复读每次读都要读到一样的数据,这里头的 “ 读 ”,指的是快照读
如果下次面试官问你,可反复读隔离级别下每次读到的数据都是一样的吗?
你该晓得怎么答复了吧?

总结

事务通过 undo 日志实现回滚的性能,从而实现事务的原子性(Atomicity)。
多个事务生成的 undo 日志形成一条版本链。快照读时事务依据 read view 来决定具体读哪个快照。以后读时事务间接读最新的快照版本。
mysql 的 innodb 引擎通过 MVCC 晋升了读写并发。

最初
最近原创更文的浏览量稳步上涨,思前想后,夜里辗转反侧。
我有个不成熟的申请。

来到广东好长时间了,好久没人叫我靓仔了。
大家能够在评论区里,叫我一靓仔吗?
我这么凶恶纯朴的欲望,能被满足吗?
如果切实叫不进口的话,能帮我点下右下角的点赞和在看吗?

别说了,一起在常识的陆地里呛水吧

退出移动版