【华为云MySQL技术专栏】MySQL 8.0 InnoDB Redo Log持久化流程简析
1. 背景介绍
预写日志(WAL:Write-Ahead Logging)是数据库最重要的组件之一,用于保证数据操作的原子性和持久性。WAL(在InnoDB中称为Redo Log)中保存了所有对数据文件的更改记录,所有的修改在提交之前都要先写入 Log 文件中,如此既可以延迟修改后的页面刷新到磁盘的时间,又可以防止数据丢失。
当写密集型工作负载写入Redo Log时,性能会因许多用户线程同步操作而受到限制。在多CPU和存储设备性能较快的场景下,这一点尤其明显。
本文将重点介绍MySQL 8.0版本的Redo Log持久化流程,主要涉及8.0版本对Redo Log的优化点以及相关线程的交互处理逻辑,包括Log Writer和Log Flusher等线程。
2. 基本流程
用户事务在更改数据时,一个数据页修改操作可能会包含多条Redo Log,每条Redo Log都会被分配一个全局唯一递增的标号LSN(Log Sequence Number),用于标识该日志记录的顺序。
每一条Redo Log都由mini-transaction(简称为mtr)原子提交,mtr是MySQL内部用于执行数据更改操作的最小单位。在数据页上执行更改动作时,MySQL会创建若干个mtr来确保操作的原子性。随之生成的Redo Log会暂时存放在mtr的m_log中,这是一个动态分配的内存空间。
当mtr提交时,会将m_log中的数据拷贝到InnoDB的Redo Log Buffer中,并进一步唤醒相关后台线程执行Redo Log的持久化。
mtr_t::commit //用户线程提交mtr
|--> mtr_t::Command::execute
| |--> mtr_t::Command::prepare_write //进行一些校验并返回将要写入的redo log长度
| |--> mtr_write_log_t write_log;
| |--> log_buffer_reserve //1.分配log buf,获得起始和终点lsn位置并初始化write_log
| | |--> log_buffer_s_lock_enter_reserve
| | | |--> log.sn.fetch_add //全局偏移log.sn原子加自身的redo长度,得到自身在log buffer中独享的空间
| | | |--> log_buffer_s_lock_wait //unlikely,如果当前start_sn被锁,则需等待start_sn解锁并允许写入buffer
| | |--> log_wait_for_space_after_reserving //等待recent_written的空间,预留足够的redo log buffer,buffer不够会调用log_write_up_to清理buffer空间,还不够则进行resize
| | | |--> log_wait_for_space_in_log_buf //确保这条redo log能完整的写入redo log Buffer,而且回环后不会覆盖尚未写入磁盘的redo log
| | | | |--> return //if (end_sn + OS_FILE_LOG_BLOCK_SIZE(文件系统的块大小,512 字节)<= write_sn + buf_size_sn)
| | | | |--> log_write_up_to //等待redo log写入到指定的lsn
| | | | | |--> log_wait_for_write //通过设置writer_event,异步触发log_writer写
| | | | | | |--> os_event_wait_for(log.write_events[slot],stop_condition); //当log.write_lsn.load() >= lsn会唤醒或者由log_write_notify唤醒
| | | | | |--> log_wait_for_flush //当flush_to_disk为true时,等待redo log刷盘到指定的lsn
| |--> m_impl->m_log.for_each_block(write_log) //2.将mtr的buffer中的内容复制到redo log buffer中
| | |--> mtr_write_log_t::operator
| | | |--> log_buffer_write //写入到redo log buffer(log->buf)
| | | |--> log_buffer_write_completed //完成赋值后,触发LinkBuf更新recent_written字段
| | | | |--> log.recent_written.add_link_advance_tail //更新本次写入的内容范围对应的LinkBuf环形数组
| |--> log_wait_for_space_in_log_recent_closed //3.在加脏页之前需要判断是否link buf已满
| |--> add_dirty_blocks_to_flush_list //4.加脏页到flush list中
| | |--> add_dirty_page_to_flush_list
| |--> log_buffer_close
| | |--> log_buffer_s_lock_exit_close //5.更新recent_closed字段
| | | |--> log.recent_closed.add_link_advance_tail(start_lsn, end_lsn);
◆ 写入完成后,Log Writer线程向前推进write_lsn,并唤醒Log Flusher和Log_Writer Notifier线程。
◆ Log Flusher负责 Redo Log 的刷脏,与数据脏页的 Flush 无关,数据脏页的 Flush 由 Buffer Pool 刷脏线程处理。当flushed_to_disk_lsn落后于write_lsn时,Log Flusher线程会执行fsync()函数,将OS Cache中的数据刷入磁盘,完成Redo Log的真正刷盘。
◆ 刷盘完成后,Log Flusher线程向前推进flushed_to_disk_lsn,并唤醒Log Flush Notifier线程。
◆ Log Write Notifier/Log Flush Notifier线程会唤醒等待write_lsn/flushed_to_disk_lsn的用户线程。MySQL通过innodb_flush_log_at_trx_commit参数控制事务提交的时机,决定是由Log Write Notifier还是Log Flush Notifier线程唤醒用户线程。
◆ 用户线程被唤醒后,会比较自身事务的commit_lsn与write_lsn/flushed_to_disk_lsn:
如果commit_lsn 小于或等于 write_lsn/flushed_to_disk_lsn,则表示对应的Redo Log已写入OS Cache/磁盘,事务可以提交。
如果commit_lsn仍大于write_lsn/flushed_to_disk_lsn,则用户线程继续等待下一次唤醒。
innodb_flush_log_at_trx_commit 的取值范围是[0,2]。不同值的含义如下:
◆ 0: InnoDB 不会在事务提交时主动将 Redo Log Buffer 的数据写入磁盘。而是每秒执行一次 write 操作和flush 操作,将 Redo Log Buffer 的数据更新到文件系统 OS Cache中,调用文件系统的fsync()将数据缓存更新至磁盘中。
◆ 1: InnoDB 会在每次事务提交时,立即执行一次 write 和 flush 操作,将 Redo Log Buffer 的数据写入文件系统缓存并刷新到磁盘。这是最安全的方式。
◆ 2: InnoDB 会在每次事务提交时,立即执行一次 write 操作,将 Redo Log Buffer 的数据写入文件系统缓存。但不会立即执行 flush 操作,而是每秒执行一次 flush,将缓存中的数据刷新到磁盘 。
MySQL InnoDB的日志系统通过多个线程和内部数据结构的协作,使Redo Log从产生到落盘再到唤醒用户线程的流程更加高效。以下是该过程的时序图:
图1 用户线程生成Redo Log并等待落盘
3. 关键数据结构
ZLink_buf
Link_buf是MySQL 8.0引入的一种数据结构,用来维护和推进Redo Log在相关阶段的lsn位点。其本质是一个环形数组,数组名称为m_links,容量为m_capacity,m_tail指向其连续尾部。该数组的索引计算方式为start_lsn对m_capacity-1取模,每个元素存储的是end_lsn,且所有元素均为原子类型以确保线程安全。特别地,当end_lsn值为0时,表示该槽位为空。
template <typename Position = uint64_t>
class Link_buf {
public:
/* ...... */
/** Add a directed link between two given positions. */
void add_link_advance_tail(Position from, Position to);
private:
/* ...... */
/** Capacity of the buffer. */
size_t m_capacity;
/** Pointer to the ring buffer (unaligned). */
std::atomic<Distance> *m_links;
/** Tail pointer in the buffer (expressed in original unit). */
alignas(ut::INNODB_CACHE_LINE_SIZE) std::atomic<Position> m_tail;
}
图2 Link_buf结构
通过合理运用std::atomic_thread_fence(std::memory_order_release)和std::atomic_thread_fence(std::memory_order_acquire)内存栅栏操作,Link_buf实现了对不同槽位lsn读写操作的无锁并发控制,从而提升了系统的并发性能。
在log_sys小节中,会结合Redo Log写入Log Buffer的流程具体分析Link_buf如何实现的无锁化。
log_sys
log_sys是管理Redo Log 元信息,Redo Log Buffer 等操作的系统单元。
struct alignas(INNOBASE_CACHE_LINE_SIZE) log_t {
atomic_sn_t sn; // 全局唯一的递增的偏移 sn.
aligned_array_pointer<byte, OS_FILE_LOG_BLOCK_SIZE> buf; // log buffer的内存区
Link_buf<lsn_t> recent_written; // 解决并发插入Redo Log Buffer后刷入ib_logfile存在空洞的问题
Link_buf<lsn_t> recent_closed; // 解决并发插入flush_list后确认checkpoint_lsn的问题
atomic_lsn_t write_lsn; // write_lsn之前的数据已经写入系统的Cache, 但不保证已经Flush
alignas(ut::INNODB_CACHE_LINE_SIZE) os_event_t *write_events; // 指向write_events事件数组的指针,当write_lsn >= lsn时,log writer notifier会通知用户线程。
alignas(ut::INNODB_CACHE_LINE_SIZE) os_event_t *flush_events; // 指向flush_events事件数组的指针,当flushed_to_disk_lsn >= lsn时,log flush notifier会通知用户线程。
atomic_lsn_t flushed_to_disk_lsn; // 已经被flush到磁盘的数据
}
/** Redo log system (singleton). */
extern char log_sys_struct[sizeof(log_t)];
#define log_sys ((log_t*)log_sys_struct)
在MySQL 5.7中,写性能受限于Redo Log的同步落盘。主要瓶颈在于,mtr在将Redo Log从m_log写入Log Buffer时需要加log_sys_t::mutex锁,而在将dirty page加入flush list时,为了保证全局有序,又需要加log_sys_t::flush_order_mutex锁。这导致其他用户线程的mtr在尝试写入Log Buffer时需要等待log_sys_t::mutex锁,同时即使向其他flush list中添加数据,也需要等待log_sys_t::flush_order_mutex锁。
为解决多个线程的mtr对Redo Log的争抢问题,MySQL引入了两个Link_buf实例:recent_write和recent_close。这两个实例的长度由innodb_log_recent_written_size和innodb_log_recent_closed_size参数决定。不同的线程可以对recent_write和recent_close中的不同slot进行无锁读写,从而避免了锁竞争。
sn
全局唯一的递增的偏移,表示逻辑层已保留的Redo Log数据字节数,不包括日志块的页眉和页脚的字节。逻辑层的全局sn和物理层的lsn可以相互转换,两者间一一对应。
图3所示,逻辑层Redo记录在转换为物理Redo时可能因为Block剩余空间不足,需要被拆分在连续的两个Block中。
图 3 sn-lsn双向映射
如基本流程中所述,不同的mtr会调用log_buffer_reserve函数,在这个函数里,全局偏移log.sn会fetch_add自己的redo长度len,得到每个mtr自身的start_sn(即sn)和end_sn(即sn + len)。由于log.sn是原子变量,这一步是无锁的。
在函数log_buffer_write中,会将把start_sn转换为start_lsn,并根据其计算出Redo Log在log buffer中的偏移,从偏移开始长度为len的空间就是该Redo Log写入的空间。因为lsn是单调递增的,所以每个Redo Log的空间都是独享的,不会有重叠。通过这种方式实现了Redo Log Buffer 的无锁并行写入。
recent_written
recent_written对应Redo Log Buffer,记录着已写入Log Buffer的lsn。其mlink以start_lsn为索引,值为end_lsn,即mlink[start_lsn] = end_lsn。
如前所述,不同的mtr会并行地将自身m_log中的数据写入Log Buffer中各自独享的空间,不会产生冲突。
由于并发写入,获得较小LSN的mtr(较早申请LSN的mtr)可能不会优先完成memcpy操作,这可能导致Log Buffer中出现空洞。然而,ib_logfile不允许存在空洞。系统通过recent_written.tail(即buf_ready_for_write_lsn)确保在此LSN之前的Redo Log buffer中不存在空洞,从而实现ib_logfile的完整写入。
在此过程中,不同的mtr作为生产者将数据写入Log Buffer,而Log Writer作为消费者负责将数据取出。Log Writer通过recent_write无锁地获取读取位置,确保mtr与Log Writer之间不会出现上锁等待。
图4 LogBuffer待写入
如图4所示,红色是已写入Log Buffer 的 redo,白色为尚未写入的部分。write_lsn是当前Log Writer已经写入到OS Cache中的最大LSN,buf_ready_for_write_lsn是当前Log Writer找到的Log Buffer中已经连续的最大LSN,current_lsn是当前已经分配给mtr的的最大lsn位置。
从write_lsn到buf_ready_for_write_lsn的范围是Log Writer可以连续写入OS Cache的区域,而从buf_ready_for_write_lsn到current_lsn的范围是当前多个mtr并发写入Log Buffer的区域。
图中下方的连续方格对应log.recent_written中的m_links,中间的两个全零空洞阻碍了buf_ready_for_write_lsn的推进。
假设reserve到第一个空洞的mtr完成写Log Buffer并更新log.recent_written,如图5所示:
图5 写入Log Buffer
这时,Log Writer从当前buf_ready_for_write_lsn位点开始遍历log.recent_written的m_links槽位,确认日志已连续写入:
图6 确认log.recent_written连续
因此,当Log Writer被唤醒后便会推进buf_ready_for_write_lsn,并将之前的槽位都清零,供之后循环复用,如图7所示。
图7 推进buf_ready_for_write_lsn
紧接着,Log Writer会将从write lsn到buf_ready_for_write_lsn范围的Redo Log写入OS Cache并提升write_lsn。
recent_closed
recent_closed记录着已写入flush list的lsn,用于在checkpoint时辅助计算checkpoint位点。其类似recent_written,允许多个mtr并发地给Buffer Pool注册脏页,其tail指针为当前连续写入flush list的最大LSN,即buffer_dirty_pages_added_up_to_lsn(dpa_lsn)。
为确保故障恢复时数据不丢失,需要确定一个合适的checkpoint位点,以保证该位点之前所有脏页均已刷盘。具体如下:
◆ Log Checkpointer首先会获取Buffer Pool中所有脏页的最小的lsn(lwm_lsn),认为这之前的脏页已落盘。然而这并不充分,由于多个mtr并发写入可能存在乱序的情况,部分mtr的Redo对应的page可能还未写入Buffer Pool脏页。若将checkpoint设置在lwm_lsn,故障恢复时会丢失这些数据。
◆ 因此还需要知道当前已经加入到flush list的lsn位置(dpa_lsn),取二者的较小值。
◆ 同时出于保守考虑,lwm_lsn还需要减掉一个recent_closed的容量大小(即最大的乱序范围)。
◆ 最后再和flushed_to_disk_lsn 取最小值,就能得到checkpoint的位点。
write_events/flush_events用户提交事务时,会根据innodb_flush_log_at_trx_commit参数,调用log_wait_for_write或log_wait_for_flush,来等待Redo Log写入到OS Cache或刷到磁盘。用户线程的通知是通过write_events/flush_events事件数组来实现的,为了避免一次通知的events过多,write_events/flush_events会像桶一样划分给不同的用户线程。
Redo Log是以一个个log block划分的,以write_events为例,其大小由参数innodb_log_write_events控制。假设write_events大小为m,则第n个log block的写入/刷盘,由write_events [n%m]事件监听。当log buffer的第L1个log block到第L2个log block写入OS Cache时,会设置L1-L2之间的log block所属的write_events,从而等待在L1-L2范围内的Redo Log的用户线程都会收到通知。
图8 Redo Log 映射到write_events
// 计算给定lsn在事件数组中的索引
static inline size_t log_compute_wait_event_slot(lsn_t lsn, size_t events_n) {
return ((lsn - 1) / OS_FILE_LOG_BLOCK_SIZE) & (events_n - 1);
}
4. 关键后台线程
Redo持久化主要涉及Log Writer,Log Flusher,Log Write Notifier,Log Flush Notifier四个后台线程。
图9 Redo Log持久化后台线程组
在InnoDB 启动时,就会启动这四个线程:
srv_start
|--> log_start_background_threads
| |--> os_thread_create(log_flush_notifier_thread_key, log_flush_notifier, &log);
| |--> os_thread_create(log_flusher_thread_key, log_flusher, &log);
| |--> os_thread_create(log_write_notifier_thread_key, log_write_notifier, &log);
| |--> os_thread_create(log_writer_thread_key, log_writer, &log);
| |--> srv_threads.m_log_flush_notifier.start();
| |--> srv_threads.m_log_flusher.start();
| |--> srv_threads.m_log_write_notifier.start();
| |--> srv_threads.m_log_writer.start();
Log Writer
Log Writer会自旋,不断检测是否有待写入OS Cache的Redo Log。其工作流程如下:
log_writer /*endless loop*/
|--> waiting.wait(stop_condition);//等待write_lsn<buf_ready_for_write_lsn
| |--> log_advance_ready_for_write_lsn
| | |--> log.recent_written.advance_tail_until(stop_condition) //推进Link_buf::m_tail,同时回收之前的槽位
|--> log_writer_write_buffer
| |--> log_write_buffer
| | |--> log.m_current_file.offset(start_lsn); //计算写入在文件的真实偏移
| | |--> compute_how_much_to_write //计算当前文件是否有足够的空间
| | |--> copy_to_write_ahead_buffer //将Redo Log从Log Buffer复制到Write-Ahead Buffer
| | |--> write_blocks //写入OS Cache
| | |--> log.write_lsn.store(new_write_lsn); //更新write_lsn
| | |--> notify_about_advanced_write_lsn
| | | |--> log_compute_write_event_slot //计算用户写入redo对应的write_events slot
| | | |--> os_event_set(log.flusher_event); //innodb_flush_log_at_trx_commit=1时唤醒log flusher
| | | |--> os_event_set(log.write_events[first_slot]); //直接唤醒用户等待first_slot的线程
| | | |--> os_event_set(log.write_notifier_event); //唤醒log_write_notifier
◆ Log Writer线程 wait 在stop_condition函数,该函数内会调用log_advance_ready_for_write_lsn()推进buf_ready_for_write_lsn,直到满足条件write_lsn < buf_ready_for_write_lsn,才返回true唤醒线程。
◆ Log Writer被唤醒后会调用log_writer_write_buffer()进行 Redo 写入,具体逻辑在log_write_buffer()中。
如果当前文件仍有空闲空间,则会判断是否需要使用 Write Ahead Buffer,以避免因小于OS_FILE_LOG_BLOCK_SIZE的 IO 操作导致 "read on write" 现象。
◆ 若要使用 Write Ahead Buffer ,则调用 copy_to_write_ahead_buffer() 函数将 Redo Log 从 Log Buffer复制到 Write Ahead Buffer。
◆ 调用 write_blocks() 函数,通过os_file_write ()写入OS Cache,每次写入都是512字节对齐。
◆ 更新log.write_lsn
◆ 调用notify_about_advanced_write_lsn()根据slot选择对应线程唤醒:
如果要写入的Redo Log都对应同一个write_events槽位,则直接唤醒等待该槽位的用户线程。即在mtr 的 commit 阶段写入 Redo Log Buffer 时,需要等待log.write_lsn.load() >= lsn的用户线程。
如果要写入的Redo Log对应一批write_events槽位,则唤醒Log Write Notifier 线程,由其唤醒所有等待这些write_events的用户线程。
Redo日志文件默认以ib_logfile0、ib_logfile1命名,为避免频繁创建和初始化文件带来的额外开销,InnoDB采用循环写入机制。用户可通过设置innodb_log_files_in_group参数来指定日志文件的数量,以优化存储和性能。默认情况下,InnoDB会使用两个重做日志文件。
Log Flusher
Log Writer提升write_lsn之后会通知Log Flusher线程,Log Flusher线程会调用fsync()将redo刷盘,并推进flush_up_to_lsn。Log Flusher与Log Writer之间仅通过write_lsn同步刷盘位置。
其工作流程如下:
log_flusher /* endless loop */
|--> os_event_wait_time_low(log.flusher_event) //innodb_flush_log_at_trx_commit != 1
|--> waiting.wait(stop_condition) //等待last_flush_lsn<log.write_lsn.load(),触发刷盘
| |--> log_flush_low
| | |--> log.m_current_file_handle.fsync();
| | |--> log.flushed_to_disk_lsn.store(flush_up_to_lsn);
| | |--> log_compute_write_event_slot //计算用户刷盘redo对应的flush_events slot
| | |--> os_event_set(log.flush_events[first_slot]) //只刷盘一个slot,则直接唤醒用户等待first_slot的线程
| | |--> os_event_set(log.flush_notifier_event); //唤醒log_flush_notifier
假如innodb_flush_log_at_trx_commit不为1,则每秒唤醒一次Log Flusher;
否则,wait 在stop_condition函数,该函数内会判断last_flush_lsn是否小于 log.write_lsn,是则执行log_flush_low()进行刷盘,并退出等待。
◆ 在log_flush_low内部,会调用fsync()进行文件刷盘,再更新 log.flushed_to_disk_lsn,最后类似Log Writer根据slot选择对应线程唤醒:
如果要刷盘的Redo Log都对应同一个flush_events槽位,则直接唤醒等待该槽位的用户线程。即在mtr 的 commit 阶段写入 Redo Log Buffer 时,需要等待log. flushed_to_disk_lsn.load() >= lsn的用户线程。
如果要写入的Redo Log对应一批flush_events槽位,则唤醒Log Flush Notifier 线程,由其唤醒所有等待这些flush_events的用户线程。
Log Notifier
Log Notifier 包含两个关键线程:Log Write Notifier 和 Log Flush Notifier。这两个线程会定期轮询(或被唤醒)各自关注的 LSN 位置:Log Write Notifier关注 write_lsn,而 Log Flush Notifier关注 flush_up_to_lsn。当这些 LSN 值推进到用户线程等待的位置时,它们会唤醒相应的用户线程。用户线程在执行 commit时会阻塞在 log_write_up_to 函数上,直到对应的 write_lsn 或 flush_up_to_lsn 推进到预期位置后,函数才会返回。
Log Write Notifier 的具体流程如下:
log_write_notifier /*endless loop*/
|--> waiting.wait(stop_condition); //log.write_lsn>= lsn时,返回true停止等待
|--> slot = log_compute_write_event_slot(log, lsn); //计算当前lsn等待在write_events的哪个slot
|--> os_event_set(log.write_events[slot]); //唤醒在等待的用户线程
|--> lsn = write_lsn + 1;
log_flush_notifier /*endless loop*/
|--> waiting.wait(stop_condition); //log.flushed_to_disk_lsn>=lsn时,返回true停止等待
|--> slot = log_compute_flush_event_slot(log, lsn); //计算当前lsn等待在flush_events的哪个slot
|--> os_event_set(log.write_events[slot]); //唤醒在等待的用户线程
|--> lsn = flush_lsn + 1;
5. 总结
本文深入分析了MySQL 8.0中Redo Log持久化模块的源码实现原理。MySQL 8.0通过引入无锁化机制,有效解决了用户线程在写入Redo Log时因锁竞争导致的性能瓶颈。此外,系统将Redo Log的文件写入和刷盘操作从用户线程中独立出来,交由专门的线程负责,使用户线程仅需处理将Redo Log写入Log Buffer的任务,而无需关注后续的落盘过程。用户线程只需等待Log Writer线程或Log Flusher线程的通知,这种设计显著提升了高并发场景下Redo日志的写入效率。
- 点赞
- 收藏
- 关注作者
评论(0)