mysql重做日志_mysql8.0中的无锁重做日志源码介绍

MySQL 8.0改进了InnoDB存储引擎的重做日志处理,实现了无锁设计,提高了并发性能。通过预分配LSN地址和使用原子操作,消除了对log_sys_t::mutex的依赖。此外,引入了log_writer线程检查logbuffer是否有空洞,并使用recent_written记录连续位置。同时,通过log_closer线程定期检查recent_closed,以保持flushlist的并发添加。这些改变将重做日志的操作分为多个阶段,包括写入、刷新到磁盘和添加到flushlist等,提升了系统的整体性能。
摘要由CSDN通过智能技术生成

InnoDB和大部分的存储引擎一样,都是采用WAL的方式进行写入数据,所有的数据都先写入到redolog,然后后续再从bufferpool刷脏到数据页又或者是备份恢复的时候从redolog恢复到bufferpoll,然后在刷脏到数据页,WAL很重要的一点是将随机写转换成了顺序写,所以在机械磁盘时代,顺序写的性能远远大于随机写的背景下,充分利用了磁盘的性能.但是也带来一个问题,就是任何的写入操作都必须加锁访问,保证上一个写入操作完成以后,才能进行下一个写入操作.在InnoDB早期版本也是这样实现,但是随着cpu核数的增长,这样频繁的加锁就无法发挥多核的性能,所以在InnoDB8.0改成了无锁实现这个是官方的介绍:https://mysqlserverteam.com/mysql-8-0-new-lock-free-scalable-wal-design

5.6版本实现

有两个操作需要获得全局的mutex,log_sys_t::mutex,log_sys_t::flush_order_mutex

每一个用户连接有一个线程,要写入数据之前必须先获得log_sys_t::mutex,用来保证只有一个用户线程在写入logbuffer那么随着连接数的增加,这个性能必然会受到影响

同样的在把已经写入完成的redolog加入到flushlist的时候,为了保证只有一个用户线程从logbuffer上添加buffer到flushlist,因此需要去获得log_sys_t::flush_order_mutex来保证

如图:

334345bf012833afca31f6721dbc0194.png

因此在5.6版本的实现中,我们需要先获得log_sys_t::mutex,然后写入buffer,然后获得log_sys_t::flush_order_mutex,释放log_sys_t::mutex,然后把对应的page加入到flushlist

所以8.0无锁实现主要就是要去掉这两个mutex

8.0无锁实现

log_sys_t::mutex*

在去掉第一个log_sys_t::mutex的时候,通过在写入之前先预先分配地址,然后在写入的时候往指定地址写入,这样就无需抢mutex.同样,问题来了:所有的线程都去获得lsn地址的时候,同样需要有一个mutex来防止冲突,InnoDB通过使用atomic来达到无锁的实现,即:constsn_tstart_sn=log.sn.fetch_add(len);

在每一个线程获得了自己要写入的lsn的位置以后,写入自然就可以并发起来了.

那么在写入的时候,如果位置在前面的线程未写完,而位置靠后的已经写完了,这个时候我该如何将Logbuffer中的内容写入到redolog,肯定不允许写入的数据有空洞.

8.0里面引入了log_writer线程,log_writer线程去检查logbuffer是否有空洞.具体实现是引入了叫recent_written用来记录logbuffer是否连续,这个recent_written是一个link_buf实现,类型于并查集.因此最大t允许并发写入的大小就是这个recent_written的大小

link_buf实现如图:

347bdaff02227f6cb05d3278a4a7a561.png

这个后台线程在用户写入数据到recent_writtenbuffer的时候,就被唤醒,检查这个recent_written连续的位置是否可以往前推进,如果可以,就往前走,将recent_writtenbuffer中的内容写入到redolog

log_sys_t::flush_order_mutex

如果不去掉flush_order_mutex,用户线程依然无法并发起来,因为用户线程在写完redolog以后,需要把对应的page加入到flushlist才可以退出,而加入到flushlist需要去获得flush_order_mutex锁,才能保证顺序的加入flushlist.因此也必须把flush_order_mutex去掉.

