mysql优化十四:InnoDB 引擎底层事务的原理

文章目录

InnoDB 引擎底层事务的原理

事务具有 4 个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为 ACID 特性。在ACID中最重要的是C一致性。其他的A原子性,I隔离性 D持久性都是保证一致性 的手段。
mysql事务中,原子性通过undo log来实现的,隔离性通过MVCC和读写锁来实现的,持久性通过redo log来实现的。
数据库必须要实现 AID 三大特性,才有可能实现一致性。同时一致性也需要应用程序的支持,应用程序在事务里故意写出违反约
束的代码,一致性还是无法保证的,例如,转账代码里从 A 账户扣钱而不给 B账户加钱,那一致性还是无法保证。

在事务的具体实现机制上,MySQL 采用的是 WAL(Write-ahead logging,预写式日志)机制来实现的。这也是是当今的主流方案。在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到系统中。通常包含 redo 和 undo 两部分信息。

为什么要采用WAL机制呢
举个例子,A账户给B账户转账,正常来说,A账户要扣钱,B账户要加钱。但是如果在这个过程中,发生不可抗力的因素导致服务宕机,比如断电,不采用WAL机制的话,如何知道A账户是否扣了钱,B账户是否加了钱。或者都没执行,又或者都执行了。这都不知道。

redo log
redo log称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log,这样当发生断电之类的情况时系统可以在重启后继续操作。
undo log
undo log称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。
在之前写的mysql优化中有说过redo log 和undo log,在mysql优化七:mysql内部执行流程和mvcc机制。不过在这里会详细的说一下这里两个日志。

Redo log

Redo log 的作用

InnoDB 存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。在Buffer Pool 的时候说过,在真正访问页面之前,需要把在磁盘上的数据页缓存到 Buffer Pool 之后才可以访问。但是事务又强调持久性,就是说对于一个已经提交的事务,即使系统发生了崩溃,这个事务对数据库中所做的更改也不能丢失。
如果我们修改数据,这个数据在buffer pool 中已经修改了,事务也提交了。如果宕机了那么数据库磁盘上的数据不一定是我们修改后的数据,也有可能是修改前的数据,取决于宕机前buffer pool 的数据有没有写入磁盘。这样就可能造成数据丢失。

那么如何保证这个持久性呢?
一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面都刷新到磁盘,但是这个简单粗暴的做法有些问题:

  1. 刷新一个完整的数据页太浪费了
    数据库是以页为单位写入,一个数据页16Kb,然而我可能只修改了一个字段,大小可能只有一个字节。那么这样做性能是不是大打折扣。
  2. 随机 IO 刷起来比较慢
    一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的 Buffer Pool 中的页面刷新到磁盘时,需要进行很多的随机 IO,随机 IO 比顺序 IO 要慢,尤其对于传统的机械硬盘来说。

那么要怎么做呢?
我们要做的是让事务提交后对数据库的修改永久生效,没必要把整个数据页都写入磁盘。我们只要记录那个页面修改了哪些记录,修改的记录值是什么,比方说某个事务将系统表空间中的第 100号页面中偏移量为 1000 处的那个字节的值 1 改成 2
我们只需要记录一下:将第 0 号表空间的 100 号页面的偏移量为 1000 处的值更新为 2。
这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了,重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。

上述记录的内容就是redo log。使用redo log 的好处在于:

  1. redo 日志占用的空间非常小
    存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。
  2. redo 日志是顺序写入磁盘的
    在执行事务的过程中,每执行一条语句,就可能产生若干条 redo 日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序 IO

Redo log 的格式

通过上边的内容我们知道,redo 日志本质上只是记录了一下事务对数据库做了哪些修改。 InnoDB 们针对事务对数据库的不同修改场景定义了多种类型的redo 日志,但是绝大部分类型的 redo 日志都有下边这种通用的结构:
mysql优化十四:InnoDB 引擎底层事务的原理

type:该条 redo 日志的类型,redo 日志设计大约有 53 种不同的类型日志。
space ID:表空间 ID。
page number:页号。
data:该条 redo 日志的具体内容。

