1 实现机制
WAL(Write-ahead logging,预写式日志)机制。
在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到系统中。通常包含 redo 和 undo 两部分信息。
这两个日志都是
InnoDB存储引擎生成的。2 redo log
重做日志,每当有操作时,在数据变更之前将操作写入redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。在系统Crash重启之类的情况时修复数据(事务的持久性)。
2.1 日志格式(通用)

type:该条redo log的类型,redo log 设计大约有53种不同的类型日志。space ID:表空间ID,但采用压缩的方式,因此占用的空间可能小于4字节。page number:页号或页的偏移量,同样采用压缩的方式。data:该条redo log的具体内容。
redo log 会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。
2.2 Mini-Transaction
MySQL 把对底层页面中的一次原子访问的过程称之为一个Mini-Transaction,比如修改一次Max Row ID 的值算是一个Mini-Transaction,向某个索引对应的B+树中插入一条记录的过程也算是一个Mini-Transaction。
- 可以包含一组
redo log,在进行崩溃恢复时这一组redo log作为一个不可分割的整体; - 一个事务可以包含若干条语句,每一条语句其实是由
若干个Mini-Transaction组成,每一个Mini-Transaction又可以包含若干条redo log,最终形成了一个树形结构。
2.3 写入过程

从重做日志缓冲(redo log buffer)往磁盘写入时,是按
512个字节,也就是一个扇区的大小进行写入。因为扇区是写入的最小单位,因此可以保证写入必定是成功的。因此在重做日志的写入过程中不需要有doublewrite。

