【InnoDB详解四】redo log和undo log

1 redo log

首先我们先明确一下InnoDB的修改数据的基本流程,当我们想要修改DB上某一行数据的时候,InnoDB是把数据从磁盘读取到内存的缓冲池上进行修改。这个时候数据在内存中被修改,与磁盘中相比就存在了差异,我们称这种有差异的数据为脏页。

InnoDB对脏页的处理不是每次生成脏页就将脏页刷新回磁盘,这样会产生海量的IO操作,严重影响InnoDB的处理性能。对于此,InnoDB有一套完善的处理策略,与我们这次主题关系不大,表过不提。既然脏页与磁盘中的数据存在差异,那么如果在这期间DB出现故障就会造成数据的丢失(持久性问题产生了)。为了解决这个问题,redo log就应运而生了。

1.1 redo log的特点

  • redo log在数据库重启恢复的时候被使用

  • redo日志占用的空间非常小,存储表空间ID、页号、偏移量以及需要更新的值所需的存储空间是很小的。

  • redo log属于物理日志,他可以将已提交事务修改的记录记录下来,即某个表空间中某页的某个偏移量的值更新为多少。因为其属于物理日志的特性,恢复速度远快于逻辑日志。而我们下文即将介绍的binlog和undo log就属于典型的逻辑日志。

  • redo日志不止记录索引插入/更新记录等操作,还有执行这个操作影响到的其他动作,如页分裂新增目录项记录,修改页信息等对数据页做的任何修改等等。

  • redo日志记录的是物理页的情况,它具有幂等性,因此记录日志的方式极其简练。幂等性的意思是多次操作前后状态是一样的,例如新插入一行后又删除该行,前后状态没有变化。

  • redo日志是顺序写入磁盘的,在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序IO,这比随机IO的性能要高得多。

1.2 redo log的工作机制简述