简单的redo日志格式
之前有说过如果我们创建表的时候没有创建主键的话,mysql的行记录有一个隐藏的DB_ROW_ID,是mysql为我们定义的主键id。mysql会在内存中维护一个全局变量,每当向某个包含隐藏的 row_id 列的表中插入一条记录时,就会把该变量的值当作新记录的 row_id 列的值,并且把该变量自增 1。每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 7 的页面中一个称之为 Max Row ID 的属性处。当系统启动时,会将上边提到的 Max Row ID 属性加载到内存中,将该值加上256 之后赋值给我们前边提到的全局变量。
我们需要为这个页面的修改记录一条 redo 日志,以便在系统崩溃后能将已经提交的该事务对该页面所做的修改恢复出来。这样不会造成id重复。
这种情况下对页面的修改是极其简单的,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):表示在页面的某个偏移量处写入一串数据。

我们上边提到的 Max Row ID 属性实际占用 8 个字节的存储空间,所以在修改页面中的该属性时,会记录一条类型为MLOG_8BYTE 的 redo 日志,MLOG_8BYTE的 redo 日志结构如下所示:offset 代表在页面中的偏移量。
mysql优化十四:InnoDB 引擎底层事务的原理
其余 MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE 类型的 redo 日志结构和MLOG_8BYTE 的类似,只不过具体数据中包含对应个字节的数据罢了。
MLOG_WRITE_STRING 类型的 redo 日志表示写入一串数据,但是因为不能确定写入的具体数据占用多少字节,所以需要在日志结构中还会多一个 len 字段。
复杂一些的 redo 日志类型
有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的 B+树)。以一条 INSERT 语句为例,它除了要向 B+树的页面中插入数据,也可能更新系统数据 Max Row ID 的值,不过对于我们用户来说,平时更关心的是语句对 B+树所做更新:表中包含多少个索引,一条 INSERT 语句就可能更新多少棵 B+树。
针对某一棵 B+树来说,既可能更新叶子节点页面,也可能更新非叶子节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂,在非叶子节点页面中添加目录项记录)。

在语句执行过程中,INSERT 语句对所有页面的修改都得保存到 redo 日志中去。实现起来是非常麻烦的,这个redo 日志记不仅仅是记录一条 MLOG_WRITE_STRING 类型的 redo 日志,表明在页面的某个偏移量处增加了哪些数据。还要考虑到File Header、Page Header、Page Directory 等等部分,所以每往叶子节点代表的数据页里插入一条可能更新 Page Directory 中的槽信息、Page Header 中的各种页面统计信息,记录时,有很多地方会跟着更新,比如说:
槽数量可能会更改,还未使用的空间最小地址可能会更改,本页面中的记录数量可能会更改,各种信息都可能会被修改,同时数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录的记录头信息中的 next_record 属性来维护这个单向链表。如果在每个修改的地方都记录一条 redo 日志,可能记录的 redo 日志占用的空间都比整个页面占用的空间都多了。
所以InnoDB 中就有非常多的 redo 日志类型来做记录。这些类型的 redo 日志既包含物理层面的意思,也包含逻辑层面的意思,具体指:

  • 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。
  • 逻辑层面看,在系统崩溃重启时,并不能直接根据这些日志里的记载,将页面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执行完这些函数后才可以将页面恢复成系统崩溃前的样子。

简单来说,一个 redo 日志类型而只是把在本页面中变动(比如插入、修改)一条记录所有必备的要素记了下来,之后系统崩溃重启时,服务器会调用相关向某个页面变动(比如插入、修改)一条记录的那个函数,而 redo 日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的相关值也就都被恢复到系统崩溃前的样子了。这就是所谓的逻辑日志的意思。
当然,如果不是为了写一个解析 redo 日志的工具或者自己开发一套 redo 日志系统的话,那就不需要去研究 InnoDB 中的 redo 日志具体格式。
所以只要记住:redo 日志会把事务在执行过程中对数据库所做的所有修改都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来。

redo 日志的写入过程

mysql优化十四:InnoDB 引擎底层事务的原理
这张图是上一篇mysql优化展示的图片。上一篇讲了Buffer Pool,innodb不论查询还是修改都会把相关的数据页加载到Buffer Pool中。那么剩下Log Buffer 是干嘛的呢。
同样的redo日志也是要写入磁盘。在写入磁盘前也是需要将redo 日志写入到内存当中,这块内存就是Log Buffer。mysql启动和Buffer Pool 一样也是会想操作系统申请一块连续的内存空间称之为 redo log buffer。这片内存空间被划分成若干个连续的
redo log block,把 redo 日志都放在了大小为 512 字节的块(block)中。
我们可以通过启动参数 innodb_log_buffer_size 来指定 log buffer的大小,该启动参数的默认值为 16MB。向log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该 block 的空闲空间用完之后再往下一个 block 中写。
redo 日志刷盘时机
日志待在 log buffer总归要落盘的,什么时候写入磁盘呢?

  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 中的日志默认情况下就是刷新到这两个磁盘文件中。
