MySQL8.0新特性-并发redo写入
一、REDO概述
为了弥补CPU与磁盘之间读写速度的巨大差异,MySQL采用了Buffer Pool来提高数据库的读写效率;同时为了保证数据持久化,大部分的事务数据库都采用WAL(预写日志),即当事务提交时,必须先确保将事务所有日志写入重做日志文件(redo log), 称之为force log at commit。当发生宕机而导致数据丢失时,通过重做日志来完成数据的恢复,这也是事务ACID中D(Durability持久性)的要求。
在Innodb存储引擎中,事务执行过程被分割成一个个MTR (Mini TRansaction),MTR主要用于Redo Log 和Undo Log写入,保证两种日志的ACID特性,每个MTR在执行过程中对数据页的更改会产生对应的Redo Log,只要保证Redo Log被持久化,就可以保证事务的持久化。
Innodb存储引擎在内存中维护了一个全局的Redo Log Buffer用以缓存对Redo Log的修改,MTR在提交的时候,会将MTR执行过程中产生的本地日志copy到全局Redo Log Buffer中,并将MTR执行过程中修改的数据页(也叫脏页dirty page)加入到一个全局的队列中Flush List。Innodb存储引擎会根据不同的策略将Redo Log Buffer中的日志落盘,或将Flush List中的脏页刷盘并推进Checkpoint。在脏页落盘以及Checkpoint推进的过程中,需要严格保证Redo日志先落盘再刷脏页的顺序。
二、MySQL8.0之前的实现方式
一个Buffer Pool实例维护一个Flush List, MTR是Innodb对物理文件操作的最小事务单元,redo log由MTR产生, 通常先写在MTR的cache里, 在8.0之前MTR提交的时候,首先在log_sys_t::mutex的互斥下,将自己的cache中的redo log写入到buffer中,并更新全局lsn;然后在log_sys_t::flush_order_mutex的互斥下,将MTR相关的脏页,按顺序放到Flush List中。保证Flush List上页按照写log buffer 拿到的LSN有序。
当多个MTR并发进行提交时,实际都是以串行的方式在进行,例如:t1得到flush_order_mutex,添加脏页的同时;t2请求flush_order_mutex,将会等待;为了保证总体的顺序,t2还持有mutex锁,那么此时其他的线程都要等待;这时整个系统的性能就下降了。
当去除掉这些锁时,会导致如下问题:
1、无法保证redo log LSN的顺序和Flush List中脏页按照LSN递增排序。
2、由于多个MTR并行copy数据到Redo Log Buffer,那必然会有一些MTR copy的快一些,有些MTR copy的比较慢,这时候Redo Log Buffer中可能会有GAP。
3、并行的添加脏页到Flush List会打破每个数据页对应LSN单调性约束,如何确定Checkpoint的位置?
8.0通过引入Link_buf数据结构与recent_written和recent_closed来解决并发redo写入。
三、8.0无锁实现方式
1、Redo Log Buffer 空洞
8.0通过获取MTR产生的redo log的大小,在全局Redo Log Buffer中分配空间预分配的方式使多个MTR不冲突的copy数据到Redo Log Buffer,但由于写入线程有快有慢,必然会造成Redo Log Buffer的空洞问题,这个使得Redo Log Buffer刷入到磁盘的行为变得复杂。
如上图所示,Redo Log Buffer中第一个和第三个线程已经完成写入,第二个线程正在写入到Redo Log Buffer中,这个时候是不能将三个线程的redo都落盘的。MySQL 8.0中引入了一个数据结构Link_buf解决这个问题。
Link_buf实际上是一个定长数组,并保证数组的每个元素的更新是原子性的,并以环形的方式复用已经释放的空间。
在Link_buf中,有单独的线程log_writer负责数组的遍历和空间回收,如果一个索引位置i对应的值n,则表示从i开始后面n个元素已被占用;如果线程在遇到空元素则暂停,意味着空洞。同时Link_buf内部维护了一个变量M表示当前最大可达的LSN。
Redo Log Buffer内部维护了两个Link_buf类型的变量recent_written和recent_closed来维护Redo Log Buffer和Flush List的修改信息。
对于Redo Log Buffer,buffer的使用情况和recent_written的对应关系如下图所示
buf_ready_for_write_lsn:这个变量维护的是可以保证无空洞的最大LSN值,即圆环中的M;小于M之前的LSN都已经写入Redo Log Buffer中,同时也是CRASH后崩溃恢复的截止点,同时也是下一个写log buffer的开始点。
write_lsn:表示已经刷盘的redo log的LSN号
current_lsn:预先分配空间后的最新LSN号
当第一个空洞位置被成功写入后,recent_written内部状态更新为如下图所示:
每次修改recent_written后,都会触发一个独立的线程log_writer向后扫描recent_written并更新buf_ready_for_write_lsn 值(即M),同时recent_written内部状态更新为如下图所示:
这样就很好的解决了MTR并发写入log_buffer造成的空洞问题。
2、Flush List 并发控制
因为移除了log_sys_t::flush_order_mutex,flush_list中的LSN就不再是有序的了,但依然要保证两个条件:
a、checkpoint的准确性,必须确保该checkpoint之前的LSN脏页都已经落盘
b、Flush List 上刷脏策略约束,保证最早被修改page的也最先从Flush List更新到磁盘, 同时还向后推进checkpoint_lsn
Innodb同样是通过引入一个Link_Buf类型的无锁结构recent_closed来跟踪并发写Flush List的状态,内部同样维护一个最大LSN(标记为M),该M前的LSN对应的脏页都已经确认加入到了Flush List,同时引入常数L(recent_closed内部容量大小,用来量化Flush List中的无序度),在MTR写入Flush List之前必须满足该MTR的start_lsn - M < L。所以在8.0中实际上加入到Flush List的行为并不是完全并发的,而是被控制到一个范围L之内的并行写入;核心思想就是整体有序,recent_closed内乱序,如下图:
3、Checkpoint推进
我们将flushList中的最早添加的脏页的lsn称为last_lsn;由于一个page可能会被修改多次,其中记录了oldest_modification和newest_modification(但是Buffer Pool中的状态始终是page最新的状态),5.7的Flush List中的每个page都满足oldest_modification >= last_lsn。而在8.0的Flush List中,没有按照lsn的顺序添加,意味着最早放到Flush List中的page的oldest_modification可能不是最小的。
由于MTR需要等待条件start_lsn - M < L成立才能加入到Flush List , 反过来说,对于Flush List中的每个Page ,如果其对应的修改的LSN为Ln,那么可以断定Ln - L对应的Page一定已经加入到了Flush List中,而且一定在当前Page之前(因为Page添加时的检查条件Ln-L < M,M之前是无空洞连续的LSN)。也就是说,在延续原有的按Flush List的顺序刷新脏页到磁盘的策略不变的情况下,只需要将Checkpoint的推进由原来的Page对应的LSN改成LSN-L即可。
TIPS:MySQL 8.0中实际实现的时候,Checkpoint推进仍然是按照Page对应的LSN写入的,只不过recover的时候从Checkpoint - L开始执行,但可能会遇到Checkpoint -L是某个Redo的中间位置而不是开始位置的情况,所以要对一些边界情况做一些额外的工作才行。
4、Log Buffer刷盘
当innodb_flush_log_at_trx_commit=1时事务提交时需要日志落盘的,8.0之后由专门的线程负责:
log_writer:8.0之前是由User Thread驱动的,每次将整个Log Buffer写出;现在只要LogBuffer中有数据可以写,专门的log_writer线程不断地将日志记录write到OS CACHE中;为了避免覆盖不完整的block,每次写都是写一个完整的block;同时更新write_lsn。
log_flusher:log_flusher不断的读取write_lsn,然后将日志落盘,同时更新flushed_to_disk_lsn。这样log_flusher和log_writer按照各自的速度同时运行,除了系统内核中的同步外(write_lsn的原子读写),没有同步操作。
四、总结
8.0的redo写入流程大概如下:
1、MTR提交时,预留LogBuffer的空间,并copy到Log Buffer中,然后得到start_lsn和end_lsn并迭代recent_write的连续最大LSN,调用log_writer将redo log刷到OS CACHE中。
2、满足start_lsn - M < L时,将脏页刷如Flush List,迭代recent_closed的连续最大LSN,调用log_flusher将redo 落盘。
MySQL官方用sysbench做了一个测试(oltp update_nokey, 8 tables, 10M rows/table, Innodb_flush_log_at_trx_commit = 1), 对比这个改进前后的性能数据, 如下所示:
可以看出在MySQL 5.7及以前,通过两个全局锁,实际上使MTR的提交过程串行化保证了RedoLog以及脏页处理的正确性,这使得MTR的提交过程因为锁竞争的缘故无法充分的发挥多核的优势。8.0中通过引入的Link_buf 数据结构将整个模块变成了Lock_free的模式,带来了性能上的提升。