文章目录
一、前言
最近在读《MySQL 是怎样运行的》、《MySQL技术内幕 InnoDB存储引擎 》,后续会随机将书中部分内容记录下来作为学习笔记,部分内容经过个人删改,因此可能存在错误,如想详细了解相关内容强烈推荐阅读相关书籍
二、事务
1. 事务的四大特性(ACID)
1.1. 原子性
一个事务中所有对数据库的操作是一个不可分割的操作序列,要么全做要么全不做
即事务是执行的最小单元,执行结果只有两个,要么成功,要么失败回滚。
1.2. 一致性
一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。数据不会因为事务的执行而遭到破坏。
比如,当数据库只包含成功事务提交的结果时,就说数据库处于一致性状态。如果数据库系统在运行中发生故障,有些事务尚未完成就被迫中断,这些未完成事务对数据库所做的修改有一部分已写入物理数据库,这时数据库就处于一种不正确的状态,或者说是不一致的状态。
1.3. 持久性
持久性是指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,接下来的任何操作和故障都不应该影响到已提交的事务执行结果。
例如我们在使用JDBC操作数据库时,在提交事务方法后,提示用户事务操作完成,当我们程序执行完成直到看到提示后,就可以认定事务以及正确提交,即使这时候数据库出现了问题,也必须要将我们的事务完全执行完成,否则就会造成我们看到提示事务处理完毕,但是数据库因为故障而没有执行事务的重大错误。
1.4. 隔离性
一个事务的执行不能其它事务干扰。即一个事务内部的操作及使用的数据对其它并发事务是隔离的,并发执行的各个事务之间不能互相干扰。
隔离性是当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。
2. 脏写、脏读、不可重复读、幻读
脏写:如果一个事务修改了另一个未提交事务修改过的数据,就意味着发生了脏写。所有的隔离级别都不会出现这种情况。
脏读:脏读又称无效数据读出。一个事务读取另外一个事务还没有提交的数据叫脏读。
例如:事务T1修改了一行数据,但是还没有提交,这时候事务T2读取了被事务T1修改后的数据,之后事务T1因为某种原因Rollback了,那么事务T2读取的数据就是脏的。
不可重复读:不可重复读是指在同一个事务内,两个相同的查询返回了不同的结果。
例如:事务T1读取某一数据,事务T2读取并修改了该数据,T1为了对读取值进行检验而再次读取该数据,便得到了不同的结果。
解决:使用行级锁,锁定该行,事务A多次读取操作完成后才释放该锁,这个时候才允许其他事务更改刚才的数据
幻读:在同一事务内,两次相同的查询返回的数据条目数量不同,在RU、RC、RR级别下,都会出现幻读
例如:事务T1读取一次表中数据总量,事务T2修改了表中数据总量(插入或者删除了数据)。这是事务T1再次读取表中数据总量,发现和第一次读取的总量不同,好像产生了幻觉。
亦或者,在可重复读的隔离级别下,事务T1查询了记录a,发现不存在,准备插入记录a,但是此时事务T2开启并插入了记录a,此时事务T1才开始准备插入,但是T1插入会失败,因为库里已经存在记录a,此时T1即便再次查询记录a也无法查询到。这是因为mvcc的特性,由于T2比T1晚开启,T1是不会读取到T2修改的记录。解决:使用表级锁,锁定整张表,事务A多次读取数据总量之后才释放该锁,这个时候才允许其他事务新增数据。
注意:
- 脏写、脏读、不可重复读、幻读,解决该问题所需要的隔离级别,高低是:脏写 < 脏读 (读已提交)< 不可重复读(可重复读) < 幻读(可串行化)
- 不可重复读针对的是多次读取内容不同,幻读针对的是多次读取,内容条数不同
- 快照读:普通的select操作,是从 ReadView 中读取数据,读取的可能是历史数据
- 当前读:insert、update、delete、select…for update这种操作,读取的总是当前的最新数据
3. 隔离级别
隔离级别 | 解释 |
---|---|
读未提交(READ UNCOMMITTED) | 这是事务最低的隔离级别,它允许事务可以看到这个事务未提交的数据。这种隔离级别会产生脏读,不可重复读和幻读。 |
读已提交 (READ COMITTED) | 保证一个事务修改的数据提交后才能被另外一个事务读取。 另外一个事务不能读取该事务未提交的数据。这种事务隔离级别可以避免脏读出现,但是可能会出现不可重复读和幻读。 |
可重复读(REPEATABLE READ) | 这种事务隔离级别可以防止脏读,不可重复读。但是可能出现幻读。它除了保证-一个事务不能读取另一个事务未提交的数据外,还保证了避免下面的情况产生(不可重复读) (MySql 默认就是这个级别) |
可串行化 (SERIALIZABLE) | 这是花费最高代价但是最可靠的事务隔离级别。事务被处理为顺序执行。除了防止脏读,不可重复读外,还避免了幻像读。但是不建议使用,他将事务完全按照串行处理。 |
三、MVCC
1. 版本链
在【MySQL02】【 InnoDB 记录存储结构】 提到过,InnoDB 中每条记录都有两个必要的隐藏列(row_id 不是必须的)
- trx_id:一个事务每次对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给 trx_id
- roll_point:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中。这个隐藏列相当于一个指针,通过它可以找到修改前的信息。
我们以下表为例(主键叫 number 而不叫id 仅仅是为了与 后面的 事务 id 区分)
CREATE TABLE `hero` (
`number` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) DEFAULT NULL,
`country` varchar(100) DEFAULT NULL,
PRIMARY KEY (`number`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
当我们执行如下SQL 插入一条记录:
INSERT INTO hero (name, country) VALUES ('刘备', '蜀');
假设插入该记录的事务id 为 80 ,那么此刻此条记录的示意图如下:
insert undo 日志只在事务回滚时发送作用,当事务提交后,该类型的undo 日志就没用了,他所占用的 Undo Log Segment 也会被系统回收,虽然真正的 insert undo 日志占用的存储空间被回收了,但是 roll_pointer 的值并不会被清除。
假设之后两个事务id 分别为 100, 200 的事务对这条记录进行 update 操作,操作流程如下图:
执行顺序 | trx 100 | trx 200 |
---|---|---|
1 | begin; | |
2 | begin; | |
3 | update hero set name = ‘关羽’ where number = 1; | |
4 | update hero set name = ‘张飞’ where number = 1; | |
5 | commit; | |
6 | update hero set name = ‘赵云’ where number = 1; | |
7 | update hero set name = ‘诸葛亮’ where number = 1; | |
8 | commit; |
每对记录修进行一次改动,都会记录一条 undo 日志,每条 undo 日志也都有一个 roll_pointer 属性(insert 操作对应的 undo 日志没有该属性,因为 insert 操作的记录并没有更早的版本),通过这个属性可以将这些 undo 日志串成一个链表,所以现在情况如下图:
在每次更新该记录后,都会将旧值放到一条 undo 日志中(也就是该记录的一个旧版本)随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,这个链表称为版本链。版本链的头节点就是当前记录的最新值。
另外,每个版本中还包含生成该版本时对应的事务id,InnoDB会利用这个记录的版本链来控制并发事务访问相同记录时的行为,这种机制称为 多版本并发控制(MVVC)
在 update 操作产生的 undo 日志中,只会记录一些索引列以及被更新的列的信息,并不会记录全部列,上述图片内容仅仅是为了方便理解。
如对于 trx_id = 80 的那条 undo 日志来说,本身并没有记录 country 列信息。如果一条 undo 日志没有记录某一列,则说明某一列的值与上个版本相同,通过版本链去上个版本查找对应值即可,如果各个版本的 undo 日志都没有记录该列的值,则说明该列从未被修改过,对应列的值跟聚簇索引中列的值相同。
2. ReadView
对于 READ UNCOMMITTED 隔离级别的事务来说,由于可以读取到未提交事务修改的记录,所以- 直接读取记录的最新版本就好;
对于 SERIALIZABLE 隔离级别的事务来说,InnoDB 采用加锁的方式来访问记录;
对于 READ COMITTED 和 REPEATABLE READ 隔离级别的事务来说,都必须要保证读到已经提交的事务修改过的记录,也就是说如果另一个事务已经修改了记录但尚未提交,则不能直接读取最新版本的记录,因此这里的核心问题就是:需要判断版本链中的哪个版本是当前事务可见的。为此 InnoDB 提出了 Read View (一致性视图)的概念。
ReadView 中主要包含4个比较重要的内容:
- m_ids:在生成 ReadView 时,当前系统中活跃的读写事务的事务id列表。
- min_trx_id:在生成 ReadView 时,当前系统中活跃的读写事务中最小的事务id,也就是 m_ids 中的最小值
- max_trx_id:在生成 ReadView 时,系统应该分配给下一个事务的事务id值,这个值会比 m_ids 中的最大值大1(因为是分配给下一个事务)
- creator_trx_id:生成该 ReadView 的事务的事务id。(只有在对表中记录做修改时,即增删改时才会为事务分配唯一事务的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 时生成该版本的事务还是活跃的,该版本不可被访问,否则则说明该版本事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见,则顺着版本链找到下一个版本的数据,并继续执行上面的步骤判断记录的可见性;依次类推,如果记录的最后一个版本也不可见则意味着该条记录对当前事务完全不可见,查询结果就不该包含这条记录。
而 READ COMITTED 和 REPEATABLE READ 隔离级别之间非常大的一个区别就是生成 ReadView 的时机并不相同:
- READ COMITTED 在每次读取数据前都生成一个 ReadView
- REPEATABLE READ 在第一次读取数据时生成一个 ReadView
注意:
- 在事务执行过程中,只有第一次执行增删改操作时才会分配 trx_id ,在此之前,事务的默认id都是0。
- 在 REPEATABLE READ 隔离级别下,如果通过 START TRANSACTION WITH CONSISTENT SNAPSHOT 开启事务会在执行该语句后立即生成一个 ReadView,而不是在执行第一条 select 语句时才执行。
3. 二级索引与 MVCC
只有聚簇索引记录中才有 trx_id 和 roll_pointer 隐藏列,如果查询某个语句是使用二级索引来执行查询的,要如何判断可见性?
以下面内容为例:
BEGIN;
SELECT name FROM hero where name = '刘备';
假设 name 为名为 idx_name 的二级索引,判断可见性的过程大致如下:
- 二级索引页面的 Page Header 部分有一个名为 PAGE_MAX_TRX_ID 的属性,每当对该页面中记录执行增删改操作时,如果执行该操作的事务的事务id大于 PAGE_MAX_TRX_ID 属性值,则将 PAGE_MAX_TRX_ID 属性值更新为当前事务id,即 PAGE_MAX_TRX_ID 代表修改该二级索引页面的最大的事务id是多少。当一个 SELECT语句访问某个二级索引记录时,首先会看一下对应的 ReadView 的min_trx_id 是否大于该页面的 PAGE_MAX_TRX_ID,如果是,则说明该页面中的所有记录都对该 ReadView 可见,否则执行下一步,在回表后判断可见性。
- 利用二级索引记录中的主键值进行回表操作,得到对应的聚簇索引记录后再按照上面的方式找到对该 ReadView 可见的第一个版本,然后判断该版本中相应的二级索引列的值是否与利用该二级索引查询时的值相同。如果是就认为这条记录满足二级索引条件,再进行其他Where 条件判断(如果有),否则就跳过该记录。
四、关于 purge
insert undo 日志在事务提交之后就可以释放掉了,而 update undo 日志由于还需要支持 MVCC,因此不能立即删除掉。
在 MySQL05【 undo 日志】 有提到过对于没有被重用的 Undo 页面链表来说,链表的第一个页面(first undo page)在真正写入 undo 日志前,会填充 Undo Page Header、Undo Log Segment Header、Undo Log Header 三部分,之后才会正式写入日志,因此一个事务写的一组 undo 日志中都有一个 Undo Log Header 部分。Undo 页面链表如下图:
这个 Undo Log Header 中有一个名为 TRX_UNDO_HISTORY_NODE 的属性,表示一个名为 History 链表的节点。当一个事务提交后就会把这个事务执行过程中产生的这一组 update undo 日志插入到 History 链表的头部。
在 MySQL05【 undo 日志】 中提到过,每个回滚段都对应一个名为 Rollback Segment Header 的页面,这个页面中有如下两个属性:
- TRX_RSEG_HISTORY :表示 History 链表的基节点
- TRX_RSEG_HISTORY_SIZE :表示 History 链表占用的页面数量
也就是说每个回滚段都有一个 History链表,一个事务在某个回滚段中写入的一组 update undo 日志在该事物提交后,就会加到这个回滚段的History链表中。系统中可能存在很多回滚段,也就意味着可能有很多个 History 链表。
不过这些加入到History 链表的 update undo 日志所占用的存储空间也没有释放,他们总归是需要释放的。
为了支持 MVCC, delete mark 操作仅仅是在记录上打一个删除标记,并没有真正将记录删除。
在一组 undo 日志中的 Undo Log Header 部分有一个名为 TRX_UNDO_DEL_MARKS 的属性,用来标记本组 undo 日志中是否包含因 delete mark 操作而产生的undo 日志。为了节约存储空间,需要在合适的时候需要把 update undo 日志以及仅仅被标记为删除的记录彻底删除掉,这个删除操作就成为 purge。
所谓合适的时候是如何判断的?
update undo 日志和被标记为删除的记录只是为了支持 MVCC 而存在的,只要系统中最早产生的那个 ReadView 不再访问他们,他们的使用就结束了,就可以删除了。而当生成这个 ReadView 的事务已经提交时,就可以断定该 ReadView 肯定就不需要访问该事务运行过程中产生的 undo 日志了(因为该事物所改动的记录的最新版本均对该 ReadView 可见)。
InnoDB 为此做了两件事:
在一个事务提交时,会为这个事务生成一个名为 事务no 的值,该值用来表示事务提交的顺序,先提交的事务小,后提交的事务大。
而在一组 undo 日志中对应的 Undo Log Header 部分有一个名为 TRX_UNDO_TRX_NO 的属性。当事务提交时,就把该事物对应的事务no 填入到该属性中,因为事务no代表各个事物提交的顺序,而 History 链表又是按照事务提交的顺序来排列各组 undo 日志的,所以 History 链表中的各组 undo 日志也是按照对应的事务no来排序的。
一个RedaView 结构除了包含前面说的几个属性,还包含一个事务no 的属性。在生成一个 ReadView 时, 会把比当前系统中最大的事务no值还大1的值赋值给这个属性。
InnoDB将当前系统中所有的 ReadVidw 按照创建时间顺序连成了一个链表。当执行 purge 操作时(后台有专门的线程执行),就把系统中最早生成的 ReadView 给取出来,如果当前系统中不存在 ReadView,就现场创建一个(新创建的 ReadView 的 事务no 肯定比当前已经提交的事务的事务no大)。然后从各个回滚段的 History链表中取出事务 no 值较小的各组 undo 日志。如果一组 undo 日志的事务no小于当前系统最早生成的 ReadView 的事务 no,就意味着该组 undo 日志包含因 delete mark 操作而产生的 undo 日志(TRX_UNDO_DEL_MARKS 的值为1),那么也需要将对应的标记为删除的记录给彻底删除。
需要注意的是:当前系统中最早生成的 ReadView 决定了 purge 操作中可以清理那些 update undo 日志以及打了删除标记的记录。如果某个事务使用了 REPEATABLE READ 隔离级别,那么该事务会一直使用最初产生的 ReadView。假如这个事务运行了很久,一直没有提交,那么最早生成的 ReadView会一直不释放,系统中的 update undo 日志和打了删除标记的记录就会越来越多,表空间对应的文件也会越来越大,一条记录的版本链就会越来越长,从而影响系统性能。
在 MySQL05【 undo 日志】 中提到过 在执行 DELETE 或 UPDATE 语句时并不会立即把对应的记录从页面中完全删除(包括聚簇索引记录和二级索引记录)而是执行一个 delete mark 操作,之所以没有完全删除,就是为了给 MVCC 服务。
五、参考内容
书籍:《MySQL是怎样运行的——从根儿上理解MySQL》、《MySQL技术内幕 InnoDB存储引擎 》
https://blog.csdn.net/filling_l/article/details/112854716
如有侵扰,联系删除。 内容仅用于自我记录学习使用。如有错误,欢迎指正