MySQL 日志篇:Redo Log Buffer

Redo Log

MySQL 的 InnoDB 存储引擎使用 Redo Log 记录系统中每个事务的修改,从而在系统崩溃重启时能够把系统恢复到崩溃时的状态。因此,Redo Log 用于保证事务的持久性,即一旦某个事务成功提交,即使系统发生了崩溃,那么在系统重启后也能看到这个事务的修改。

Redo Log 始于 mini-transaction (mtr),止于磁盘文件:

  • 一个事务在修改数据页时会开启一个 mtr,对数据页的修改会以 Redo Record 的形式暂存在该 mtr 中;
  • 当提交 mtr 时,会把其中的 Redo Record 复制到 Log Buffer 中形成 Redo Log;
  • log writer 线程负责把 Log Buffer 中的 Redo Log 写入操作系统内核的 Page Cache;
  • log flusher 线程负责把 Page Cache 中的 Redo Log 写入磁盘文件 (Redo File)。

Log Sequence Number (LSN) 用于标记 Redo Log:

  • last_checkpoint_lsn 标识最近一次检查点的位置。在 last_checkpoint_lsn 前的 Redo Log 对应的脏页已被写入磁盘,因此在 last_checkpoint_lsn 前的 Redo Log 可以被回收。last_checkpoint_lsn 可以看作 Redo Log 的开头;
  • flushed_to_disk_lsn 标识已落盘的 Redo Log 的位置。在 flushed_to_disk_lsn 前的 Redo Log 已被写入磁盘,这些 Redo Log 不会因为系统掉电而丢失;
  • write_lsn 标识已写入 Page Cache 的 Redo Log 的位置。在 write_lsn 前的 Redo Log 已被写入操作系统缓存,这些 Redo Log 不会因为数据库意外退出而丢失,操作系统会继续把这些 Redo Log 写入磁盘;
  • current_lsn 标识最新的 Redo Log 的位置。已有的 Redo Log 的 LSN 不会超过 current_lsncurrent_lsn 可以看作 Redo Log 的末尾。

由此可见,完整的 Redo Log 包括三部分:

  • last_checkpoint_lsn ~ flushed_to_disk_lsn:这些 Redo Log 位于磁盘中,除非磁盘损坏,否则不会丢失;
  • write_lsn ~ flushed_to_disk_lsn:这些 Redo Log 位于 Page Cache 中,除非系统掉电,否则不会丢失;
  • current_lsn ~ write_lsn:这些 Redo Log 位于用户空间缓冲区中,除非数据库意外退出,否则不会丢失。

上述 LSN 的关系为 (左右滑动查看完整公式):
l a s t _ c h e c k p o i n t _ l s n ≤ f l u s h e d _ t o _ d i s k _ l s n ≤ w r i t e _ l s n ≤ c u r r e n t _ l s n last\_checkpoint\_lsn \le flushed\_to\_disk\_lsn \le write\_lsn \le current\_lsn last_checkpoint_lsnflushed_to_disk_lsnwrite_lsncurrent_lsn

Log Buffer

Log Buffer 存放用户空间缓冲区的 Redo Log。Log Buffer 以 Redo Block 为单位组织 Redo Record。一个 Redo Block 的大小固定为 512 字节,包括 12 字节的头部和 4 字节的尾部,剩下的 496 字节留给 Redo Record。在为 Redo Log 分配 LSN 时会纳入 Redo Record 以及每个 Redo Block 的头部和尾部。由于 mtr 只记录 Redo Record,不记录 Redo Block 的头部和尾部,因此 Sequence Number (SN) 单独用于标记 Redo Record。由于每个 Redo Block 都是固定大小,因此 SN 和 LSN 的转换十分简单,通过 log_translate_sn_to_lsn() 和 log_translate_lsn_to_sn() 函数可以实现 SN 和 LSN 间的互相转换。

Log Buffer 的逻辑起点和逻辑终点分别是 write_lsncurrent_lsn。在 write_lsn 前的 Redo Log 已写入 Page Cache 或磁盘文件,current_lsn 是最新的 Redo Log 的位置。实际实现中维护的是 log.sn,它标识最新的 SN,在 log_get_lsn() 函数中通过调用 log_translate_sn_to_lsn() 函数把 log.sn 转换成 current_lsn

