InnoDB之redo log格式

118 阅读8分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」


1. redo log作用

MySQL有一个组件叫Buffer Pool,它通过内存来缓存磁盘里的数据页,来提升数据读写性能。所有数据的读写首先经过Buffer Pool,并不直接和磁盘打交道。加速读不会有问题,但是加速写可能会存在数据不一致的问题。试想这样一个场景:如果只在Buffer Pool中修改了页面,事务已经提交了,但是Buffer Pool里的脏页还没来得及刷新到磁盘,此时系统崩溃,内存里的数据失效,就会导致刚刚提交的事务数据丢失,这一点是不可接受的,它违背了事务的持久性。 最简单的解决方式,就是事务提交时把在Buffer Pool修改的所有数据页都同步刷新到磁盘,但是这么做开销太大了:

  • 页是刷盘的基本单位,大量事务仅修改了几个字节的数据,为此却要把16KB完整的页刷盘。
  • 有的事务会修改大量数据页,而且这些数据页往往是随机分布的,磁盘随机写性能太差,尤其是传统的机械硬盘。

为了解决这些问题,MySQL引入了WAL(Write Ahead Logging)机制,通过先写日志再写磁盘的方式,在保证了事务持久性的同时,避免了数据页频繁刷盘和随机写的问题。 我们的目的是事务提交后,数据可以永久生效不丢失。除了粗暴的直接把内存中修改的数据页刷新到磁盘,还有一种方式,我们可以先记一个log,把内存中哪些数据页做了哪些修改给记录下来,事务提交时只需要保证log刷新到磁盘即可,即使发生故障,MySQL重启时也可以根据log将数据给恢复回来,这就是redo log。

redo log也是要刷盘的,内存中的脏页也是刷盘,同样是刷盘,采用redo log有哪些优势呢?

  • redo log占用空间小,相比一个完整的页,它只需要记录页的哪个位置修改了哪些数据。
  • redo log是顺序写的,相比于随机写,性能要高得多。

2. redo log格式

针对不同的修改场景,InnoDB定义了几十种redo log类型,绝大部分类型的redo log都具备下述结构: 在这里插入图片描述

属性说明
Typeredo log类型,有几十种
Space ID页所属的表空间ID
Page Number页号
Dataredo log具体内容

redo log的类型有几十种,包含极其简单的物理日志和复杂的逻辑日志。 先来看极其简单的物理日志,这里列举几个:

名称Type十进制说明
MLOG_1BYTE1在页面的某个偏移量处写入1字节数据
MLOG_2BYTE2在页面的某个偏移量处写入2字节数据
MLOG_4BYTE4在页面的某个偏移量处写入4字节数据
MLOG_8BYTE8在页面的某个偏移量处写入8字节数据
MLOG_WRITE_STRlNG30在页面的某个偏移量处写入一段字节序列

这种极其简单的物理日志应用场景举例:InnoDB存储引擎下,当表没有定义主键和唯一非空索引时,InnoDB会生成隐藏列row_id并自动赋值,row_id是全局递增的,它存储在系统表空间页号为7的一个名为Max Row ID的属性中,MySQL启动时会将该页加载到内存,并将该属性赋值给全局变量。当我们向有row_id隐藏列的表插入记录时,InnoDB会递增该变量并赋值给row_id,当该变量递增到256的倍数时,InnoDB会将该值写入到Buffer Pool对应的页里,这个写入过程也是发生在内存里的,所以也必须要写一条redo log记录下来。Max Row ID占用8个字节,对应的redo log类型是MLOG_8BYTE,Data部分包含偏移量和最新的row_id值。

要求256的倍数是为了避免频繁刷盘,MySQL启动时从磁盘里加载完Max Row ID会自动再加上256,避免row_id分配冲突。

复杂的redo log类型,以insert语句为例,光是向表中插入一条记录,修改的数据页就不是一般的多啊,包括:

  • 聚簇索引和所有二级索引都要插入记录。
  • 悲观插入时导致的页分裂,需要创建新页,迁移用户记录,内节点页也需要修改。
  • 可能更新Page Directory。
  • 可能更新Page Header、File Header各种统计信息。
  • 记录单向链表排序,需要修改上一条记录的next_record指针。
  • 等等... ...

