前言作为一个成熟的跨平台数据库引擎,InnoDB实现了一套高效易用的IO接口,包括同步IO和异步IO,以及IO合并。本文简单介绍其内部实现。主要代码集中在文件os0file.cc中。本文分析默认基于MySQL5.6、CentOS6、gcc4.8,其他版本信息另行指出。基础知识WAL技术:logfirst技术,基本上所有的数据库都会用到这个技术。简单来说,当需要写入数据块时,数据库前台线程先将相应的日志(批量顺序写入)写入磁盘,然后告诉客户端操作成功。至于写入数据块的实际操作(discreterandomwrite)则放在后台IO线程中。使用这种技术,虽然多了一次磁盘写操作,但是由于是批量顺序写入日志,效率非常高,所以客户端可以很快得到响应。另外,如果数据库在真正的数据块被放到磁盘上之前崩溃了,数据库可以在重启时使用日志从崩溃中恢复,而不会造成数据丢失。数据预读:当读取到与数据块A“相邻”的数据块B和C时,B和C也会有很大的概率被读取,所以可以提前将B读入内存,这样是数据预读技术。这里所说的邻接有两种含义,一种是物理邻接,一种是逻辑邻接。在底层数据文件中相邻称为物理相邻。如果数据文件不是相邻的,而是逻辑相邻的(id=1的数据和id=2的数据逻辑上相邻,但物理上不一定相邻,同一个文件中可能有不同的位置),称为逻辑相邻。文件打开方式:Open系统调用常见的三种方式:O_DIRECT、O_SYNC和默认方式。O_DIRECT模式是指后续对文件的操作不使用文件系统的缓存,用户态直接操作设备文件,绕过了缓存和内核的优化。从另一个角度看,使用O_DIRECT方式写文件。如果返回成功,数据就真的放到了磁盘上(不管磁盘自己的缓存有没有),使用O_DIRECT方式读取文件。每次读操作实际上都是从磁盘中读取,不会从文件系统的缓存中读取。O_SYNC表示使用操作系统缓存,文件的读写都经过内核,但是这种模式也保证了数据每次写入都会写入磁盘。默认模式与O_SYNC模式类似,只是不保证数据写入后一定会写入磁盘。数据可能仍在文件系统中。当主机宕机时,数据可能会丢失。此外,写操作不仅需要写入修改或添加的数据,还需要写入文件的元数据。只有两部分都写了,数据才不会丢失。O_DIRECT模式不保证文件元数据会被放到磁盘上(但大多数文件系统都会,Bug#45892),所以如果不进行其他操作,用O_DIRECT写入文件后也有丢失的风险。O_SYNC确保数据和元数据都放在磁盘上。在默认模式下,这两种数据都无法保证。调用函数fsync后,可以保证数据和日志都放在磁盘上,所以用O_DIRECT和默认模式打开的文件,写入数据后需要调用fsync函数。同步IO:我们常用的读写功能(在Linux上)就是这种IO。特点是函数执行时,调用者会等待函数执行完成,没有消息通知机制,因为函数返回,就意味着操作完成,直接查看返回值就可以知道是否操作是否成功。这种IO操作编程起来比较简单,所有的操作都可以在同一个线程中完成,但是调用者需要等待。在数据库系统中,比较适合在急需一些数据的时候调用。比如WAL中的日志,必须要返回给客户端,如果放了之前的磁盘,进行同步IO操作。异步IO:在数据库中,后台刷数据块的IO线程基本都是用异步IO。数据库前台线程只需要将刷块请求提交到异步IO队列就可以返回做其他事情,而后台线程IO线程会周期性的检查提交的请求是否已经完成,如果完成了,做一些后续处理.同时,异步IO往往是分批请求提交的。如果不同的请求访问同一个文件,并且offset连续,可以合并为一个IO请求。比如第一个请求读取文件1,从偏移量100开始读取200字节的数据,第二个请求读取文件1,从偏移量300开始读取100字节的数据,那么这两个请求可以合并为读取文件1,300字节ofdatastartsoffset100.数据预读中的逻辑预读往往采用异步IO技术。目前Linux上的异步IO库要求文件以O_DIRECT方式打开,并且数据块的内存地址、文件读写的偏移量、读写的数据量必须是整数倍文件系统的逻辑块大小。可以使用类似于sudoblockdev--getss/dev/sda5的语句查询块大小。如果以上三者不是文件系统逻辑块大小的整数倍,调用读写函数时会报错EINVAL,但如果文件不是用O_DIRECT打开,程序仍然可以运行,只是退化了在io_submit函数调用上级中进入同步IO和块。InnoDB常规IO操作和同步IO在InnoDB中,如果系统有pread/pwrite函数(os_file_read_func和os_file_write_func),则使用它们进行读写,否则使用lseek+read/write方案。这就是InnoDB同步IO。查看pread/pwrite文档,可以看出这两个函数不会改变文件句柄的偏移量,是线程安全的,所以推荐在多线程环境下使用,而lseek+read/写入解决方案需要受互斥锁保护。在并发的情况下,频繁陷入内核态会对性能产生一定的影响。在InnoDB中,使用open系统调用打开文件(os_file_create_func)。除了O_RDONLY(只读)、O_RDWR(读写)、O_CREAT(创建文件)之外,该模式还使用了O_EXCL(保证这个线程创建这个文件)和O_TRUNC(清除文件)。默认情况下(数据库没有设置为只读模式),所有文件都以O_RDWR模式打开。innodb_flush_method这个参数比较重要,我简单介绍一下:如果innodb_flush_method设置为O_DSYNC,那么日志文件(ib_logfileXXX)是用O_SYNC打开的,所以写入后就不需要再调用函数fsync刷新数据了,数据文件(ibd)是默认打开的,所以写入数据后需要调用fsync刷盘。如果innodb_flush_method设置为O_DIRECT,日志文件(ib_logfileXXX)以默认方式打开,写入数据后需要调用fsync函数刷盘。数据文件(ibd)以O_DIRECT方式打开,写入数据后需要调用fsync函数刷盘。如果innodb_flush_method设置为fsync或不设置,数据文件和日志文件都以默认方式打开,写入数据后需要fsync刷盘。如果innodb_flush_method设置为O_DIRECT_NO_FSYNC,文件打开方式类似于O_DIRECT方式。不同的是,数据文件写入后,不会调用fsync函数刷盘。O_DIRECT主要是针对文件系统,可以保证文件的元数据也放在磁盘上。InnoDB目前不支持以O_DIRECT方式打开日志文件,也不支持以O_SYNC方式打开数据文件。注意,如果使用linux原生的aio(详见下一节),innodb_flush_method必须配置为O_DIRECT,否则会退化为同步IO(错误日志中不会有任务提示)。InnoDB使用文件系统的文件锁来保证只有一个进程读写一个文件(os_file_lock),并且使用建议锁而不是强制锁,因为强制锁在很多系统中使用是有bug的,包括linux。在非只读模式下,所有文件在打开后都被文件锁锁定。InnoDB中目录的创建使用递归方法(os_file_create_subdirs_if_needed和os_file_create_directory)。比如需要创建目录/a/b/c/,先创建c,再创建b,再创建a,调用mkdir函数创建目录。另外,创建上层目录需要调用os_file_create_simple_func函数而不是os_file_create_func,需要注意。InnoDB还需要临时文件。临时文件的创建逻辑比较简单(os_file_create_tmpfile),就是在tmp目录下创建文件成功后,使用unlink函数直接释放句柄,这样当进程结束时(无论是正常结束还是异常结束),这个文件会自动释放。为了创建一个临时文件,InnoDB首先重用了服务器层函数mysql_tmpfile的逻辑。后来因为需要调用server层函数释放资源,所以调用了dup函数复制了一个句柄。如果需要获取文件的大小,InnoDB不会检查文件的元数据(stat函数),而是使用lseek(file,0,SEEK_END)来获取文件大小。这样做的原因是为了防止在更新元数据时延迟获取的文件大小不正确。InnoDB会为所有新创建的文件(包括数据文件和日志文件)预分配一个大小,预分配文件的内容全部设置为零(os_file_set_size)。当当前文件已满时,它将被扩展。另外,在创建日志文件时,即install_db阶段,会在错误日志中以100MB为间隔输出分配进度。一般来说,常规IO操作和同步IO都比较简单,但是在InnoDB中,异步IO基本都是用来写数据文件的。InnoDB异步IO由于MySQL诞生于Linux原生aio之前,所以在MySQL异步IO代码中有两种实现异步IO的方案。第一个是原始的模拟aio。InnoDB在引入Linux原生air之前以及在一些不支持air的系统上模拟了一个aio机制。当一个异步读写请求被提交时,只是放入一个队列然后返回,程序可以做其他的事情。后台有多个异步io处理线程(由innobase_read_io_threads和innobase_write_io_threads这两个参数控制)不断从这个队列中取出请求,然后用同步IO完成读写请求和读写后的工作写入完成。另一个是Nativeaio。目前是在linux上使用io_submit和io_getevents等函数完成的(不要用glibcaio,这个也是模拟的)。提交请求使用io_submit,等待请求使用io_getevents。另外,window平台也有自己对应的aio,这里就不介绍了。如果使用window技术栈,数据库应该使用sqlserver。目前其他平台(Linux和windows除外)只能使用Simulateaio。首先介绍一些常用的函数和结构,然后详细介绍Linux上的Simulatealo和Nativeaio。os0file.cc中定义了一个全局数组,类型为os_aio_array_t,这些数组是Simulateaio用来缓存读写请求的队列,数组的每个元素都是os_aio_slot_t类型,记录了每个IO请求的类型,文件的fd,偏移量,要读取的数据量,发起IO请求的时间,IO请求是否已经完成等。另外,Linuxnativeio中的structiocb也在os_aio_slot_t中。在数组结构体os_aio_slot_t中,记录了一些统计信息,比如使用了多少个数据元素(os_aio_slot_t),是否为空,是否满等。一共有5个这样的全局数组,用来保存数据文件读取异步请求(os_aio_read_array)、数据文件写入异步请求(os_aio_write_array)、日志文件写入异步请求(os_aio_log_array)、插入缓冲区写入异步请求(os_aio_ibuf_array)、数据文件同步读写请求(os_aio_sync_array)。日志文件的数据块写入是同步IO,但是为什么我们这里的日志写入需要分配一个异步请求队列(os_aio_log_array)呢?原因是检查点信息需要记录在InnoDB日志文件的日志头中。目前checkpoint信息的读写还是通过异步IO实现的,因为不是很紧急。在window平台下,如果对特定文件使用异步IO,则不能对这个文件使用同步IO,所以引入了数据文件同步读写请求队列(os_aio_sync_array)。日志文件不需要读取异步请求队列,因为只有在做崩溃恢复时才需要读取日志,而在做崩溃恢复时,数据库还不可用,所以不需要使用异步读取方式全部。这里要注意一点,不管innobase_read_io_threads和innobase_write_io_threads这两个变量的参数,只有os_aio_read_array和os_aio_write_array,但是数据中的os_aio_slot_t元素会相应增加。在linux中,变量增加1,元素个数增加256个。比如innobase_read_io_threads=4,则os_aio_read_array数组分为四部分,每部分有256个元素,每部分都有自己独立的锁,信号量和统计变量模拟4个线程,类似于innobase_write_io_threads。从这里我们也可以看出,每个异步读写线程可以缓存的读写请求是有上限的,都是256个,如果超过这个数,后续的异步请求就需要等待。256可以理解为InnoDB层对异步IO并发数的控制,在文件系统层和磁盘层面也有长度限制,使用cat/sys/block/sda/queue/nr_requests和cat/sys/block/sdb/queue/分别查询nr_requests。os_aio_init在InnoDB开始初始化各种结构时被调用,包括上面提到的全局数组,以及Simulateaio中使用的锁和互斥锁。os_aio_free释放相应的结构体。os_aio_print_XXX系列函数用于输出aio子系统的状态,主要用在showengineinnodbstatus语句中。Simulateaio与Nativeaio相比,Simulateaio相对复杂,因为InnoDB自己实现了一套模拟机制。入口函数是os_aio_func。在debug模式下,会检查参数,比如数据块所在的内存地址,文件读写的偏移量,读写的数据量是否是OS_FILE_LOG_BLOCK_SIZE的整数倍,但是未检查文件打开模式。是否使用O_DIRECT,因为Simulateaio最后使用的是同步IO,打开文件不需要使用O_DIRECT。验证通过后,调用os_aio_array_reserve_slot将IO请求分配给某个后台io处理线程(通过innobase_xxxx_io_threads分配,但实际上在同一个全局数组中),并记录io请求的相关信息。方便的后台io线程处理。如果IO请求类型相同,请求的是同一个文件,并且offset比较接近(默认offset相差在1M以内),InnoDB会将这两个请求分配给同一个io线程,方便后续步骤在IO合并中。提交IO请求后,需要唤醒后台io处理线程,因为如果后台线程检测到没有IO请求,就会进入等待状态(os_event_wait)。此时函数返回,程序可以做其他事情,后续的IO处理交给后台线程。介绍后台IO线程是如何处理的。InnoDB启动时,会启动后台IO线程(io_handler_thread)。它会调用os_aio_simulated_handle从全局数组中取出IO请求,然后用同步IO处理。结束后,需要做收尾工作。比如是写请求,需要在bufferpoolremove中的dirtypagelist中移除相应的数据页。os_aio_simulated_handle首先需要从数组中选择一个IO请求来执行。选择算法不是简单的先进先出。它在所有请求中选择偏移量最小的请求并首先处理它。这样做是为了方便后续IO合并的计算。但是也容易导致一些offset特别大的孤立请求长时间执行不完,也就是饿死。为了解决这个问题,在选择IO请求之前,InnoDB会先做一次遍历。如果发现一个请求在2s前被Pushed(也就是等待2s),但是还没有执行,那么最老的请求会先被执行,防止这些请求被饿死。如果有两个请求的等待时间相同,则选择偏移量较小的请求。os_aio_simulated_handle接下来的工作就是进行IO合并。比如读请求1请求file1,从offset100开始200字节,读请求2请求file1,从offset300开始100字节,那么这两个请求可以合并成一个请求:file1,从offset100开始300字节,IO返回后,将数据复制到原始请求的缓冲区中。写入请求类似。在写操作之前,将要写入的数据复制到一个临时空间,然后一次性全部写入。需要注意的是,只有offsets连续的IO才会合并,有中断或者重叠的IO不会合并,完全相同的IO请求也不会合并,所以这也算是一个可以优化的点。如果os_aio_simulated_handle发现现在没有IO请求,就会进入waiting状态,等待被唤醒。综上所述,可以看出IO请求与一个一个push是相反的。每次推送进入后台线程时,都会进行处理。如果后台线程的优先级比较高的话,IO合并的效果可能会很差。为了解决这个问题,Simulateaio提供了一个类似分组提交的功能,即一组IO请求提交后,唤醒后台线程统一处理,这样IO合并的效果会更好。但这仍然是一个小问题。如果后台线程繁忙,则不会进入等待状态,也就是说只要请求进入队列,就会被处理。这个问题可以在下面的Nativeaio中解决。总的来说,InnoDB实现的模拟机制是比较安全可靠的。如果平台不支持Nativeaio,则使用该机制读写数据文件。对于Linuxnativeaio,如果系统中安装了libaio库,并且在配置文件中设置innodb_use_native_aio=on,则启动时会使用Nativeaio。入口函数还是os_aio_func。在调试模式下,仍然会检查传入的参数。它也不会检查文件是否以O_DIRECT模式打开。这有点冒险。如果用户不知道linux原生的aio需要以O_DIRECT方式打开,只有文件才能利用aio,那么性能就达不到预期。这里建议做一个检查,如果有问题输出到错误日志。检查通过后,像Simulatedaio一样调用os_aio_array_reserve_slot将IO请求分配给后台线程。分配算法也考虑了后续的IOmerge,这点和Simulatedaio是一样的。主要区别在于iocb结构需要用IO请求的参数进行初始化。除了初始化iocb外,还需要在全局数组的slot中记录IO请求的相关信息,主要是在os_aio_print_XXX系列函数中方便统计。调用io_submit提交请求。此时函数返回,程序可以做其他事情,后续的IO处理交给后台线程。接下来是后台IO线程。和Simulateaio类似,InnoDB启动的时候也会启动后台IO线程。如果是Linux原生的aio,后面会调用函数os_aio_linux_handle。该函数的作用类似于os_aio_simulated_handle,但底层实现比较简单,只是调用io_getevents函数等待IO请求完成。超时时间为0.5s,也就是说如果0.5s内没有IO请求完成,函数就会返回,继续调用io_getevents等待。当然在等待之前会判断服务器是否关闭,如果关闭则退出。在分配IO线程时,尽量将相邻的IO放在一个线程中。这个和Simulateaio类似,但是对于后续的IO合并操作,Simulateaio自己实现,Nativeaio交给kernel去完成,所以代码比较简单。.另外一个区别是当没有IO请求时,Simulateaio会进入等待状态,而Nativeaio会每0.5秒唤醒一次,做一些检查工作,然后继续等待。因此,当有新的请求到来时,Simulatedaio需要用户线程被唤醒,而Nativeaio则不需要。另外,Simulateaio也需要在服务器关闭时唤醒,而Nativeaio则不需要。可以发现Nativeaio和Simulateaio类似,都是请求一个一个提交,然后一个一个处理,会导致IO合并效果不佳。Facebook团队提交了一个Nativeaio群提交优化:先缓存IO请求,IO请求到达后调用io_submit函数,一次性提交之前的所有请求(io_submit可以一次提交多个请求),这样内核会更方便的做IO优化。Simulateaio在IO线程压力大的情况下,group提交优化会失败,而Nativeaio不会。请注意,对于群组提交优化,您不能一次提交过多的提交。如果超过了aio等待队列的长度,就会强制发起一个io_submit。小结本文详细介绍了InnoDB中IO子系统的实现以及使用时需要注意的几点。InnoDB日志使用同步IO,数据使用异步IO,异步IO的写入顺序不是先进先出的。这几点需要注意。虽然Simulateaio有比较大的学习价值,但在现代操作系统中还是推荐使用Nativeaio。