redo log包括两部分:一是内存中的日志缓冲(redo log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(redo log file),该部分日志是持久的,并且事务的记录是顺序追加的,性能非常高(磁盘的顺序写性能比内存的写性能差不了太多)。

InnoDB使用日志+缓存的策略来减少提交事务时的开销。因为日志中已经记录了事务,所以就无须为了保证持久性而在每个事务提交时都把缓冲池的脏数据刷新(flush)到磁盘中。

事务修改的数据和索引通常会映射到表空间的随机位置,所以刷新这些变更到磁盘需要很多随机IO。InnoDB假设使用常规磁盘,随机IO比顺序IO昂贵得多,因为一个IO请求需要时间把磁头移到正确的位置,然后等待磁盘上读出需要的部分,再转到开始位置。

InnoDB用日志把随机IO变成顺序IO。一旦日志安全写到磁盘,事务就持久化了,即使断电了,InnoDB可以重放日志并且恢复已经提交的事务。

为了确保每次日志数据都能写入到磁盘的事务日志文件中,在每次将log buffer中的日志写入日志文件的过程中都会调用一次操作系统的fsync操作(即fsync()系统调用)。

因为MySQL是工作在用户空间的,MySQL的log buffer处于用户空间的内存中。要写入到磁盘上的log file中(也就是redo的ib_logfileN文件,undo的share tablespace或.ibd文件,后面讲undo log时会讲到),中间还要经过操作系统内核空间的os buffer,调用fsync()的作用就是将OS buffer中的日志刷到磁盘上的log file中。

也就是说,从redo log buffer写日志到磁盘的redo log file中,过程如下:

1.3 redo log的数据结构(log block)

InnoDB存储引擎中,redo log以块为单位进行存储的,每个块占512字节(同磁盘扇区大小一致,可以保证块的写入是原子操作。),这称为redo log block。所以不管是log buffer中还是os buffer中以及redo log file on disk中,都是这样以512字节的块存储的

每个redo log block由3部分组成:header、tailer和body。其中日志块头header占用12字节,日志块尾tailer占用8字节,所以每个redo log block的日志主体部分body只有512-12-8=492字节。

因为redo log记录的是数据页的变化,当一个数据页产生的变化需要使用超过492字节的redo log来记录,那么就会使用多个redo log block来记录该数据页的变化。

上面所说的是一个日志块的内容,在redo log buffer或者redo log file on disk中,由很多log block组成。如下图:

1.3.1 block header

header包含4部分:

  • log_block_hdr_no:(4字节)该日志块在redo log buffer/os buffer/log file中的位置ID。log buffer/redo log file on disk是由log block组成,在log buffer内部就好似一个数组,因此LOG_BLOCK HDR_NO用来标记这个数组中的位置。其是递增并且循环使用的。
  • log_block_hdr_data_len:(2字节)该log block中已记录的log大小。写满该log block时为0x200,表示512字节。
  • log_block_first_rec_group:(2字节)该log block中新的数据页对应的log的开始偏移位置。
  • lock_block_checkpoint_no:(4字节)写入checkpoint信息的位置。

关于log block块头的第三部分log_block_first_rec_group,因为有时候一个数据页产生的日志量超出了一个日志块,这时需要用多个日志块来记录该页的相关日志。

例如,某一T1事务产生了792个字节的日志量,那么需要占用两个日志块,第一个日志块占用492字节,第二个日志块需要占用270个字节,那么对于第二个日志块来说,它记录的关于下一个数据页B的第一个log的开始位置就是282字节(270+12)。

如果log_block_first_rec_group的值和log_block_hdr_data_len相等,则说明该log block中没有新开始记录下一个数据页的日志,即表示该日志块用来延续前一个日志块

1.3.2 block tailer

tailer只有一个部分:

  • log_block_trl_no ,该值和块头的 log_block_hdr_no 相等。

1.3.3 block body

因为innodb存储引擎存储数据的单元是页(和SQL Server中一样),所以redo log也是基于页的格式来存放的。默认情况下,innodb的页大小是16KB(由innodb_page_size变量控制),一个页内可以存放非常多的log block(每个512字节),而log block中记录的又是数据页的变化。

其中log block中492字节的部分是block body,block body存储了很多条的redo日志,每条redo日志的格式分为4部分

  • type:占用1个字节,8bit,其中高位的一个bit另做它用,剩下7个bit表示redo log的日志类型,其值有很多,在MySQL 5.7.21这个版本中,InnoDB一共为redo日志设计了53种不同的类型,下文将详细分析。

  • space ID:表示表空间的ID,采用压缩的方式后,占用的空间可能小于4字节。

  • page number:表示页的偏移量,同样是压缩过的。

  • data:表示每个redo日志的数据部分,恢复时会调用相应的函数进行解析。例如insert语句和delete语句写入redo log的内容是不一样的。

1.3.4 redo log的类型

type字段的低位7个bit用来区分redo log的日志类型,我们来看下简单的场景和复杂的场景下,redo日志的不同类型。

1.3.4.1 简单的redo日志类型

我们前边介绍InnoDB的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并且表中也没有定义Unique键,那么InnoDB会自动的为表添加一个称之为row_id的隐藏列作为主键。

这时服务器会在内存中维护一个全局变量,每当向某个包含隐藏的row_id列的表中插入一条记录时,就会把该变量的当前值当作新记录的row_id列的值,并且把该变量自增1。

每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为7的页中一个称之为Max Row ID的属性处。

这是Max Row ID的持久化,即Max Row ID每增加256,就持久化一次,如果期间发生了系统宕机,那么重新启动后,服务器会将持久化的最大的Max Row ID取出,并加上256,当做新的Max Row ID。

比如Max Row ID自增到800的时候,系统已经持久化了Max Row ID的三个值256,512,768。这时,系统崩溃了,重新启动后,系统取出了最新的768,但不能直接从768开始用,为了防止重复,新的Max Row ID=768+256=1024。

这个Max Row ID属性占用的存储空间是8个字节,当某个事务向某个包含row_id隐藏列的表插入一条记录,并且为该记录分配的row_id值刚好为256的倍数时,就会向系统表空间页号为7的页面的相应偏移量处写入8个字节的值。

但是我们要知道,这个写入实际上是在Buffer Pool中完成的,我们需要为这个页的修改记录一条redo日志,以便在系统奔溃后能将已经提交的该事务对该页面所做的修改恢复出来。这种情况下对页的修改是极其简单的,redo日志中只需要记录一下页号为7的页面的某个偏移量处修改了几个字节的值,以及具体被修改的内容是啥就好了

这种简单的redo日志,InnoDB定义了如下的type的值,来表示对应字节的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日志结构如下所示:

其余MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE类型的redo日志结构和MLOG_8BYTE的类似,只不过具体数据中包含对应个字节的数据罢了。MLOG_WRITE_STRING类型的redo日志表示写入一串数据,但是因为不能确定写入的具体数据占用多少字节,所以需要在日志结构中添加一个len字段:

其实只要将MLOG_WRITE_STRING类型的redo日志的len字段填充上1、2、4、8这些数字,就可以分别替代MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE这些类型的redo日志,为啥还要多此一举设计这么多类型呢?还不是因为省空间啊,能不写len字段就不写len字段,省一个字节算一个字节。

1.3.4.2 复杂的redo日志类型

有时候执行一条语句会修改非常多的页面,包括系统数据页面(比如上文提到的全局变量Max Row ID的更新)和用户数据页面(用户数据指的就是聚簇索引和二级索引对应的B+树)。

以一条INSERT语句为例,它除了要向B+树的页面中插入数据,也可能更新系统数据Max Row ID的值,不过对于我们用户来说,平时更关心的是语句对B+树所做更新:

  • 表中包含多少个索引,一条INSERT语句就可能更新多少棵B+树。
  • 针对某一棵B+树来说,既可能更新叶子节点页面,也可能更新内节点页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足以存放该记录时,会进行页面的分裂)。
  • 对于B+树上的页来说,新的行被插入,页中的Page Directory的槽信息、Page Header中的各种统计信息,行记录链表的后驱next_record等都要随之更新。

画一个简易的示意图就像是这样:

说了这么多,就是想表达:把一条记录插入到一个页面时需要更改的地方非常多。这时我们如果使用上边介绍的简单的物理redo日志来记录这些修改时,可以有两种解决方案:

  • 方案一:在每个修改的地方都记录一条redo日志。

    • 也就是如上图所示,有多少个加粗的块,就写多少条物理redo日志。这样子记录redo日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记录的redo日志占用的空间都比整个页面占用的空间都多了。
  • 方案二:将整个页面的第一个被修改的字节到最后一个修改的字节之间所有的数据当成是一条物理redo日志中的具体数据。

    • 从图中也可以看出来,第一个被修改的字节到最后一个修改的字节之间仍然有许多没有修改过的数据,我们把这些没有修改的数据也加入到redo日志中去岂不是太浪费了。

