本文正在参加「金石计划 . 瓜分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都具备下述结构:
| 属性 | 说明 |
|---|---|
| Type | redo log类型,有几十种 |
| Space ID | 页所属的表空间ID |
| Page Number | 页号 |
| Data | redo log具体内容 |
redo log的类型有几十种,包含极其简单的物理日志和复杂的逻辑日志。 先来看极其简单的物理日志,这里列举几个:
| 名称 | Type十进制 | 说明 |
|---|---|---|
| MLOG_1BYTE | 1 | 在页面的某个偏移量处写入1字节数据 |
| MLOG_2BYTE | 2 | 在页面的某个偏移量处写入2字节数据 |
| MLOG_4BYTE | 4 | 在页面的某个偏移量处写入4字节数据 |
| MLOG_8BYTE | 8 | 在页面的某个偏移量处写入8字节数据 |
| MLOG_WRITE_STRlNG | 30 | 在页面的某个偏移量处写入一段字节序列 |
这种极其简单的物理日志应用场景举例: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_INSERT | 38 | 插入一条使用紧凑行格式的记录 |
| MLOG_COMP_REC_DELETE | 42 | 删除一条使用紧凑行格式的记录 |
| MLOG_COMP_PAGE_CREATE | 58 | 创建一个使用紧凑行格式记录的页面 |
| MLOG_COMP_LIST_START_DELETE | 44 | 从给定记录开始,删除页面中所有紧凑行格式的记录 |
| MLOG_COMP_LIST_ END_DELETE | 43 | 删除页面中所有紧凑行格式的记录,直到给定记录结束 |
根据范围删除记录时,使用后两种类型可以减少redo log的数量。
这些redo log就比较复杂了,既有物理层面的意思,表示要对哪个页进行修改;也有逻辑层面的意思,崩溃恢复时,并不能根据redo log直接恢复页面,只是保留了恢复数据必要的参数,需要进一步调用系统函数来恢复页面。
以MLOG_COMP_REC_INSERT为例,日志格式如下所示:
| 属性 | 说明 |
|---|---|
| Type | redo 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是否单独成组。