日志先行的技术普遍应用于现代数据库中,其保证了数据库在数据不丢的状况下,进一步提升了数据库的性能。本文主要分析了WAL模块在MySQL各个版本中的演进以及在阿里云新一代数据库POLARDB中的改进。mysql
用户若是对数据库中的数据就好了修改,必须保证日志先于数据落盘。当日志落盘后,就能够给用户返回操做成功,并不须要保证当时对数据的修改也落盘。若是数据库在日志落盘前crash,那么相应的数据修改会回滚。在日志落盘后crash,会保证相应的修改不丢失。有一点要注意,虽然日志落盘后,就能够给用户返回操做成功,可是因为落盘和返回成功包之间有一个微小的时间差,因此即便用户没有收到成功消息,修改也可能已经成功了,这个时候就须要用户在数据库恢复后,经过再次查询来肯定当前的状态。 在日志先行技术以前,数据库只须要把修改的数据刷回磁盘便可,用了这项技术,除了修改的数据,还须要多写一份日志,也就是磁盘写入量反而增大,可是因为日志是顺序的且每每先存在内存里而后批量往磁盘刷新,相比数据的离散写入,日志的写入开销比较小。 日志先行技术有两个问题须要工程上解决:算法
MySQL中为了解决上述两个问题,采用了如下机制:sql
在第一点中的,咱们提到了私有变量mtr,这个结构除了存储了修改产生的日志和脏页外,还存储了修改脏页时加的锁。在适当的时候(例如日志提交完且脏页加入到脏页链表)能够把锁给释放。数据库
接下来,咱们结合各个版本的实现,来剖析一下具体实现细节。注意,如下内容须要一点MySQL源码基础,适合MySQL内核开发者以及资深的DBA。数组
5.1的版本是MySQL比较早的版本,那个时候InnoDB仍是一个插件。所以设计也相对粗糙,简化后的伪代码以下:缓存
日志进入全局缓存:数据结构
mutex_enter(log_sys->mutex); copy local redo log to global log buffer mtr.start_lsn = log_sys->lsn mtr.end_lsn = log_sys->lsn + log_len + log_block_head_or_tail_len increase global lsn: log_sys->lsn, log_sys->buf_free for every lock in mtr if (lock == share lock) release share lock directly else if (lock == exclusive lock) if (lock page is dirty) if (page.oldest_modification == 0) //This means this page is not in flush list page.oldest_modification = mtr.start_lsn add to flush list // have one flush list only release exclusive lock mutex_exit(log_sys->mutex);
日志写入磁盘:多线程
mutex_enter(log_sys->mutex); log_sys->write_lsn = log_sys->lsn; write log to log file mutex_exit(log_sys->mutex);
更新checkpoint:并发
page = get_first_page(flush_list) checkpoint_lsn = page.oldest_modification write checkpoint_lsn to log file
奔溃恢复:app
read checkpoint_lsn from log file start parse and apply redo log from checkpoint_lsn point
从上述伪代码中能够看出,因为日志进入全局的缓存都在临界区内,不但保证了拷贝日志的有序性,也保证了脏页进入脏页链表的有序性。须要获取checkpoint_lsn时,只需从脏页链表中获取第一个数据页的oldest_modification便可。奔溃恢复也只须要从记录的checkpoint点开始扫描便可。在高并发的场景下,有不少线程须要把本身的local日志拷贝到全局缓存,会形成锁热点,另外在全局日志写入日志文件的地方,也须要加锁,进一步形成了锁的争抢。此外,这个数据库的缓存(Buffer Pool)只有一个脏页链表,性能也不高。这种方式存在于早期的InnoDB代码中,通俗易懂,但在如今的多核系统上,显然不能作到很好的扩展性。
这三个版本是目前主流的MySQL版本,不少分支都在上面作了很多优化,可是主要的处理逻辑变化依然不大:
日志进入全局缓存:
mutex_enter(log_sys->mutex); copy local redo log to global log buffer mtr.start_lsn = log_sys->lsn mtr.end_lsn = log_sys->lsn + log_len + log_block_head_or_tail_len increase global lsn: log_sys->lsn, log_sys->buf_free mutex_enter(log_sys->log_flush_order_mutex); mutex_exit(log_sys->mutex); for every page in mtr if (lock == exclusive lock) if (page is dirty) if (page.oldest_modification == 0) //This means this page is not in flush list page.oldest_modification = mtr.start_lsn add to flush list according to its buffer pool instance mutex_exit(log_sys->log_flush_order_mutex); for every lock in mtr release all lock directly
日志写入磁盘:
mutex_enter(log_sys->mutex); log_sys->write_lsn = log_sys->lsn; write log to log file mutex_exit(log_sys->mutex);
更新checkpoint:
for ervery flush list: page = get_first_page(curr_flush_list); if current_oldest_modification > page.oldest_modification current_oldest_modification = page.oldest_modification checkpoint_lsn = current_oldest_modification write checkpoint_lsn to log file
奔溃恢复:
read checkpoint_lsn from log file start parse and apply redo log from checkpoint_lsn point
主流的版本中最重要的一个优化是,除了log_sys->mutex外,引入了另一把锁log_sys->log_flush_order_mutex。在脏页加入到脏页链表的操做中,不须要log_sys->mutex保护,而是须要log_sys->log_flush_order_mutex保护,这样减小了log_sys->mutex的临界区,从而减小了热点。此外,引入多个脏页链表,减小了单个链表带来的冲突。 注意,主流的分支还作了不少其余的优化,例如:
mutex_enter(log_sys->write_mutex); check if other thead has done write for us mutex_enter(log_sys->mutex); calculate the range log need to be write switch log buffer so that user threads can still copy log during writing mutex_exit(log_sys->mutex); align log to specified size if needed write log to log file log_sys->write_lsn = log_sys->lsn; mutex_exit(log_sys->write_mutex);
能够看到log_sys->mutex被进一步缩小。往日志文件里面写日志的阶段已经不准要log_sys->mutex保护了。 有了以上的优化,MySQL的日志子系统在大多数场景下不会达到瓶颈。可是,用户线程往全局日志缓存拷贝日志以及脏页加入脏页链表这两个操做,依然是基于锁机制的,很难发挥出多核系统的性能。
以前的版本虽然作了不少优化,可是没有真正作到lock free,在高并发下,能够看到不少锁冲突。官方所以在这块下了大力气,彻头彻尾的大改了一番。 详细细节能够参考上个月这篇月报。 这里再简单归纳一下。 在日志写入阶段,经过atomic变量分配保留空间,因为atomic变量增加是个原子操做,因此这一步不要加锁。分配完空间后,就能够拷贝日志,因为上一步中空间已经被预留,因此多线程能够同时进行拷贝,而不会致使日志有重叠。可是不能保证拷贝完成的前后顺序,有可能先拷贝的,后完成,因此须要有一种机制来保证某个点以前的日志已经都拷贝到全局日志缓存了。这里,官方就引入了一种新的lock free数据结构Link_buf,它是一个数组,用来标记拷贝完成的状况。每一个用户线程完成拷贝后,就在那个数组中标记一下,而后后台再开一个线程来计算是否有连续的块完成拷贝了,完成了就能够把这些日志刷到磁盘。 在脏页插入脏页链表这一块,官方也提出了一种有趣的算法,它也是基于新的lock free数据结构Link_buf。基本思想是,脏页链表的有序性能够被部分的打破,也就是说,在必定范围内能够无序,可是总体仍是有序的。这个无序程序是受控的。假设脏页链表第一个数据页的oldest_modification为A, 在以前的版本中,这个脏页链表后续的page的oldest_modification都严格大于等于A,也就是不存在一个数据页比第一个数据页还老。在MySQL 8.0中,后续的page的oldest_modification并非严格大于等于A,能够比A小,可是必须大于等于A-L,这个L能够理解为无序度,是一个定值。那么问题来了,若是脏页链表顺序乱了,那么checkpoint怎么肯定,或者说是,奔溃恢复后,从那个checkpoint_lsn开始扫描日志才能保证数据不丢。官方给出的解法是,checkpoint依然由脏页链表中第一个数据页的oldest_modification的肯定,可是奔溃恢复从checkpoint_lsn-L开始扫描(有可能这个值不是一个mtr的边界,所以须要调整)。 因此能够看到,官方经过link_buf这个数据结构很巧妙的解决了局部日志往全局日志拷贝的问题以及脏页插入脏页链表的问题。因为都是lock free算法,所以扩展性会比较好。 可是,从实际测试的状况来看,彷佛是由于用了太多的条件变量event,在咱们的测试中没有官方标称的性能。后续咱们会进一步分析缘由。
POLARDB做为阿里云下一代关系型云数据库,咱们天然在InnoDB日志子系统作了不少优化,其中也包含了上述的领域。这里能够简单介绍一下咱们的思路:
每一个buffer pool instance都额外增长了一把读写锁(rw_locks),主要用来控制对全局日志缓存的访问。 此外还引入两个存储脏页信息的集合,咱们这里简称in-flight set和ready-to-process set。主要用来临时存储脏页信息。
日志进入全局缓存:
release all share locks holded by this mtr's page acquire log_buf s-locks for all buf_pool instances for which we have dirty pages reserver enough space on log_buf via increasing atomit variables //Just like MySQL 8.0 copy local log to global log buffer add all pages dirtied by this mtr to in-flight set release all exclusive locks holded by this mtr's page release log_buf s-locks for all buf_pool instances
日志写入磁盘:
mutex_enter(log_sys->write_mutex) check if other thead has done write for us mutex_enter(log_sys->mutex) acquire log_buf x-locks for all buf_pool instances update log_sys->lsn to newest switch log buffer so that user threads can still copy log during writing mutex_exit(log_sys->mutex) release log_buf x-locks for all buf_pool instances align log to specified size if needed write log to log file log_sys->write_lsn = log_sys->lsn; mutex_exit(log_write_mutex)
刷脏线程(每一个buffer pool instance):
acquire log_buf x-locks for specific buffer pool instance toggle in-flight set with ready-to-process set. Only this thread will toggle between these two. release log_buf x-locks for specific buffer pool instance for each page in ready-to-process add page to flush list do normal flush page operations
更新checkpoint:
for ervery flush list: acquire log_buf x-locks for specific buffer pool instance ready_to_process_lsn = minimum oldest_modification in ready-to-process set flush_list_lsn = get_first_page(curr_flush_list).oldest_modification min_lsn = min(ready_to_process_lsn, flush_list_lsn) release log_buf x-locks for specific buffer pool instance if current_oldest_modification > min_lsn current_oldest_modification = min_lsn checkpoint_lsn = current_oldest_modification write checkpoint_lsn to log file
奔溃恢复:
read checkpoint_lsn from log file start parse and apply redo log from checkpoint_lsn point
在局部日志拷贝入全局日志这块,与官方MySQL 8.0相似,首先利用atomic变量的原子增加来分配空间,可是MySQL 8.0是使用link_buf来保证拷贝完成,而在POLARDB中,咱们使用读写锁的机制,即在拷贝以前加上读锁,拷贝完才释放读锁,而在日志写入磁盘前,首先尝试加上写锁,利用写锁和读锁互斥的特性,保证在获取写锁时全部读锁都释放,即全部拷贝操做都完成。 在脏页进入脏页链表这块,官方MySQL容许脏页链表有必定的无序度(也是经过link_buf保证),而后经过在奔溃恢复的时候从checkpoint_lsn-L开始扫描的机制,来保证数据的一致性。在POLARDB中,咱们解决办法是,把脏页临时加入到一个集合,在刷脏线程工做前再按顺序加入脏页链表,经过获取写锁来保证在加入脏页链表前,整个集合是完整的。换句话说,假设这个脏页集合最小的oldest_modification为A,那么能够保证没有加入脏页集合的脏页的oldest_modification都大于等于A。 从脏页集合加入到脏页链表的操做,咱们没有加锁,因此在更行checkpoint的时候,咱们须要使用min(ready_to_process_lsn, flush_list_lsn)来做为checkpoint_lsn。在奔溃恢复的时候,直接从checkpoint_lsn扫描便可。 此外,咱们在POLARDB上,还作了额外的优化:
最后咱们测试了一下性能,在non_index_updates的全内存高并发测试下,性能有10%的提升。
Upstream 5.6.40: 71K MySQL-8.0: 132K PolarDB (master): 162K PolarDB(master + mtr_optimize): 178K
固然,这不是咱们最高的性能,能够小小透露一下,经过对事务子系统的优化,咱们能够达到200K的性能。 更多更好用的功能都在路上,欢迎使用POLARDB!
日志子系统是关系型数据库不可获取的模块,也是数据库内核开发者很是感兴趣的模块,本文结合代码分析了MySQL不一样版本的WAL机制的实现,但愿对你们有所帮助。