具体做法允许把logbuffer中的对应的脏页无序的添加到flushlist.用户写完logbuffer以后就可以把对应的logbuffer对应的脏页添加到flushlist.而无需去抢flush_order_mutex.这样可能出现加入到flushlist上的pagelsn是无序的,因此在做checkpoint的时候,就无法保证每一个flushlist上面最头的pagelsn是最小的

InnoDB用一个recent_closed来记录添加到flushlist的这一段logbuffer是否连续,那么容易得出,flushlist上pagelsn-recent_closed.size()得到的lsn用于做checkpoint肯定的安全的.

同样,InnoDB后台有Log_closer线程定期检查recent_closed是否连续,如果连续就把recent_closedbuffer向前推进,那么checkpoint的信息也可以往前推进了

所以在8.0的实现中,把一个writeredolog的操作分成了几个阶段

获得写入位置,实现:用户线程

写入数据到logbuffer实现:用户线程

将logbuffer中的数据写入到redolog文件实现:logwriter

将redolog中的pagecacheflush到磁盘实现:logflusher

将redolog中的logbuffer对应的page添加到flushlist

更新可以打checkpoint位点信息recent_closed实现:logcloser

根据recent_closed打checkpoint信息实现:logcheckpointer

代码实现

redolog里面主要的内存结构

logfile.也就是我们常见的ib_logfile文件

logbuffer,通常的大小是64M.用户在写入的时候先从mtr拷贝到redologbuffer,然后在logbuffer里面会加入相应的header/footer信息,然后由logbuffer刷到redologfile.

logrecentwrittenbuffer默认大小是4M,这个是MySQL8.0加入的,为的是提高写入时候的concurrent,早5.6版本的时候,写入Logbuffer的时候是需要获得Lock,然后顺序的写入到LogBuffer.在8.0的时候做了优化,写入logbuffer的时候先reserve空间,然后后续的时候写入就可以并行的写入了,也就是这一段的内容是允许有空洞的.

2822a7da149caa8cc45979a0a6a508d3.png

logrecentclosedbuffer默认大小也是4M,这个也是MySQL8.0加入的,可以理解为logrecentwrittenbuffer在这个logbuffer的最前面,logrecentclosedbuffer在logbuffer的最后面.也是为了添加到flushlist的时候提供concurrent.具体实现方式和logrecentwrittenbuffer类似.5.6版本的时候,将page添加到flushlist的时候,必须有一个Mutex加锁,然后按照顺序的添加到flushlist上.8.0的时候运行recentclosedbuffer大小的page是并行的加入到flushlist,也就是这一段的内容是允许有空洞的.

logwriteaheadbuffer默认大小是4k,用于避免写入小于4k大小数据的时候需要先将磁盘上的读取,然后修改一部分的内容,在写入回去.

主要的lsn

log.write_lsn

这个lsn是到这个lsn为止,之前所有的data已经从logbuffer写到logfiles了,但是并没有保证这些logfile已经flush到磁盘上了,下面log.fushed_to_disk_lsn指的才是已经flush到磁盘的lsn了.

这个值是由logwriterthread来更新

log.buf_ready_for_write_lsn

这个lsn主要是由于redolog引入的concurrentwrites才引进的,也就是logrecentwrittenbuffer.也就是到了这个lsn为止,之前的logbuffer里面都不会有空洞,

这个值也是由logwriterthread来更新

log.flushed_to_disk_lsn

到了这个lsn为止,所有的写入到redolog的数据已经flush到logfiles上了

这个值是由logflusherthread来更新

所以有log.flushed_to_disk_lsn<= log.write_lsn <= log.buf_ready_for_write_lsn

log.sn

也就是不算上12字节的header,4字节的checksum以后的实际写入的字节数信息.通常用这个log.sn去换算获得当前的current_lsn