2.3.1 redo log buffer
InnoDB 为了更好的进行系统崩溃恢复,把通过 Mini-Transaction 生成的 redo log都放在了大小为 512 字节的块(block)中。
- 为了解决
磁盘速度过慢的问题而引入了Buffer Pool。同理,写入redo log时也不能直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,我们也可以简称为log buffer。这片内存空间被划分成若干个连续的redo log block,我们可以通过启动参数innodb_log_buffer_size来指定 log buffer 的大小,该启动参数的默认值为16MB。 - 顺序写:先往前边的
block中写,当该block的空闲空间用完之后再往下一个block中写。 - 并不是每生成一条
redo log,就将其插入到log buffer中,而是每个Mini-Transaction运行过程中产生的日志先暂存到一个地方,当该Mini-Transaction结束的时候,将过程中产生的一组redo log再全部复制到log buffer中。
2.3.2 redo log刷盘
上一章节里有提到日志刷盘的参数及其各自意思。这里只列举下集中刷盘的场景:
- log buffer 空间不足时,
log buffer的大小是有限的(通过系统变量innodb_log_buffer_size指定),如果不停的往这个有限大小的log buffer里塞入日志,很快它就会被填满。InnoDB 认为如果当前写入log buffer的 redo log量已经占满了log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。 - 事务提交时,之所以使用 redo log主要是因为它
占用的空间少,还是顺序写,在事务提交时可以不把修改过的 Buffer Pool 页面刷新到磁盘, 但是为了保证持久性,必须要把修改这些页面对应的 redo log刷新到磁盘。 - 后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo log到磁盘。
- 正常关闭服务器时等等。
2.3.3 redo log文件组
磁盘默认文件名 ib_logfile0 和 ib_logfile1。log buffer 中的日志默认情况下就是刷新到这两个磁盘文件中。如果我们对默认的 redo log文件不满意,可以通过下边几个启动参数来调节:
innodb_log_group_home_dir,该参数指定了 redo log文件所在的目录,默认值就是当前的数据目录。innodb_log_file_size, 该参数指定了每个 redo log文件的大小,默认值为 48MB,innodb_log_files_in_group,该参数指定 redo log文件的个数,默认值为 2,最大值为 100。
所以磁盘上的 redo log文件可以不只一个,而是以一个日志文件组的形式出现的。这些文件以ib_logfile[数字](数字可以是0、1、2…)的形式进行命名。
在将 redo log写入日志文件组时,是从 ib_logfile0 开始写,如果 ib_logfile0 写满了,就接着ib_logfile1写,同理,ib_logfile1写满了就去写ib_logfile2,依此类推。
如果写到最后一个文件该咋办?那就重新转到ib_logfile0继续写。
说明:
write pos:表示redo log当前记录的LSN(逻辑序列号)位置。
check point:表示「数据页更改记录」刷盘后对应redo log所处的LSN(逻辑序列号)位置。
write pos到check point之间的部分是redo log空着的部分,用于记录新的记录。
check point到write pos之间是redo log待落盘的数据页更改记录。
当write pos追上check point时,会先推动check point向前移动,空出位置再记录新的日志。2.3.4 redo log文件格式
- 由若干个
512字节大小的block组成。 前2048个字节,也就是前4个block是用来存储一些管理信息的。从第2048字节往后是用来存储log buffer中的block镜像的。
2.4 Log Sequence Number
每一组由Mini-Transaction生成的redo log都有一个唯一的LSN值与其对应,LSN值越小,说明redo log产生的越早。
规定初始的 LSN 值为 8704(也就是一条 redo log也没写入时,LSN的值为8704)。
2.5 崩溃后的恢复
2.5.1 恢复机制
关键词:LSN hash 槽 页恢复
- MySQL 可以根据 redo log中的各种
LSN值,来确定恢复的起点和终点。然后将 redo log中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个槽中。 - 之后就可以遍历
哈希表,因为对同一个页面进行修改的 redo log都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机IO)。并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面,进而提升崩溃恢复的速度。
2.5.2 崩溃后的恢复为什么不用 binlog?
| - | redo log | binlog |
|---|---|---|
| 作用不同 | MySQL自己使用,用于保证在数据库崩溃时的事务持久性 | 用作主从复制、人工恢复数据,如:误删数据恢复 |
| 层次不同 | 是InnoDB引擎特有的 | 是MySQL 的Server层实现的,所有引擎都可以使用 |
| 内容不同 | 物理日志,记录的是在某个数据页上做了什么修改,恢复的速度更快 | 逻辑日志,记录的是这个语句的原始逻辑,比如给ID=2这的c 字段加1 |
| 写入时机不同 | 写入时机有三种(具体在上文中有介绍) | 在事务提交完成后进行一次写入 |
| 写入方式不同 | 循环写的日志文件,redo log只会记录未刷盘的日志,已经刷入磁盘的数据都会从redo log这个有限大小的日志文件里删除 | 追加日志,保存的是全量的日志 |
当数据库crash 后,想要恢复
未刷盘但已经写入redo log 和binlog 的数据到内存时,binlog 是无法恢复的,但redo log 不一样,只要刷入磁盘的数据,都会从redo log 中抹掉,数据库重启后,直接把redo log 中的数据都恢复至内存就可以了.binlog是【用于时间点恢复】的,保证服务器可以
基于时间点恢复数据或者用于主从复制;redo log是用于【崩溃恢复】.
3 binlog
文章开头说道,redo log和undo log这两个日志都是 InnoDB 存储引擎生成的,属于InnoDB存储引擎特有。而binlog属于MySQL Server层,所有引擎均可使用。
3.1 为什么需要 binlog?
binlog文件是记录了所有数据库表结构变更和表数据修改的日志,不会记录查询类的操作,比如 SELECT 和 SHOW 操作。早期的MySQL版本是没有InnoDB这个存储引擎的,MySQL自带的引擎是MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog日志只能用于归档。后来版本加入InnoDB后,利用redo log实现了crash-safe的能力。
两者的区别,上一小节已经列举,参见2.5.2节。
3.2 binlog文件是如何刷入磁盘的?
通过参数sync_binlog来设定,默认为0,各个值的意义如下表所示:
| 值 | 描述 | 特点 |
|---|---|---|
| 0 | 每次提交事务都只 write,不 fsync,后续交由操作系统决定何时将数据持久化到磁盘; | 性能最好,一旦主机发生异常重启,还没持久化到磁盘的数据就会丢失。 |
| 1 | 每次提交事务都会 write,然后马上执行 fsync; | 最安全但是性能损耗最大。 |
| N(N>1) | 每次提交事务都 write,但累积 N 个事务后才 fsync。 | 能容忍少量事务的binlog日志丢失的风险,兼顾写入性能,一般设置100~1000中的某个值。 |
3.3 binlog文件格式
binlog 有 3 种格式类型,分别是 STATEMENT(默认格式)、ROW、 MIXED。
STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中(相当于记录了逻辑操作,所以针对这种格式, binlog 可以称为逻辑日志),主从复制中 slave 端再根据 SQL 语句重现;- 优点:不需要记录每一行的变化,减少了 binlog 日志量,节约了 IO,从而提高了性能;
- 缺点:动态函数的问题,比如你用了
uuid或者now这些函数,你在主库上执行的结果并不是你在从库执行的结果,这种随时在变的函数会导致复制的数据不一致;
ROW:记录行数据最终被修改成什么样了(这种格式的日志,就不能称为逻辑日志了),不会出现 STATEMENT 下动态函数的问题。- 优点:
不会出现某些特定情况下的存储过程、或function、或trigger的调用和触发无法被正确复制的问题; - 缺点:
每行数据的变化结果都会被记录,比如执行批量update语句,更新多少行数据就会产生多少条记录,使 binlog 文件过大,而在STATEMENT格式下只会记录一个 update 语句而已;
- 优点:
MIXED:包含了STATEMENT和ROW模式,它会根据不同的情况自动使用ROW模式和STATEMENT模式;
3.4 写入了什么数据?
MySQL 在提交事务的时候,不仅仅会将 redo log buffer 中的数据写入到redo log文件中,同时也会将本次修改的数据记录到 binlog文件中,同时会将本次修改的binlog文件名和修改的内容在binlog中的位置记录到redo log中,最后还会在redo log最后写入commit 标记,这样就表示本次事务被成功地提交了。