mysql优化十四:InnoDB 引擎底层事务的原理
我的是在Windows上的。
mysql优化十四:InnoDB 引擎底层事务的原理
如果我们对默认的 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脏页。
Log Sequence Number
自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日志。redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增,永远不可能缩减了。InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN。规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,LSN 的值为 8704)。redo 日志都有一个唯一的 LSN 值与其对应,LSN 值越小,说明 redo 日志产生的越早。
redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文件。InnoDB 中有一个称之为buf_next_to_write的全局变量,标记当前 log buffer中已经有哪些日志被刷新到磁盘中了。
我们前边说 lsn 是表示当前系统中写入的 redo 日志量,这包括了写到 log buffer而没有刷新到磁盘的日志,相应的,InnoDB 也有一个表示刷新到磁盘中的 redo日志量的全局变量,称之为 flushed_to_disk_lsn。系统第一次启动时,该变量的值和初始的 lsn 值是相同的,都是 8704。随着系统的运行,redo 日志被不断写入log buffer,但是并不会立即刷新到磁盘,lsn 的值就和flushed_to_disk_lsn 的值拉开了差距。

需要注意的是:应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的 fsync 函数。其实只有当系统执行了 fsync 函数后,flushed_to_disk_lsn
的值才会跟着增长,当仅仅把 log buffer 中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为 write_lsn的值跟着增长。

当然系统的 LSN 值远不止我们前面描述的 lsn,还有很多。我们可以使用SHOW ENGINE INNODB STATUS命令查看当前InnoDB存储引擎中的各种 LSN 值的情况,比如:
SHOW ENGINE INNODB STATUS;
mysql优化十四:InnoDB 引擎底层事务的原理
status里面和LSN相关数据就是:

LOG
---
Log sequence number 11241900858
Log flushed up to   11241900858
Pages flushed up to 11241900858
Last checkpoint at  11241900849
0 pending log flushes, 0 pending chkp writes
10 log i/o's done, 0.00 log i/o's/second

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 Log

开头说过redo log 实现了事务的持久性。undo log 实现事务的原子性。原子性也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:

  • 情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
  • 情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的事务的执行。

这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚(英文名:rollback),这样就可以造成这个事务看起来什么都没做,所以符合原子性要求。
每当我们要对一条记录做改动时(这里的改动可以指 INSERT、DELETE、UPDATE),都需要把回滚时所需的东西都给记下来。比方说:

  • 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉。
  • 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中。
  • 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值。

这些为了回滚而记录的这些东西称之为回滚日志,英文名为 undo log/undo 日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的 undo 日志。
当然,在真实的 InnoDB 中,undo 日志其实并不像我们上边所说的那么简单,不同类型的操作产生的 undo 日志的格式也是不同的。

undo log相关概念

一个事务可以是一个只读事务,或者是一个读写事务:

  • 我们可以通过 START TRANSACTION READ ONLY 语句开启一个只读事务。
    在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对用户临时表做增、删、改操作。
  • 我们可以通过 START TRANSACTION READ WRITE 语句开启一个读写事务,或者使用 BEGINSTART TRANSACTION 语句开启的事务默认也算是读写事务。在读写事务中可以对表执行增删改查操作。

分配事务id
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务 id,分配方式如下:

  • 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话是不分配事务 id 的。
    我们前边说过对某个查询语句执行 EXPLAIN 分析它的查询计划时,有时候在Extra 列会看到 Using temporary 的提示,这个表明在执行该查询语句时会用到内部临时表。这个所谓的内部临时表和我们手动用CREATE TEMPORARY TABLE 创建的用户临时表并不一样,在事务回滚时并不需要把执行 SELECT 语句过程中用到的内部临时表也回滚,在执行 SELECT 语句用到内部临时表时并不会为它分配事务 id
  • 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务id 的。
    有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。