*current_lsn=log_get_lsn(log);inlinelsn_tlog_get_lsn(constlog_t&log){return(log_translate_sn_to_lsn(log.sn.load()));}constexprinlinelsn_tlog_translate_sn_to_lsn(lsn_tsn){return(sn/LOG_BLOCK_DATA_SIZE*OS_FILE_LOG_BLOCK_SIZE+sn%LOG_BLOCK_DATA_SIZE+LOG_BLOCK_HDR_SIZE);}

以下几个lsn跟checkpoint相关

log.buffer_dirty_pages_added_up_to_lsn

到这个lsn为止,所有的redolog对应的dirtypage已经添加到bufferpool的flushlist了.

这个值其实就是recent_closed.tail()

inlinelsn_tlog_buffer_dirty_pages_added_up_to_lsn(constlog_t&log){return(log.recent_closed.tail());}

这个值由logcloserthread来更新

log.available_for_checkpoint_lsn

到这个lsn为止,所有的redolog对应的dirtypage已经flush到btree上了,因此这里我们flush的时候并不是顺序的flush,所以有可能存在有空洞的情况,因此这个lsn的位置并不是最大的redolog已经被flush到btree的位置.而是可以作为checkpoint的最大的位置.

这个值是由logcheckpointerthread来更新

log.last_checkpoint_lsn

到这个lsn为止,所有的btreedirtypage已经flushed到disk了,并且这个lsn值已经被更新到了ib_logfile0这个文件去了.

这个lsn也是下一次recovery的时候开始的地方,因为last_checkpoint_lsn之前的redolog已经保证都flush到btree中去了.所以比这个lsn小的redolog文件已经可以删除了,因为数据已经都flush到btreedatapage中去了.

这个值是由logcheckpointerthread来更新

所以log.last_checkpoint_lsn<= log.available_for_checkpoint_lsn <= log.buf_dirty_pages_added_up_to_lsn

为什么会有这么多的lsn?

主要还是由于写redolog这个过程被拆开成了多个异步的流程.

先写入到logbuffer,然后由logwriter异步写入到redolog,然后再由logflusher异步进行刷新.

中间在logwriter写入到redolog的时候,引入了logrecentwrittenbuffer来提高concurrent写入性能.

同时在把这个page加入到flushlist的时候,也一样是为了提高并发,增加了recent_closedbuffer.

redolog模块后台thread

025ef2323645de088f48a466cf6b41a3.png

f8971ecb984fd12cb619fe9590fafb5a.png

在启动的函数Log_start_background_threads的时候,会把相应的线程启动

os_thread_create(log_checkpointer_thread_key,log_checkpointer,&log);os_thread_create(log_closer_thread_key,log_closer,&log);os_thread_create(log_writer_thread_key,log_writer,&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_flush_notifier_thread_key,log_flush_notifier,&log);

这里主要有

log_writer:

log_writer这个线程等在writer_event这个os_event上,然后判断的是log.write_lsn.load()< ready_lsn. 这个ready_lsn 是去扫一下log buffer, 判断是否有新的连续的内存了. 这个线程主要做的事情就是不断去检查 log buffer 里面是否有连续的已经写入数据的内存 buffer,  执行的函数是 log_writer_write_buffer()=>log_files_write_buffer()=>write_blocks()=>fil_redo_io()=>shard->do_redo_io()=>os_file_write()=>...=>pwrite(m_fh,m_buf,m_n,m_offset);

这里这个io是同步,非directIO.

将这部分的数据内容刷到redolog中去,但是不执行fsync命令,具体执行fsync命令的是log_flusher.

问题:谁来唤醒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:

log_write_notifer是等待在write_notifier_event这个os_event上,然后判断的是log.write_lsn.load()>=lsn,lsn是上一次的log.write_lsn.也就是判断Log.write_lsn有没有增加,如果有增加就唤醒这个log_write_notifier,然后log_write_notifier就去唤醒那些等待在log.write_events[slot]的用户thread.

从上面可以看到,由log_writer执行os_event_set唤醒

有哪些线程等待在log.write_events上呢?

都是用户的thread最后会等待在Log.write_events上,用户的线程调用log_write_up_to,最后根据

srv_flush_log_at_trx_commit这个变量来判断是执行