用户线程提交 mtr 时写 Log Buffer 的流程如下:

  1. 在 Log Buffer 中申请一段连续的空闲空间,这段空间的大小与该 mtr 本次要写的 Redo Log 长度相等;
  2. 把 mtr 中的 Redo Record 复制到申请的空闲空间中,复制时留出每个 Redo Block 的头部和尾部,log writer 线程负责填入每个 Redo Block 的头部和尾部 1 \textcolor{red}{^1} 1

其中,第 1 步必须串行执行,第 2 步可以并行执行。在实现上,Log Buffer 是一个循环队列,采用无锁化设计允许多个 mtr 同时写 Log Buffer,提高了写 Redo Log 的速率。

1 \textcolor{red}{1} 1:只有一个例外,每个 Redo Block 头部中的 first_rec_group 字段由提交 mtr 的用户线程负责填入。

申请空闲空间

提交 mtr 的用户线程先调用 prepare_write() 函数算出全部 Redo Record 的长度,再调用 log_buffer_reserve() 函数向 Log Buffer 申请一段连续的空闲空间:

  1. 调用 log_buffer_s_lock_enter_reserve() 函数获得当前的 SN 值作为起始 SN,加上 Redo Record 的长度后得到终止 SN;
  2. 调用 log_translate_sn_to_lsn() 函数基于起始 SN 和终止 SN 计算得到起始 LSN 和终止 LSN,此时纳入了 Redo Block 的头部和尾部,本次 Redo Log 的长度从起始 LSN 到终止 LSN;
  3. 若 Redo Log 的长度超出 Log Buffer 的空闲空间大小 2 \textcolor{red}{^2} 2,则调用 log_wait_for_space_after_reserving() 函数使用户线程等待 log writer 线程把 Log Buffer 中较老的 Redo Log 写入 Page Cache;
  4. 若 Redo File 中的空闲空间不足,则调用 log_writer_wait_on_checkpoint() 函数使 log writer 线程等待 page cleaner 线程刷脏页以及等待 log checkpoint 线程执行检查点来清除 Redo File 中较老的 Redo Log;
  5. 申请空闲空间成功。

2 \textcolor{red}{2} 2:由于 write_lsn 是 Log Buffer 的逻辑起点,因此只需比较终止 LSN 是否大于 write_lsn 加上 Log Buffer 的容量 (实际会再减去两块 Redo Block 的大小,参见 log_update_buf_limit() 函数)。

复制 Redo Record

mtr 用一个链表 (mtr.m_impl->m_log.m_list) 暂存 Redo Record。该链表由若干个 Block 组成,每个 Block 存储若干条 Redo Record,只有当前一个 Block 放满后才会动态创建后一个 Block。一个 mtr 中所有的 Redo Record 组成一组,这组 Redo Record 在崩溃恢复时要么全部重做,要么全部不做 (即,原子性)。Redo Block 头部的 first_rec_group 字段用于标识该 Redo Block 中第一组的起始位置,该字段由用户线程在提交 mtr 时填入 (log writer 线程并不知道哪些 Redo Record 属于同一组,无法填入 first_rec_group 字段)。

申请空闲空间成功后,用户线程调用 log_buffer_write() 函数把 mtr 中的 Log Record 复制到 Log Buffer 中:

  1. 从 mtr 中的第一个 Block 开始,依次把 Block 中的 Redo Record 复制到 Log Buffer 中,复制时留出每个 Redo Block 的头部和尾部;
  2. 处理完所有 Block 后,若 mtr 中的第一条 Redo Record 和最后一条 Redo Record 不在同一个 Redo Block 中,则调用 log_buffer_set_first_record_group() 函数填入最后一条 Redo Record 所在的 Redo Block 头部的 first_rec_group 字段。字段值为终止 LSN 对 Redo Block 大小取模,即下一组在该 Redo Block 中的起始位置。

Recent Written Buffer

用户线程把 mtr 中的 Redo Record 复制到 Log Buffer 中后,由 log writter 线程负责把 Log Buffer 中的 Redo Log 写入 Page Cache。由于多个用户线程可以并行地写 Log Buffer,因此存在一些用户线程已经复制完 Redo Record,另一些用户线程还没复制完 Redo Record,这会使 Log Buffer 中出现“空洞”。下图中 m t r 1 mtr_1 mtr1 m t r 2 mtr_2 mtr2 m t r 4 mtr_4 mtr4 都已经复制完 Redo Record,但 m t r 3 mtr_3 mtr3 还没复制完 Redo Record, m t r 3 mtr_3 mtr3 对应的位置就是 Log Buffer 中的“空洞”。因此,还需要追踪 Log Buffer 中从 write_lsn 到哪个位置的 Redo Log 是连续的,从而避免 log writer 线程越过“空洞” (即,漏写“空洞”对应的 Redo Log)。