正因为上述两种使用物理redo日志的方式来记录某个页面中做了哪些修改比较浪费,InnoDB的设计者本着勤俭节约的初心,提出了一些新的redo日志类型,比如:

  • MLOG_REC_INSERT(type字段对应的十进制数字为9):表示插入一条使用非紧凑行格式记录时的redo日志类型(如redundant)
  • MLOG_COMP_REC_INSERT(type字段对应的十进制数字为38):表示插入一条使用紧凑行格式记录时的redo日志类型(如compact/dynamic/compressed)
  • MLOG_COMP_PAGE_CREATE(type字段对应的十进制数字为58):表示创建一个存储紧凑行格式记录的页面的redo日志类型。
  • MLOG_COMP_REC_DELET(type字段对应的十进制数字为42):表示删除一条使用紧凑行格式记录的redo日志类型
  • MLOG_COMP_LIST_START_DELETE(type字段对应的十进制数字为44):表示从某条给定记录开始删除页面中的一系列使用紧凑行格式记录的redo日志类型。
  • MLOG_ZIP_PAGE_COMPRESS(type字段对应的十进制数字为51):表示压缩一个数据页的redo日志类型。
  • MLOG_COMP_LIST_END_DELETE(type字段对应的十进制数字为43):与MLOG_COMP_LIST_START_DELETE类型的redo日志呼应,表示删除一系列记录直到MLOG_COMP_LIST_END_DELETE类型的redo日志对应的记录为止。

那这些新类型和旧的类型有什么区别呢?如果还是简单的把所有的物理层面的数据变动都记录下来,那岂不是没什么区别?

区别就是,新的日志类型,除了能体现物理层面的变动,还包含了逻辑层面的变动,它主要是搭配系统恢复的函数的来使用的。

  1. 物理层面:修改的是哪个表空间,哪个页,以及页的偏移量。
  2. 逻辑层面:是插入操作还是删除操作;操作对象是行记录还是其他?如果是行记录,那是什么格式的行记录?紧凑的还是非紧凑的。

这样有什么好处呢??我们以插入一条使用紧凑行格式的记录时的redo日志(MLOG_COMP_REC_INSERT)为例,直接看一下这个类型为MLOG_COMP_REC_INSERT的redo日志的结构,橙色部分都是block body:

这个类型为MLOG_COMP_REC_INSERT的redo日志结构有几个地方需要大家注意:

  1. 在一个数据页里,行记录都是按照索引列从小到大的顺序排序的。对于二级索引来说,当索引列的值相同时,记录还需要按照主键值进行排序。图中n_uniques的值的含义是在一条记录中,需要几个字段的值才能确保记录的唯一性,这样当插入一条记录时就可以按照记录的前n_uniques个字段进行排序。对于聚簇索引来说,n_uniques的值为主键的列数,对于其他二级索引来说,该值为索引列数+主键列数。这里需要注意的是,唯一二级索引的值可能为NULL,所以该值仍然为索引列数+主键列数。
  2. field1_len ~ fieldn_len代表着该记录若干个字段占用存储空间的大小,需要注意的是,这里不管该字段的类型是固定长度大小的(比如INT),还是可变长度大小(比如VARCHAR(M))的,该字段占用的大小始终要写入redo日志中。
  3. offset代表的是该记录的前一条记录在页面中的地址。为啥要记录前一条记录的地址呢?这是因为每向数据页插入一条记录,都需要修改该页面中维护的记录链表,每条记录的记录头信息中都包含一个称为next_record的属性,所以在插入新记录时,需要修改前一条记录的next_record属性。

很显然这个类型为MLOG_COMP_REC_INSERT的redo日志并没有记录PAGE_N_DIR_SLOTS的值修改为了啥,PAGE_HEAP_TOP的值修改为了啥,PAGE_N_HEAP的值修改为了啥等等这些信息,而只是把在本页面中插入一条记录所有必备的要素记了下来,之后系统奔溃重启时,服务器会调用相关向某个页面插入一条记录的那个函数,而redo日志中的那些数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的PAGE_N_DIR_SLOTS、PAGE_HEAP_TOP、PAGE_N_HEAP等等的值也就都被恢复到系统奔溃前的样子了。这就是所谓的逻辑日志的意思。

如下图,分别是insert和delete大致的记录方式。

1.4 redo日志的原子性(Mini-Transaction)

前文说到执行一条INSERT的SQL语句,InnoDB在向某个B+树中插入新的记录的过程,会产生许多条的redo日志,因为可能涉及页的分裂,各种段的修改、区的统计信息,各种链表的统计信息等等。

我们知道向某个索引对应的B+树中插入一条记录的这个过程必须是原子的,不能说插了一半之后就停止了。在B+树上插入一个新的行,触发的页的分裂,这时新的页面已经分配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向数据节点中插入一条目录项记录,那么这个插入过程就是不完整的,这样会形成一棵不正确的B+树。

我们知道redo日志是为了在系统奔溃重启时恢复崩溃前的状态,如果在INSERT的过程中只记录了一部分redo日志,那么在系统奔溃重启时会将索引对应的B+树恢复成一种不正确的状态,这是InnoDB设计者们所不能忍受的。

MySQL把这种不容许分割的,对底层页面中的一次原子操作的过程称之为一个Mini-Transaction,简称mtr,比如上边所说的修改一次Max Row ID的值算是一个Mini-Transaction,向某个索引对应的B+树中插入一条记录的过程也算是一个Mini-Transaction。

