前面其实单独写过事务隔离级别和 MVCC 的文章,但是感觉仍然没法把知识串起来,今天正好借着这道面试题来巩固一下知识体系,虽然文章比较长,不过都是大伙熟悉的知识,帮助大家理清思路而已~
老规矩,背诵版在文末。
众所周知,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。提到事务,你肯定立马脱口而出 ACID(原子性 Atomicity、一致性 Consistency、隔离性 Isolation、持久性 Durability)
本文的主旨,就是其中的 Isolation,也就是 “隔离性”。
当数据库上有多个事务同时执行的时候,就可能出现一些并发一致性问题:丢失更新(Last To Modify)、脏读(Dirty Read)、不可重复读(Unrepeatable Read)、幻读(Phantom Read)
那么为了解决这些问题,就有了 “隔离级别” 的概念。
万事终归有利有弊,隔离级别越高,隔离得越严实,并发一致性问题就越少,那么相应的数据库付出的性能代价也就越大。所以,很多时候,我们都要在这二者之间寻找一个平衡点。
丢失更新非常好理解,简单来说其就是一个事务的更新操作会被另一个事务的更新操作所覆盖,从而导致数据的不一致。
举个例子:
1)事务 T1 将行记录 r 更新为 v1,但是事务 T1 并未提交
2)与此同时,事务 T2 将行记录 r 更新为 v2,事务 T2 未提交
3)事务 T1 提交
4)事务 T2 提交
如下图所示,显然,事务 T1 丢失了自己的修改。
但是,事实上,这种情况准确来讲并不会发生。
因为我们说过对于行进行更新操作的时候,需要对行或其他粗粒度级别的对象加锁,因此当事务 T1 修改行 r 但是没提交的时候,事务 T2 对行 r 进行更新操作的时候是会被阻塞住的,直到事务 T1 提交释放锁。
所以,从数据库层面来讲,数据库本身是可以帮助我们阻止丢失更新问题的发生的。
不过,在真实的开发环境中,我们还经常会遇到逻辑意义上的丢失更新。举个例子:
1)事务 T1 查询一行数据 r,放入本地内存,并显示给一个用户 User1
2)事务 T2 也查询该行数据,并将取得的数据显示给另一个用户 User2
3)User1 修改了行记录 r 为 v1,更新数据库并提交
4)User2 修改了行记录 r 为 v2,更新数据库并提交
显然,最终这行记录的值是 v2,User1 的更新操作被 User2 覆盖掉了,丢失了他的修改。
可能还是云里雾里,我来举个更现实点的例子吧,一个部门共同查看一个在线文档,员工 A 发现自己的性别信息有误,于是将其从 “女” 改成了 “男”,就在这时,HR 也发现了员工 A 的部门信息有误,于是将其从 ”测试“ 改成了 ”开发“,然后,员工 A 和 HR 同时点了提交,但是 HR 的网络稍微慢一点,再次刷新,员工 A 就会发现,擦,我的性别怎么还是 ”女“?
所谓脏读,就是说一个事务读到了另外一个事务中的 "脏数据",脏数据就是指事务未提交的数据
如下图所示,在事务并没有提交的前提下,事务 T1 中的两次 SELECT 操作取得了不同的结果:
注意,如果想要再现脏读这种情况,需要把隔离级别调整在 Read UnCommitted(读取未提交)。所以事实上脏读这种情况基本不会发生,因为现在大部分数据库的隔离级别都至少设置成 READ COMMITTED
不可重复读是指在一个事务内多次读取同一数据集合。在这个事务还没有结束时,另外一个事务也访问该同一数据集合,并做了一些修改操作。因此,在第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据可能是不一样的。
举个例子:事务 T1 读取一行数据 r,T2 将该行数据修改成了 v1。如果 T1 再次读取这行数据,此时读取的结果和第一次读取的结果是不同的
不可重复读和脏读的区别是:脏读是读到未提交的数据,而不可重复读读到的却是已经提交的数据,但是其违反了事务一致性的要求。
幻读本质上是属于不可重复读的一种情况,区别在于,不可重复读主要是针对数据的更新(即事务的两次读取结果值不一样),而幻读主要是针对数据的增加或减少(即事务的两次读取结果返回的数量不一样)
举个例子:事务 T1 读取某个范围的数据,事务 T2 在这个范围内插入了一些新的数据,然后 T1 再次读取这个范围的数据,此时读取的结果比第一次读取的结果返回的记录数要多
SQL 标准定义了四种越来越严格的事务隔离级别,用来解决我们上述所说的四种事务的并发一致性问题。
1)READ UNCOMMITTED 读取未提交:一个事务还没提交时,它做的变更就能被别的事务看到
上面提到过,数据库本身其实已经具备阻止丢失更新的能力,也就是说,即使是最低的隔离级别也可以阻止丢失更新问题。所以:
2)READ COMMITTED 读取已提交:一个事务提交之后,它做的变更才会被其他事务看到。换句话说,一个事务所做的修改在提交之前对其它事务是不可见的。
3)REPEATABLE READ 可重复读(InnoDB 存储引擎默认的隔离级别):保证在同一个事务中多次读取同一数据的结果是一样的。当然了,在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
书中就是这么解释的,好像也挺通俗易懂的,那为了方便下面的行文,我再给一个更简单的解释: 可重复读就是:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。或者简单来说,事务在执行期间看到的数据前后是一致的。
4)SERIALIZABL 可串行化:顾名思义,强制事务串行执行,对于同一行记录,“写” 会加 “写锁”,“读” 会加 “读锁”,当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。这样多个事务互不干扰,不会出现并发一致性问题
可以看到四种隔离级别能阻止的并发一致性问题越来越多,但并不代表越高的隔离级别就越好,因为事务隔离级别越高,数据库付出的性能代价也就相应地越大。
另外,多提一嘴,InnoDB 存储引擎在 REPEATABLE READ 可重复读的隔离级别下,使用 Next-Key Lock 锁的算法避免了幻读的产生, 具体可以看这篇文章 幻读为什么会被 MySQL 单独拎出来解决?。也就是说,InnoDB 存储引擎在其默认的 REPEATABLE READ 事务隔离级别下就已经能完全保证事务的隔离性要求了,即达到了 SQL 标准的 SERIALIZABLE 隔离级别。
举个例子,看下图,我们来看看在不同的隔离级别下,事务 A 对同一个字段的查询会得到哪些不同的返回结果:
image-20211209104207832
1)READ UNCOMMITTED 读取未提交:V1 V2、V3 都是 2。事务 B 虽然还没有提交,但是修改的结果结果已经被 A 看到了
2)READ COMMITTED 读取已提交:V1 是 1,然后事务 B 对字段的修改提交了,能被 A 看到,所以,V2 V3 的值都是 2
3)REPEATABLE READ 可重复读:V1 V2 是 1,V3 是 2。回想下这句话你就懂了:一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的
4)SERIALIZABL 可串行化:事务 B 执行 “将字段 a 的值改为 2” 的时候会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从事务 A 的角度看, V1 V2 的值是 1,V3 的值是在事务 2 提交后的,所以 V3 是 2。
读取未提交和可串行化的实现没什么好说的,一个是啥也不干,一个是直接无脑加锁避开并行化 让你啥也干不成。
重头戏就是读取已提交和可重复读是如何实现的。这就是我们要说的 MVCC 了,也就是面试中的超级高频题。
我先来简单说一下,对于这两个隔离级别,数据库会为每个事务创建一个视图 (ReadView),访问的时候以视图的逻辑结果为准:
那么问题了就来了,已经执行了这么多的操作,事务该如何重新回到之前视图记录的状态?数据库会通过某种手段记录这之间执行的种种操作吗?
这就是 undo log 版本链做的事 👇
在 MySQL 中,每条记录在更新的时候都会同时记录一条回滚操作(也就是 undo log),当前记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
简单理解,undo log 就是每次操作的反向操作,比如比如当前事务执行了一个插入 id = 100 的记录的操作,那么 undo log 中存储的就是删除 id = 100 的记录的操作。
也就是说,B+ 索引树上对应的记录只会有一个最新版本,但是 InnoDB 可以根据 undo log 得到数据的历史版本。同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)
那么,还有个问题,undo log 是如何和某条行记录产生联系的呢?换句话说,我怎么能通过这条行记录找到它拥有的 undo log 呢?
具体来说,InnoDB 存储引擎中每条行记录其实都拥有两个隐藏的字段:trx_id
和 roll_pointer
:
trx_id
就是最近更新这条行记录的事务 IDroll_pointer
就是指向之前生成的 undo log 的指针掏出我们的 user 表,来举个例子,假设 id = 100 的事务 A 插入一条行记录(id = 1, username = "Jack", age = 18),那么,这行记录的两个隐藏字段 trx_id = 100
和 roll_pointer
指向一个空的 undo log,因为在这之前并没有事务操作 id = 1 的这行记录。如图所示:
然后,id = 200 的事务 B 修改了这条行记录,把 age 从 18 修改成了 20,于是,这条行记录的 trx_id
就变成了 200,rooll_pointer
就指向事务 A 生成的 undo log :
接着,id = 300 的事务 C 再次修改了这条行记录,把 age 从 20 修改成了 30,如下图:
可以看到,每次修改行记录都会更新 trx_id 和 roll_pointer 这两个隐藏字段,之前的多个数据快照对应的 undo log 会通过 roll_pointer 指针串联起来,从而形成一个版本链。
那么问题又来了,一个记录会被一堆事务进行修改,一个记录上会存在许许多多的 undo log,那么对于其中某一个事务来说,它能看见哪些 undo log?或者说,对于其中某一个事务来说,它能够根据哪些 undo log 执行回滚操作?
让我们来详细解释一下这个视图(ReadView)机制 👇
ReadView 机制就是用来判断当前事务能够看见哪些版本的,一个 ReadView 主要包含如下几个部分:
m_ids
:生成 ReadView 时有哪些事务在执行但是还没提交的(称为 “活跃事务”),这些活跃事务的 id 就存在这个字段里min_trx_id
:m_ids 里最小的值max_trx_id
:生成 ReadView 时 InnoDB 将分配给下一个事务的 ID 的值(事务 ID 是递增分配的,越后面申请的事务 ID 越大)creator_trx_id
:当前创建 ReadView 事务的 ID接下来,再掏出 user 表,通过一个例子来理解下 ReaView 机制是如何做到判断当前事务能够看见哪些版本的:
假设表中已经被之前的事务 A(id = 100)插入了一条行记录(id = 1, username = "Jack", age = 18),如图所示:
接下来,有两个事务 B(id = 200) 和 C(id = 300)过来并发执行,事务 B 想要更新(update)这行 id = 1 的记录,而事务 C(select)想要查询这行数据,这两个事务都执行了相应的操作但是还没有进行提交:
如果现在事务 B 开启了一个 ReadView,在这个 ReadView 里面:
m_ids
就包含了当前的活跃事务的 id,即事务 B 和事务 C 这两个 id,200 和 300min_trx_id
就是 200max_trx_id
是下一个能够分配的事务的 id,那就是 301creator_trx_id
是当前创建 ReadView 事务 B 的 id 200现在事务 B 进行第一次查询(select 操作不会生成 undo log 的哈),会把这行记录的隐藏字段 trx_id
和 ReadView 的 min_trx_id
进行下判断,此时,发现 trx_id 是 100,小于 ReadView 里的 min_trx_id
(200),这说明在事务 B 开始之前,修改这行记录的事务 A 已经提交了,所以开始于事务 A 提交之后的事务 B、是可以查到事务 A 对这行记录的更新的。
row.trx_id < ReadView.min_trx_id
接着事务 C 过来修改这行记录,把 age = 18 改成了 age = 20,所以这行记录的 trx_id
就变成了 300,同时 roll_pointer
指向了事务 C 修改之前生成的 undo log:
那这个时候事务 B 再次进行查询操作,会发现这行记录的 trx_id
(300)大于 ReadView 的 min_trx_id
(200),并且小于 max_trx_id
(301)。
row.trx_id > ReadView.min_trx_id && row.trx_id < max_trx_id
这说明一个问题,就是更新这行记录的事务很有可能也存在于 ReadView 的 m_ids(活跃事务)中。所以事务 B 会去判断下 ReadView 的 m_ids 里面是否存在 trx_id = 300
的事务,显然是存在的,这就表示这个 id = 300 的事务是跟自己(事务 B)在同一时间段并发执行的事务,也就说明这行 age = 20 的记录事务 B 是不能查询到的。
既然无法查询,那该咋整?事务 B 这次的查询操作能够查到啥呢?
没错,undo log 版本链
这时事务 B 就会顺着这行记录的 roll_pointer 指针往下找,就会找到最近的一条 trx_id = 100
的 undo log,而自己的 id 是 200,即说明这个 trx_id = 100 的 undo log 版本必然是在事务 B 开启之前就已经提交的了。所以事务 B 的这次查询操作读到的就是这个版本的数据即 age = 18。
通过上述的例子,我们得出的结论是,通过 undo log 版本链和 ReadView 机制,可以保证一个事务不会读到并发执行的另一个事务的更新。
那自己修改的值,自己能不能读到呢?
这当然是废话,肯定可以读到呀。上面的例子我们只涉及到了 ReadView 中的前三个字段,而 creator_trx_id
就与自己读自己的修改有关,所以这里还是图解出来让大家更进一步理解下 ReadView 机制:
假设事务 C 的修改已经提交了,然后事务 B 更新了这行记录,把 age = 20 改成了 age = 66,如下图所示:
然后,事务 B 再来查询这条记录,发现 trx_id = 200
与 ReadView 里的 creator_trx_id = 200
一样,这就说明这是我自己刚刚修改的啊,当然可以被查询到。
row.trx_id = ReadView.creator_trx_id
那如果在事务 B 的执行期间,突然开了一个 id = 500 的事务 D,然后更新了这行记录的 age = 88 并且还提交了,然后事务 B 再去读这行记录,能读到吗?
答案是不能的。
因为这个时候事务 B 再去查询这行记录,就会发现 trx_id = 500
大于 ReadView 中的 max_trx_id = 301
,这说明事务 B 执行期间,有另外一个事务更新了数据,所以不能查询到另外一个事务的更新。
row.trx_id > ReadView.max_trx_id
那通过上述的例子,我们得出的结论是,通过 undo log 版本链和 ReadView 机制,可以保证一个事务只可以读到该事务自己修改的数据或该事务开始之前的数据。
🥸 面试官:讲一下数据库的四种隔离级别,以及具体的实现 😎 小牛肉:数据库的四种隔离级别主要是用来解决四种并发一致性问题的,隔离级别越高,能够处理的并发一致性问题越多,相应的数据库付出的性能代价也就越高。 最低的隔离级别是读取未提交,一个事务还没提交时,它做的变更就能被别的事务看到:可以解决丢失更新问题(所谓丢失更新问题,就是指一个事务的更新操作会被另一个事务的更新操作所覆盖); 然后是读取已提交,一个事务提交之后,它做的变更才会被其他事务看到:可以解决丢失更新和脏读问题(所谓脏读,就是一个事务读到了另外一个事务未提交的数据); 然后是 InnoDB 默认的隔离级别可重复读,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的:可以解决丢失更新、脏读和不可重复读问题(所谓不可重复读,就是指第一个事务中的两次读数据之间,由于第二个事务的修改,那么第一个事务两次读到的数据是不一样的)。另外,InnoDB 的这个默认隔离级别,会通过 Next-Lock key 来解决幻读问题,所以其实是可以达到 SQL 标准的可串行化隔离级别的; 最后是可串行化,强制事务串行执行,对于同一行记录,“写” 会加 “写锁”,“读” 会加 “读锁”,当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。这样可以避免并发一致性问题,解决丢失更新、脏读、不可重复读和幻读问题(所谓幻读,和不可重复读差不多,不过幻读侧重于记录数量的增减,不可重复读侧重于记录的修改) 对于读取已提交和可重复读这两个隔离级别来说,其底层实现就是多版本并发控制 MVCC。 具体来说,对于这两个隔离级别,数据库会为每个事务创建一个视图 (ReadView),访问的时候以视图的逻辑结果为准。通过 undo log 版本链使得事务可以回滚到视图记录的状态。 而这两个隔离级别的区别就在于,它们生成 ReadView 的时机是不同的:
流水不争先,争的是滔滔不绝,我是小牛肉,小伙伴们下篇文章再见 👋