在事务的实现机制上,MySQL 采用的是 WAL(Write-ahead logging,预写式
日志)机制来实现的。
在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到
系统中。通常包含 redo 和 undo 两部分信息。
redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log, 这样当发生掉电之类的情况时系统可以在重启后继续操作。
undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销 日志恢复到变更之间的状态。
redo 日志
1、redo 日志占用的空间非常小
存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。
2、redo 日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条 redo 日志,这 些日志是按照产生的顺序写入磁盘的,也就是使用顺序 IO。
redo 日志格式

type:该条 redo 日志的类型,redo 日志设计大约有 53 种不同的类型日志。 space ID:表空间 ID。
page number:页号。
data:该条 redo 日志的具体内容。
redo 日志既包含物理层面的意思,也包含逻辑层面的意思。
物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。
逻辑层面看,在系统崩溃重启时,并不能直接根据这些日志里的记载,将页 面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执 行完这些函数后才可以将页面恢复成系统崩溃前的样子。
只要记住:redo 日志会把事务在执行过程中对数据库所做的所有修改 都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。
Mini-Transaction 的概念
所以 MySQL 把对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction,比如上边所说的修改一次 Max Row ID 的值算是一个 Mini-Transaction,向某个索引对应的 B+树中插入一条记录的过程也算是一个 Mini-Transaction。
一个所谓的 Mini-Transaction 可以包含一组 redo 日志,在进行崩溃恢复时这 一组 redo 日志作为一个不可分割的整体。
一个事务可以包含若干条语句,每一条语句其实是由若干个 Mini-Transaction 组成,每一个 Mini-Transaction 又可以包含若干条 redo 日志,最终形成了一个树 形结构。
redo 日志的写入过程
redo 日志刷盘时机
1、log buffer 空间不足时,log buffer 的大小是有限的(通过系统变量 innodb_log_buffer_size 指定),如果不停的往这个有限大小的 log buffer 里塞入 日志,很快它就会被填满。InnoDB 认为如果当前写入 log buffer 的 redo 日志量已 经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
2、事务提交时,我们前边说过之所以使用 redo 日志主要是因为它占用的空 间少,还是顺序写,在事务提交时可以不把修改过的 Buffer Pool 页面刷新到磁盘, 但是为了保证持久性,必须要把修改这些页面对应的 redo 日志刷新到磁盘。
3、后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁 盘。
4、正常关闭服务器时等等。
Log Sequence Number
InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN。规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,LSN 的值为 8704)。
flushed_to_disk_lsn
redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文 件。InnoDB 中有一个称之为 buf_next_to_write 的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了。
我们前边说 lsn 是表示当前系统中写入的 redo 日志量,这包括了写到 log buffer 而没有刷新到磁盘的日志,相应的,InnoDB 也有一个表示刷新到磁盘中的 redo 日志量的全局变量,称之为 flushed_to_disk_lsn。
系统第一次启动后,向 log buffer 中写入了 mtr_1、mtr_2、mtr_3 这三个 mtr 产生的 redo 日志,假设这三个 mtr 开始和结束时对应的 lsn 值分别是:
mtr_1:8716 ~ 8916
mtr_2:8916 ~ 9948
mtr_3:9948 ~ 10000
此时的 lsn 已经增长到了 10000,但是由于没有刷新操作,所以此时 flushed_to_disk_lsn 的值仍为 8704。
随后进行将 log buffer 中的 block 刷新到 redo 日志文件的操作,假设将 mtr_1 和 mtr_2 的日志刷新到磁盘,那么 flushed_to_disk_lsn 就应该增长 mtr_1 和 mtr_2 写入的日志量,所以 flushed_to_disk_lsn 的值增长到了 9948。
当有新的 redo 日志写入到 log buffer 时,首先 lsn 的值会增长, 但flushed_to_disk_lsn不变,随后随着不断有log buffer中的日志被刷新到磁盘上, flushed_to_disk_lsn 的值也跟着增长。如果两者的值相同时,说明 log buffer 中的 所有 redo 日志都已经刷新到磁盘中了。
查看系统中的各种 LSN 值
我们可以使用 SHOW ENGINE INNODB STATUS 命令查看当前 InnoDB 存储引擎 中的各种 LSN 值的情况

