21.2 事务的隔离级别
实现隔离性最粗暴的方式就是在系统中的同一时刻只允许一个事务运行。但是会严重降低系统的吞吐量和资源利用率。因此可以通过限制并发事务访问相同部分数据,来实现可串行化执行。
事务并发执行遇到的一致性问题
- 脏写
如果一个事务修改了另一个未提交事务修改过的数据,就发生了脏写。
- 脏读
如果一个事务读取到另一个未提交事务修改过的数据,就发生了脏读。
- 不可重复读
如果一个事务修改了另一个未提交事务读取的数据,就发生了不可重复读、模糊读。
- 幻读
如果一个事务先根据某些搜索条件查询出一些记录,在该事务未提交时,另一个事务写入一些符合那些条件的记录(包含 INSERT 、 DELETE 、 UPDATE 操作),就发生了幻读。在MySQL 中,幻读强调的就是一个事务在按照某个相同的搜索条件多次读取记录时,后读取时读到了之前没有读到的记录。
SQL 标准中的 4 种隔离级别
并发事务执行过程中遇到的现象,按照可能导致一致性问题的严重性排序:
脏写 > 脏读 > 不可重复读 > 幻读
舍弃一部分隔离性来换取一部分性能:设立一些隔离级别,隔离级别越低,就越可能发生越严重的问题。SQL 设立了 4 个隔离级别:
- READ UNCOMMITTED : 未提交读;
- READ COMMITTED : 已提交读;
- REPEATABLE READ :可重复读;
- SERIALIZABLE : 可串行化。
由于脏写这一现象对一致性影响太严重了,无论是哪种隔离级别,都不允许脏写。
MySQL 支持的 4 种隔离级别
MySQL 的默认隔离级别为 REPEATABLE READ 。
设置事务的隔离级别
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
//level 有4个可选值
level:{
REPEATABLE READ |
READ COMMITTED |
READ UNCOMMITTED |
SERIALIZABLE }
在设置事务隔离级别的语句中, SET 关键字后可用 GLOBAL 和 SESSION 或什么都不放:
- 使用 GLOBAL 关键字:
- 只对执行完该语句后新产生的会话起作用;
- 当前已经存在的会话无效。
- 使用 SESSION 关键字:
- 对当前会话所有的后续事务有效;
- 该语句可以在已经开启的事务中执行,但不会影响当前正在执行的事务;
- 如果在事务之间执行,则对后续的事务有效。
- GLOBAL 和 SESSION 都不使用:
- 只对当前会话已结束中下一个即将开启的事务有效;
- 下一个事务执行完后,后续事务将恢复到之前的隔离级别;
- 该语句不能在已经开启的事务中执行,否则会报错。
21.3 MVCC 原理
版本链
每对记录进行一次改动,都会记录一条 undo 日志。每条 undo 日志也都有一个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为 INSERT 操作的记录没有更早的版本),通过这个属性可以将这些 undo 日志串成一个链表:
在每次更新该记录后,都会将旧值放到一条 undo 日志中(算作该记录的一个旧版本)。随着更新次数增多,所有版本都会被 roll_pointer 属性连接成一个链表——版本链。版本链的头节点就是当前记录的最新值。此外,每个版本还包含生成该版本时对应的事务 id。
使用版本链控制并发事务访问相同记录时的行为——多版本并发控制( MVCC )。
ReadView
对于使用 READ UNCOMMITTED 隔离级别的事务:由于可以读到未提交事务修改过的记录,直接读取记录的最新版本即可。
对于使用 SERIALIZABLE 隔离级别的事务:InnoDB 规定使用加锁的方式访问记录。
对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务:必须保证读到已经提交的事务修改过的记录。
核心问题:需要判断版本链中的哪个版本是当前事务可见的。
由此提出 ReadView (一致性视图)。包含 4 个内容:
- m_ids:在生成 ReadView 时,当前系统中活跃的读写事务的事务 id 列表;
- min_trx_id:在生成 ReadView 时,当前系统中活跃的读写事务中最小的事务 id,即 m_ids 中的最小值;
- max_trx_id:在生成 ReadView 时,系统分配给下一个事务的事务 id 值。
- creator_trx_id:生成该 ReadView 时的事务的事务 id 。
HINT:只有在对表中记录进行改动时,才会为事务分配唯一的事务 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 时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见,就顺着版本链找到下一个版本的数据,并重复上述步骤,直到版本链中的最后一个版本。如果最后一个版本也不可见,就表示这条记录对当前事务完全不可见,查询结果就不包含该记录。
在 MySQL 中, READ COMMITTED 和 REPEATABLE READ 隔离级别之间非常大的区别就是它们生成 ReadView 的时机不同。
READ COMMITTED —— 每次读取数据前都生成一个 ReadView
REPEATABLE READ —— 在第一次读取数据时生成一个 ReadView,后面的查询复用第一次读取生成的 ReadView 。
二级索引与 MVCC
只有在聚簇索引记录中才有 trx_id 和 roll_pointer 隐藏列。如果查询语句使用二级索引来执行查询,如何判断可见性?
二级索引页面的 Page Header 部分有一个名为 PAGE_MAX_TRX_ID 的属性,每当对该页面记录执行增删改操作时,如果执行该操作的事务的事务 ID 大于 PAGE_MAX_TRX_ID 属性值,就会把 PAGE_MAX_TRX_ID 属性设置为执行该操作的事务的事务 id 。当 SELECT 语句访问某个二级索引记录时,首先看一下对应的 ReadView 的 min_trx_id 是否大于该页面的 PAGE_MAX_TRX_ID 属性值。如果是,说明该页面的所有记录都对该 ReadView 可见;否则就执行步骤2 ,在回表之后再判断可见性;
利用二级索引记录中的主键值进行回表操作,得到对应的聚簇索引记录之后再按照前面的方式找到对该 ReadView 可见的第一个版本,然后判断该版本中相应的二级索引列值是否与利用该二级索引查询时使用的值相同。如果是,就把这条记录发送给客户端,否则就跳过该记录。
MVCC 小结
MVCC 指在使用 READ COMMITTED 、 REPEATABLE READ 这种隔离级别的事务执行普通的 SELECT 操作时,访问记录的版本链的过程。这样可以使不同事务的读 - 写、写 - 读操作并发执行,从而提升系统性能。
21.4 关于 purge
insert undo 日志在事务提交后就可以释放掉,而 update undo 日志由于还要支持 MVCC ,因此不能立即删除。
每个回滚段都对应一个名为 Rollback Segment Header 的页面,该页面有两个属性:
- TRX_RSEG_HISTORY :表示 History 链表的基节点;
- TRX_RSEG_HISTORY_SIZE:表示 History 链表占用的页面数量。
即每个回滚段都有一个 History 链表,一个事务在某个回滚段中写入的一组 update undo 日志在该事务提交后,就会加入到这个回滚段的 History 链表中。
为了支持 MVCC , delete mark 操作仅仅是在记录上打一个删除标记(懒惰删除),并没有将记录删除。
在一组 undo 日志中的 Undo Log Header 部分有一个名为 TRX_UNDO_DEL_MARKS 的属性,用来本组 undo 日志中是否包含因 delete mark 操作而产生的 undo 日志。
为了节约存储空间,适时彻底删除仅被标记删除的记录—— purge 。
update undo 日志和被标记为删除的记录只是为了支持 MVCC 而存在的,只要系统中最早产生的那个 ReadView 不再访问它们,它们的使命就结束了。只要保证生成 ReadView 时某个事务已经提交,那么该 ReadView 就不需要访问该事务运行过程中产生的 undo 日志了。
InnoDB 做了两件事:
在一个事务提交时,会为该事务生成一个名为事务 no 的值,该值用来表示提交的顺序;
一个 ReadView 结构还会包含一个事务 no 的属性。在生成一个 ReadView 时,会把比当前系统中最大的事务 no 值还大 1 的值赋给这个属性。
InnoDB 还把当前系统中所有的 ReadView 按照创建时间连成一个链表。当执行 purge 操作时,就把系统中最早生成的 ReadView 取出来。如果当前系统中不存在 ReadView ,就现场创建一个。然后从各个回滚段的 History 链表中取出事务 no 值较小的各组 undo 日志。如果一组 undo 日志的事务 no 值小于当前系统最早生成的 ReadView 的事务 no 属性值,就意味着该组 undo 日志的事务 no 值小于当前系统最早生成的 ReadView 的事务 no 属性值,就表示该组 undo 日志没有用了,就会从 History 链表中移除,并且释放掉占用的存储空间。如果该组包含因 delete mark 操作产生的 undo 日志,那么也需要将对应的标记为删除的记录给彻底删除。