一个mtr可能产生单条或者多条redo日志,就像对redo日志进行编组一样,在进行奔溃恢复时这一组redo日志将作为一个不可分割的整体,要么一起恢复,要么都不恢复。

一个事务可以包含若干条语句,每一条语句其实是由若干个mtr组成,每一个mtr又可以包含若干条redo日志,画个图表示它们的关系就是这样:

那么如何对一个mtr产生的redo日志进行编组呢?这得分情况讨论:

  1. 有的操作会生成多条redo日志,比如向某个索引对应的B+树中进行一次插入就需要生成许多条redo日志。
  2. 有的需要保证原子性的操作只生成一条redo日志,比如更新全局变量Max Row ID属性的操作就只会生成一条redo日志。

1.4.1 原子操作生成多条redo日志

针对第一种情况,InnoDB定义了一种新的类型(MLOG_MULTI_REC_END,type字段对应的十进制数字为31)的redo log结构:

所以某个需要保证原子性的操作产生的一系列redo日志必须要以一个类型为MLOG_MULTI_REC_END结尾,就像这样:

这样在系统奔溃重启进行恢复时,只有当解析到类型为MLOG_MULTI_REC_END的redo日志,才认为解析到了一组完整的redo日志,才会进行恢复。否则的话直接放弃前边解析到的不完整部分的redo日志。

1.4.2 原子操作生成单条redo日志

针对第二种情况,其实在一条日志后边跟一个类型为MLOG_MULTI_REC_END的redo日志也是可以的,但这比较浪费。

别忘了虽然redo日志的类型比较多,但撑死了也就是几十种,是小于127这个数字的,也就是说我们用7个比特位就足以包括所有的redo日志类型,而type字段其实是占用1个字节8比特位的,也就是说我们可以省出来一个比特位用来表示该需要保证原子性的操作只产生单一的一条redo日志,示意图如下:

如果type字段的第一个比特为为1,代表该需要保证原子性的操作只产生了单一的一条redo日志,否则表示该需要保证原子性的操作产生了一系列的redo日志。

1.5 redo日志的写入

我们前边说过,InnoDB为了解决磁盘速度过慢的问题而引入了Buffer Pool。同理,写入redo日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作系统申请了一大片称之为redo log buffer的连续内存空间,翻译成中文就是redo日志缓冲区,我们也可以简称为log buffer。这片内存空间被划分成若干个连续的redo log block,就像这样:

向log buffer中写入redo日志的过程是顺序的,也就是先往前边的block中写,当该block的空闲空间用完之后再往下一个block中写。当我们想往log buffer中写入redo日志时,第一个遇到的问题就是应该写在哪个block的哪个偏移量处,所以InnoDB特意提供了一个称之为buf_free的全局变量,该变量指明后续写入的redo日志应该写入到log buffer中的哪个位置,如图所示:

我们前边说过一个mtr执行过程中可能产生若干条redo日志,这些redo日志是一个不可分割的组,所以其实并不是每生成一条redo日志,就将其插入到log buffer中,而是每个mtr运行过程中产生的日志先暂时存到一个地方,当该mtr结束的时候,将过程中产生的一组redo日志再全部复制到log buffer中(所以同一mtr的一组log都是一起连续出现)

我们现在假设有两个名为T1、T2的事务,每个事务都包含2个mtr,每个mtr都产生若干个redo log:

不同的事务可能是并发执行的,所以T1、T2之间的mtr可能是交替执行的。

每当一个mtr执行完成时,伴随该mtr生成的一组redo日志就需要被复制到log buffer中,也就是说不同事务的mtr可能是交替写入log buffer的,我们画个示意图(为了美观,我们把一个mtr中产生的所有的redo日志当作一个整体来画):

从示意图中我们可以看出来,不同的mtr产生的一组redo日志占用的存储空间可能不一样,有的mtr产生的redo日志量很少,比如mtr_t1_1、mtr_t2_1就被放到同一个block中存储,有的mtr产生的redo日志量非常大,比如mtr_t1_2产生的redo日志甚至占用了3个block来存储。

1.6 redo日志的持久化

前面我们说过,和InnoDB的数据修改一样,redo log也是借助了日志缓冲区来调节磁盘和CPU的矛盾,提升了性能。

1.6.1 redo日志的持久化文件

我们知道数据页持久化后,是保存在ibdata1(没有开启innodb_file_per_table时的共享表空间文件)或者.ibd(开启 innodb_file_per_table时)文件中的。

InnoDB定义了一个组(log group)的概念,一个组内由多个大小完全相同的redo log file组成。组内redo log file的数量由变量innodb_log_files_group决定,默认值为2,即两个redo log file。

log group为redo日志组,其中有多个redo log file。虽然源码中已支持log group 的镜像功能,但是在ha_innobase.cc 文件中禁止了该功能。因此InnoDB存储引擎实际只有一个log group。

这个组是一个逻辑的概念,并没有真正的文件来表示这是一个组,但是可以通过变量innodb_log_group_home_dir来定义组的目录,redo log file都放在这个目录下,默认是在datadir下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mysql> show global variables like "innodb_log%";
+-----------------------------+----------+
| Variable_name | Value |
+-----------------------------+----------+
| innodb_log_buffer_size | 8388608 |
| innodb_log_compressed_pages | ON |
| innodb_log_file_size | 50331648 |
| innodb_log_files_in_group | 2 |
| innodb_log_group_home_dir | ./ |
+-----------------------------+----------+
[root@xuexi data]# ll /mydata/data/ib*
-rw-rw---- 1 mysql mysql 79691776 Mar 30 23:12 /mydata/data/ibdata1
-rw-rw---- 1 mysql mysql 50331648 Mar 30 23:12 /mydata/data/ib_logfile0
-rw-rw---- 1 mysql mysql 50331648 Mar 30 23:12 /mydata/data/ib_logfile1

