本文我们来简单过一下InnoDB的IO子系统相关模块的代码逻辑。主要包括IO读写线程、预读逻辑、InnoDB读写Page以及社区的一些改进。
前言
InnoDB对page的磁盘操作分为读操作和写操作。
对于读操作,在将数据读入磁盘前,总是为其先预先分配好一个block,然后再去磁盘读取一个新的page,在使用这个page之前,还需要检查是否有change buffer项,并根据change buffer,进行数据变更。
读操作分为两种场景:普通的读page及预读操作,前者为同步读,后者为异步读
Page写操作也分为两种,一种是batch write,一种是single page write。写page默认受double write buffer保护,因此对double write buffer的写磁盘为同步写,而对数据文件的写入为异步写。
同步读写操作通常由用户线程来完成,而异步读写操作则需要后台线程的协同。
举个简单的例子,假设我们向磁盘批量写数据,首先先写到double write buffer,当dblwr满了之后,一次性将dblwr中的数据同步刷到Ibdata,在确保sync到dblwr后,再将这些page分别异步写到各自的文件中。注意这时候dblwr依旧未被清空,新的写Page请求会进入等待。
当异步写page完成后,io helper线程会调用buf_flush_write_complete,将写入的Page从flush list上移除。当dblwr中的page完全写完后,在函数buf_dblwr_update里将dblwr清空。这时候才允许新的写请求进dblwr。
同样的,对于异步写操作,也需要IO Helper线程来检查page是否完好、merge change buffer等一系列操作。
除了page的写入,还包括日志异步写入线程、及ibuf后台线程。
后台线程
* IO READ 线程 —- 后台读线程数,线程数目通过参数innodb_read_io_threads配置
主要处理INNODB 数据文件异步读请求,任务队列为os_aio_read_array,任务队列包含slot数为线程数 * 256(linux 平台),也就是说,每个read线程最多可以pend 256个任务;
* IO WRITE 线程 —- 后台写线程数,线程数目通过参数innodb_write_io_threads配置
主要处理INNODB 数据文件异步写请求,任务队列为os_aio_write_array,任务队列包含slot数为线程数 * 256(linux 平台),也就是说,每个read线程最多可以pend 256个任务;
* LOG 线程 — 写日志线程
只有在写checkpoint信息时才会发出一次异步写请求。任务队列为os_aio_log_array,共1个segment,包含256个slot
* IBUF 线程 — 负责读入change buffer页的后台线程,任务队列为os_aio_ibuf_array,共1个segment,包含256个slot
所有的同步写操作都是由用户线程或其他后台线程执行。上述IO线程只负责异步操作。
发起请求
入口函数:os_aio_func
a.首先对于同步读写请求(OS_AIO_SYNC),发起请求的线程直接调用os_file_read_func 或者os_file_write_func 去读写文件 ,然后返回
b.对于异步请求,用户线程从对应操作类型的任务队列中选取一个slot,将需要读写的信息存储于其中(os_aio_array_reserve_slot):
##首先在任务队列数组中选择一个segment
local_seg = (offset >> (UNIV_PAGE_SIZE_SHIFT + 6))
% array->n_segments;
这里根据偏移量来算segment,因此可以尽可能的将相邻的读写请求放到一起,这有利于在IO层的合并操作。
##然后加mutex,遍历该segement,选择空闲的slot,如果没有则等待。
##将对应的文件读写请求信息赋值到slot中,例如写入的目标文件,偏移量,数据等
slot->is_reserved = true; slot->reservation_time = ut_time(); slot->message1 = message1; slot->message2 = message2; slot->file = file; slot->name = name; slot->len = len; slot->type = type; slot->buf = static_cast<byte*>(buf); slot->offset = offset; slot->io_already_done = false; …… //对于Native AIO 还需要调用如下逻辑 aio_offset = (off_t) offset; ut_a(sizeof(aio_offset) >= sizeof(offset) || ((os_offset_t) aio_offset) == offset); iocb = &slot->control; if (type == OS_FILE_READ) { io_prep_pread(iocb, file, buf, len, aio_offset); } else { ut_a(type == OS_FILE_WRITE); io_prep_pwrite(iocb, file, buf, len, aio_offset); } iocb->data = (void*) slot; slot->n_bytes = 0; slot->ret = 0;
c.对于Native AIO (使用linux自带的LIBAIO库),调用函数os_aio_linux_dispatch,将IO请求分发给kernel层。
d.如果没有开启Native AIO,且没有设置wakeup later 标记,则会去唤醒io线程(os_aio_simulated_wake_handler_thread),这是早期libaio还不成熟时,InnoDB在内部模拟aio实现的逻辑。
Tips:编译Native AIO需要安装Libaio-dev包,并打开选项srv_use_native_aio
处理异步AIO请求
IO线程入口函数为io_handler_thread –> fil_aio_wait
a. 对于Native AIO,调用函数os_aio_linux_handle 获取读写请求
IO线程会反复以500ms的超时时间通过io_getevents确认是否有任务已经完成了(函数os_aio_linux_collect),如果有读写任务完成,则返回上层函数
逻辑中还处理了AIO部分读写的场景,这里会再次提交aio请求。(什么场景会这样 ??)
找到已完成任务的slot后,释放对应的槽位。(os_aio_array_free_slot)
b.对于simulated aio,调用函数os_aio_simulated_handle 获取读写请求,这里相比NATIVE AIO要复杂些
##首先,如果这是异步读队列,并且os_aio_recommend_sleep_for_read_threads被设置,则暂时不处理,而是等待一会,让其他线程有机会将更过的IO请求发送过来。目前linear readhaed 会使用到该功能。这样可以得到更好的IO合并效果。
##如果有超过2秒未被调度的请求,则选择最老的slot,防止饿死,否则,找一个文件读写偏移量最小的位置的slot.
##根据上一步找到的slot,遍历其他操作,找到与其连续的IO请求,加入数组consecutive_ios中。直到遍历完成,后者数组中slot个数超过64
## 根据连续IO的slot数,分配新的内存块,并进行一次IO读或写。
c. 调用函数fil_node_complete_io, 递减node->n_pending, 对于文件写操作,需要加入到fil_system->unflushed_spaces链表上,表示这个文件修改过了,后续需要被sync到磁盘。
如果设置为O_DIRECT_NO_FSYNC,对于数据文件,无需加入到unflushed_spaces链表上。这在某些文件系统上是可行的。(fil_buffering_disabled)
d. 对于数据文件读写或IMPORT操作,调用buf_page_io_complete,做page corruption检查、change buffer merge等操作;对于LRU FLUSH产生的写操作,还会将其对应的block释放到free list上;对于日志文件操作,调用log_io_complete执行一次fil_flush,并更新内存内的checkpoint信息(log_complete_checkpoint)
并发控制
a. 由于文件底层使用pwrite/pread来进行文件I/O,因此用户线程对文件普通的并发I/O操作无需加锁。但在windows平台下,则需要加锁进行读写。
b. 当文件处于扩展阶段时(fil_space_extend),将fil_node的being_extended设置为true,避免产生并发extend,或其他关闭文件或者rename操作等
c. 当正在删除一个表时,会检查是否有pending的操作(fil_check_pending_operations)
将fil_space_t::stop_new_ops设置为true;
检查是否有Pending的change buffer merge (space->n_pending_ops);有则等待
检查是否有pending的IO(fil_node_t::n_pending) 或者pending的flush操作(fil_node_t::n_pending_flushes);有则等待
d. 当truncate一张表时,和drop table类似,也会调用函数fil_check_pending_operations,检查表上是否有pending的操作,并将space->is_being_truncated设置为true
e. 当rename一张表时(fil_rename_tablespace),将文件的stop_ios标记设置为true,阻止其他线程所有的I/O操作
=====
当进行文件读写操作时,如果是读操作,发现stop_new_ops或者被设置了但is_being_truncated未被设置,会返回报错;但依然允许写操作(why ? 函数fil_io)
当进行文件flush操作时,如果发现stop_new_ops 或者is_being_truncated被设置了,则忽略文件flush操作 (fil_flush_file_spaces)。
文件预读
文件预读是一项在SSD普及前普通磁盘上比较常见的技术,通过预读的方式进行连续IO而非带价高昂的随机IO
InnoDB有两种预读方式:随机预读及线性预读; Facebook另外还实现了一种逻辑预读的方式
a.随机预读
入口函数:buf_read_ahead_random
以64个Page为单位(这也是一个extend的大小),当前读入的page no所在的64个pagno 区域[ (page_no/64)*64, (page_no/64) *64 + 64],如果最近被访问的Page数超过BUF_READ_AHEAD_RANDOM_THRESHOLD(通常值为13),则将其他Page也读进内存。这里采取异步读。
随机预读受参数innodb_random_read_ahead控制
b.线性预读
入口函数:buf_read_ahead_linear
所谓线性预读,就是在读入一个新的page时,和随机预读类似的64个连续page范围内,默认从低到高Page no,如果最近连续被访问的page数超过innodb_read_ahead_threshold,则将该extend之后的其他page也读取进来。
c.逻辑预读
由于表可能存在碎片空间,因此很可能对于诸如全表扫描这样的场景,连续读取的page并不是物理连续的,线性预读不能解决这样的问题,另外一次读取一个extend对于需要全表扫描的负载并不足够。因此facebook引入了逻辑预读。
其大致思路为,扫描聚集索引,搜集叶子节点号,然后根据叶子节点的page no (可以从非叶子节点获取)顺序异步读入一定量的page。
由于Innodb aio一次只支持体检一个page读请求,虽然Kernel层本身会做读请求合并,但那显然效率不够高。他们对此做了修改,使INNODB可以支持一次提交(io_submit)多个aio请求。
入口函数:row_search_for_mysql –> row_read_ahead_logical
具体参阅这篇博文:http://planet.mysql.com/entry/?id=516236
或者webscalesql上的几个commit:
git show 2d61329446a08f85c89a4119317ae85baacf2bbb // 合并多个AIO请求,对所有的预读逻辑(上述三种)采用这种方式
git show 9f52bfd2222403f841fe5fcbedd1333f78a70a4b // 逻辑预读的主要代码逻辑
git show 64b68e07430b50f6bff5ed67374b336623db24b6 // 防止事务在多个表上读取操作时预读带来的影响
日志填充写入
由于现代磁盘通常的block size都是大于512字节的,例如一般是4096字节,为了避免 “read-on-write” 问题,在5.7版本里添加了一个参数innodb_log_write_ahead_size,你可以通过配置该参数,在写入redo log时,将写入区域配置到block size对齐的字节数。
在代码里的实现,就是在写入redo log 文件之前,为尾部字节填充0,(参考函数log_write_up_to)
Tips:所谓READ-ON-WRITE问题,就是当修改的字节不足一个block时,需要将整个block读进内存,修改对应的位置,然后再写进去;如果我们以block为单位来写入的话,直接完整覆盖写入即可。