综上所述,一条insert语句涉及到大量的数据修改,最简单的解决方案就是把每一处修改都记一条redo log,这种方案会导致写入大量的redo log。还有一种方案是记录所有被修改页的起始地址和终止地址,redo log的数量少了,但是会浪费大量空间。 针对这种情况,InnoDB设计了一些复杂的包含逻辑语义的redo log类型:

名称Type十进制说明
MLOG_COMP_REC_INSERT38插入一条使用紧凑行格式的记录
MLOG_COMP_REC_DELETE42删除一条使用紧凑行格式的记录
MLOG_COMP_PAGE_CREATE58创建一个使用紧凑行格式记录的页面
MLOG_COMP_LIST_START_DELETE44从给定记录开始,删除页面中所有紧凑行格式的记录
MLOG_COMP_LIST_ END_DELETE43删除页面中所有紧凑行格式的记录,直到给定记录结束

根据范围删除记录时,使用后两种类型可以减少redo log的数量。

这些redo log就比较复杂了,既有物理层面的意思,表示要对哪个页进行修改;也有逻辑层面的意思,崩溃恢复时,并不能根据redo log直接恢复页面,只是保留了恢复数据必要的参数,需要进一步调用系统函数来恢复页面。 以MLOG_COMP_REC_INSERT为例,日志格式如下所示:

属性说明
Typeredo log类型
Space ID页所属的表空间ID
Page Number页号
n_fields记录的字段数
n_uniques决定记录唯一的字段数
field 1_len字段1的长度
field 2_len字段2的长度
......
field n_len字段n的长度
offset前一条记录的地址
end_seg_len计算当前记录占用的存储空间大小
记录头信息
extra_size记录额外信息占用的空间
mismatch index为了节省redo log空间设立的属性
记录数据

n_uniques:确保记录唯一的字段数,对于聚簇索引,它是主键列数;对于二级索引,它是二级索引列数+主键列数。

可以看出MLOG_COMP_REC_INSERT日志并没有把哪些页的哪些地方做了哪些修改给记录下来,而是保留了在当前页插入一条记录的所有必要数据,崩溃恢复时拿这些必要的数据调用系统函数,页面才能恢复。

3. Mini Transaction

我们已经知道,光是一条insert语句,就会修改若干个页面,对应的需要写入若干条redo log,这些redo log被InnoDB强行划成若干个不可分割的组,一组redo log是一个原子操作,要么不恢复,要么全部恢复。比如:

  • 更新Max Row ID是不可分割的。
  • 向聚簇索引页面插入一条记录是不可分割的。
  • 向二级索引页面插入一条记录是不可分割的。
  • 等等......

为什么需要分组?

redo log只写部分是很危险的,以插入一条记录为例:悲观插入时,由于页面无法再容纳一条新的记录,此时会创建一个新的页面,然后将部分数据迁移到新页面,将新页面加入到链表中,同时内节点还要添加一条目录项记录指向新的页面。这中间任何一步被打断,都会导致B+树结构遭到破坏,最终形成一棵不正确的B+树,这是InnoDB所不能忍受的,所以这整个过程必须是原子的,不可分割,要么全部执行,要么都不执行。

InnoDB把这种对底层页面进行一次原子访问的操作称作一个Mini Transaction,缩写MTR。一个事务可以包含多条更新语句,一条语句可以包含若干个MTR,一个MTR包含若干条redo log,它们的关系是: 在这里插入图片描述

redo log如何分组?

InnoDB会在一组redo log后插入一条特殊的redo log MLOG_MULTI_REC_END,用来区分一组日志,这条特殊的redo log只有一个Type字段,对应的十进制是31。MySQL崩溃恢复时,必须读取到MLOG_MULTI_REC_END才会认为是一组完整的redo log,否则丢弃这些log,不予恢复。

redo log分组优化

有的redo log一条就是一组,这个时候为了区分一组redo log再插入一条redo log就不太划算了,有点浪费空间。InnoDB做了一点小优化,在Type字段上做了一点小文章,虽然redo log类型很多,但最多不会超过127,所以7个比特就够用了,InnoDB将Type字段的第1个比特位拿来标记该条redo log是否单独成组。