目录
MVCC是什么?
MVCC即多版本并发控制,能够保证多个读请求之间不会进行阻塞,根据事物隔离级别和事物id来确定当前事物能够查询到的数据的版本。对于每行记录来说,可能会存在多个版本,而这些版本会使用使用链表进行关联起来,从而控制一个事务能够查询到的数据的版本。
在MVCC中有以下几个核心概念:
- 脏读 不可重复读 幻读
- 快照读和当前读
- 事务隔离级别
- undo日志
- TRX_ID
- ReadView
下面将介绍这几个概念,知道的可以直接略过。
脏读 可重复读 不可重复读 幻读
脏读
脏读指的是读到了其他事务未提交的数据,未提交意味着这些数据可能会回滚,也就是可能最终不会存到数据库中,也就是不存在的数据。读到了并一定最终存在的数据,这就是脏读。
可重复读
可重复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据都是一致的。通常针对数据更新(UPDATE)操作。
不可重复读
对比可重复读,不可重复读指的是在同一事务内,不同的时刻读到的同一批数据可能是不一样的,可能会受到其他事务的影响,比如其他事务改了这批数据并提交了。通常针对数据更新(UPDATE)操作。
幻读
幻读是针对数据插入(INSERT)操作来说的。假设事务A对某些行的内容作了更改,但是还未提交,此时事务B插入了与事务A更改前的记录相同的记录行,并且在事务A提交之前先提交了,而这时,在事务A中查询,会发现好像刚刚的更改对于某些数据未起作用,但其实是事务B刚插入进来的,让用户感觉很魔幻,感觉出现了幻觉,这就叫幻读。
注意:不可重复读和幻读两者有些相似。 但不可重复读重点在于update和delete,而幻读的重点在于insert。
快照读和当前读
当前读
当前读包含的 SQL 语句如下:
- update , delete , insert
- select......for update
- select......lock in share mode
快照读
同样顾名思义,就是读取快照的数据,这个快照一般指的是历史快照。
快照读包含的 SQL 语句为简单的 select 语句,就是不包含 ...for update, ...lock in share mode 关键字的。
因为查询不涉及数据的更新,一般查询只关注当前时机的历史快照数据,不像update那样更新数据是要最新版本才行。所以快照读能够在一定程度上提升 Mysql 的并发性能。
事务隔离级别
SQL 标准定义了四种隔离级别,MySQL 全都支持。这四种隔离级别分别是:
- 读未提交(READ UNCOMMITTED)
- 读提交 (READ COMMITTED)
- 可重复读 (REPEATABLE READ)
- 串行化 (SERIALIZABLE)
从上往下,隔离强度逐渐增强,性能逐渐变差。采用哪种隔离级别要根据系统需求权衡决定,其中,可重复读是 MySQL 的默认级别。
事务隔离其实就是为了解决上面提到的脏读、不可重复读、幻读这几个问题,下面展示了 4 种隔离级别对这三个问题的解决程度。
undo日志
事务的第一个特性就是原子性,原子性就是要保证一个事务中的增删改操作要么都成功,要么都不做。这时就需要 undo log,在对数据库进行修改前,会先记录对应的 undo log,然后在事务失败或回滚的时候,就可以用这些 undo log 来将数据回滚到修改之前了。
当一个事务执行修改操作时,InnoDB会将这些操作写入到回滚段中:
在回滚段中分配一段空间来存储该事务的undo log。
将修改操作的逆向操作(即撤销操作)写入到回滚段的空间中。这些撤销操作包括对记录的修改、插入和删除等。
将事务ID和undo log的起始位置写入到回滚段头部的事务表中,以便在事务回滚或MVCC读取时能够快速定位到该事务的undo log。
TRX_ID
trx_id是什么?
用来体现事务一致性视图,即:可见性判定(后面的review会详细介绍)
内部实现原理是什么?
InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1
会持久化?
每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为5的页面中一个称之为Max Trx ID的属性处,这个属性占用8个字节的存储空间。
当系统下一次重新启动时,会将上边提到的Max Trx ID属性加载到内存中,将该值加上256之后赋值给我们前边提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID属性值)。
如何查询一个事物的trx_id?
use information_schema; select trx_id, trx_mysql_thread_id from innodb_trx;
非只读事务一次只会加一?
是的,但是你看到的不一定是加一。因为:1)update 和 delete 语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到 purge 队列里等待后续物理删除,这个操作也会把 max_trx_id+1, 因此在一个事务中至少加 2;2)InnoDB 的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务。
只读事务id会分配trx_id么,分配算法是什么?
只读事务,InnoDB 并不会分配 trx_id。算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 2的48次方。
为什么用这个算法?
同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx 还是在 innodb_locks 表里,同一个只读事务查出来的 trx_id 就会是一样的。--为什么加 2的48次方?区别非只读事务,让人一下子就能明显看出来。
只读事务不会分配trx_id的好处是什么?
1)减小了事务的一致性视图大小。2)减少锁竞争。
一旦trx_id到达上限那么会如何?
可重复读隔离级别下会出现脏读现象
ReadView
readView是基于undo log版本链条实现的一套读视图机制。
ReadView 主要包含这样几部分:
- m_ids,当前有哪些事务正在执行,且还没有提交,这些事务的 id 就会存在这里;
- min_trx_id,是指 m_ids 里最小的值;
- max_trx_id,是指下一个要生成的事务 id。下一个要生成的事务 id 肯定比现在所有事务的 id 都大;
- creator_trx_id,每开启一个事务都会生成一个 ReadView,而 creator_trx_id 就是这个开启的事务的 id。
ReadView主要用于实现不同事物对于不同版本数据的可见性,历史版本数据会保存在undolog当中。
对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;
对于使用SERIALIZABLE隔离级别的事务来说,mysql规定使用加锁的方式来访问记录;
对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。
在此之前需要说明的是InnoDB 存储引擎的行数据会有2个隐藏的字段,roll_pointer和trx_id,roll_pointer主要用于回滚(指向之前生成的 undo log),trx_id
就是最近更新这条行记录的事务 ID。
RC隔离级别实现
假设有一个这样的行数据:
现在模拟A(trx_id=40)、B(trx_id=60)事务并行:
首先B事务执行了一条Update,此时undo log里面保存了历史数据
分情况讨论A事务进行查询前:
1.A事务此时执行查询,B事务还未提交
事务A在执行查询时会生成一个ReadView(当一个事务设置处于RC隔离级别时,每次发起查询,都重新生成一个ReadView),容易知道此时
readView = {m_ids=[40,60],min_trx_id=40,max_trx_id=61,creator_trx_id=40},这个时候去读数据发现第一条数据trx_id = 60,不等于creator_trx_id,说明并不是自己修改的,并且trx_id 大于min_trx_id,小于max_trx_id,说明此数据在事务A处理的范围内,并且在活跃事务表当中有trx_id = 60,说明此时该事务并未提交,根据RC隔离级别的要求,此时第一条数据不可见。
根据roll_pointer找到下一条记录,trx_id = 20
发现此时trx_id 不在min_trx_id和max_trx_id范围内,说明在事务开启前这条数据就已经有了,于是A事务就把这条数据查出来了。
2.A事务此时执行查询,B事务已经提交
readView = {m_ids=[40],min_trx_id=40,max_trx_id=61,creator_trx_id=40}不同的是m_ids已经没有了活跃事务70,说明已经提交了,第一条数据也就可读了。
2.A事务此时执行更新,B事务还未提交
A执行更新以后:
此时A进行查询,readView = {m_ids=[40,60],min_trx_id=40,max_trx_id=61,creator_trx_id=40}
对于第一条记录(trx_id = 40,x = 3)trx_id在min_trx_id和max_trx_id之间并且trx_id等于creator_trx_id,说明这条记录是自己修改的,是可见的直接返回。
关键点在于每次查询都生成新的ReadView,那么如果在你这次查询之前,有事务修改了数据还提交了,你这次查询生成的ReadView里,那个m_ids列表当然不包含这个已经提交的事务了,既然不包含已经提交的事务了,那么当然可以读到人家修改过的值了。
RR隔离级别实现
基于ReadView机制可以实现RC隔离级别,即你每次查询的时候都生成一个ReadView。
这样的话,只要在你这次查询之前有别的事务提交了,那么别的事务更新的数据,你是可以看到的。
在RR级别下,你这个事务读一条数据,无论读多少次,都是一个值,别的事务修改数据之后哪怕提交了,你也是看不到人家修改的值的,这就避免了不可重复读的问题。
有以下数据:
现在模拟A(trx_id=40)、B(trx_id=60)事务并行:
A第一次查询生成ReadView = {m_ids=[40,60],min_trx_id=40,max_trx_id=61,creator_trx_id=40}并查询发现此时数据 x = 1
现在B事务开始执行Update
B事务此时还未提交,A事务再次执行查询,此时无需生成readView,依照上面已经说过的情况这条数据不可见,查询的结果应该还是x = 1
假设此时B事务已经提交,这个时候ReadView依然是第一次生成的那个,也就是说此时无论遍历多少次链表,都会查询出 x = 1,因为ReadView在第一次查询的时候就已经确定了。这里也可以说明在这里的ReadView里的m_ids并不能够代表“实时”的活跃事务,也可能是以前查询时生成ReadView时的活跃事务。
start transaction和start transaction with consistent snapshot(用于开启事务)可以影响ReadView的生成时机,具体来是start transaction是在第一执行select的时候生成ReadView,而start transaction with consistent snapshot则会在事务开启时生成ReadView,
深入幻读问题场景
首先来看看 MySQL 文档是怎么定义幻读(Phantom Read)的:
The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.
翻译:当同一个查询在不同的时间产生不同的结果集时,事务中就会出现所谓的幻象问题。例如,如果 SELECT 执行了两次,但第二次返回了第一次没有返回的行,则该行是“幻像”行。
接下来就是RR级别下MVCC是如何解决一部分幻读问题:
我们还是拿上面那个例子
此时B事务执行插入一条数据,但还没有提交
这个时候A事务执行第一次查询select count(*) from table where x > 0,发现trx_id=60的这条数据并不可见,于是查询结果为1一条
(x = 1,trx_id = 20),这就说明了其他事务的插入并不能影响A事务的正常读取。
但是此时如果事务A更新x=100这条数据,就会发现更新成功,说明了这条数据是存在的,并且在这条更新语句的后面再次进行该查询发现数据条数为2条,明明之前查询的时候是1条,这个时候又会出现幻读的情况。简单点说就是对于快照读, MVCC 并不能完全避免幻读现象。因为当事务 A 更新了一条事务 B 插入的记录,那么事务 A 前后两次查询的记录条目就不一样了,所以就发生幻读。
因为这种特殊现象的存在,所以我们认为 MySQL Innodb 中的 MVCC 并不能完全避免幻读现象。
除了上面这一种场景会发生幻读现象之外,还有下面这个场景也会发生幻读现象。
- T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。
- T2 时刻:事务 B 往插入一个 id= 200 的记录并提交;
- T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。
要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。
举个具体例子,场景如下:
事务 A 执行了这面这条锁定读语句后,就在对表中的记录加上 id 范围为 (2, +∞] 的 next-key lock(next-key lock 是间隙锁+记录锁的组合)。
然后,事务 B 在执行插入语句的时候,判断到插入的位置被事务 A 加了 next-key lock,于是事物 B 会生成一个插入意向锁,同时进入等待状态,直到事务 A 提交了事务。这就避免了由于事务 B 插入新记录而导致事务 A 发生幻读的现象。