Recent Written Buffer 负责追踪用户线程写 Log Buffer 的进度。Recent Written Buffer 的逻辑起点是 buf_ready_for_write_lsn,在 buf_ready_for_write_lsn 前的 Redo Log 已经存在 Log Buffer 中。可以看出 (左右滑动查看完整公式):

w r i t e _ l s n ≤ b u f _ r e a d y _ f o r _ w r i t e _ l s n ≤ c u r r e n t _ l s n write\_lsn \le buf\_ready\_for\_write\_lsn \le current\_lsn write_lsnbuf_ready_for_write_lsncurrent_lsn

每把一个 Block 中的 Redo Record 复制到 Log Buffer 后,就在 Recent Written Buffer 中插入一条链接 (Link)。以上图中的 m t r 1 mtr_1 mtr1 为例, m t r 1 mtr_1 mtr1 使用了三个 Block 暂存 Redo Record。对于每个 Block,先调用 log_buffer_write() 函数把该 Block 中的 Redo Record 复制到 Log Buffer 中,再调用 log_buffer_write_completed() 函数在 Recent Written Buffer 中插入一条链接,链接的起点和终点分别是该 Block 的起始 LSN 和终止 LSN。若 buf_ready_for_write_lsn 和一条链接的起点重合,则将 buf_ready_for_write_lsn 向前移动到该链接的终点。

具体地,调用 log_buffer_write_completed() 函数在 Recent Written Buffer 中插入一条链接的流程如下:

  1. 检查 Recent Written Buffer 中是否有足够的空闲空间 3 \textcolor{red}{^3} 3。若空闲空间不足,则等待其他用户线程复制完 Redo Record 并且等待 buf_ready_for_write_lsn 向前移动;
  2. 调用 log.recent_written.add_link_advance_tail() 函数根据起始 LSN 和终止 LSN 向 Recent Written Buffer 中插入一条链接。首先令起始 LSN 对 Recent Written Buffer 的容量取模,得到起始 LSN 在 Recent Written Buffer 中的位置,然后在该位置填入终止 LSN;
  3. 用户线程向前移动 buf_ready_for_write_lsn,直到遇到“空洞”或终止 LSN 为止。

由此可知:

  • mtr 每把一个 Block 的 Redo Record 复制到 Log Buffer 中后,就在 Recent Written Buffer 中加入一条链接;
  • 只要 Recent Written Buffer 中出现链接,就意味着这条链接对应的 Redo Log 已经存在 Log Buffer 中;
  • log writer 线程和用户线程都能向前移动 buf_ready_for_write_lsn。log writer 线程每次把从 write_lsnbuf_ready_for_write_lsn 的 Redo Log 写入 Page Cache,并把 write_lsn 设为 buf_ready_for_write_lsn

3 \textcolor{red}{3} 3:由于 buf_ready_for_write_lsn 是 Recent Written Buffer 的逻辑起点,因此只需比较 buf_ready_for_write_lsn 加上 Recent Written Buffer 的容量是否大于起始 LSN (参见 log.recent_written.has_space() 函数)。

Recent Closed Buffer

用户线程最后还要把脏页加入到 Buffer Pool 的 flush list 中。mtr 使用一个链表 (mtr.m_impl->m_memo.m_list) 记录本次修改到的数据页,这些数据页要么是第一次变脏 (由本次 mtr 修改),要么已经在 flush list 中 (由较早 mtr 修改后,还没来得及落盘)。

Recent Closed Buffer 负责追踪 mtr 中的脏页被加入到 flush list 中的进度。Recent Closed Buffer 的逻辑起点是 buf_dirty_pages_added_up_to_lsn,在 buf_dirty_pages_added_up_to_lsn 前的 Redo Log 对应的脏页已经存在 flush list 中。与 buf_ready_for_write_lsn 一样,buf_dirty_pages_added_up_to_lsn 也是通过链接向前移动。

