深入浅出InnoDB 引擎底层事务的实现机制
事务特性
事务应该具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性。
原子性指的是一个事务中的操作要么全部成功,要么全部失败。
一致性指的是数据库总是从一个一致性的状态转换到另外一个一致性的状态。
隔离性指的是一个事务的修改在最终提交前,对其他事务是不可见的。
持久性指的是一旦事务提交,所做的修改就会永久保存到数据库中。
事务特性之间的关系
事务的一致性通过原子性、隔离性、持久性来保证。也就是说 ACID四大特性之中,C(一致性)是目的,A(原子性)、I(隔离性)、D(持久性)是手段,是为了保证一致性,数据库提供的手段。数据库必须要实现 AID 三大特性,才有可能实现一致性。
事务的实现机制
WAL(Write-ahead logging,预写式日志)
预写式即将数据修改的前后数据都预写记录下来,MYSQL采用的即为该机制,也是当下主流的实现方案。
MYSQL实现机制概述:
MYSQL将所有的修改都先被写入到日志中,然后再被应用到系统中。通常包含 redo 和 undo 两部分信息。
redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。
undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。
MySQL 中用 redo log 来在系统 Crash 重启之类的情况时修复数据(事务的持久性),而 undo log 来保证事务的原子性,使用读写锁+MVCC 来实现隔离性。
Commit Logging(提交日志)
该实现方式只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化。
与WAL的区别是,WAL 允许在事务提交之前,提前写入变动数据,而 Commit Logging 则不行;WAL中有undo 日志,Commit Logging 没有。
Shadow Paging(影子分页)
大体思路是对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。在事务过程中,被修改的数据会同时存在两份,一份是修改前的数据,一份是修改后的数据,这也是“影子”(Shadow)这个名字的由来。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。
InnoDB事务实现机制
Redo日志
redo 日志的作用
InnoDB 存储引擎是以页为单位来管理存储空间的,用户对MYSQL所进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。在真正访问页面之前,需要把在磁盘上的页缓存到内存中的 Buffer Pool 之后才可以访问。但是在事务的时候又强调过一个称之为持久性的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。
思考:下面问题,怎么保证持久性
如果只在内存的 Buffer Pool 中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库中所做的更改也就跟着丢失了,这样的话数据持久性没有保障了。那么如何保证这个持久性呢?
一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:
1、刷新一个完整的数据页太浪费了
有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在 InnoDB 中是以页为单位来进行磁盘 IO 的,也就是说我们在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,我们又知道一个页面默认是 16KB 大小,只修改一个字节就要刷新 16KB 的数据到磁盘上显然是太浪费了。
2、随机 IO 刷起来比较慢
一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的 Buffer Pool 中的页面刷新到磁盘时,需要进行很多的随机 IO,随机 IO 比顺序 IO 要慢,尤其对于传统的机械硬盘来说。
其实,保证持久性也就是让已经提交了的事务对数据库中数据所做的修改永久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘,只需要把修改了哪些东西记录一下就好。比如:
某事物在系统表空间中的第 100号页面中偏移量为 1000 处的那个字节的值 1 改成 2 ,只需要记录一下:
将第 0 号表空间的 100 号页面的偏移量为 1000 处的值更新为 2。
这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。因为在系统崩溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也被称之为重做日志,英文名为 redo log,也可以称之为 redo 日志。与在事务提交时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生的 redo 日志刷新到磁盘的好处如下:
1、redo 日志占用的空间非常小
存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。
2、redo 日志是顺序写入磁盘的
在执行事务的过程中,每执行一条语句,就可能产生若干条 redo 日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序 IO。
redo 日志格式
,redo 日志本质上只是记录了一下事务对数据库做了哪些修改。 InnoDB 们针对事务对数据库的不同修改场景定义了多种类型的redo 日志,但是绝大部分类型的 redo 日志都有下边这种通用的结构:
各个部分的详细释义如下:
type:该条 redo 日志的类型,redo 日志设计大约有 53 种不同的类型日志。
space ID:表空间 ID。
page number:页号。
data:该条 redo 日志的具体内容。
简单redo日志
在事务中,有些情况下对页面的修改是极其简单的,redo 日志中只需要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容是什么就好了,InnoDB 把这种极其简单的 redo 日志称之为物理日志。
根据在页面中写入数据的多少划分了几种不同的 redo 日志类型:
MLOG_1BYTE(type 字段对应的十进制数字为 1):表示在页面的某个偏移量处写入 1 个字节的 redo 日志类型。
MLOG_2BYTE(type 字段对应的十进制数字为 2):表示在页面的某个偏移量处写入 2 个字节的 redo 日志类型。
MLOG_4BYTE(type 字段对应的十进制数字为 4):表示在页面的某个偏移量处写入 4 个字节的 redo 日志类型。
MLOG_8BYTE(type 字段对应的十进制数字为 8):表示在页面的某个偏移量处写入 8 个字节的 redo 日志类型。
MLOG_WRITE_STRING(type 字段对应的十进制数字为 30):表示在页面的某
个偏移量处写入一串数据。
结构如下:
offset 代表在页面中的偏移量。
MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE 、MLOG_8BYTE类型的 redo 日志结构和上面结构类似,只不过具体数据中包含对应个字节的数据罢了。而MLOG_WRITE_STRING 类型的 redo 日志表示写入一串数据,但是因为不能确定写入的具体数据占用多少字节,所以需要在日志结构中还会多一个 len 字段。
复杂的 redo 日志类型
有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的 B+树)。以一条 INSERT 语句为例,它除了要向 B+树的页面中插入数据,也可能更新系统数据 Max Row ID 的值,不过对于我们用户来说,平时更关心的是语句对 B+树所做更新:
表中包含多少个索引,一条 INSERT 语句就可能更新多少棵 B+树。针对某一棵 B+树来说,既可能更新叶子节点页面,也可能更新非叶子节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂,在非叶子节点页面中添加目录项记录)。
一个数据页中除了存储实际的记录之后,还有什么 File Header、Page Header、Page Directory 等等部分,所以每往叶子节点代表的数据页里插入一条记录时,还有其他很多地方会跟着更新。
其实说到底,把一条记录插入到一个页面时需要更改的地方非常多
如果使用简单的物理 redo 日志来记录这些修改时,那可以有两种解决方案:
方案一:在每个修改的地方都记录一条 redo 日志。
每涉及一个地方的修改,就记录该块的记录,就会记录一条物理 redo 日志。这样子记录 redo 日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记录的 redo 日志占用的空间都比整个页面占用的空间都多了。
方案二:将整个页面的第一个被修改的字节到最后一个修改的字节之间所有的数据当成是一条物理 redo 日志中的具体数据。
但这样第一个被修改的字节到最后一个修改的字节之间仍然有许多没有修改过的数据,我们把这些没有修改的数据也加入到 redo 日志中去依然很浪费。
因为上述两种方案使用物理 redo 日志的方式来记录某个页面中做了哪些修改比较浪费,InnoDB 中就有非常多的 redo 日志类型来做记录。
这些类型的 redo 日志既包含物理层面的意思,也包含逻辑层面的意思,具体
指:
物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。
逻辑层面看,在系统崩溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统崩溃前的样子。
总之,redo 日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。
redo 日志的写入
redo log block 和日志缓冲区InnoDB 为了更好的进行系统崩溃恢复,把 redo 日志都放在了大小为 512 字节的块(block)中。
为了解决磁盘速度过慢的问题而引入了 Buffer Pool。同理,写入 redo 日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为 redo log buffer 的连续内存空间,翻译成中文就是 redo 日志缓冲区,我们也可以简称为 log buffer。这片内存空间被划分成若干个连续的
redo log block,可以通过启动参数 innodb_log_buffer_size 来指定 log buffer的大小,该启动参数的默认值为 16MB。 向log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该 block 的空闲空间用完之后再往下一个 block 中写。
redo 日志刷盘时机
redo 日志基于内存中有个 log buffer操作的,可是这些日志总在内存里占着,所以在一些情况下它们会被刷新到磁盘里,及就是刷盘时机。
从开发者角度来说,一般这种时机措施会是:定时执行、特殊事件触发、资源不足时触发、服务宕机时。所以redolog具体的刷盘时机是:
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、正常关闭服务器时等等。
redo 日志文件组
MySQL 的数据目录(使用 SHOW VARIABLES LIKE 'datadir’查看)下默认有两个名为 ib_logfile0 和 ib_logfile1 的文件,log buffer 中的日志默认情况下就是刷新到这两个磁盘文件中。如果我们对默认的 redo 日志文件不满意,可以通过下边几个启动参数来调节:
innodb_log_group_home_dir,该参数指定了 redo 日志文件所在的目录,默认值就是当前的数据目录。
innodb_log_file_size,该参数指定了每个 redo 日志文件的大小,默认值为 48MB,
innodb_log_files_in_group,该参数指定 redo 日志文件的个数,默认值为 2,最大值为 100。
所以磁盘上的 redo 日志文件可以不只一个,而是以一个日志文件组的形式出现的。这些文件以 ib_logfile[数字](数字可以是 0、1、2…)的形式进行命名。
在将 redo 日志写入日志文件组时,是从 ib_logfile0 开始写,如果 ib_logfile0 写满了,就接着 ib_logfile1 写,同理,ib_logfile1 写满了就去写 ib_logfile2,依此类推。如果写到最后一个文件,就重新转到 ib_logfile0 继续写。
所以 Redo log 文件是循环写入的,在覆盖写之前,总是要保证对应的脏页已经刷到了磁盘。在非常大的负载下,为避免错误的覆盖,InnoDB 会强制的 flush脏页。
redo 日志文件格式
log buffer本质上是一片连续的内存空间,被划分成了若干个512字节大小的 block。将 log buffer 中的 redo 日志刷新到磁盘的本质就是把 block 的镜像写入日志文件中,所以 redo 日志文件其实也是由若干个 512 字节大小的block 组成。
redo 日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组成:
前 2048 个字节,也就是前 4 个 block 是用来存储一些管理信息的。
从第 2048 字节往后是用来存储 log buffer 中的 block 镜像的。
Log Sequence Number
自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日志。redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。
InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN。规定初始的 lsn值为 8704(也就是一条 redo 日志也没写时,LSN 的值为 8704)。
redo 日志都有一个唯一的 LSN 值与其对应,LSN 值越小,说明 redo 日志产生的越早。
flushed_to_disk_lsn
redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文件。InnoDB 中有一个 buf_next_to_write 的全局变量,标记当前 log buffer中已经有哪些日志被刷新到磁盘中了。
同lsn一样,InnoDB 也有一个表示刷新到磁盘中的 redo日志量的全局变量,称之为flushed_to_disk_lsn。系统第一次启动时,该变量的值和初始的 lsn 值是相同的,都是 8704。随着系统的运行,redo 日志被不断写入log buffer,但是并不会立即刷新到磁盘。即flushed_to_disk_lsn是要落后于LSN的。当两者的值相同时,说明 log buffer 中的所有 redo 日志都已经刷新到磁盘中了。
应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的 fsync 函数。其实只有当系统执行了 fsync 函数后,flushed_to_disk_lsn的值才会跟着增长,当仅仅把 log buffer 中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为 write_lsn 的值跟着增长。当然系统的 LSN 值远不止我们前面描述的 lsn,还有很多。
使用SHOW ENGINE INNODB STATUS命令查看当前InnoDB存储引擎中的各种 LSN 值的情况,比如:
SHOW ENGINE INNODB STATUS;
主要字段说明:
Log sequence number:代表系统中的 lsn 值,也就是当前系统已经写入的 redo日志量,包括写入 log buffer 中的日志。
Log flushed up to:代表 flushed_to_disk_lsn 的值,也就是当前系统已经写入磁盘的 redo 日志量。
Pages flushed up to:代表 flush 链表中被最早修改的那个页面对应的oldest_modification 属性值。
Last checkpoint at:当前系统的 checkpoint_lsn 值。
innodb_flush_log_at_trx_commit 的用法
为了保证事务的持久性,用户线程在事务提交时需要将该事务执行过程中产生的所有 redo 日志都刷新到磁盘上。会很明显的降低数据库性能。如果对事务的持久性要求不是那么强烈的话,可以选择修改这个
innodb_flush_log_at_trx_commit 的系统变量的值,该变量有 3 个可选的值:
0:当该系统变量值为0时,表示在事务提交时不立即向磁盘中同步redo日志,这个任务是交给后台线程做的。这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线程没有及时将 redo 日志刷新到磁盘,那么该事务对页面的修改会丢失。
1:当该系统变量值为 1 时,表示在事务提交时需要将 redo 日志同步到磁盘,可以保证事务的持久性。1 也是 innodb_flush_log_at_trx_commit 的默认值。
2:当该系统变量值为 2 时,表示在事务提交时需要将 redo 日志写到操作系统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保证的,但是操作系统也挂了的话,那就不能保证持久性了。
undo日志
事务回滚
事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚(rollback),这样就可以造成这个事务看起来什么都没做,所以符合原子性要求。
每当我们要对一条记录做改动时(这里的改动可以指 INSERT、DELETE、UPDATE),都需要把回滚时所需的东西都给记下来。比如说:
你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉。
你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中。
你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值。
这些为了回滚而记录的这些东西称之为撤销日志,也叫 undo log/undo 日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的 undo 日志。当然,在真实的 InnoDB 中,undo 日志其实并不像我们上边所说的那么简单,不同类型的操作产生的 undo 日志的格式也是不同的。
事务 id
给事务分配 id 的时机
一个事务可以是一个只读事务,或者是一个读写事务。
可以通过 START TRANSACTION READ ONLY 语句开启一个只读事务。
在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对用户临时表做增、删、改操作。
可以通过 START TRANSACTION READ WRITE 语句开启一个读写事务,或者使用 BEGIN、START TRANSACTION 语句开启的事务默认也算是读写事务。
在读写事务中可以对表执行增删改查操作。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务 id,分配方式如下:
只读事务:
对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话是不分配事务 id 的。
用户创建的临时表是指手动用 CREATE TEMPORARY TABLE 创建的用户临时表,而在查询执行计划中Extra 列可能看到的 Using temporary属于内部临时表,在执行 SELECT 语句用到内部临时表时并不会为它分配事务 id。
读写事务:
对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务id 的。
有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。
事务 id 生成机制
事务 id 本质上就是一个数字,服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id
时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存储空间。
当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中,将该值加上 256 之后赋值给我们前边提到的全局变量(因为在上次关机时该全局变量的值可能大于 Max Trx ID 属性值)。这样就可以保证整个系统中分配的事务 id 值是一个递增的数字。先被分配 id的事务得到的是较小的事务 id,后被分配 id 的事务得到的是较大的事务 id。
trx_id 隐藏列
聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为 trx_id、roll_pointer 的隐藏列,如果用户没有在表中定义主键以及 UNIQUE 键,还会自动添加一个名为 row_id的隐藏列。
trx_id 列就是某个对这个聚簇索引记录做改动的语句所在的事务对应的事务 id 而已(此处的改动可以是 INSERT、DELETE、UPDATE 操作)。
undo 日志的格式
为了实现事务的原子性,InnoDB 存储引擎在实际进行增、删、改一条记录时,都需要先把对应的 undo 日志记下来。一般每对一条记录做一次改动,就对应着一条 undo 日志,但在某些更新记录的操作中,也可能会对应着 2 条 undo 日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的 undo 日志,这些 undo 日志会被从 0 开始编号,也就是说根据生成的顺序分别被称为第 0 号 undo 日志、第 1 号 undo 日志、…、第 n 号 undo日志等,这个编号也被称之为 undo NO。
表空间是由许许多多的页面构成的,页面默认大小为 16KB。这些页面有不同的类型,其中有一种称之为FIL_PAGE_UNDO_LOG 类型的页面是专门用来存储 undo 日志的。也就是说 Undo page 跟储存的数据和索引的页等是类似的。
FIL_PAGE_UNDO_LOG 页面可以从系统表空间中分配,也可以从一种专门存放undo 日志的表空间,也就是所谓的 undo tablespace 中分配。
INSERT 操作对应的 undo 日志
当向表中插入一条记录时最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。InnoDB 的设计了一个类型为 TRX_UNDO_INSERT_REC 的 undo 日志。
当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录 undo 日志时,只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。
roll_pointer 的作用
roll_pointer 本质上就是一个指向记录对应的 undo 日志的一个指针。比方说我们向表里插入了2条记录,每条记录都有与其对应的一条 undo 日志。记录被存储到了类型为 FIL_PAGE_INDEX 的页面中(就是我们前边一直所说的数据页),undo 日志被存放到了类型为 FIL_PAGE_UNDO_LOG 的页面中。roll_pointer 本质就是一个指针,指向记录对应的 undo 日志。
DELETE 操作对应的 undo 日志
当数据被删除时,会根据记录头信息中的 next_record 属性组成一个链表,这个链表称为垃圾链表。只不过这个链表中的记录占用的存储空间可以被重新利用。Page Header 部分有一个称之为 PAGE_FREE 的属性,它指向由被删除记录组成的垃圾链表中的头节点。
从上图中可以看出,正常记录链表中包含了 3 条正常记录,垃圾链表里包含了 2 条已删除记录。现在需要删除最后一条数据
删除的过程需要经历两个阶段:
阶段一:将记录的 delete_mask 标识位设置为 1,这个阶段称之为 delete mark。
正常记录链表中的最后一条记录的 delete_mask 值被设置为 1,但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态。在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态(主要是为了实现 MVCC 的功能)。
阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置 PAGE_LAST_INSERT、垃圾链表头节点的指针 PAGE_FREE、页面中可重用的字节数量 PAGE_GARBAGE、还有页目录的一些信息等等。这个阶段称之为 purge。
阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了。
所以在删除语句所在的事务提交之前,只会经历阶段一,也就是 delete mark 阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一做的影响进行回滚)。InnoDB 中就会产生一种称之为
TRX_UNDO_DEL_MARK_REC 类型的 undo 日志。
版本链
在对一条记录进行 delete mark 操作前,需要把该记录的旧的 trx_id 和roll_pointer 隐藏列的值都给记到对应的 undo 日志中来,就是图中显示的 old trx_id 和 old roll_pointer 属性。这样有一个好处,那就是可以通过 undo 日志的old roll_pointer 找到记录在修改之前对应的 undo 日志。
版本链在事务的隔离性中有很重要的作用。
UPDATE 操作对应的 undo 日志
在执行 UPDATE 语句时,InnoDB 对更新主键和不更新主键这两种情况有截然不同的处理方案。
不更新主键的情况
在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。
就地更新(in-place update)
更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。需要强调的是每个列在更新前后占用的存储空间必须一样大,有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后占用的存储空间小都不能进行就地更新。
先删除掉旧记录,再插入新记录
在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。请注意一下,我们这里所说的删除并不是 delete mark 操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如PAGE_FREE、PAGE_GARBAGE 等这些信息)。由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。
这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。针对 UPDATE 不更新主键的情况(包括上边所说的就地更新和先删除旧记录再插入新记录),InnoDB 设计了一种类型为 TRX_UNDO_UPD_EXIST_REC 的 undo 日志。
更新主键的情况
在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变。针对 UPDATE 语句中更新了记录主键值的这种情况,InnoDB 在聚簇索引中分了两步处理:
1、将旧记录进行 delete mark 操作
也就是说在 UPDATE 语句所在的事务提交前,对旧记录只做一个 delete mark操作,在事务提交后才由专门的线程做 purge 操作,把它加入到垃圾链表中。
这里一定要和我们上边所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!之所以只对旧记录做 delete mark 操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的 MVCC。
2、创建一条新记录
根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。
针对 UPDATE 语句更新记录主键值的这种情况,在对该记录进行 delete mark操作前,会记录一条类型为 TRX_UNDO_DEL_MARK_REC 的 undo 日志;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC 的 undo 日志,也就是说每对一条记录的主键值做改动时,会记录 2 条undo 日志。
事务的流程
事务流程分为事务的执行流程和事务恢复流程。
事务执行流程
MySQL 在事务执行的过程中,会记录相应 SQL 语句的 UndoLog 和Redo Log,然后在内存中更新数据并形成数据脏页。接下来 RedoLog 会根据一定规则触发刷盘操作,Undo Log 和数据脏页则通过刷盘机制刷盘。事务提交时,会将当前事务相关的所有 Redo Log 刷盘,只有当前事务相关的所有 Redo Log 刷盘成功,事务才算提交成功。
事务恢复流程
MySQL 由于某种原因崩溃或者宕机或者手动rollback,需要进行数据的恢复或者回滚操作。
两种情况:
1、如果事务在执行第 8 步,即事务提交之前,MySQL 崩溃或者宕机,此时会先使用Redo Log 恢复数据,然后使用 Undo Log 回滚数据。
2、如果在执行第8步之后MySQL崩溃或者宕机,此时会使用Redo Log恢复数据
恢复流程如下
总结:MySQL 崩溃恢复后,首先会获取日志检查点信息,随后根据日志检查点信息使用 Redo Log 进行恢复。MySQL 崩溃或者宕机时事务未提交,则接下来使用 Undo Log 回滚数据。如果在 MySQL 崩溃或者宕机时事务已经提交,则用Redo Log 恢复数据即可。
恢复机制
在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录就可以将页面恢复到系统崩溃前的状态。MySQL 可以根据 redo 日志中的各种 LSN 值,来确定恢复的起点和终点。然后将 redo 日志中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个槽中。之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机 IO)。
并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面,进而提升崩溃恢复的速度。
redo、undo、binlog相关问题
问题1:崩溃后的恢复为什么不用 binlog?
1、这两者使用方式不一样
binlog 会记录表所有更改操作,包括更新删除数据,更改表结构等等,主要用于人工恢复数据,而 redo log 对于我们是不可见的,它是 InnoDB 用于保证crash-safe 能力的,也就是在事务提交后 MySQL 崩溃的话,可以保证事务的持久性,即事务提交后其更改是永久性的。
一句话概括:binlog 是用作人工恢复数据,redo log 是 MySQL 自己使用,用于保证在数据库崩溃时的事务持久性。
2、redo log 是 InnoDB 引擎特有的,binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
3、redo log 是物理日志,记录的是“在某个数据页上做了什么修改”,恢复的速度更快;binlog 是逻辑日志,记录的是这个语句的原始逻辑 ;
4、redo log 是“循环写”的日志文件,redo log 只会记录未刷盘的日志,已经刷入磁盘的数据都会从 redo log 这个有限大小的日志文件里删除。binlog 是追加日志,保存的是全量的日志。
5、最重要的是,当数据库 crash 后,想要恢复未刷盘但已经写入 redo log 和binlog 的数据到内存时,binlog 是无法恢复的。虽然 binlog 拥有全量的日志,但没有一个标志让 innoDB 判断哪些数据已经入表(写入磁盘),哪些数据还没有。redo log 不一样,只要刷入磁盘的数据,都会从 redo log 中抹掉,数据库重启后,直接把 redo log 中的数据都恢复至内存就可以了。
问题二:Redo 日志和 Undo 日志的关系
数据库崩溃重启后,需要先从 redo log 中把未落盘的脏页数据恢复回来,重新写入磁盘,保证用户的数据不丢失。当然,在崩溃恢复中还需要把未提交的事务进行回滚操作。由于回滚操作需要 undo log 日志支持,undo log 日志的完整性和可靠性需要 redo log 日志来保证,所以数据库崩溃需要先做 redo log 数据恢复,然后做 undo log 回滚。
在事务执行过程中,除了记录 redo 一些记录,还会记录 undo log 日志。Undo log 记录了数据每个操作前的状态,如果事务执行过程中需要回滚,就可以根据undo log 进行回滚操作。
因为 redo log 是物理日志,记录的是数据库页的物理修改操作。所以 undo log(可以看成数据库的数据)的写入也会伴随着 redo log 的产生,这是因为 undo log也需要持久化的保护。
事务进行过程中,每次 sql 语句执行,都会记录 undo log 和 redo log,然后更新数据形成脏页。事务执行 COMMIT 操作时,会将本事务相关的所有 redo log进行落盘,只有所有的 redo log 落盘成功,才算 COMMIT 成功。然后内存中的undo log 和脏页按照同样的规则进行落盘。如果此时发生崩溃,则只使用 redo log恢复数据。
问题三:同时写 Redo 和 Binlog 怎么保持一致?
当我们开启了 MySQL 的 BinLog 日志,很明显需要保证 BinLog 和事务日志的一致性,为了保证二者的一致性,使用了两阶段事务 2PC(所谓的两个阶段是指:第一阶段:准备阶段和第二阶段:提交阶段,具体的内容请参考分布式事务的相关的课程内容)。步骤如下:
1)当事务提交时 InnoDB 存储引擎进行 prepare 操作。
2)MySQL 上层会将数据库、数据表和数据表中的数据的更新操作写入 BinLog文件。
3)InnoDB 存储引擎将事务日志写入 Redo Log 文件中。