上边描述的事务 id 分配策略是针对 MySQL 5.7 来说的,前边的版本的分配方式可能不同。
事务 id 生成机制
这个事务 id 本质上就是一个数字,它的分配策略和我们前边提到的对隐藏列row_id(当用户没有为表创建主键和 UNIQUE 键时 InnoDB 自动创建的列)的分配策略大抵相同,具体策略如下:
服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存储空间。
当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中,将该值加上 256 之后赋值给我们前边提到的全局变量(因为在上次关机时该全局变量的值可能大于 Max Trx ID 属性值)。
这样就可以保证整个系统中分配的事务 id 值是一个递增的数字。先被分配 id的事务得到的是较小的事务 id,后被分配 id 的事务得到的是较大的事务 id。

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 中分配。先来看看不同操作都会产生什么样子的 undo 日志
INSERT 操作对应的 undo 日志
当我们向表中插入一条记录时最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的 undo 日志时,主要是把这条记录的主键信息记上。InnoDB 的设计了一
个类型为 TRX_UNDO_INSERT_REC 的 undo 日志。
当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录 undo 日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。
后边说到的 DELETE 操作和 UPDATE 操作对应的 undo 日志也都是针对聚簇索引记录而言的。
roll_pointer 的作用
mysql优化十四:InnoDB 引擎底层事务的原理
之前再说行格式的时候说到DB_ROLL_PTR:7 字节,表示回滚指针。但没有细说(DB_ROLL_PTR和roll_pointer是一个意思表示一个指向记录对应的 undo 日志的一个指针)。
比方说我们向表里插入了 2 条记录,每条记录都有与其对应的一条 undo 日志。记录被存储到了类型为 FIL_PAGE_INDEX 的页面中(就是我们前边一直所说的数据页),undo 日志被存放到了类型为FIL_PAGE_UNDO_LOG 的页面中。roll_pointer 本质就
是一个指针,指向记录对应的 undo 日志。

DELETE 操作对应的 undo 日志
我们知道插入到页面中的记录会根据记录头信息中的 next_record 属性组成一个单向链表,我们把这个链表称之为正常记录链表;被删除的记录其实也会根据记录头信息中的 next_record 属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表,在delete mask 标记为1。Page Header 部分有一个称之为 PAGE_FREE 的属性,它指向由被删除记录组成的垃圾链表中的头节点。
假设此刻某个页面中的记录分布情况是这样的:
mysql优化十四:InnoDB 引擎底层事务的原理
我们只把记录的 delete_mask 标志位展示了出来。从图中可以看出,正常记录链表中包含了 3 条正常记录,垃圾链表里包含了 2 条已删除记录。页面的 Page Header 部分的 PAGE_FREE 属性的值代表指向垃圾链表头节点的指针。
假设现在我们准备使用 DELETE 语句把正常记录链表中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:
阶段一:将记录的 delete_mask 标识位设置为 1,这个阶段称之为 delete mark
mysql优化十四:InnoDB 引擎底层事务的原理
可以看到,正常记录链表中的最后一条记录的 delete_mask 值被设置为 1,但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态。在删除语句所在的事务提交之前,被删除的记录一直都处于这种所谓的中间状态。
为啥会有这种奇怪的中间状态呢?其实主要是为了实现 MVCC 的功能(MVCC机制在mysql优化七中有说过,不在重复)
阶段二:当该删除语句所在的事务提交之后,会有专门的线程来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置 PAGE_LAST_INSERT、垃圾链表头节点的指针 PAGE_FREE、页面中可重用的字节数量 PAGE_GARBAGE、还有页目录的一些信息等等。这个阶段称之为 purge。
把阶段二执行完了,这条记录就算是真正的被删除掉了。这条已删除记录占用的存储空间也可以被重新利用了
mysql优化十四:InnoDB 引擎底层事务的原理
从上边的描述中我们也可以看出来,在删除语句所在的事务提交之前,只会经历阶段一,也就是 delete mark 阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一做的影响进行回滚)。InnoDB 中就会产生一种称之为TRX_UNDO_DEL_MARK_REC 类型的 undo 日志。

UPDATE 操作对应的 undo 日志
在执行 UPDATE 语句时,InnoDB 对更新主键和不更新主键这两种情况有截然不同的处理方案。
不更新主键的情况:在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。
就地更新(in-place update)
更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。再次强调一边,是每个列在更新前后占用的存储空间一样大,有任何一个被更新的列更新前比更新后占用的存储空间大,或者更新前比更新后占用的存储空间小都不能进行就地更新。
先删除掉旧记录,再插入新记录
在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。
注意一下,我们这里所说的删除并不是 delete mark 操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如 PAGE_FREE、PAGE_GARBAGE 等这些信息)。由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。