在处理完所有 mtr 中的 Block 之后,用户线程一次性标记脏页并把所有新的脏页加入到 flush list 中:

  1. 调用 log_wait_for_space_in_log_recent_closed() 函数检查 Recent Closed Buffer 中是否有足够的空闲空间 4 \textcolor{red}{^4} 4。若空闲空间不足,则等待其他用户线程把脏页加入到 flush list 中并且等待 buf_dirty_pages_added_up_to_lsn 向前移动;
  2. 调用 add_dirty_page_to_flush_list() 函数标记脏页并把新的脏页加入到 flush list 中。对于每一个数据页,调用 buf_flush_note_modification() 函数把数据页的 newest modification 置为终止 LSN。对于第一次变脏的数据页,调用 buf_flush_insert_into_flush_list() 函数把数据页的 oldest modification 置为起始 LSN,并把该数据页加入到 flush list 中;
  3. 调用 log_buffer_close() 函数根据起始 LSN 和终止 LSN 向 Recent Closed Buffer 中插入一条链接。首先令起始 LSN 对 Recent Closed Buffer 的容量取模,得到起始 LSN 在 Recent Closed Buffer 中的位置,然后在该位置填入终止 LSN;
  4. 用户线程向前移动 buf_dirty_pages_added_up_to_lsn,直到遇到“空洞”或终止 LSN 为止。

4 \textcolor{red}{4} 4:由于 buf_dirty_pages_added_up_to_lsn 是 Recent Closed Buffer 的逻辑起点,因此只需比较 buf_dirty_pages_added_up_to_lsn 加上 Recent Closed Buffer 的容量是否大于起始 LSN (参见 log.recent_closed.has_space() 函数)。

Last Checkpoint LSN

为使 Log File 留有足够的空闲空间,需要及时把脏页刷盘并向前移动 last_checkpoint_lsn。更新 last_checkpoint_lsn 需借助 flush list 和 Recent Closed Buffer。

已知 flush list 存放着等待落盘的脏页,并且每个脏页的 oldest modification 记录着该数据页变脏时的 LSN,那么能否把 last_checkpoint_lsn 置为 flush list 中最小的 oldest modification?答案是不能。以下图为例, m t r 4 mtr_4 mtr4 已经把脏页加入到 flush list 中, m t r 3 mtr_3 mtr3 还在复制 Redo Record 到 Log Buffer 中,其脏页还没加入到 flush list 中, m t r 1 mtr_1 mtr1 m t r 2 mtr_2 mtr2 的脏页已经刷盘。此时 flush list 中最小的 oldest modification m t r 4 mtr_4 mtr4 的起始 LSN,如果把 last_checkpoint_lsn 置为 m t r 4 mtr_4 mtr4 的起始 LSN,那么意味着 m t r 3 mtr_3 mtr3 的脏页也已落盘,但实际上 m t r 3 mtr_3 mtr3 的脏页还未落盘!

当 Recent Closed Buffer 中存在“空洞”时,flush list 并未包含所有脏页,需借助 Recent Closed Buffer 追踪那些还未加入 flush list 的脏页。buf_dirty_pages_added_up_to_lsn 之前的 Redo Log 对应的脏页已经加入到 flush list 中;buf_dirty_pages_added_up_to_lsn 之后可能存在“空洞”,“空洞”对应的脏页还未加入到 flush list 中;“空洞”之后可能存在链接,链接对应的脏页已经加入到 flush list 中。因此,只需取 flush list 中最小的 oldest modificationbuf_dirty_pages_added_up_to_lsn 二者中的较小值,就能确保该值之前的 Redo Log 对应的脏页已经落盘。

实际的计算方法更复杂,log_compute_available_for_checkpoint_lsn() 函数负责计算 available_for_checkpoint_lsn 用于标识下一次执行检查点的位置。available_for_checkpoint_lsn 被设为 flush list 中最小的 oldest modification 减去 Recent Closed Buffer 的容量 5 \textcolor{red}{^5} 5buf_dirty_pages_added_up_to_lsn 以及 flushed_to_disk_lsn 三者中的最小值。log checkpoint 线程每次执行检查点时把 last_checkpoint_lsn 推进到 available_for_checkpoint_lsn

5 \textcolor{red}{5} 5:为了兼顾性能,实际并不会遍历 flush list 中的所有脏页,得到的只是最小的 oldest modification 的近似值 (参见 buf_pool_get_oldest_modification_lwm() 函数)。

欢迎关注微信公众号:fightingZh

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值