0. Overview
卡夫卡说:不要害怕文件系统。
它就那么简简单单地用顺序写的普通文件,借力于Linux内核的Page Cache,不(显式)用内存,胜用内存,完全没有别家那样要同时维护内存中数据、持久化数据的烦恼——只要内存足够,生产者与消费者的速度也没有差上太多,读写便都发生在Page Cache中,完全没有同步的磁盘访问。
整个IO过程,从上到下分成文件系统层(VFS+ ext3)、 Page Cache 层、通用数据块层、 IO调度层、块设备驱动层。 这里借着Apache Kafka的由头,将Page Cache层与IO调度层重温一遍,记一篇针对Linux kernel 2.6的科普文。
1. Page Cache
1.1 读写空中接力
Linux总会把系统中还没被应用使用的内存挪来给Page Cache,在命令行输入free,或者cat /proc/meminfo,"Cached"的部分就是Page Cache。
Page Cache中每个文件是一棵Radix树(又称PAT位树, 一种多叉搜索树),节点由4k大小的Page组成,可以通过文件的偏移量(如0x1110001)快速定位到某个Page。
当写操作发生时,它只是将数据写入Page Cache中,并将该页置上dirty标志。
当读操作发生时,它会首先在Page Cache中查找,如果有就直接返回了,没有的话就会从磁盘读取文件写入Page Cache再读取。
可见,只要生产者与消费者的速度相差不大,消费者会直接读取之前生产者写入Page Cache的数据,大家在内存里完成接力,根本没有磁盘访问。
而比起在内存中维护一份消息数据的传统做法,这既不会重复浪费一倍的内存,Page Cache又不需要GC(可以放心使用60G内存了),而且即使Kafka重启了,Page Cache还依然在。
1.2 后台异步flush的策略
这是大家最需要关心的,因为不能及时flush的话,OS crash(不是应用crash) 可能引起数据丢失,Page Cache瞬间从朋友变魔鬼。
当然,Kafka不怕丢,因为它的持久性是靠replicate保证,重启后会从原来的replicate follower中拉缺失的数据。
在内核3.2之前,由内核线程flusher(2.6蔡刚升级成pdflush,在2.6.32又再升级为flushser)负责将有dirty标记的页面,发送给IO调度层。内核会每5秒(/proc/sys/vm/dirty_writeback_centisecs)唤醒一次,根据下面三个参数来决定行为:
1. 如果page dirty的时间超过了30秒(/proc/sys/vm/dirty_expire_centisecs,单位是百分之一秒),就会被刷到磁盘,所以OS crash时最多丢30秒左右的数据。
2. 如果dirty page的总大小已经超过了10% (/proc/sys/vm/dirty_background_ratio) 的空余内存 ( Free+ Page Cache - mmap文件),则会在后台启动flusher 线程写盘,但不影响当前的write(2)操作(除非正好两个操作要同一个Page的锁了)。增减这个值是最主要的调优手段。
3. 如果wrte(2)的速度太快,比flusher还快,dirty page 迅速涨到 20% (/proc/sys/vm/dirty_ratio) 的总内存 (cat /proc/meminfo里的MemTotal),则此时所有进程的write(2)操作都会被block,各自在自己的时间片里去执行对自己文件的flush操作,因为操作系统认为现在已经来不及写盘了,如果crash会丢太多数据,要让大家都冷静点。这个代价有点大,要尽量避免。在Redis2.8以前,Rewrite AOF就经常导致这个大面积阻塞,现在已经改为Redis每32Mb先主动flush一下了。
详细的文章可以看: The Linux Page Cache and pdflush
1.3 主动flush的方式
对于重要数据,应用需要自己触发flush保证写盘。
1. 系统调用fsync() 和 fdatasync()
fsync(fd)将属于该文件描述符的所有dirty page的写入请求发送给IO调度层。
fsync()总是同时flush文件内容与文件元数据, 而fdatasync()只flush文件内容与后续操作必须的文件元数据。元数据含时间戳,大小等,大小可能是后续操作必须,而时间戳就不是必须的。因为文件的元数据保存在另一个地方,所以fsync()总是触发两次IO,性能要差一点。
2. 打开文件时设置O_SYNC,O_DSYNC标志或O_DIRECT标志
O_SYNC、O_DSYNC标志表示每次write后要等到flush完成才返回,效果等同于write()后紧接一个fsync()或fdatasync(),不过按APUE里的测试,因为OS做了优化,性能会比自己调write() + fsync()好一点,但与只是write()相比就慢很多了。注意此时write()还是先写到page cache的,read可以直接从page cache中获取数据。
O_DIRECT标志表示直接IO,完全跳过Page Cache。不过这也放弃了读文件时的Page Cache,必须每次读取磁盘文件,这是与O_SYNC的区别。而且要求所有IO请求长度,偏移都必须是底层扇区大小的整数倍。所以使用直接IO的时候一定要在应用层做好Cache。
1.4 Page Cache的清理策略
当内存满了,就需要清理Page Cache,或把应用占的内存swap到文件去。有一个swappiness的参数(/proc/sys/vm/swappiness)决定是swap还是清理page cache,值在0到100之间,设为0表示尽量不要用swap,这也是很多优化指南让你做的事情,因为默认值居然是60,Linux认为Page Cache更重要。Linux心情的量化公式如下:
swap_tendency = mapped_ratio / 2 + distress + vm_swappiness;
其中mapped_ratio是该区域内真正进程内存所占的比例,distress是回收pageCache的难度(取值在0-100), vm_swappiness是我们的设置值,三者相加大于100则进行swap out,否则回收Page Cache。
Page Cache的清理策略是LRU的升级版。如果简单用LRU,一些新读出来的但可能只用一次的数据会占满了LRU的头端。因此将原来一条LRU队列拆成了两条,一条放新的Page,一条放已经访问过好几次的Page。Page刚访问时放在新LRU队列里,访问几轮了才升级到旧LRU队列(想想JVM Heap的新生代老生代)。清理时就从新LRU队列的尾端开始清理,直到清理出足够的内存。
1.5 预读策略
根据清理策略,Apache Kafka里如果消费者太慢,堆积了几十G的内容,Cache还是会被清理掉的。这时消费者就需要读盘了。
内核这里又有个动态自适应的预读策略,每次读请求会尝试预读更多的内容(反正都是一次读操作)。内核如果发现一个进程一直使用预读数据,就会增加预读窗口的大小(最小16K,最大128K),否则会关掉预读窗口。连续读的文件,明显适合预读。
2. IO调度层
如果所有读写请求都直接发给硬盘,对传统硬盘来说太残忍了。IO调度层主要做两个事情,合并和排序。 合并是将相同和相邻扇区(每个512字节)的操作合并成一个,比如我现在要读扇区1,2,3,那可以合并成一个读扇区1-3的操作。排序就是将所有操作按扇区方向排成一个队列,让磁盘的磁头可以按顺序移动,有效减少了机械硬盘寻址这个最慢最慢的操作。
排序看上去很美,但可能造成严重的不公平,比如某个应用在相邻扇区狂写盘,其他应用就都干等在那了,pdflush还好等等没所谓,读请求都是同步的,耗在那会很惨。
所有又有多种算法来解决这个问题,其中内核2.6的默认算法是CFQ(完全公正排队),把总的排序队列拆分成每个发起读写的进程/线程 自己有一条排序队列,然后以时间片轮转调度每个队列,轮流从每个进程的队列里拿出若干个请求来执行(默认是4)。
在Apache Kafka里,消息的读写都发生在内存中,真正写盘的就是那条fluher内核线程,因为都是顺序写,即使一台服务器上有多个Partition文件,经过合并和排序后都能获得很好的性能,或者说,Partition文件的个数并不影响性能,不会出现文件多了变成随机读写的情况。
如果是SSD硬盘,没有寻址的花销,排序好像就没必要了,但合并的帮助依然良多,所以还有另一种只合并不排序的NOOP算法可供选择。
题外话
另外,硬盘上还有一块几十M的缓存,硬盘规格上的外部传输速率(总线到缓存)与内部传输速率(缓存到磁盘)的区别就在此......IO调度层以为已经写盘了,其实可能依然没写成,断电的话靠硬盘上的电池或大电容保命......
延伸阅读:Kafka文件存储机制那些事 by 美团技术团队