可以看到在默认的数据目录下,有两个ib_logfile开头的文件,它们就是log group中的redo log file,而且它们的大小完全一致且等于变量innodb_log_file_size定义的值。

在innodb将log buffer中的redo log block刷到这些log file中时,会以追加写入的方式循环轮训写入。即先在ib_logfile0的尾部追加写,直到满了之后向ib_logfile1追加写。当ib_logfile1满了,则又重新向ib_logfile0进行覆盖写

由于是将log buffer中的日志刷到log file,所以在log file中记录日志的方式也是log block的方式。在每个组的第一个redo log file中,前2KB负责记录4个特定的部分,从2KB之后才开始记录log block。除了第一个redo log file中会记录这2KB的部分外,log group中的其他log file不会记录这2KB,但是却会腾出这2KB的空间

redo log file的大小对innodb的性能影响非常大,设置的太大,恢复的时候就会时间较长,设置的太小,就会导致在写redo log的时候循环切换redo log file。

1.6.1 redo日志的持久化策略

那么,log buffer里面的日志,什么时候刷到log file中呢?

  1. 事务提交时
  2. 当log buffer中有一半的内存空间已经被使用时
  3. log checkpoint 时

其中1. 事务提交时是InnoDB事务的持久性的保证,但就像我们在《【InnoDB详解三】锁和事务》一文中介绍的那样,为了性能,InnoDB允许牺牲一定的持久性,允许执行不同的redo日志持久化策略。

MySQL支持用户自定义在事务提交时是否将log buffer中的日志刷log file中。这种控制通过变量 innodb_flush_log_at_trx_commit 的值来决定。该变量有3种值:0、1、2,默认为1。

  • 当设置为0的时候,事务提交时不会将log buffer中日志写入到os buffer。那什么时候写入呢?由master thread通过每秒一次的频率来异步写入。该值为0时性能较好,但是会丢失掉master thread还没刷新进磁盘部分的数据。

    这里我想简单介绍一下master thread,这是InnoDB一个在后台运行的主线程,从名字就能看出这个线程相当的重要。它做的主要工作包括但不限于:刷新日志缓冲,合并插入缓冲,刷新脏页等。master thread大致分为每秒运行一次的操作和每10秒运行一次的操作。master thread中刷新数据,属于checkpoint的一种。

  • 当设置为1的时候,当然是最安全的,即每次commit都会强迫flush到log file,但是数据库性能会受一定影响。
  • 当设置为2的时候,每次提交都仅写入到操作系统的内核空间os buffer,然后由操作系统异步每秒调用一次fsync()将os buffer中的日志写入到log file。

1.6.3 redo日志持久化策略的性能

选择刷日志的策略会严重影响数据修改时的性能,特别是刷到磁盘的过程。下例就测试了innodb_flush_log_at_trx_commit分别为0、1、2时的差距。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#创建测试表
drop table if exists test_flush_log;
create table test_flush_log(id int,name char(50))engine=innodb;

#创建插入指定行数的记录到测试表中的存储过程
drop procedure if exists proc;
delimiter $$
create procedure proc(i int)
begin
declare s int default 1;
declare c char(50) default repeat('a',50);
while s<=i do
start transaction;
insert into test_flush_log values(null,c);
commit;
set s=s+1;
end while;
end$$
delimiter ;

当前环境下, innodb_flush_log_at_trx_commit 的值为1,即每次提交都刷日志到磁盘。测试此时插入10W条记录的时间。

1
2
mysql> call proc(100000);
Query OK, 0 rows affected (15.48 sec)

结果是15.48秒。

再测试值为2的时候,即每次提交都刷新到os buffer,但每秒才刷入磁盘中。

1
2
3
4
5
mysql> set @@global.innodb_flush_log_at_trx_commit=2;
mysql> truncate test_flush_log;

mysql> call proc(100000);
Query OK, 0 rows affected (3.41 sec)

结果插入时间大减,只需3.41秒。

最后测试值为0的时候,即每秒才刷到os buffer和磁盘。

1
2
3
4
5
mysql> set @@global.innodb_flush_log_at_trx_commit=0;
mysql> truncate test_flush_log;

mysql> call proc(100000);
Query OK, 0 rows affected (2.10 sec)

结果只有2.10秒。

最后可以发现,其实值为2和0的时候,它们的差距并不太大,但2却比0要安全的多。它们都是每秒从os buffer刷到磁盘,它们之间的时间差体现在log buffer刷到os buffer上。因为将log buffer中的日志刷新到os buffer只是内存数据的转移,并没有太大的开销,所以每次提交和每秒刷入差距并不大。可以测试插入更多的数据来比较,以下是插入100W行数据的情况。从结果可见,值为2和0的时候差距并不大,但值为1的性能却差太多。