!=1log_wait_for_write(log,end_lsn);然后等待在log.write_events[slot]上.

constautowait_stats=os_event_wait_for(log.write_events[slot],max_spins,srv_log_wait_for_write_timeout,stop_condition);

=1log_wait_for_flush(log,end_lsn);等待在log.flush_events[slot]上.

constautowait_stats=os_event_wait_for(log.flush_events[slot],max_spins,srv_log_wait_for_flush_timeout,stop_condition);

log_flusher

log_flusher是等待在log.flusher_event上,

从上面可以看到一般来说,由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刷盘的场景,那么执行的是

os_event_wait_time_low(log.flusher_event,flush_every_us-time_elapsed_us,0);

也就是会定期的根据时间来唤醒,然后执行flusher操作.

最后执行完成flush以后唤醒的是log.flush_notifier_eventos_event_set(log.flush_notifier_event);

log_flush_notifier

和log_write_notifier基本一样,等待在flush_notifier_event上,然后判断的是log.flushed_to_disk_lsn.load()>=lsn,这里lsn是上一次的flushed_to_disk_lsn,也就是判断flushed_to_disk_lsn有没有增加,如果有增加就唤醒等待在flush_events[slot]上面的用户线程,跟上面一样,也是用户线程最后会等待在flush_events上

从上面可以看到,有log_flusher唤醒它

log_closer

log_closer这个线程是在后台不断的去清理recent_closed的线程,在mtr/mtr0mtr.cc:execute()也就是mtrcommit的时候,会把这个mtr修改的内容对应start_lsn,end_lsn的内容添加到recent_closedbuffer里面,并且在添加到recent_closedbuffer之前,也会把相应的page都挂到bufferpool的flushlist里面.

和其他线程不一样的地方在于,Log_closer并没有wait在一个条件变量上,只是每隔1s的轮询而已.

而在这1s一次的轮询里面,一直执行的操作是log_advance_dirty_pages_added_up_to_lsn()这个函数类似recent_writtern里面的log_advance_ready_for_write_lsn(),去这个recent_close里面的Link_buf里面

/**从recent_closed.m_tail一直往下找,只要有连续的就串到一起,直到*找到有空洞的为止*只要找到数据,就更新m_tail到最新的位置,然后返回true*一条数据都没有返回false*注意:在advance_tail_until操作里面,本身同时会进行的操作就是回收之前的空间*所以执行完advance_tail_until以后,连续的内存就会被释放出来了*下面还有validate_no_links函数进行检查是否释放正确*/

这样一直清理着recent_closedbuffer,就可以保证recent_closedbuffer一直是有空间的

log_closerthread会一直更新着这个log_advance_dirty_pages_added_up_to_lsn(),这个函数里面就是一直去更新recent_closebuffer里面的log_buffer_dirty_pages_added_up_to_lsn(),然后在做checkpointer的时候,会一直去检查这个log_buffer_dirty_pages_added_up_to_lsn(),可以做checkpoint的lsn必须小于这个log_buffer_dirty_pages_added_up_to_lsn(),因为log_buffer_dirty_pages_added_up_to_lsn表示的是recentclosebuffer里面的其实位置,在这个位置之前的Lsn都已经被填满,是连续的了,在这个位置之后的lsn没有这个保证.

那么是谁负责更新recent_closed这个数组呢?log_closedthread

什么时候把dirtypage加入到bufferpool的flushlist上?

在mtr->commit()的时候,就会把这个mtr修改过的page都加到flushlist上,在添加到flushlist上之前,我们会保证写入到redolog,并且这个redolog已经flush了.

log_checkpointer

这个线程等待在log.checkpointer_event上,然后判断的是10*1000,也就是10s的时间,

os_event_wait_time_low(log.checkpointer_event,10*1000,sig_count);

os_event_wait_time_low是等待checkpointer_event被唤醒,或者超时时间10s到了,其实就是pthread_cond_timedwait()

正常情况下都是等10s然后log_checkpointer被唤醒,那么被通知到checkpointer_event被唤醒的场景在哪里呢?

