REDO Log的生成(mtr)
结合MySQL 8.0.19 代码
InnoDB的REDO Log都是通过mtr产生的。InnoDB会将事务执行过程拆分为若干个Mini Transaction(mtr),mtr是保证若干个page原子性变更的单位,也就是通过mtr来保证多个page的原子性修改。一个mtr可能涉及到多个page的修改,所以一个 mtr可能会包含若条日志记录,每条日志记录都是对某个page的修改且只涉及一个page的修改。如果一个mtr包含多个page的修改,也就是有多条日志记录,然后如果该mtr提交失败等,那么这几个page的修改全部都要回滚,也就是mtr修改的这几个 page要保持同步,要么都修改成功,要么都失败。每个mtr包含一系列如加锁,写数据,写redo,放锁等操作。修改一个页需要获得该页的x-latch,访问一个页是需要获得该页的s-latch或者x-latch,持有该页的latch直到修改或者访问该页的操作完成。
redo log通常先写在mtr的cache(mtr.m_impl.m_log)里, 在mtr提交时, 将cache中的redo log写入到log buffer(公共buffer), 一般来说在一个MTR中会做两个事情:
-
mtr产生redo log,然后将产生的redo log 提交到公共log buffer
-
修改page,挂载脏页到flush list.
mtr 数据结构
mtr的数据结构:
/** Mini-transaction handle and buffer */ struct mtr_t { /** State variables of the mtr */ struct Impl { // mtr 持有锁的栈 /** memo stack for locks etc. */ mtr_buf_t m_memo; // mtr产生的日志 /** mini-transaction log */ mtr_buf_t m_log; // 是否产生buffer pool脏页 /** true if mtr has made at least one buffer pool page dirty */ bool m_made_dirty; // insert buffer 是否修改 /** true if inside ibuf changes */ bool m_inside_ibuf; // 是否修改buffer pool pages /** true if the mini-transaction modified buffer pool pages */ bool m_modifications; /** true if mtr is forced to NO_LOG mode because redo logging is disabled globally. In this case, mtr increments the global counter at ::start and must decrement it back at ::commit. */ bool m_marked_nolog; /** Shard index used for incrementing global counter at ::start. We need to use the same shard while decrementing counter at ::commit. */ size_t m_shard_index; // 一个MTR已产生log记录个数 /** Count of how many page initial log records have been written to the mtr log */ ib_uint32_t m_n_log_recs; /* MTR的日志模式包括 MTR_LOG_ALL:默认模式,记录所有会修改磁盘数据的操作; MTR_LOG_NONE:不记录redo,脏页也不放到flush list上; MTR_LOG_NO_REDO:不记录redo,但脏页放到flush list上,如临时表的修改; MTR_LOG_SHORT_INSERTS:插入记录操作REDO,在将记录从一个page拷贝到 另外一个新建的page时用到,此时忽略写索引信息到redo log中。 */ // 参考函数page_cur_insert_rec_write_log /** specifies which operations should be logged; default value MTR_LOG_ALL */ mtr_log_t m_log_mode; /* mtr状态: MTR_STATE_INIT = 0, TR_STATE_ACTIVE = 12231, MTR_STATE_COMMITTING = 56456, MTR_STATE_COMMITTED = 34676 */ /** State of the transaction */ mtr_state_t m_state; /** Flush Observer */ FlushObserver *m_flush_observer; private: Impl m_impl; /** LSN at commit time */ lsn_t m_commit_lsn; /** true if it is synchronous mini-transaction */ bool m_sync; class Command; friend class Command; };
重点看下 mtr_t 中 m_memo 和 m_log 成员的实现
m_memo和m_log都是mtr_buf_t类型的对象,mtr_buf_t是由一个双向链表组成的动态buffer,每个元素是512Byte大小的buffer(512Byte刚好匹配一个log block大小)。随着 mtr_buf_t 存储的数据的增加,它会自动生成新的512B的buffer,并加入双向链表中。
m_memo: 使用动态buffer的方式是把锁类型、锁地址或page地址加入动态buffer。在mtr_s_lock或mtr_memo_push中会执行如下操作:
mtr_memo_slot_t *slot; // 先在动态buffer中申请能容纳锁类型+地址的空间,再对该空间进行初始化 slot = m_impl.m_memo.push<mtr_memo_slot_t *>(sizeof(*slot)); // 锁类型 slot->type = type; // 锁地址或page地址 slot->object = object;
m_log: 使用动态buffer的方式是把日志类型、space id、page no、以及具体的操作信息加入动态buffer。
mtr 的执行步骤
mtr的执行总的来说分为3步:
第一步、启动mtr
mtr_start(&mtr);
第二步、写mtr
这一步主要是对page进行修改以及记录对page的修改,将 mtr 生产的 redo 保存在 mtr 的 mlog 中
// 预分配待写入的日志空间,若空间不够,则增加新的buffer到动态buffer中。 // 预分配待写入的日志空间,若空间不够,则增加新的buffer到动态buffer中。 byte *mlog_open(mtr_t *mtr, ulint size) do_work(MTR & Btree) --> 对index加锁、修改page // 写入日志类型、space id、page no,且m_n_log_recs加1 mlog_write_initial_log_record_fast() 记录其他相关信息 // 获取mtr buffer(双向链表)的最后一个节点,更新mtr日志大小 mlog_close(mtr, log_ptr) // 将mtr的数据move到mtr buffer(m_log)中 mtr->get_log()->push()
第三步. 提交mtr
mtr_commit(&mtr);
这一步主要是: (1) 将 mtr mlog 中的redo写入到公共 log buffer (2) 将脏页挂载到 flush_list
下面将依次详细介绍这三步:
第一步 mtr_start(&mtr)
启动 mtr, 初始化 一些变量
/** Start a mini-transaction. */ #define mtr_start(m) (m)->start()
即 mtr_t::start
mtr的启动函数mtr_t::start(bool sync, bool read_only), 包含两个参数:
sync:表示是否当前的mtr是同步
read_only: 表示当前mtr 是否只读
默认情况下sync=true, read_only=false.
/** Start a mini-transaction. @param sync true if it is a synchronous mini-transaction @param read_only true if read only mini-transaction */ void mtr_t::start(bool sync, bool read_only) { m_sync = sync; m_commit_lsn = 0; new (&m_impl.m_log) mtr_buf_t(); new (&m_impl.m_memo) mtr_buf_t(); m_impl.m_mtr = this; m_impl.m_log_mode = MTR_LOG_ALL; m_impl.m_inside_ibuf = false; m_impl.m_modifications = false; m_impl.m_made_dirty = false; m_impl.m_n_log_recs = 0; m_impl.m_state = MTR_STATE_ACTIVE; m_impl.m_flush_observer = nullptr; m_impl.m_marked_nolog = false; }
REDO Log Buffer 介绍
公共 Log Buffer内存空间的大小由启动参数innodb_log_buffer_size来指定,默认是16MB,通常为64M,这片内存空间按照log block格式存储(包含12B的header和4B的trailer,详见前文中的日志块结构),被划分成若干个连续的redo log block,这也是Log Buffer的内存结构,每个log block大小为512B,并且持久化时以512B进行对齐。每个log block中能存储日志内容的空间为512-12-4=496B。Innodb引擎还定义了一个称之为buf_free的全局变量,标示redo log日志写到了Log Buffer的哪个位置,如下图所示:
公共log buffer有个原子变量log.sn,其统计的是公共buffer中曾经存储过的日志内容的大小。通过sn可以很容易计算出对应的lsn,其统计的是公共buffer中曾经存储过的以log block格式的日志量的大小。 lsn = (sn / 496 * 512 + sn % 512 + 12)(sn记录的是log buffer中总共有多大的log block body, sn 不包括log block 的头和尾,lsn是包括block的头和尾)
公共log buffer是个循环buffer,其中有三个重要的位点log.write_lsn,log.sn对应的lsn,log.buf_limit_sn对应的lsn。其中log.write_lsn表示已写入系统的page cache, 不保证已经flush到日志文件,log.sn对应的lsn表示已占位待拷贝的日志位点,log.buf_limit_sn对应的lsn表示可以占位的最大日志位点。满足log.write_lsn <= log.sn对应的lsn <= log.buf_limit_sn对应的lsn。
redo log buffer数据结构:
/** Redo log - single data structure with state of the redo log system. In future, one could consider splitting this to multiple data structures. */ struct alignas(ut::INNODB_CACHE_LINE_SIZE) log_t { //通常用这个log.sn ,通过log_get_lsn去换算获得当前的lsn atomic_sn_t sn; // log buffer的内存区 aligned_array_pointer<byte, OS_FILE_LOG_BLOCK_SIZE> buf; // 解决并发插入Redo Log Buffer后刷入ib_logfile存在空洞的问题 Link_buf<lsn_t> recent_written; // 解决并发插入flush_list后确认checkpoint_lsn的问题 Link_buf<lsn_t> recent_closed; // write_lsn之前的数据已经写入系统的Cache, 但不保证已经Flush atomic_lsn_t write_lsn; // 已经被flush到磁盘的数据,由log flusher thread 来更新 // log.flushed_to_disk_lsn <= log.write_lsn atomic_lsn_t flushed_to_disk_lsn; // log buffer缓冲区的大小 size_t buf_size; /* 表示可以进行checkpoint的最大的lsn 到这个lsn 为止, 所有的redo log 对应的dirty page 已经flush 到btree 上了, because flush 的时候并不是顺序的flush, 所以有可能存在有空洞的情况, 因此这个lsn 的位置并不是最大的redo log 已经被flush 到btree 的位置. 而是可以作为checkpoint 的最大的位置. 这个值是由log checkpointer thread 来更新 */ lsn_t available_for_checkpoint_lsn; /* 在此lsn之前的所有被添加到buffer pool的flush list的log数据已经被flsuh, 下一次checkpoint可以make在这个lsn. 与last_checkpoint_lsn的区别是 该lsn尚未被真正的checkpoint. */ // 下次需要进行checkpoint的lsn lsn_t requested_checkpoint_lsn; // 目前最新的checkpoint的lsn /* 到这个lsn 为止, 所有的btree dirty page 已经flushed 到disk了, 并且这个lsn 值已经被更新到了ib_logfile0 这个文件去了. 这个lsn 也是下一次recovery 的时候开始的地方, 因为last_checkpoint_lsn 之前的redo log 已经保证都flush 到btree 中去了. 所以比这个lsn 小的redo log 文件已经可以删除了, 因为数据已经都flush 到btree data page 中去了. 这个值是由log checkpointer thread 来更新 log.last_checkpoint_lsn <= log.available_for_checkpoint_lsn <= log.buf_dirty_pages_added_up_to_lsn */ atomic_lsn_t last_checkpoint_lsn; // write ahead 的Buffer大小 uint32_t write_ahead_buf_size; // 当前正在fsync到的LSN,不断增加并且不回环的,它是redo log实际内容在逻辑上的增长 lsn_t current_file_lsn; //代表在所有redo 物理文件的偏移 uint64_t current_file_real_offset; // 当前ib_logfile文件末尾的offset uint64_t current_file_end_offset; // 当前ib_logfile的文件大小 uint64_t file_size; //表示2个文件实际大小总和 file_size * n_files uint64_t files_real_capacity ...... }
为什么会有这么多的LSN?
主要还是由于写redo log 这个过程被拆开成了多个异步的流程 以及 加入了无锁优化,提高写入log buffer的并发以及将脏页加到flush list的并发。
比如用户线程将mtr生成的redo log先写入到log buffer, 然后由log writer 异步写入到文件系统 page cache, 最后再由log flusher 异步进行写到磁盘.
第三步 mtr_commit(&mtr)
提交一个mini transaction的过程比较复杂,大致流程是先将m_log中的日志写入公共log buffer,再将m_memo中的加锁并且发生修改的脏page加入flush list,最后释放m_memo中的所有锁。
将m_log中的日志写入公共log buffer:
-
根据日志数m_n_log_recs是否为1,来判断是single log还是multiple log。对于single log,在日志的开头的日志类型字段中增加MLOG_SINGLE_REC_FLAG。而对于multiple log,在日志结尾增加1B的MLOG_MULTI_REC_END。
-
在公共log buffer中使用原子变量log.sn进行日志占位。
-
在往已占位的日志空间中拷贝日志前,有以下两种情况需要等待:
-
若当前的log.sn位点被SN_LOCKED锁定,则要等待log.sn_locked 超过占位前的log.sn。当公共log buffer需要在线变更大小的时候,会进行SN_LOCKED加锁。
-
若日志写入速度过快,来不及写磁盘,就会把log buffer占满,这时需要阻塞等待日志的写磁盘。
-
-
将m_log动态buffer拷贝到公共log buffer,是按照512B大小的buffer粒度进行拷贝的:
-
若日志长度超过log block剩余大小,则要做截断,并增加tail和新的header,以满足log block格式
-
若写到log buffer的结尾(默认大小为16M),要继续转向log buffer开头继续拷贝。由于log buffer大小是log block的倍数,所以这里不需要再次做截断。
-
mtr 每个 buffer 拷贝完成后触发一次log.recent_written的Link_buf更新
-
-
当m_log日志都拷贝完,要检查已写入的日志是否横跨log block,若横跨了,则要在结尾的log block的header的LOG_BLOCK_FIRST_REC_GROUP字段中标识新mtr的位点end_lsn。
将m_memo中加锁并且发生修改的脏page加入flush list:
-
遍历m_memo动态buffer中的每个buffer中的每个锁对象mtr_memo_slot_t
-
若是page锁,且该page发生了修改,则将该page加入flush list
-
-
触发一次log.recent_closed的Link_buf更新,log.recent_closed记录添加到flush list的最大连续日志的lsn
以下是详细流程:
mtr_t::commit | |-> mtr_t::Command | (m_n_log_recs>0 || m_modifications) | |-> (yes) | v // 将mtr.m_impl->m_log写入公共log buffer,把脏页加入flush list | Command::execute | | | |-> prepare_write | | | | | |-> 若 mtr.m_impl->m_log_mode为 MTR_LOG_NO_REDO或MTR_LOG_NONE, | | | 则直接返回 | | | | | |-> 若 mtr.m_impl->m_n_log_recs==1,则 | | m_log.front()->begin()|=MLOG_SINGLE_REC_FLAG,在日志头Type字段 | | 中标识,否则 m_log->push(MLOG_MULTI_REC_END),在日志结尾附加1B | | | |-> /* 在公共log buffer中为日志预留空, 不同的mtr会首先调用 | | log_buffer_reserve函数获得起始和终点lsn位置 */ | |-> log_buffer_reserve | | | | | |-> log_buffer_s_lock_enter_reserve | | | | | | | |-> 对 log.pfs_psi加 s-lock | | | | | | | |-> /* 在公共的log buffer中占位, 用自己的REDO长度,原子地对 | | | | 全局偏移log.sn做fetch_add,得到自己在Log Buffer中独享的空间*/ | | | |-> log.sn.fetch_add(mtr.m_impl->m_log.m_size) | | | | | | | |-> /* 若log.sn被SN_LOCKED,则等待log.sn_locked, | | | | 直到log.sn unlock 且超过占位前的log.sn */ | | | |-> log_buffer_s_lock_wait | | | | | |-> /* 将日志内容的偏移量log.sn 转为log block格式的偏移量start_lsn, | | | start_lsn可以唯一表示日志在log block和公共log buffer中的位置*/ | | |-> log_translate_sn_to_lsn | | | | | |-> // 不同mtr并行的将自己的m_log中的数据拷贝到各自独享的空间内。 | | |-> // 若end_sn > log.buf_limit_sn,则等待 | | |-> log_wait_for_space_after_reserving | | | |-> /* 确保这条 redo log 能完整的写入 redo log Buffer, | | | |-> 而且回环后不会覆盖尚未写入磁盘的redo log. */ | | | |-> log_wait_for_space_in_log_buf | | | | |-> log_write_up_to | | | | | |-> log_wait_for_write | | | | | | |-> //log_write_notify会唤醒这个条件变量 | | | | | | |-> os_event_wait_for(log.write_events[slot], | | | | | | | stop_condition); | | | | | |-> log_wait_for_flush | | | |-> // 从mtr的buffer中内容写入到redolog的buffer. | |-> m_impl->m_log.for_each_block(write_log) | | |-> mtr_write_log_t::operator | | | |-> //写入到redo log buffer(log->buf). | | | |-> log_buffer_write | | | |-> //拷贝完成后触发LinkBuf更新,更新recent_written字段 | | | |-> log_buffer_write_completed | | | | |-> //更新本次写入的内容范围对应的LinkBuf内特定的数组项值 | | | | |-> log.recent_written.add_link | |-> // 在加脏页之前需要判断是否link buf已满。 | |-> log_wait_for_space_in_log_recent_closed | | | |-> // 将mtr锁管理中记录的脏页加入flush list | |-> add_dirty_blocks_to_flush_list(mtr.m_impl->m_memo) | | | | | (reverse loop mtr_buf_t::block in m_memo) | | v | | (reverse loop mtr_memo_slot_t in block) | | | | | |-> /* 为了去掉flush_order_mutex,把mtr对应的脏页无序的添加到flush list, | | | 在做checkpoint时, 无法保证flush list 上面最头的page lsn是最小的*/ | | |-> add_to_flush | | | | | | | |-> /* 把修改后的page加入flush list,当mtr_memo_slot_t.type | | | | 为MTR_MEMO_PAGE_X_FIX或MTR_MEMO_PAGE_SX_FIX, | | | | 或为MTR_MEMO_BUF_FIX, | | | | 且mtr_memo_slot_t.object->made_dirty_with_no_latch*/ | | | |-> add_dirty_page_to_flush_list | | | | | | | | | | | | -> buf_flush_note_modification(mtr_memo_slot_t.object) | | | |-> /* 将mtr锁管理中记录的脏页处理完后触发一次Link_buf更新, | | log.recent_closed记录添加到flush list的最大连续日志的lsn, | | 以log.recent_closed.m_tail的lsn来做checkpoint肯定是安全的 */ | |-> log_buffer_close | | | | | | | | |->log_buffer_s_lock_exit_close | | | | | | | |-> 对 log.pfs_psi解锁 s-lock | | | | | | | |-> log.recent_closed.add_link_advance_tail | |-> Command::release_all | | | |-> Release_all(mtr.m_impl->m_memo) 释放mtr持有的锁 | |-> Command::release_resources -> clean mtr.m_impl->m_log & m_memo
mysql8.0 无锁优化:
并发写入redo log buffer--移除 log_sys->mutex
高并发的环境中,会同时有非常多的min-transaction(mtr)需要拷贝数据到Log Buffer,如果通过锁互斥,那么毫无疑问这里将成为明显的性能瓶颈。为此,从MySQL 8.0开始,设计了一套无锁的写log机制,其核心思路是引入recent_written,允许不同的mtr,同时并发地写Log Buffer的不同位置。不同的mtr会首先调用log_buffer_reserve函数 reserve 空间 ,用自己的REDO长度,原子地对全局偏移log.sn做fetch_add,得到自己在Log Buffer中独享的空间。之后不同mtr并行的将自己的m_log中的数据拷贝到各自独享的空间内。所以这里是允许 redo log Buffer 存在空洞的,而写入ib_logfile不允许,所以利用recent_written.tail 来保证在此 lsn 之前的 redo log Buffer 是不存在空洞的,从而完成ib_logfile的完整写入。具体的实现见后文。
recent_written: link_buf 结构,默认大小是4M
/** The recent written buffer. Protected by: locking sn not to add. */ Link_buf<lsn_t> recent_written;
并发加入flush list--移除log_sys_t::flush_order_mutex
mysql 5.6 版本的时候, 将page 添加到flush list 的时候, 必须有一个Mutex 加锁, 然后按照顺序的添加到flush list 上。
mysql 8.0 移除了锁结构log_sys_t::flush_order_mutex, 这就使得并发写flush list的LSN递增性质保证不了。但是依然要保证WAL,以及flush list上的刷脏策略仍然是从oldest page开始。如何解决dirty page加入flush list的空洞问题?引入另一个无锁结构体的变量recent_closed:维护buffer_dirty_pages_added_up_to_lsn(recent_closed.tail()),保证小于当前buffer_dirty_pages_added_up_to_lsn的脏页都已经加入到flush list中。允许局部乱序的加入flush list中。
recent_closed : link_buf 结构,默认大小是4M
REDO Log写入日志文件
redo log buffer 里的redo log 什么时候写入日志文件?
有几种场景可能会触发redo log写文件:
-
Redo log buffer 空间不足时
-
后台线程
-
做checkpoint
-
实例shutdown时
-
binlog切换时
-
提交逻辑事务时,会因为参数innodb_flush_log_at_trx_commit值的不同,产生不同的行为:
-
当设置该值为1时,每次事务提交都要做一次fsync,这是最安全的配置,即使宕机也不会丢失事务;
-
当设置为2时,则在事务提交时只做write操作,只保证写到系统的page cache,因此实例crash不会丢失事务,但宕机则可能丢失事务;
-
当设置为0时,事务提交不会触发redo写操作,而是留给后台线程每秒一次的刷盘操作,因此实例crash将最多丢失1秒钟内的事务。
下图表示了不同配置值的持久化程度:
显然对性能的影响是随着持久化程度的增加而增加的。通常我们建议在日常场景将该值设置为1,但在系统高峰期临时修改成2以应对大负载。
由于各个事务可以交叉的将事务日志拷贝到log buffer中,因而一次事务提交触发的写redo到文件,可能隐式的帮别的线程“顺便”也写了redo log,从而达到group commit的效果。
REDO Log Buffer里的REDO是怎么写入日志文件的?
1、写入Page Cache
写入到Log Buffer中的REDO数据需要进一步写入系统的Page Cache,InnoDB中有单独的log_writer来做这件事情。这里有个问题,由于Log Buffer中的数据是不同mtr并发写入的,这个过程中Log Buffer中是有空洞的,因此log_writer需要感知当前Log Buffer中连续日志的末尾,将连续日志通过pwrite系统调用写入文件系统Page Cache。整个过程中应尽可能不影响后续mtr进行数据拷贝,InnoDB在这里引入了log.recent_written,recent_written是一种link_buf的数据结构,也可以说是通过引入link_buf来实现的。link_buf的数据结构如下图所示:
link_buf
link_buf是一个循环使用的数组,对每个lsn取模可以得到其在link_buf上的一个槽位,在这个槽位中记录REDO长度。另外一个线程从开始遍历这个link_buf,通过槽位中的长度可以找到这条REDO的结尾位置,一直遍历到下一位置为0的位置,可以认为之后的REDO有空洞,而之前已经连续,这个位置叫做link_buf的tail。下面看看log_writer和众多mtr是如何利用这个link_buf数据结构的。这里的这个link_buf为log.recent_written,如下图所示:
log.recent_written 1
图中上半部分是REDO日志示意图,write_lsn是当前log_writer已经写入到Page Cache中日志末尾(即写入到page cache的最大lsn),current_lsn是当前已经分配给mtr的的最大lsn位置,而buf_ready_for_write_lsn是当前log_writer找到的Log Buffer中已经连续的日志结尾,从write_lsn到buf_ready_for_write_lsn是下一次log_writer可以连续调用pwrite写入Page Cache的范围,而从buf_ready_for_write_lsn到current_lsn是当前mtr正在并发写Log Buffer的范围。下面的连续方格便是log.recent_written的数据结构,可以看出由于中间的两个全零的空洞导致buf_ready_for_write_lsn无法继续推进,接下来,假如reserve到中间第一个空洞的mtr也完成了写Log Buffer,并更新了log.recent_written*,如下图:
log.recent_written 2
这时,log_writer从当前的buf_ready_for_write_lsn向后遍历log.recent_written,发现这段已经连续:
log.recent_written 3
因此提升当前的buf_ready_for_write_lsn,并将log.recent_written的tail位置向前滑动,之后的位置清零,供之后循环复用,之后log_writer将连续的内容刷盘并提升write_lsn。
log.recent_written 4
在log buffer中,buf_ready_for_write_lsn 之后位置的redo是严格连续的,就可以以512字节进行写盘, checkpoint_lsn位置之后的redo在log buffer中可以清除,以便进行循环使用
在recent_written中buf_ready_for_write_lsn位置之后记录的redo信息可以清除,以便进行循环使用
log.recent_closed 原理类似
2、循环写redo文件
初始时current_file_real_offset和current_file_lsn对应起来。之后的每次写入都同步更新这两个值,就可以完成逻辑上无限的current_file_lsn到实际有限的current_file_real_offset的映射转换。
current_file_end_offset代表当前在写的这个文件对应的结尾位置,如果current_file_real_offset超过这个位置就需要将其加上2K Header表示切换到下一个文件
files_real_capacity表示2个文件实际大小总和,如果current_file_real_offset超过这个值,代表当前2个文件都已经写完了,需要回绕到第一个文件重新写,这里就会将current_file_real_offset重新置为2048,完成回绕。
多个线程通过一些内部数据结构的辅助,完成高效的从REDO产生,到REDO写盘,再到唤醒用户线程的流程。大量的用户线程调用log_write_up_to等待在自己的lsn位置,为了避免大量无效的唤醒,InnoDB将阻塞的条件变量拆分为多个,log_write_up_to根据自己需要等待的lsn所在的block取模对应到不同的条件变量上去。同时,为了避免大量的唤醒工作影响log_writer或log_flusher线程,InnoDB中引入了两个专门负责唤醒用户的线程:log_wirte_notifier和log_flush_notifier,当超过一个条件变量需要被唤醒时,log_writer和log_flusher会通知这两个线程完成唤醒工作。
后台线程:
如上图所示,redo log的异步工作线程为4个,另2个异步辅助线程:分别是:log_writer, log_flusher, log_flush_notifier, log_write_notifier, log_checkpointer,log_close,log_flush_notifier /log_write_notifier为图中log notifier线程组,辅助线程为log_checkpointer, log_closer。在启动的函数 log_start_background_threads() 的时候, 会把相应的后台线程启动。
log_writer : 负责将日志从log buffer写入磁盘的filesystem page cache,并推进write_lsn(原子数据)
log_flusher : 负责fsync,并推进flushed_to_disk_lsn(原子数据)
log_write_notifier : 监听write_lsn,唤醒等待write_events的用户线程
log_flush_notifier : 监听flushed_to_disk_lsn,唤醒等待log fsync的用户线程。
log_closer : 1、在正常退出时清理所有redo_log相关lsn\log buffer相关数据结构;2、定期清理recent_closer的过老数据
log_checkpointer : 定期做checkpoint检查,根据flush list刷dirty page情况推进check point,释放log buffer等
线程间同步的条件变量:
writer_event
write_events[] ,默认2048个slot
write_notifier_event
flusher_event
flush_events[] 默认2048个slot
flush_notifier_event
redo log 模块线程间的协作:
图1 写 redo log
用户线程
并发写入log buffer
,如果写之前发现log buffer剩余空间不足,则唤醒等在writer_event上的log_writer线程来将log buffer数据写入page cache释放log buffer空间,在此期间,用户线程会等待在write_events[]上,等待log_writer线程写完page cache后唤醒,用户线程被唤醒后,代表当前log buffer有空间写入mtr对应的redo log,将其拷贝到log buffer对应位置,然后在recent_written上更新对应区间标记,接着将对应脏页挂到flush list上,并且在recent_closed上更新对应区间标记
log_writer
在writer_event上
等待用户线程唤醒或者timeout,唤醒后扫描recent_written
,检测从write_lsn后,log buffer中是否有新的连续log,有的话就将他们一并写入page cache
,然后唤醒此时可能等待在write_events[]上的用户线程或者等待在write_notifier_event上的log_write_notifier线程,接着唤醒等待在flusher_event上的log_flusher线程
|--> log_writer | |--> /*endless loop*/ | |--> //Innodb将redo log buffer内容写入日志文件时需要保证不能存在空洞, | |--> //即在写入前需要获得当前最大的无空洞lsn。这依赖LinkBuf。 | |--> //在后台写日志线程log_writer的log_advance_ready_for_write_lsn函数中完成。 | |--> log_advance_ready_for_write_lsn | | |--> //获取之前的tail值,这里仅为验证作用 | | |--> log_buffer_ready_for_write_lsn | | |--> //推进Link_buf::m_tail,同时回收之前空间 | | |--> log.recent_written.advance_tail_until(stop_condition) | |--> log_buffer_ready_for_write_lsn | |--> //Do the actual work | |--> log_writer_write_buffer | | |--> /* Do the write to the log files */ | | |--> log_files_write_buffer | | | |--> notify_about_advanced_write_lsn | | | | |--> //去唤醒write_notifier_event | | | | |--> os_event_set(log.write_notifier_event); | | | |--> log_update_buf_limit
谁来唤醒log_writer线程?
正常情况下. srv_flush_log_at_trx_commit == 1 的时候是没有人去唤醒这个log_writer, 这个os_event_wait_for 是在pthread_cond_timedwait 上的, 这个时间为 srv_log_writer_timeout = 10 微秒.
这个线程被唤醒以后, 执行log_writer_write_buffer() 后, 在执行log_files_write_buffer() 函数里面 执行 notify_about_advanced_write_lsn() 函数去唤醒write_notifier_event,
同时, 在执行完成 log_writer_write_buffer() 后. 会判断srv_flush_log_at_trx_commit == 1 就去唤醒 log.flusher_event
log_write_notifier
监控write_lsn,如果有增加,就去唤醒等待write_events[slot]的用户线程
log_write_notifier { /*endless loop*/ while(1) { sleep(); if (log.write_lsn.load() >= lsn) { while (lsn <= notified_up_to_lsn) { const auto slot = log_compute_write_event_slot(log, lsn); lsn += OS_FILE_LOG_BLOCK_SIZE; //唤醒在等待的用户线程 os_event_set(log.write_events[slot]); } lsn = write_lsn + 1; } } }
用户的thread 最后会等待在Log.write_events上, 用户的线程调用log_write_up_to, 最后根据
srv_flush_log_at_trx_commit 这个变量来判断执行路径
1、srv_flush_log_at_trx_commit != 1 的场景:
log_wait_for_write(log, end_lsn); 然后等待在log.write_events[slot] 上.
const auto wait_stats = os_event_wait_for(log.write_events[slot], max_spins,
srv_log_wait_for_write_timeout, stop_condition);
2、srv_flush_log_at_trx_commit == 1 的场景:
log_wait_for_flush(log, end_lsn); 等待在log.flush_events[slot] 上.
const auto wait_stats = os_event_wait_for(log.flush_events[slot], max_spins,
srv_log_wait_for_flush_timeout, stop_condition);
log_flusher
log_flusher, 在flusher_event上等待log_writer线程或者其他用户线程(调用log_write_up_to true)将其唤醒,比较上次刷盘的flushed_to_disk_lsn和当前写入page cache的write_lsn,如果小于后者,就将增量刷盘,然后唤醒可能等待在flush_events[]上的用户线程(调用log_write_up_to true)或者等待在flush_notifier_event上的log_flush_notifier
从上面可以看到一般由log_writer 执行os_event_set 唤醒
如果是 srv_flush_log_at_trx_commit == 1 的场景, 也就是我们最常见的写了事务, 必须flush 到磁盘, 才能返回的场景. 然后判断的是 last_flush_lsn < log.write_lsn.load(), 也就是上一次last_flush_lsn 比当前的write_lsn 小, 说明有新数据写入了, 那么就可以执行flush 操作了,
如果是 srv_flush_log_at_trx_commit != 1 的场景, 也就是写了事务不需要保证redolog 刷盘的场景, 那么就是定期被唤醒执行flush操作
os_event_wait_time_low(log.flusher_event, flush_every_us - time_elapsed_us, 0);
log_flusher:
|--> log_flusher | |--> /*endless loop*/ | |--> if (srv_flush_log_at_trx_commit != 1) | |--> os_event_wait_time_low(log.flusher_event) | |--> endif | |--> if (last_flush_lsn < log.write_lsn.load()) | |--> log_flush_low | |--> endif
log_flush_notifier
监控flushed_to_disk_lsn ,如果有增加就唤醒等待在 flush_events[slot] 上面的用户线程, 跟上面一样, 也是用户线程最后会等待在flush_events 上
由 log_flusher 来唤醒
log_flush_notifier { /*endless loop*/ while(1) { if (log.flushed_to_disk_lsn.load() >= lsn) { while (lsn <= notified_up_to_lsn) { const auto slot = log_compute_flush_event_slot(log, lsn); lsn += OS_FILE_LOG_BLOCK_SIZE; //唤醒在等待的log_flusher线程 os_event_set(log.flush_events[slot]); } lsn = flush_lsn + 1; } } }
图2 写 dirty page
log_closer
不等待任何条件变量,每隔一段时间,会扫描recent_closed,向前推进recent_closed.m_tail, recent_closed.m_tail代表之前的dirty page已经挂在flush_list上了,用来取checkpoint时用。
log_closer 这个线程是在后台不断的去清理recent_closed 的线程, 在mtr/mtr0mtr.cc:execute() 也就是mtr commit 的时候, 会把这个mtr 修改的内容对应start_lsn, end_lsn 的内容添加到recent_closed buffer 里面, 并且在添加到recent_closed buffer 之前, 也会把相应的page 都挂到buffer pool 的flush list 里面. 和其他线程不一样的地方在于, Log_closer 并没有wait 在一个条件变量上, 只是每隔1s 的轮询而已.
在这1s 一次的轮询里面, 一直执行的操作是 log_buffer_dirty_pages_added_up_to_lsn() 这个函数
这样一直清理着recent_closed buffer, 就可以保证recent_closed buffer 一直是有空间的
log_checkpointer
REDO的作用是避免只写了内存的数据由于故障丢失,那么打Checkpiont的位置就必须保证之前所有REDO所产生的内存脏页都已经刷盘。最直接的,可以从Buffer Pool中获得当前所有脏页对应的最小REDO LSN:lwm_lsn。 但光有这个还不够,因为有一部分min-transaction的REDO对应的Page还没有来的及加入到Buffer Pool的脏页中去,如果checkpoint打到这些REDO的后边,一旦这时发生故障恢复,这部分数据将丢失,因此还需要知道当前已经加入到Buffer Pool的REDO lsn位置:dpa_lsn。取二者的较小值作为最终checkpoint的位置,其核心逻辑如下:
/* LWM lsn for unflushed dirty pages in Buffer Pool / lsn_t lwm_lsn = buf_pool_get_oldest_modification_lwm(); / Note lsn up to which all dirty pages have already been added into Buffer Pool */ const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log); lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);
MySQL 8.0中为了能够让mtr之间更大程度的并发,允许并发地给Buffer Pool注册脏页。类似与log.recent_written和log_writer,这里引入一个叫做recent_closed的link_buf来处理并发带来的空洞,由单独的线程log_closer来提升recent_closed的tail,也就是当前连续加入Buffer Pool脏页的最大LSN,这个值也就是下面提到的dpa_lsn。需要注意的是,由于这种乱序的存在,lwm_lsn的值并不能简单的获取当前Buffer Pool中的最老的脏页的LSN,保守起见,还需要减掉一个recent_closed的容量大小,也就是最大的乱序范围。
/* LWM lsn for unflushed dirty pages in Buffer Pool / const lsn_t lsn = buf_pool_get_oldest_modification_approx(); const lsn_t lag = log.recent_closed.capacity(); lsn_t lwm_lsn = lsn - lag; / Note lsn up to which all dirty pages have already been added into Buffer Pool */ const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log); lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);
这里有个疑问,由于recent_closed最多只能有2M空洞,也就是说flush_list的空洞始终增量限制在2M之内,任何时候从flush_list上取得oldest_modification减去2M后,这个值已经可以确保:
在此之前的lsn对应的脏页都已经挂在flush_list上了
该值已经是当前还没有落盘的数据页最小oldest_modification的lsn了
直接用它感觉就可以了,没必要和recent_closed.m_tail作比较了??
综上所述,由于mysql8.0进行了无锁优化,导致 checkpoint_lsn可能位于redo log record中间。
因为flush_list 中的page->oldest_modification ,并非严格有序,而是局部有序。每次推进checkpoint的值,都是基于以下3个值:
-
recent_closed.m_tail,它代表在此之前的lsn对应的脏页都已经挂在了flush_list上
-
flush_list上取oldest_modification最小的lsn,它代表之前的lsn对应的脏页都已经刷到盘上。由于局部乱序,需要将上述的oldest_modification减去recent_closed.capacity()。
-
flushed_to_disk_lsn,它代表此之前lsn对应的redo log都已经刷到盘上
真正的checkpoint_lsn为min(1,2,3)。即:
-
此lsn对应的脏页都已经挂在flush_list上了
-
该值已经是当前还没有落盘的数据页最小oldest_modification的lsn了
之前的逻辑:
而之前的checkpoint_lsn为flush_list上page的oldest_modification,即mtr提交时刻的start_lsn,必然为mtr涉及到的日志的起始位置。不会在redo log record中间。
具体的做法:
1、遍历所有的buffer pool 的flush list, 然后只需要取出flush list 里面的最后一个元素(虽然因为引入了recent_closed 不能保证是最老的 lsn), 也就是最老的lsn, 然后对比8个flush_list, 最老的lsn 就是目前大概的lsn 了
2、在buf_pool_get_oldest_modification_lwm() 里面, 会将buf_pool_get_oldest_modification_approx() 获得的 lsn 减去recent_closed buffer 的大小, 这样得到的lsn 可以确保是可以打checkpoint 的, 但是这个lsn 不能保证是最大的可以打checkpoint 的lsn. 而且这个 lsn 不一定是指向一个记录的开始, 更多的时候是指向一个记录的中间, 因为这里会强行减去一个 recent_closed buffer 的size.
3、通过 log_consider_checkpoint(log); 来确定这次是否要写这个checkpointer 信息,在 log_should_checkpoint() 具体的有3个条件来判断是否要做 checkpointer,也就是找到真正的checkpoint_lsn
4、如果要做checkpoint, 通过 log_files_write_checkpoint 把checkpoint 信息写入到ib_logfile0 文件中
log_checkpointer的流程:
|--> log_checkpointer | |--> /*endless loop*/ | |--> log_update_available_for_checkpoint_lsn(log) | | |--> log_compute_available_for_checkpoint_lsn(log) | | | |--> //当前连续加入Buffer Pool脏页的最大LSN: dpa_lsn | | | |--> dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log) | | | |--> //获得当前所有脏页对应的最小REDO LSN:lwm_lsn | | | |--> lwm_lsn = buf_pool_get_oldest_modification_lwm(); | | | |--> flushed_lsn = log.flushed_to_disk_lsn.load(); | | | |--> lsn = std::min(dpa_lsn, lwm_lsn, flushed_lsn) | |--> /* Consider flushing some dirty pages. */ | |--> sync_flushed = log_consider_sync_flush(log); | | |--> log_request_sync_flush | | | |--> buf_flush_request_force | | | | |--> //buf_flush_sync_lsn作为page cleaner线程同步刷脏的依据 | | | | |--> buf_flush_sync_lsn = lsn_target; | | | | |--> os_event_set(buf_flush_event); | | |--> log_update_available_for_checkpoint_lsn | |--> /* Consider writing checkpoint. | |--> 把checkpoint 信息写入到ib_logfile0 文件中*/ | |--> checkpointed = log_consider_checkpoint(log) | | |--> log_checkpoint | | | |--> log_files_write_checkpoint(log, checkpoint_lsn); | | | | |--> fil_redo_io