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来保证
如图:
因此在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实现如图:
这个后台线程在用户写入数据到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空间,然后后续的时候写入就可以并行的写入了,也就是这一段的内容是允许有空洞的.
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
在启动的函数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文件中