尽管设置为0和2可以大幅度提升插入性能,但是在故障的时候可能会丢失1秒钟数据,这1秒钟很可能有大量的数据,从上面的测试结果看,100W条记录也只消耗了20多秒,1秒钟大约有4W-5W条数据,尽管上述插入的数据简单,但却说明了数据丢失的大量性。更好的插入数据的做法是将值设置为1,然后修改存储过程,将每次循环都提交修改为只提交一次,这样既能保证数据的一致性,也能提升性能,修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
drop procedure if exists proc;
delimiter $$
create procedure proc(i int)
begin
declare s int default 1;
declare c char(50) default repeat('a',50);
start transaction;
while s<=i DO
insert into test_flush_log values(null,c);
set s=s+1;
end while;
commit;
end$$
delimiter ;

测试值为1时的情况。

1
2
3
4
5
mysql> set @@global.innodb_flush_log_at_trx_commit=1;
mysql> truncate test_flush_log;

mysql> call proc(1000000);
Query OK, 0 rows affected (11.26 sec)

1.7 利用redo日志做系统恢复

1.7.1 LSN和Checkpoint

说到恢复,就不得不提LSN,我们在《【InnoDB详解一】体系架构和关键特性》一文中已经有过介绍,为方便计,我们粘贴过来。

对于InnoDB存储引擎而言,是通过LSN(Log Sequence Number)来标记版本的。LSN是一个一直递增的8字节整型数字,表示事务写入到redo日志的字节总量(注意LSN的含义是日志的字节总量)。每个页都有LSN字段,重做日志中也有LSN,Checkpoint也有LSN。

在每个数据页头部的LSN字段,记录当前页最后一次数据修改所对应的重做日志的LSN值,用于在recovery时对比重做日志LSN值,以决定是否对该页进行恢复数据。前面说的checkpoint也是有LSN号记录的,checkpoint的LSN表示已刷新到磁盘的最新的数据所对应的重做日志的LSN,LSN号串联起一个事务开始到恢复的过程。

比如redo日志的文件是600M,LSN的值已经为1G了,也就是LSN=1000000000。因为redo日志是循环使用的,所以我们可以知道LSN=1G=600M+400M,所以redo日志已经重复使用过一整遍后,目前最新的可写入点,在redo日志偏移量400M的位置。

我们执行了一个update语句,产生了一个事务t,这次数据的修改,假设产生了512个字节的日志量,那么LSN就会增加到1000000512,而事务t的修改使得A、B、C三个数据页成为了脏页,那么A、B、C三个数据页的LSN值就会更新为1000000512。如果这时,触发了checkpoint,刚刚好将事务t为止的修改刷新到磁盘,那么此时checkpoint LSN也是1000000512。

除了LSN之外,我们还要知道Checkpoint,同样在《【InnoDB详解一】体系架构和关键特性》一文中已经有过介绍。简单来说就是Checkpoint会定时将buffer里面的redo日志持久化到磁盘。

1.7.2 恢复过程

InnoDB存储引擎在启动时不管上次数据库运行时是否正常关闭,都会尝试进行恢复。因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志,如二进制日毒要快很多。与此同时,InnoDB存储引擎自身也对恢复进行了一定程度的优化,如顺序读取及并行应用重做日志,这样可以进一步地提高数据库恢复的速度。

由于checkpoint会记录已经刷新到磁盘页上的LSN,因此在恢复过程中仅需恢复checkpoint开始的日志部分。假设当数据库在checkpoint的LSN为10000时发生宕机,恢复操作仅恢复LSN10000~13000范围内的日志。

恢复的过程中,系统会根据redo日志的类型,调用相关的恢复函数进行恢复,而redo日志中的那些数据就可以被当成是调用这个函数所需的参数。从而使数据库恢复原样。

注意,调用相关的恢复函数的结果是幂等的,即便是insert一条行记录的redo日志,即便多次被恢复函数调用,其结果也是幂等的。

2 undo log

undo log有两个作用:

  1. 提供回滚
    • InnoDB在数据修改的时候,不仅记录了redo,还记录了相对应的undo,如果因为某些原因导致事务失败或回滚了,可以借助该undo进行回滚。
  2. 行版本控制(MVCC)
    • 有时候应用到行版本控制的时候,一个行各个版本的行数据之间的衔接需要依靠undo log。同时对于无用的老版本行记录,其删除逻辑也是通过undo log来实现的,后文我们详解。

undo log和redo log记录物理日志不一样,它是逻辑日志。因此只是将数据库逻辑地恢复到原来的样子。所有修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能大不相同。

这是因为在多用户并发系统中,可能会有数十、数百甚至数千个并发事务。数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条记录进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作

例如,用户执行了一个INSERT 10W条记录的事务,这个事务会导致分配一个新的段,即表空间会增大。在用户执行ROLLBACK时,会将插入的事务进行回滚,但是表空间的大小并不会因此而收缩。因此,当InnoDB存储引擎回滚时,它实际上做的是与先前相反的工作。

可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录

undo log是采用段(segment)的方式来记录的,每个undo操作在记录的时候占用一个undo log segment。

另外,undo log也会产生redo log,因为undo log也要实现持久性保护

2.1 undo log在MVCC中的使用

2.1.1 undo log连接各版本行记录

在Mysql中,MVCC是在通过unod log得到支持的,Innodb为每行记录都实现了一个隐藏字段:7字节的回滚指针(DB_ROLL_PTR)。

当事务对某行记录进行更新:

  1. 初始数据行

F1~F6是某行列的名字,1~6是其对应的数据。后面三个隐含字段分别对应该行的事务号和回滚指针,假如这条数据是刚INSERT的,可以认为ID为1,其他两个字段为空。

  1. 事务1更改该行的各字段的值

