21.1 事前准备
CREATE TABLE hero (
number INT,
NAME VARCHAR ( 100 ),
country VARCHAR ( 100 ),
PRIMARY KEY ( number )
) ENGINE = INNODB CHARSET = utf8;
INSERT INTO hero VALUES ( 1, '刘备', '蜀' );
21.2 事务隔离级别
在保证事务隔离性的前提下,使用不同的隔离级别,来尽量提高多个事务访问同一数据的性能。
21.2.1 事务并发执行遇到的问题
- 脏写(Dirty Write):一个事务修改了另一个未提交事务修改过的数据。
- 脏读(Dirty Read):一个事务读到了另一个未提交事务修改过的数据。
- 不可重复读(Non-Repeatable Read):一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交,该事务都能查询得到最新值。
- 幻读(Phantom):一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次查询时,能把另一个事务插入的记录也读出来。
21.2.2 SQL 标准中的四种隔离级别
按严重性:
脏写 > 脏读 > 不可重复读 > 幻读
隔离级别 | 中文名 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
READ UNCOMMITTED | 读未提交 | Possible | Possible | Possible |
READ COMMITTED | 读已提交 | Not Possible | Possible | Possible |
REPAEATABLE READ | 可重复读 | Not Possible | Not Possible | Possible |
SERIALIZABLE | 串行化 | Not Possible | Not Possible | Not Possible |
不论哪种隔离级别,都不允许脏写的情况发生。
21.2.3 MySQL 中支持的四种隔离级别
MySQL 的默认隔离级别为 REPEATABLE READ ,我们可以手动修改一下事务的隔离级别。
# 如何设置事务的隔离级别
# SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;
# 查看当前会话默认的隔离级别
SHOW VARIABLES LIKE 'transaction_isolation';
21.3 MVCC 原理
21.3.1 版本链
对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:
- trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给 trx_id 隐藏列。
- roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo log 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
Tips:实际上 insert undo 只在事务回滚时起作用,当事务提交后,该类型的 undo log 就没用了。
假设之后有两个事务 id 分别为100和200的事务对这条记录进行 UPDATE 操作:
对该记录每次更新后,都会将旧值放到一条 undo log 中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称为 版本链,它的头节点就是当前记录最新的值,另外,每个版本还包含生成该版本时对应的事务 id。
21.3.2 ReadView
- 对于 READ UNCOMMITTED 级别的事务来说,直接读取记录的最新版本;
- 对于 SERIALIZABLE 级别的事务来说,使用加锁的方式来访问;
- 对于 READ COMMITED 和 REPAEATABLE READ 级别的事务来说,必须保证讲到已经提交了的事务修改过的记录,而不能直接读取最新版本版本的记录。那么怎么判断版本链中哪个版本是当前事务可见的?于是便有了 ReadView 的概念。
ReadView 主要包括以下4个重点:
- m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的**事务 id **列表
- min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的**事务 id **,也就是 m_ids 中的最小值。
- max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。
- creator_trx_id:表示生成该 ReadView 的事务的 事务 id。
TIPS:只有在对表中的记录做改动时才会为事务分配事务 id,否则在一个只读事务中的事务 id 都默认为 0。
通过 ReadView 就可以判断访问某条记录时,某个版本是否可见:
- 如果被访问版本的 trx_id 与 ReadView 中的 creator_trx_id 相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果被访问版本的 trx_id 小于 ReadView 中的 min_trx_id,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
- 如果被访问版本的 trx_id 大于 ReadView 中的 max_trx_id,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。
- 如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间,那就需要判断一下 trx_id 是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
public boolean isShow(long trx_id, long creator_trx_id, long min_trx_id, long max_trx_id, List<Long> m_ids) {
if (trx_id == creator_trx_id) {
return true;
}
if (trx_id < min_trx_id) {
return true;
}
if (trx_id > max_trx_id) {
return false;
}
return !m_ids.contains(trx_id);
}
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本,直到最后一个版本。
READ COMMITTED 和 REPEATABLE READ 生成 ReadView 的时机不同。
21.3.2.1 READ COMMITTED——每次读取数据前都生成一个ReadView
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
此时 number=1
的记录版本链:
使用 READ COMMITED 隔离级别的事务开始执行查询:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
SELECT1 的执行过程如下:
-
在执行 SELECT 语句时会先生成一个 ReadView:
{ m_ids:[100, 200], min_trx_id: 100, max_trx_id: 201, creator_trx_id: 0 }
-
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘张飞’ ,trx_id = 100 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
-
下一个版本的列 name = ‘关羽’ ,trx_id = 100 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
-
下一个版本的列 name = ‘刘备’ ,trx_id = 80 ,小于 ReadView 中的 min_trx_id 值100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为 ‘刘备’ 的记录。
之后,把事务id=100的事务提交,并使用事务id=200的事务继续更新
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此时 number=1
的记录版本链:
再次使用 READ COMMITED 隔离级别的事务开始执行查询:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞
SELECT2 的执行过程如下:
-
在执行 SELECT 语句时会先生成一个 ReadView:
{ m_ids:[200], min_trx_id: 200, max_trx_id: 201, creator_trx_id: 0 }
-
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘诸葛亮’ ,trx_id = 200 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
-
下一个版本的列 name = ‘赵云’ ,trx_id = 200 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
-
下一个版本的列 name = ‘张飞’ ,trx_id = 100 ,小于 ReadView 中的 min_trx_id 值200 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为 ‘张飞’ 的记录
每次 SELECT 查询都可以读到最新已提交的记录,这就是读已提交
21.3.2.2 REPAEATABLE READ——在第一次读取数据时生成一个ReadView
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
此时 number=1
的记录版本链:
使用 REPAEATABLE READ 隔离级别的事务开始执行查询:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备
SELECT1 的执行过程如下:
-
在执行 SELECT 语句时会先生成一个 ReadView:
{ m_ids:[100, 200], min_trx_id: 100, max_trx_id: 201, creator_trx_id: 0 }
-
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘张飞’ ,trx_id = 100 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
-
下一个版本的列 name = ‘关羽’ ,该版本的 trx_id = 100 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
-
下一个版本的列 name = ‘刘备’ ,该版本的 trx_id = 80 ,小于 ReadView 中的 min_trx_id 值100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为 ‘刘备’ 的记录。
之后,把事务id=100的事务提交,并使用事务id=200的事务继续更新
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
COMMIT;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此时 number=1
的记录版本链:
再次使用 REPAEATABLE READ 隔离级别的事务开始执行查询:
# 使用 REPEATABLE READ 隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备
SELECT2 的执行过程如下:
-
使用 REPEATABLE READ 隔离级别时,因为之前执行 SELECT1 时已经生成过 ReadView 了,所以此时不再重新生成,而是直接复用之前的 ReadView
{ m_ids:[100, 200], min_trx_id: 100, max_trx_id: 201, creator_trx_id: 0 }
-
然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name = ‘诸葛亮’ ,trx_id = 200 ,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。
-
下一个版本 name = ‘赵云’ ,trx_id = 200 ,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。
-
下一个版本的列 name = ‘张飞’ ,trx_id = 100 ,也在 m_ids 列表内,所以也不符合要求,同理下一个列 name 的内容是 ‘关羽’ 的版本也不符合要求。继续跳到下一个版本。
-
下一个版本的列 name = ‘刘备’ ,trx_id = 80 ,小于 ReadView 中的 min_trx_id 值
100 ,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 c 为 ‘刘备’ 的记录。
两次 SELECT 查询得到的结果都是相同的,这就是所谓的可重复读。
21.3.3 二级索引与MVCC
前面使用的 trx_id 和 roll_pointer 隐藏列都只存在于聚簇索引,当使用二级索引查询时,该如何判断可见性?
BEGIN;
SELECT name FROM hero WHERE name = '刘备';
- 二级索引页面的 Page Header 部分有一个名为 PAGE_MAX_TRX_ID 的属性,每次有事务增删改本页面中的记录时,如果该事务的 id 大于 PAGE_MAX_TRX_ID ,就把这个 id 赋值给 PAGE_MAX_TRX_ID。这也就意味 PAGE_MAX_TRX_ID 是修改该二级索引页面的最大事务 id。
- 当 SELECT 语句访问某个二级索引记录时,如果对应的 ReadView 的 min_trx_id 大于该页面的 PAGE_MAX_TRX_ID,说明该页面的所有记录都对该 ReadView 可见,否则执行步骤 3。
- 利用二级索引进行回表,得到对应的聚簇索引记录后按照前面说的方式判断是否可见。
21.3.4 MVCC 小结
- 所谓 MVCC(Multi-Version Concurrency Control,多版本并发控制)指的就是在使用 READ COMMITED、REPEATABLE READ 这两种隔离级别的事务在执行普通的 SELECT 操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。
- READ COMMITED、REPEATABLE READ 这两种隔离级别主要不同在于生成 ReadView 的时机。
- 只有在进行普通 SELECT 查询时,MVCC 才生效。
21.4 关于 purge
在合适的时候把 update undo 日志以及仅仅被标记为删除的记录彻底删除掉,这个删除操作就称为 purge。
21.5 总结
- 并发的事务在运行过程中会出现一些可能的引发一致性问题
- SQL 标准中有4种隔离级别
- 版本链
- ReadView