崩溃后的恢复
在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让 性能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录 就可以将页面恢复到系统崩溃前的状态。
崩溃后的恢复为什么不用 binlog?
1、这两者使用方式不一样
binlog 会记录表所有更改操作,包括更新删除数据,更改表结构等等,主要 用于人工恢复数据,而 redo log 对于我们是不可见的,它是 InnoDB 用于保证 crash-safe 能力的,也就是在事务提交后 MySQL 崩溃的话,可以保证事务的持久 性,即事务提交后其更改是永久性的。
一句话概括:binlog 是用作人工恢复数据,redo log 是 MySQL 自己使用, 用于保证在数据库崩溃时的事务持久性。
2、redo log 是 InnoDB 引擎特有的,binlog 是 MySQL 的 Server 层实现的, 所有引擎都可以使用。
3、redo log 是物理日志,记录的是“在某个数据页上做了什么修改”,恢复 的速度更快**;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这的 c 字段加 1 ”** ;
4、redo log 是“循环写”的日志文件,redo log 只会记录未刷盘的日志,已 经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是 追加日志,保存的是全量的日志。
5、最重要的是,当数据库 crash 后,想要恢复未刷盘但已经写入 redo log 和 binlog 的数据到内存时,binlog 是无法恢复的。虽然 binlog 拥有全量的日志, 但没有一个标志让 innoDB 判断哪些数据已经入表(写入磁盘),哪些数据还没有。
比如,binlog 记录了两条日志: 给 ID=2 这一行的 c 字段加1 给 ID=2 这一行的 c 字段加1
在记录 1 入表后,记录 2 未入表时,数据库 crash。重启后,只通过 binlog 数据库无法判断这两条记录哪条已经写入磁盘,哪条没有写入磁盘,不管是两条 都恢复至内存,还是都不恢复,对 ID=2 这行数据来说,都不对。
但 redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据 库重启后,直接把 redo log 中的数据都恢复至内存就可以了。
undo 日志
事务回滚的需求
一个事务可以是一个只读事务,或者是一个读写事务:
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务 id,分配方式如下:
对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执 行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务 id 的。
有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句, 并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。
这些为了回滚而记录的这些东西称之为撤销日志,英文名为 undo log/undo 日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记 录,所以在查询操作执行时,并不需要记录相应的 undo 日志。
事务 id 生成机制
这个事务 id 本质上就是一个数字,它的分配策略和我们前边提到的对隐藏 列 row_id(当用户没有为表创建主键和 UNIQUE 键时 InnoDB 自动创建的列)的 分配策略大抵相同,具体策略如下:
服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id 时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。
每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的 页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存 储空间。
trx_id 隐藏列
我们在学习 InnoDB 记录行格式的时候重点强调过:聚簇索引的记录除了会 保存完整的用户数据以外,而且还会自动添加名为 trx_id、roll_pointer 的隐藏列, 如果用户没有在表中定义主键以及 UNIQUE 键,还会自动添加一个名为 row_id 的隐藏列。

INSERT 操作对应的 undo 日志
当我们向表中插入一条记录时最终导致的结果就是这条记录被放到了一个 数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说
在写对应的 undo 日志时,主要是把这条记录的主键信息记上。InnoDB 的设计了 一个类型为 TRX_UNDO_INSERT_REC 的 undo 日志。
roll_pointer 的作用
roll_pointer 本质上就是一个指向记录对应的 undo 日志的一个指针。比方说
我们向表里插入了 2 条记录,每条记录都有与其对应的一条 undo 日志。记录被 存储到了类型为 FIL_PAGE_INDEX 的页面中(就是我们前边一直所说的数据页), undo 日志被存放到了类型为 FIL_PAGE_UNDO_LOG 的页面中。roll_pointer 本质就 是一个指针,指向记录对应的 undo 日志。
MVCC
全称 Multi-Version Concurrency Control,即多版本并发控制。
MySQL 在 REPEATABLE READ 隔离级别下,是可以很大程度避免幻读问题的 发生的,MySQL 是怎么做到的?
版本链
我们知道,对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包 含两个必要的隐藏列(row_id 并不是必要的,我们创建的表中有主键或者非 NULL 的 UNIQUE 键时都不会包含 row_id 列):
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事 务 id 赋值给 trx_id 隐藏列。
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修 改前的信息。
为了说明这个问题,我们创建一个演示表
CREATE TABLE teacher ( number INT,
name VARCHAR(100), domain varchar(100), PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
然后向这个表里插入一条数据:
INSERT INTO teacher VALUES(1, ‘Jack’, ‘源码系列’);
假设插入该记录的事务 id 为 60,那么此刻该条记录的示意图如下所示:

假设之后两个事务 id 分别为 80、120 的事务对这条记录进行 UPDATE 操作,操 作流程如下:


每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一 个 roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没 有更早的版本),可以将这些 undo 日志都连起来,串成一个链表,所以现在的 情况就像下图一样:

对该记录每次更新后,都会将旧值放到一条 undo 日志中,就算是该记录的 一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成 一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的 值。另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记 录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版 本并发控制(Mulit-Version Concurrency Control MVCC)。
ReadView

对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交 事务修改过的记录,所以直接读取记录的最新版本就好了。
对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访 问记录。
对于使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务来说,都 必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修 改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是: READ COMMITTED 和 REPEATABLE READ 隔离级别在不可重复读和幻读上的区别 是从哪里来的,其实结合前面的知识,这两种隔离级别关键是需要判断一下版本 链中的哪个版本是当前事务可见的。
为此,InnoDB 提出了一个 ReadView 的概念,这个 ReadView 中主要包含 4 个比较重要的内容:
m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表。 min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事
务 id,也就是 m_ids 中的最小值。
max_trx_id:表示生成 ReadView 时系统中应该分配给下一个事务的 id 值。 注意 max_trx_id 并不是 m_ids 中的最大值,事务 id 是递增分配的。比方说现在 有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务 在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4。
creator_trx_id:表示生成该 ReadView 的事务的事务 id。
1、如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同, 意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
2、如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明 生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当 前事务访问。
3、如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不 可以被当前事务访问。
4、如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经 被提交,该版本可以被访问。
5、如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一 个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最 后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务 完全不可见,查询结果就不包含该记录。
在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的的一个非 常大的区别就是它们生成 ReadView 的时机不同。
还是以表 teacher 为例,假设现在表 teacher 中只有一条由事务 id 为 60 的事务插入的一条记录,接下来看一下 READ COMMITTED 和 REPEATABLE READ所谓的生成ReadView的时机到底不同在哪里。
READ COMMITTED —— 每次读取数据前都生成一个 ReadView
REPEATABLE READ —— 在第一次读取数据时生成一个 ReadView
MVCC 下的幻读解决和幻读现象
前面我们已经知道了,REPEATABLE READ 隔离级别下 MVCC 可以解决不可重 复读问题,那么幻读呢?MVCC 是怎么解决的?幻读是一个事务按照某个相同条 件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一 个事务添加的新记录。
我们可以想想,在 REPEATABLE READ 隔离级别下的事务 T1 先根据某个搜索 条件读取到多条记录,然后事务 T2 插入一条符合相应搜索条件的记录并提交, 然后事务 T1 再根据相同搜索条件执行查询。结果会是什么?按照 ReadView 中的 比较规则:
3、如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值, 表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以 被当前事务访问。
4、如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间(min_trx_id < trx_id < max_trx_id),那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经 被提交,该版本可以被访问。
不管事务 T2 比事务 T1 是否先开启,事务 T1 都是看不到 T2 的提交的。请自 行按照上面介绍的版本链、ReadView 以及判断可见性的规则来分析一下。
但是,在 REPEATABLE READ 隔离级别下 InnoDB 中的 MVCC 可以很大程度地 避免幻读现象,而不是完全禁止幻读。怎么回事呢?我们来看下面的情况:

我们首先在事务 T1 中:
select * from teacher where number = 30; 很明显,这个时候是找不到 number = 30 的记录的。 我们在事务 T2 中,执行:

通过执行 insert into teacher values(30,‘Luffy’,‘ELK’);,我们往表中插入了一条 number = 30 的记录。
此时回到事务 T1,执行:

update teacher set domain=‘RabbitMQ’ where number=30; select * from teacher where number = 30; 嗯,怎么回事?事务 T1 很明显出现了幻读现象。
在 REPEATABLE READ 隔离级别下,T1 第一次执行普通的 SELECT 语句时生成 了一个 ReadView,之后 T2 向 teacher 表中新插入一条记录并提交。
ReadView 并不能阻止 T1 执行 UPDATE 或者 DELETE 语句来改动这个新插入 的记录(由于 T2 已经提交,因此改动该记录并不会造成阻塞),但是这样一来, 这条新记录的 trx_id 隐藏列的值就变成了 T1 的事务 id。之后 T1 再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返 回给客户端。因为这个特殊现象的存在,我们也可以认为 MVCC 并不能完全禁 止幻读。
1742

被折叠的 条评论
为什么被折叠?