3.5 主从复制是怎么实现?
MySQL 的主从复制依赖于 binlog ,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上。复制的过程就是将 binlog 中的数据从主库传输到从库上。
这个过程一般是异步的,也就是主库上执行事务操作的线程不会等待复制 binlog 的线程同步完成。

具体详细过程如下:
- MySQL 主库在收到客户端提交事务的请求之后,会先写入 binlog,再提交事务,更新存储引擎中的数据,事务提交完成后,返回给客户端“操作成功”的响应。
- 从库会创建一个专门的
I/O 线程,连接主库的log dump 线程,来接收主库的 binlog 日志,再把 binlog 信息写入relay log(中继日志)里,再返回给主库“复制成功”的响应。 - 从库会创建一个用于回放 binlog 的
SQL线程,去读relay log中继日志,然后回放 binlog 更新存储引擎中的数据,最终实现主从的数据一致性。
在完成主从复制之后,你就可以在写数据时只写主库,在读数据时只读从库,这样即使写请求会锁表或者锁记录,也不会影响读请求的执行。

并不是从库越多越好,随着从库数量的增加,从库连接上来的
I/O 线程也比较多,主库也要创建同样多的log dump 线程来处理复制的请求,对主库资源消耗比较高,同时还受限于主库的网络带宽。一般情况下,一个主库一般跟
2~3个从库(1 套数据库,1 主 2 从 1 备主)。MySQL 主从复制还有哪些模型?
主要有三种:
同步复制:MySQL 主库提交事务的线程要等待所有从库的复制成功响应,才返回客户端结果。- 缺点:一是
性能很差,因为要复制到所有节点才返回响应;二是可用性也很差,主库和所有从库任何一个数据库出问题,都会影响业务。
- 缺点:一是
异步复制(默认模型):MySQL 主库提交事务的线程并不会等待 binlog 同步到各个从库,就返回客户端结果。- 缺点:这种模式一旦主库宕机,数据就会发生丢失。
半同步复制:MySQL 5.7版本之后增加的一种复制方式,介于两者之间,事务线程不用等待所有的从库复制成功响应,只要一部分复制成功响应回来就行,比如一主二从的集群,只要数据成功复制到任意一个从库上,主库的事务线程就可以返回给客户端。- 优点:这种半同步复制的方式,兼顾了异步复制和同步复制的优点,即使出现主库宕机,至少还有一个从库有最新的数据,不存在数据丢失的风险。
3.6 两阶段提交
事务提交后,redo log 和 binlog 都要持久化到磁盘,但是这两个是独立的逻辑,可能出现半成功的状态,这样就造成两份日志之间的逻辑不一致。
MySQL 为了避免出现两份日志之间的逻辑不一致的问题,使用了「两阶段提交」来解决,两阶段提交其实是分布式事务一致性协议,它可以保证多个逻辑操作要不全部成功,要不全部失败,不会出现半成功的状态。
两阶段提交分为两个部分:
prepare阶段:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将 redo log 对应的事务状态设置为prepare,然后将 redo log 持久化到磁盘(innodb_flush_log_at_trx_commit = 1 的作用);commit阶段:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘(sync_binlog = 1 的作用),接着调用引擎的提交事务接口,将 redo log 状态设置为commit,此时该状态并不需要持久化到磁盘,只需要 write 到文件系统的 page cache 中就够了,因为只要 binlog 写磁盘成功,就算 redo log 的状态还是 prepare 也没有关系,一样会被认为事务已经执行成功。
将3.4节的图简化下,如下所示:

异常情况:
- 如果在写入
redo log之前崩溃,那么此时redo log与binlog中都没有,是一致的情况,崩溃也无所谓。 - 如果在写入redo log
prepare阶段后立马崩溃,之后会在崩溃恢复时,由于redo log没有被标记为commit。于是拿着redo log中的XID去binlog中查找,此时肯定是找不到的,那么执行回滚操作; - 如果在写入
binlog后立马崩溃,在恢复时,由于redo log中的XID可以找到对应的binlog,这个时候直接提交即可。
总的来说,在崩溃恢复后,只要redo log不是处于commit阶段,那么就拿着redo log中的XID去binlog中寻找,找得到就提交,否则就回滚。
事务没提交的时候,redo log 也是可能被持久化到磁盘的.
两阶段提交有什么问题?
两阶段提交虽然保证了两个日志文件的数据一致性,但是性能很差,主要有两个方面的影响:
- 磁盘 I/O 次数高:对于“双1”配置,每个事务提交都会进行两次 fsync(刷盘),一次是 redo log 刷盘,另一次是 binlog 刷盘。
- 锁竞争激烈:两阶段提交虽然能够保证「单事务」两个日志的内容一致,但在「多事务」的情况下,却不能保证两者的提交顺序一致,因此,在两阶段提交的流程基础上,还需要加一个锁来保证提交的原子性,从而保证多事务的情况下,两个日志的提交顺序一致。
解决方案:参见组提交.
4 undo log
每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要把回滚时所需的东西都给记下来。比方说:
- 你
插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉。 - 你
删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中。 - 你
修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值。
这些为了回滚而记录的这些东西称之为撤销日志,英文名为undo log。其作用就是:
当一些变更执行到一半
无法完成时,可以根据撤销日志恢复到变更之前的状态。
保证ACID中的原子性(Atomic)。
在事务没提交之前,MySQL 会先记录更新前的数据到undo log日志文件里面,当事务回滚时,可以利用undo log来进行回滚。如下图:
在真实的 InnoDB 中,undo log其实并不像我们上边所说的那么简单,不同类型的操作产生的undo log的格式也是不同的。
一条记录的每一次更新操作产生的 undo log 格式都有一个 roll_pointer 指针和一个 trx_id 事务id:
- 通过
trx_id可以知道该记录是被哪个事务修改的; - 通过
roll_pointer指针可以将这些undo log串成一个链表,这个链表就被称为版本链;
这是undo log又一重要作用,实现
MVCC。
5 MVCC
MVCC,即multi-version concurrency control,多版本并发控制。通过 ReadView + undo log 实现。undo log 为每条记录保存多份历史数据,MySQL 在执行快照读(普通 select 语句)的时候,会根据事务的 Read View 里的信息,顺着 undo log 的版本链找到满足其可见性的记录。
5.1 原理
要真正了解MVCC的原理,首先必须要了解事务并发会产生哪些问题以及事务的隔离级别。
在【MySQL学习】2.事务和事务的隔离级别,我已经理清了一遍。这里不再赘述。
5.2 隐式字段
上一小节提到
一条记录的每一次
更新操作产生的 undo log 格式都有一个roll_pointer指针和一个trx_id事务id.
对于数据库中的一条记录,其真实格式如下:
其中,row_id、trx_id、roll_ptr为三个隐藏字段。若某表已经定义了主键或者唯一索引,则无row_id。
| 隐藏字段 | 描述 | 是否必须 |
|---|---|---|
| row_id | 隐藏主键,若表中无主键或唯一索引,则会生成一单调递增的行ID作为聚簇索引 | 否 |
| trx_id | 事务ID,也称事务版本号,占用6B,事务开始前,会从数据库中获得一自增长的事务ID,用以判断事务的执行顺序 | 是 |
| roll_ptr | 占用7B,回滚指针,指向当前记录的上一版本undo log,存储于回滚段中 | 是 |
5.3 版本链
假设,有这样一个表:
CREATE TABLE teacher
(
number INT,
name VARCHAR(100),
domain varchar(100),
PRIMARY KEY (number)
) Engine = InnoDB
CHARSET = utf8mb4;然后向这个表里插入一条数据:
INSERT INTO teacher VALUES(1, 'Jack', '源码系列');假设插入该记录的事务 id 为 60,那么此刻该条记录的示意图如下所示:
之后两个事务 id 分别为 80、120 的事务对这条记录进行 UPDATE 操作,操作流程如下:
| Trx-80 | Trx-120 |
|---|---|
| BEGIN | |
| BEGIN | |
| UPDATE teacher SET name = ‘Mark’ WHERE number = 1; | |
| UPDATE teacher SET name = ‘James’ WHERE number = 1; | |
| COMMIT | |
| UPDATE teacher SET name = ‘King’ WHERE number = 1; | |
| UPDATE teacher SET name = ‘大飞’ WHERE number = 1; | |
| COMMIT |
每次对记录进行改动,都会记录一条 undo log,每条 undo log也都有一 个 roll_pointer 属性(INSERT 操作对应的 undo log没有该属性,因为该记录并没有更早的版本),可以将这些 undo log都连起来,串成一个链表,所以现在的情况就像下图一样:
对该记录每次更新后,都会将旧值放到一条 undo log中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为多版本并发控制(Mulit-Version Concurrency Control, MVCC)。
5.4 ReadView
MySQL中,有4种事务隔离级别,对于这几种级别,要实现快照读的下的并发控制,MySQL是这样处理的:
- 对于使用
READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。 - 对于使用
SERIALIZABLE隔离级别的事务来说,InnoDB 使用加锁的方式来访问记录。 - 对于使用
READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录。因此需要判断一下版本链中的哪个版本是当前事务可见的。
为此,InnoDB 提出了一个 ReadView 的概念,这个 ReadView 中主要包含 4 个比较重要的内容:
| 组成 | 描述 |
|---|---|
| creator_trx_id | 创建当前read view的事务ID |
| m_ids | 当前系统中所有的活跃事务的 id,活跃事务指的是当前系统中开启了事务,但还没有提交的事务 |
| m_low_limit_id | 表示在生成ReadView时,当前系统中活跃的读写事务中最小的事务id,即m_ids中的最小值 |
| m_up_limit_id | 当前系统中事务的 id 值最大的那个事务 id 值再加 1,也就是系统中下一个要生成的事务 id |
有了这个 ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
- 如果被访问版本的
trx_id == creator_trx_id, 意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问; - 如果被访问版本的
trx_id < min_trx_id,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。 - 如果被访问版本的
trx_id >= max_trx_id,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的
min_trx_id < trx_id < max_trx_id,那就需要判断一下trx_id属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。 - 如果某个版本的数据对当前事务不可见的话,那就
顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
在 MySQL 中,
READ COMMITTED和REPEATABLE READ隔离级别的一个非常大的区别就是它们生成 ReadView 的时机不同。
- READ COMMITTED:ReadView在事务后
每次select都会重新生成;- REPEATABLE READ:ReadView在
第一次select时生成,事务操作期间不更新。
以表 teacher 为例,假设现在表 teacher 中只有一条由事务 id 为 60 的事务插入的一条记录,如下表所示,接下来看一下 READ COMMITTED 和 REPEATABLE READ 所谓的生成 ReadView 的时机不同到底不同在哪里。
| number | name | domain |
|---|---|---|
| 1 | Jack | 源码系列 |
5.4.1 READ COMMITTED
在READ COMMITTED隔离级别下,每次读取数据前都生成一个新的ReadView。
现在系统里有两个事务 id 分别为 80、120 的事务在执行。
| 步骤 | Trx-80 | Trx-120 | Trx-0 |
|---|---|---|---|
| p1 | BEGIN | ||
| p2 | BEGIN | ||
| p3 | UPDATE teacher SET name = ‘Mark’ WHERE number = 1; | ||
| p4 | UPDATE teacher SET name = ‘James’ WHERE number = 1; | ||
| p5 | BEGIN | ||
| p6 | SELECT * FROM teacher WHERE number = 1; | ||
| p7 | COMMIT | ||
| p8 | UPDATE teacher SET name = ‘King’ WHERE number = 1; | ||
| p9 | UPDATE teacher SET name = ‘大飞’ WHERE number = 1; | ||
| p10 | SELECT * FROM teacher WHERE number = 1; | ||
| p11 | COMMIT |
执行完p4后,表 teacher 中 number=1 的记录得到的版本链表如下所示:
在执行到步骤p6时,假定现在查询的事务id为0,得到的结果为:
| number | name | domain |
|---|---|---|
| 1 | Jack | 源码系列 |
现具体分析下为什么name=Jack。
p6SQL语句执行过程如下:
- 生成一个
ReadView,结果如下:
| 组成 | 描述 |
|---|---|
| creator_trx_id | 0 |
| m_ids | [80, 120] |
| m_low_limit_id | 80 |
| m_up_limit_id | 121 |
- 先分析版本链第一条记录,其
trx_id=80,且位于活跃事务列表m_ids中,根据5.4节中,可见性判别规则第4条,对于当前查询事务id为0,不可见,根据roll_ptr跳到下一个版本; - 下一个版本的列
name='Mark',该版本的trx_id=80,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本; - 下一个版本的列
name='Jack',该版本的trx_id=60,小于 ReadView 中的min_trx_id值,所以这个版本是符合要求的(5.4节中,可见性判别规则第2条),可见,最后返回给用户的版本就是这条列name='Jack'的记录。
然后,将事务id 80提交,对事务id 120执行完update语句,然后分析下进行到步骤p10,可见性分析情况:
执行到步骤p10后,版本链情况:
则事务id 0查询得到的结果为:
| number | name | domain |
|---|---|---|
| 1 | James | 源码系列 |
在执行步骤p10当中的语句时又会重新生成一个ReadView,结果如下所示:
| 组成 | 描述 |
|---|---|
| creator_trx_id | 0 |
| m_ids | [120] |
| m_low_limit_id | 120 |
| m_up_limit_id | 121 |
- 版本链第一条记录,
name='大飞',trx_id=120,位于m_ids当中,不可见,根据roll_ptr跳到下一个版本; - 该版本的列
name='King',该版本的trx_id=120,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本; - 该版本
name='James',trx_id=80,小于ReadView中的min_trx_id值120,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name='James'的记录。
总结:使用
READ COMMITTED隔离级别的事务,在每次查询开始时都会生成一个新的、独立的 ReadView。
5.4.2 REPEATABLE READ
对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView,之后的查询就不会重复生成了。
同样拿上一节中的例子,这次的查询事务为REPEATABLE READ 隔离级别下的,id=1。
在执行到步骤p6时,事务id为80、120均未提交,得到的结果为:
| number | name | domain |
|---|---|---|
| 1 | Jack | 源码系列 |
分析:
- 在执行
p6中的SELECT 语句时,会先生成一个ReadView,ReadView结果如下:
| 组成 | 描述 |
|---|---|
| creator_trx_id | 1 |
| m_ids | [80, 120] |
| m_low_limit_id | 80 |
| m_up_limit_id | 121 |
版本链与上节一样,为:
- 最新版本的列
name='James',该版本的trx_id=80,在m_ids列表内,所以不符合可见性要求,根据roll_ptr跳到下一个版本; - 下一个版本的列
name='Mark',该版本的trx_id=80,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本; - 下一个版本的列
name='Jack',该版本的trx_id=60,小于ReadView中的min_trx_id值,所以这个版本是符合要求的,最后返回给用户的 版本就是这条列name='Jack'的记录。
然后,提交一下事务id为80的事务。
执行到步骤p10后,版本链情况同上节:
则事务id 1查询得到的结果为:
| number | name | domain |
|---|---|---|
| 1 | Jack | 源码系列 |
在执行步骤p10当中的语句时不会重新生成一个ReadView,仍然是步骤p6时的结果:
| 组成 | 描述 |
|---|---|
| creator_trx_id | 1 |
| m_ids | [80, 120] |
| m_low_limit_id | 80 |
| m_up_limit_id | 121 |
执行过程分析类似,这里不再赘述。两次 SELECT 查询得到的结果是重复的,记录的列 name 值都是Jack, 这就是可重复读的含义。如果我们之后再把事务 id 为 120 的记录提交了,然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number=1 的记录,得到的结果还是’Jack’。
总结:使用
REPEATABLE READ隔离级别的事务,在在第一次读取数据时生成一个 ReadView。
5.5 MVCC 下的幻读解决
REPEATABLE READ 隔离级别下 MVCC 可以解决不可重复读问题,那么幻读呢?MVCC 是怎么解决的?
幻读是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,而这个记录来自另一个事务添加的新记录。
在 REPEATABLE READ 隔离级别下的事务 T1 先根据某个搜索条件读取到多条记录,然后事务 T2 插入一条符合相应搜索条件的记录并提交, 然后事务 T1 再根据相同搜索条件执行查询。结果会是什么?
按照 ReadView 中的比较规则:
- 如果被访问版本的
trx_id >= max_trx_id值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。 - 如果被访问版本的
min_trx_id < trx_id < max_trx_id,那就需要判断一下trx_id属性值是不是在 m_ids 列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的, 该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
不管事务 T2 比事务 T1 是否先开启,事务 T1 都是看不到 T2 的提交。
但是,在 REPEATABLE READ 隔离级别下 InnoDB 中的 MVCC 可以很大程度地避免幻读现象,而不是完全禁止幻读。怎么回事呢?我们来看下面的情况:
还是5.3节中的表teacher,目前拥有以下数据:
| number | name | domain |
|---|---|---|
| 1 | Jack | 源码系列 |
| 3 | Mark | 并发编程 |
| 9 | James | Redis |
| 15 | King | JVM |
| 21 | Dafei | MySQL |
执行以下操作:
| T1 | T2 |
|---|---|
| select * from teacher where number = 30; | |
| Begin | |
| insert into teacher values(30,’Luffy’,’ELK’); | |
| commit; | |
| update teacher set domain=’RabbitMQ’ where number=30; | |
| select * from teacher where number = 30; |
结果如下:
咋回事儿?
在 REPEATABLE READ 隔离级别下,T1 第一次执行普通的 SELECT 语句时生成 了一个 ReadView,之后 T2 向 teacher 表中新插入一条记录并提交。
ReadView并不能阻止T1执行UPDATE或者DELETE语句来改动这个新插入的记录(由于T2已经提交,因此改动该记录并不会造成阻塞),但是这样一来,这条新记录的trx_id隐藏列的值就变成了T1的事务id。之后T1再使用普通的SELECT语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。
因为这个特殊现象的存在,我们也可以认为MVCC并不能完全禁止幻读。