其实也是在log_writer_write_buffer()函数里面,先判断

while(1){constlsn_tlsn_diff=min_next_lsn-checkpoint_lsn;if(lsn_diff<= log.lsn_capacity) {   checkpoint_limited_lsn = checkpoint_lsn + log.lsn_capacity;   break; } log_request_checkpoint(log, false);   ... } // 为什么需要在log_writer 的过程加入这个逻辑, 这个逻辑是判断lsn_diff(当前这次要写入的数据的大小) 是否超过了log.lsn_capacity(redolog 的剩余容量大小), 如果比它小, 那么就可以直接进行写入操作, 就break 出去, 如果比它大, 那么说明如果这次写入写下去的话, 因为redolog 是rotate 形式的, 会把当前的redolog 给写坏, 所以必须先进行一次checkpoint, 把一部分的redolog 中的内容flush 到btree data中, 然后把这个checkpoint 点增加, 腾出空间. // 所以我们看到如果checkpoint 做的不够及时, 会导致redolog 空间不够, 然后直接影响到线上的写入线程.

首先我们必须知道一个问题是,一次transaction修改的page什么时候flush下去,我们是不知道的.因为用户只需要写入到redolog,并且确认redolog已经flush了以后,就直接返回了.至于什么时候从Bufferpoolflush到btreedata,这个是后台异步的,用户也不关注的.但是我们打checkpoint以后,在checkpoint之前的redolog应该是都可以删除的,因此我们必须保证打的checkpointlsn的这个点之前的redolog已经将对应的pageflush到磁盘上了,

那么这里的问题就是如何确定这个checkpointlsn点?

在函数log_update_available_for_checkpoint_lsn(log);里面更新log.available_for_checkpoint_lsn

具体的更新过程:

然后在log_request_checkpoint里面执行log_update_available_for_checkpoint_lsn(log)=>

constlsn_toldest_lsn=log_get_available_for_checkpoint_lsn(log);

然后执行lsn_tlwn_lsn=buf_pool_get_oldest_modification_lwm()=>

buf_pool_get_oldest_modification_approx()

这里buf_pool_get_oldest_modification_approx()指的是获得大概的最老的lsn的位置,这里是引入了recent_closedbuffer带来的一个问题,因为引入了recent_closedbuffer以后,从redolog上面的page添加到bufferpool的flushlist是不能保证有序的,有可能一个flushlist上面存在的是98=>85=>110这样的情况.因此这个函数只能获得大概的oldest_modificationlsn

具体的做法就是遍历所有的bufferpool的flushlist,然后只需要取出flushlist里面的最后一个元素(虽然因为引入了recent_closed不能保证是最老的lsn),也就是最老的lsn,然后对比8个flush_list,最老的lsn就是目前大概的lsn了

然后在buf_pool_get_oldest_modification_lwm()还是里面,会将buf_pool_get_oldest_modification_approx()获得的lsn减去recent_closedbuffer的大小,这样得到的lsn可以确保是可以打checkpoint的,但是这个lsn不能保证是最大的可以打checkpoint的lsn.而且这个lsn不一定是指向一个记录的开始,更多的时候是指向一个记录的中间,因为这里会强行减去一个recent_closedbuffer的size.而以前在5.6版本是能够保证这个lsn是默认一个redolog的record的开始位置

最后通过log_consider_checkpoint(log);来确定这次是否要写这个checkpointer信息

然后在log_should_checkpoint()具体的有3个条件来判断是否要做checkpointer

最后决定要做的时候通过log_checkpoint(log);来写入checkpointer的信息

在log_checkpoint()函数里面

通过log_determine_checkpoint_lsn()来判断这次checkpointer是要写入dict_lsn,还是要写入available_for_checkpoint_lsn.在dict_lsn指的是上一次DDL相关的操作,到dict_lsn为止所有的metadata相关的都已经写入到磁盘了,这里为什么要把DDL相关的操作和非DDL相关的操作分开呢?

最后通过log_files_write_checkpoint把checkpoint信息写入到ib_logfile0文件中

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值