这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。
针对 UPDATE 不更新主键的情况(包括上边所说的就地更新和先删除旧记录再插入新记录),InnoDB 设计了一种类型为 TRX_UNDO_UPD_EXIST_REC 的 undo 日志。

更新主键的情况
在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从 1 更新为 10000,如果还有非常多的记录的主键值分布在 1 ~ 10000 之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。
针对 UPDATE 语句中更新了记录主键值的这种情况,InnoDB 在聚簇索引中分了两步处理:

  • 将旧记录进行 delete mark 操作
    也就是说在 UPDATE 语句所在的事务提交前,对旧记录只做一个 delete mark操作,在事务提交后才由专门的线程做 purge 操作,把它加入到垃圾链表中。这里一定要和上边所说的在不更新记录主键值时,先真正删除旧记录,再插入新记录的方式区分开!
    之所以只对旧记录做 delete mark 操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的 MVCC。
  • 创建一条新记录
    根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。
    由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。

针对 UPDATE 语句更新记录主键值的这种情况,在对该记录进行 delete mark操作前,会记录一条类型为 TRX_UNDO_DEL_MARK_REC 的 undo 日志;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC的 undo 日志,也就是说每对一条记录的主键值做改动时,会记录 2 条 undo 日志。

总结事务的流程

总的来说,事务流程分为事务的执行流程和事务恢复流程

事务执行

我们已经知道了 MySQL 的事务主要主要是通过 Redo Log 和 Undo Log 实现的。MySQL 事务执行流程如下图
mysql优化十四:InnoDB 引擎底层事务的原理
可以看出,MySQL 在事务执行的过程中,会记录相应 SQL 语句的 UndoLog 和Redo Log,然后在内存中更新数据并形成数据脏页。接下来 RedoLog 会根据一定规则触发刷盘操作,Undo Log 和数据脏页则通过刷盘机制刷盘。事务提交时,会将当前事务相关的所有 Redo Log 刷盘,只有当前事务相关的所有 Redo Log 刷盘成功,事务才算提交成功。

事务恢复

如果一切正常,则 MySQL 事务会按照上图中的顺序执行。如果 MySQL 由于某种原因崩溃或者宕机,当然进行数据的恢复或者回滚操作。如果事务在执行第 8 步,即事务提交之前,MySQL 崩溃或者宕机,此时会先使用Redo Log 恢复数据,然后使用 Undo Log 回滚数据。如果在执行第8步之后MySQL崩溃或者宕机,此时会使用Redo Log恢复数据,
大体流程如下图所示。
mysql优化十四:InnoDB 引擎底层事务的原理
很明显,MySQL 崩溃恢复后,首先会获取日志检查点信息,随后根据日志检查点信息使用 Redo Log 进行恢复。MySQL 崩溃或者宕机时事务未提交,则接下来使用 Undo Log 回滚数据。如果在 MySQL 崩溃或者宕机时事务已经提交,则用Redo Log 恢复数据即可。
恢复机制
在服务器不挂的情况下,redo 日志简直就是个大累赘,不仅没用,反而让性能变得更差。但是万一数据库挂了,就可以在重启时根据 redo 日志中的记录就可以将页面恢复到系统崩溃前的状态。
MySQL 可以根据 redo 日志中的各种 LSN 值,来确定恢复的起点和终点。然后将 redo 日志中的数据,以哈希表的形式,将一个页面下的放到哈希表的一个槽中。之后就可以遍历哈希表,因为对同一个页面进行修改的 redo 日志都放在了一个槽里,所以可以一次性将一个页面修复好(避免了很多读取页面的随机 IO)。并且通过各种机制,避免无谓的页面修复,比如已经刷新的页面,进而提升崩溃恢复的速度。

问题总结

针对上述内容会有以下一些问题:
崩溃后的恢复为什么不用 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 判断哪些数据已经入表(写入磁盘),哪些数据还没有。
    但 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 文件中。

上一篇:《工业控制网络安全技术与实践》一2.6.4 市政交通行业的工业控制网络


下一篇:Google MediaPipe 演示Demo(脸部、手指、肢体动作识别,及即时运动跟踪)