当事务1更改该行的值时,会进行如下操作:

  • 用排他锁锁定该行
  • 记录redo log
  • 把该行修改前的值Copy到undo log,即上图中下面的行
  • 修改当前行的值,填写事务编号,使回滚指针指向undo log中的修改前的行
  1. 事务2修改该行的值

与事务1相同,此时undo log,中有有两行记录,并且通过回滚指针连在一起。

因此,如果undo log一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸的时在Innodb中存在purge线程,它会查询那些比现在最老的活动事务还早的undo log,并删除它们,从而保证undo log文件不至于无限增长。

2.1.2 undo log控制无用版本记录删除

在详述undo log控制无用版本记录删除之前,我们需要了解一个前置知识点:purge线程

delete和update操作可能并不直接删除原有的数据。例如表t(a,b)如下的SQL语句∶

DELETE FROM t WHERE a=1;

表t上列a有聚集索引,列b上有辅助索引。

对于上述的delete操作,在MVCC的章节介绍已经知道仅是将主键列等于1的记录delete flag设置为1,记录并没有被删除,即记录还是存在于B+树中。其次,对辅助索引上a等于1,b等于1的记录同样没有做任何处理,甚至没有产生undo log。而真正删除这行记录的操作其实被”延时”了,最终在 purge操作中完成。

purge用于最终完成delete和 update操作。这样设计是因为InnoDB存储引擎支持MVCC,所以记录不能在事务提交时立即进行处理。这时其他事物可能正在引用这行,故InnoDB存储引擎需要保存记录之前的版本。而是否可以删除该条记录通过purge来进行判断。若该行记录已不被任何其他事务引用,那么就可以进行真正的delete操作。

可见,purge操作是清理之前的delete和update操作,将上述操作”最终”完成。而实际执行的操作为delete操作,清理之前行记录的版本。

为了节省存储空间,InnoDB存储引擎的undo log设计是这样的:

  1. 一个页上允许多个事务的undo log存在。虽然这不代表事务在全局过程中提交的顺序,但是后面的事务产生的undo log总在最后。
  2. 此外,ImnoDB存储引擎还有一个history列表,它根据事务提交的顺序,将undo log进行链接。

在图7-17的例子中,history list表示按照事务提交的顺序将undo log进行组织。在InnoDB存储引擎的设计中,先提交的事务总在尾端。

undo page存放了undo log,由于可以重用,因此一个undo page中可能存放了多个不同事务的undo log。tx5的灰色阴影表示该 undo log还被其他事务引用。

执行 purge的过程中,InnoDB存储引擎首先从history list中找到第一个需要被清理的记录,这里为trx1,清理之后InnoDB存储引擎会在trx1的undo log所在的页中继续寻找是否存在可以被清理的记录,这里会找到事务trx3,接着找到trx5,但是发现trx5被其他事务所引用而不能清理,故去再次去history list中查找,发现这时最尾端的记录为trx2,接着找到trx2所在的页,然后依次再把事务trx6、trx4的记录进行清理。

InnoDB存储引擎这种先从history list中找undo log,然后再从undo page中找undo log的设计模式是为了避免大量的随机读取操作,从而提高 purge的效率

2.2 undo log的存储方式

Innodb存储引擎对undo的管理采用段(segment)的方式。rollback segment称为回滚段,每个回滚段中有1024个undo log segment。

在以前老版本,只支持1个rollback segment,这样就只能记录1024个undo log segment。后来MySQL5.5可以支持128个rollback segment,即支持128*1024个undo操作,还可以通过变量 innodb_undo_logs (5.6版本以前该变量是 innodb_rollback_segments )自定义多少个rollback segment,默认值为128。

undo log默认存放在共享表空间中。

1
2
[root@xuexi data]# ll /mydata/data/ibda*
-rw-rw---- 1 mysql mysql 79691776 Mar 31 01:42 /mydata/data/ibdata1

同样的,如果开启了 innodb_file_per_table ,将放在每个表的.ibd文件中。

在MySQL5.6中,undo的存放位置还可以通过变量 innodb_undo_directory 来自定义存放目录,默认值为”.”表示datadir。

默认rollback segment全部写在一个文件中,但可以通过设置变量 innodb_undo_tablespaces 平均分配到多少个文件中。该变量默认值为0,即全部写入一个表空间文件。该变量为静态变量,只能在数据库示例停止状态下修改,如写入配置文件或启动时带上对应参数。但是innodb存储引擎在启动过程中提示,不建议修改为非0的值,如下:

1
2
3
4
2017-03-31 13:16:00 7f665bfab720 InnoDB: Expected to open 3 undo tablespaces but was able
2017-03-31 13:16:00 7f665bfab720 InnoDB: to find only 0 undo tablespaces.
2017-03-31 13:16:00 7f665bfab720 InnoDB: Set the innodb_undo_tablespaces parameter to the
2017-03-31 13:16:00 7f665bfab720 InnoDB: correct value and retry. Suggested value is 0

2.3 undo log的数据结构

InnoDB采用回滚段的方式来维护undo log是为了保证事务并发操作时,在写各自的undo log时不产生冲突。回滚段实际上是一种 Undo 文件组织方式,每个回滚段又有多个undo log slot。具体的文件组织方式如下图所示:

当事务开启时,会给它指定使用哪个rollback segment,然后在真正执行操作时,分配具体的slot,通常会有两种slot:

  • insert_undo:只用于事务内的insert语句
    • insert undo log是指在insert操作中产生的undo log。因为insert操作的记录,只对事务本身可见,对其他事务不可见(这是事务隔离性的要求),故该undo log不会被其他事务引用,不用进行purge操作,可以在事务提交后直接删除(事务提交后就没有回滚需求了)。
  • update_undo: 只用于事务内的update语句
    • update undo log记录的是对delete和 update操作产生的undo log。该undo log可能需要提供MVCC机制,因此不能在事务提交时就进行删除。提交时放入undo log链表,等待 purge线程进行最后的删除。

通常如果事务内只包含一种操作类型,则只使用一个slot。但也有例外,例如insert操作,如果insert的记录在page上已经存在了,但是是无效的,那么就可以直接通过更新这条无效记录的方式来实现插入,这时候使用的是update_undo。

2.3.1 insert_undo的数据结构

insert undo log的数据结构如下图:

其中*表示对存储的字段进行了压缩。

  1. insete undo log开始的前两个字节next 记录的是下一个undo log的位置,通过该next的字节可以知道一个undo log所占的空间字节数。
  2. 类似地,尾部的两个字节记录的是undo log的开始位置。
  3. type_cmpl占用一个字节,记录的是undo的类型,对于insert undo log,该值总是为11。
  4. undo_no记录事务的ID,table_id记录undo log所对应的表对象。这两个值都是在压缩后保存的。
  5. 接着的部分记录了所有主键的列和值。在进行 rollback操作时,根据这些值可以定位到具体的记录,然后进行删除即可。

2.3.2 update_undo的数据结构

update undo log的结构如图所示。

update undo log相对于之前介绍的insert undo log,记录的内容更多,所需点用的空间也更大。

  1. next、start、undo_no、table_id与之前介绍的insert undo log部分相同。
  2. 这里的 type_cmpl,由于update undo log本身还有分类,故其可能的值如下∶
    • 12 TRXUNDO_UPD_EXIST_REC更新 non-delete-mark的记录
    • 13 TRX_UNDO_UPD_DEL_REC将delete的记录标记为not delete
    • 14 TRX_UNDO_DEL_MARK_REC将记录标记为delete
  3. 接着的部分记录 update_vector信息,update_vector表示update操作导致发生改变的列。每个修改的列信息都要记录的undo log中。

对于不同的undo log类型,可能还需要记录对索引列所做的修改。

2.4 相关参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mysql> show global variables like '%undo%';
+--------------------------+------------+
| Variable_name | Value |
+--------------------------+------------+
| innodb_max_undo_log_size | 1073741824 |
| innodb_undo_directory | ./ |
| innodb_undo_log_truncate | OFF |
| innodb_undo_logs | 128 |
| innodb_undo_tablespaces | 3 |
+--------------------------+------------+

mysql> show global variables like '%truncate%';
+--------------------------------------+-------+
| Variable_name | Value |
+--------------------------------------+-------+
| innodb_purge_rseg_truncate_frequency | 128 |
| innodb_undo_log_truncate | OFF |
+--------------------------------------+-------+
  • innodb_undo_directory
    • 变量 innodb_undo_directory 来自定义存放目录,默认值为”.”表示datadir。
  • innodb_max_undo_log_size
    • 控制最大undo tablespace文件的大小,当启动了innodb_undo_log_truncate 时,undo tablespace 超过innodb_max_undo_log_size 阀值时才会去尝试truncate。该值默认大小为1G,truncate后的大小默认为10M。
  • innodb_undo_tablespaces
    • 设置undo独立表空间个数,范围为0-128, 默认为0,0表示表示不开启独立undo表空间,且 undo日志存储在ibdata文件中。该参数只能在最开始初始化MySQL实例的时候指定,如果实例已创建,这个参数是不能变动的,如果在数据库配置文件 .cnf 中指定innodb_undo_tablespaces 的个数大于实例创建时的指定个数,则会启动失败,提示该参数设置有误。
    • 设置该参数后,会在路径inodb_undo_directory看到undo为前缀的文件,该文件就代表rollback segment文件。
  • innodb_undo_log_truncate
    • InnoDB的purge线程,根据innodb_undo_log_truncate设置开启或关闭、innodb_max_undo_log_size的参数值,以及truncate的频率来进行空间回收和undo file的重新初始化。
    • 该参数生效的前提是,已设置独立表空间且独立表空间个数大于等于2个。
    • purge线程在truncate undo log file的过程中,需要检查该文件上是否还有活动事务,如果没有,需要把该undo log file标记为不可分配,这个时候,undo log 都会记录到其他文件上,所以至少需要2个独立表空间文件,才能进行truncate 操作。
    • 标注不可分配后,会创建一个独立的文件undo__trunc.log,记录现在正在truncate 某个undo log文件,然后开始初始化undo log file到10M,操作结束后,删除表示truncate动作的 undo__trunc.log 文件,这个文件保证了即使在truncate过程中发生了故障重启数据库服务,重启后,服务发现这个文件,也会继续完成truncate操作,删除文件结束后,标识该undo log file可分配。
  • innodb_purge_rseg_truncate_frequency
    • 用于控制purge回滚段的频度,默认为128。假设设置为n,则说明,当Innodb Purge操作的协调线程 purge事务128次时,就会触发一次History purge,检查当前的undo log 表空间状态是否会触发truncate。
0%