1 page cache & buffer cache
1.1 概念
Page cache缓存文件的页以优化文件IO。 Buffer cache缓存块设备的块以优化块设备IO。
页是逻辑上的概念,因此page cache是与文件系统同级的;块是物理上的概念,因此buffer cache是与块设备驱动程序同级的。
-
在Linux 2.4版本的内核之前,page cache与buffer cache是完全分离的。但是,块设备大多是磁盘,磁盘上的数据又大多通过文件系统来组织,这种设计导致很多数据被缓存了两次,浪费内存。 -
在2.4版本内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了page cache,那么同时buffer cache只需要维护块指向页的指针就可以了。只有那些没有文件表示的块,或者绕过了文件系统直接操作(如dd命令)的块,才会真正放到buffer cache里。因此,我们现在提起page cache,基本上都同时指page cache和buffer cache两者,本文之后也不再区分,直接统称为buffer cache。
无论如何内核要对块设备执行基于块的IO而不是虚拟内存page。由于绝大部分的块表示文件的数据部,所以大部分的buffer cache都可由page cache表达,但是有少量的比如文件元数据仍然是保留在buffer cache中来缓存。
1.1.1 Page Cache(页缓存)
- 由内存中的物理page组成,其内容对应磁盘上的block。
- page cache的大小是动态变化的。
- backing store:cache缓存的存储设备
- 一个page通常包含多个block, 而block不一定是连续的。
读Cache
- 当内核发起一个读请求时, 先会检查请求的数据是否缓存到了page cache中。
- 如果有,那么直接从内存中读取,不需要访问磁盘, 此即 cache hit(缓存命中)
- 如果没有, 就必须从磁盘中读取数据, 然后内核将读取的数据再缓存到cache中, 如此后续的读请求就可以命中缓存了。
- page可以值缓存一个文件的部分内容, 而不需要把整个文件都缓存进来。
写Cache
- 当内核发起一个写请求时, 也是直接往cache中写入, 后备存储中的内容不会直接更新。
- 内核会将被写入的page标记为dirty, 并将其加入到dirty list中。
- 内核会周期性地将dirty list中的page写回到磁盘上, 从而使磁盘上的数据和内存中缓存的数据一致。
Cache回收
- Page cache的另一个重要工作是释放page, 从而释放内存空间。
- cache回收的任务是选择合适的page释放
- 如果page是dirty的, 需要将page写回到磁盘中再释放。
LRU算法 LRU(Least Recently used): 最近最少使用算法, Redis中也有此策略, 该算法在Java中可以使用LinkedHashMap进行实现。 Two-List策略
- Two-List策略维护了两个list: active list && inactive list
- 在active list上的page被认为是hot的,不能释放。
- 只有inactive list上的page可以被释放的。
- 首次缓存的数据的page会被加入到inactive list中,已经在inactive list中的page如果再次被访问,就会移入active list中。
- 两个链表都使用了伪LRU算法维护,新的page从尾部加入,移除时从头部移除
- 如果active list中page的数量远大于inactive list,那么active list头部的页面会被移入inactive list中,从而实现两个表的平衡。
1.1.2 Buffer Cache
- buffer cache就是一块含有许多数据块的内存区域,这些数据块主要都是数据文件里的数据块内容的拷贝。
- 从buffer cache中读取一个数据块一般需要100ns左右,从一般的存储硬盘中读取一个数据块需要10ms;所以大概算一下,从内存中读取数据块比从硬盘中快近十万倍。
- 每一个数据块在被读入buffer cache时,都会先在buffer cache中构造一个buffer header,buffer header与数据块一一对应(buffer header 中有指定buffer 具体内存地址的信息)。
- 值得注意的是在Linux2.4中,buffer cache和 page cache之间是独立的
- 前者使用老版本的buffer_head进行存储,这导致了一个磁盘block可能在两个cache中同时存在,造成了内存的浪费。
- 2.6内核中将两者合并到了一起,使buffer_head只存储buffer-block的映射信息,不再存储block的内容, 从而减少了内存浪费。
1.1.3 Flusher Threads
- Page Cache推迟了文件写入后备存储的时间, 但是dirty page最终还是要被写回磁盘的。
- 内核会在以下三种情况下将dirty page 写回磁盘:
- 用户进程调用sync() 和 fsync()系统调用
- 空闲内存低于特定的阈值(threshold)
- Dirty数据在内存中驻留的时间超过一个特定的阈值
- 线程群的特点是让一个线程负责一个存储设备(比如一个磁盘驱动器),多少个存储设备就用多少个线程, 从而避免阻塞或者竞争的情况,提高效率。
- 当空闲内存低于阈值时,内核就会调用wakeup_flusher_threads()来唤醒一个或者多个flusher线程,将数据写回磁盘。
- 为了避免dirty数据在内存中驻留过长时间(避免在系统崩溃时丢失过多数据),内核会定期唤醒一个flusher线程,将驻留时间过长的dirty数据写回磁盘。
1.2 作用
page cache与buffer cache的共同目的都是加速数据I/O:
- 写数据时首先写到缓存,将写入的页标记为dirty,然后向外部存储flush,也就是缓存写机制中的write-back(另一种是write-through,Linux未采用);
- 读数据时首先读取缓存,如果未命中,再去外部存储读取,并且将读取来的数据也加入缓存。
操作系统总是积极地将所有空闲内存都用作page cache和buffer cache,当内存不够用时也会用LRU等算法淘汰缓存页。
缓存(cache)与缓冲(buffer)的主要区别
- Buffer的核心作用是用来缓冲,缓和冲击。比如你每秒要写100次硬盘,对系统冲击很大,浪费了大量时间在忙着处理开始写和结束写这两件事嘛。用个buffer暂存起来,变成每10秒写一次硬盘,对系统的冲击就很小,写入效率高了。
- Cache的核心作用是加快取用的速度。比如一个很复杂的计算做完了,下次还要用结果,就把结果放手边一个好拿的地方存着,下次不用再算了。加快了数据取用的速度。
- page cache用于优化文件系统的I/O,buffer cache用于优化磁盘的I/O
- page cache常用于读操作的时候,将常常读取的file缓存起来;buffer cache则是将要写入磁盘的内容缓冲(零存整取)。
1.3 缓存大小如何查看
执行free命令,注意到会有一列名为buff/cache【在centos 7.2上执行】, buff/cache即表示当前页、块缓存的大小,如截图,占用了43GB
1.4 IO流
如下图:
- IO控制流就是带着元数据的流转,driver中会管理着buffer cache、文件的元数据信息;
- 数据直接按照数据流从page cache刷入disk的cache(磁盘也是有自己的cache的)。
对于具体的Linux文件系统,会以block(磁盘块)的形式组织文件,为了减少对物理块设备的访问,在文件以块的形式调入内存后,使用块高速缓存进行管理。每个缓冲区由两部分组成,第一部分称为缓冲区首部,用数据结构buffer_head表示,第二部分是真正的存储的数据。由于缓冲区首部不与数据区域相连,数据区域独立存储。因而在缓冲区首部中,有一个指向数据的指针和一个缓冲区长度的字段。
1.5 两类缓存的逻辑关系
从linux-2.6.18的内核源码来看, Page Cache和Buffer Cache是一个事物的两种表现:对于一个Page而言,对上,它是某个File的一个Page Cache,而对下,它同样是一个Device上的一组Buffer Cache 。 File在地址空间上,以4K(page size)为单位进行切分,每一个4k都可能对应到一个page上(这里 可能 的含义是指,只有被缓存的部分,才会对应到page上,没有缓存的部分,则不会对应),而这个4k的page,就是这个文件的一个Page Cache。
而对于落磁盘的一个文件而言,最终这个4k的page cache,还需要映射到一组磁盘block对应的buffer cache上,假设block为1k,那么每个page cache将对应一组(4个)buffer cache,而每一个buffer cache,则有一个对应的buffer cache与device block映射关系的描述符:buffer_head,这个描述符记录了这个buffer cache对应的block在磁盘上的具体位置。
2 大数据产品针对page cache的利用
2.1 Kafka对page cache的利用
Kafka为什么不自己管理缓存,而非要用page cache?原因有如下三点:
- JVM中一切皆对象,数据的对象存储会带来所谓object overhead,浪费空间;
- 如果由JVM来管理缓存,会受到GC的影响,并且过大的堆也会拖累GC的效率,降低吞吐量;
- 一旦程序崩溃,自己管理的缓存数据会全部丢失。
Kafka三大件(broker、producer、consumer)与page cache的关系可以用下面的简图来表示。 producer生产消息发到Server端时,会调用writeFullyTo【会调用Java NIO中是FileChannel.write() API】按偏移量写入数据,并且都会先写入page cache里。consumer消费消息时,会使用sendfile()系统调用【对应FileChannel.transferTo() API】,零拷贝地将数据从page cache传输到broker的Socket buffer,再通过网络传输。 具体参见 org.apache.kafka.common.record.FileRecords中的
public int append(MemoryRecords records) throws IOException {
if (records.sizeInBytes() > 2147483647 - this.size.get()) {
throw new IllegalArgumentException("Append of size " + records.sizeInBytes() + " bytes is too large for segment with current file position at " + this.size.get());
} else {
int written = records.writeFullyTo(this.channel);
this.size.getAndAdd(written);
return written;
}
}
public int writeFullyTo(GatheringByteChannel channel) throws IOException {
this.buffer.mark();
int written;
for(written = 0; written < this.sizeInBytes(); written += channel.write(this.buffer)) {
}
this.buffer.reset();
return written;
}
@Override
public long writeTo(GatheringByteChannel destChannel, long offset, int length) throws IOException {
long newSize = Math.min(channel.size(), end) - start;
int oldSize = sizeInBytes();
if (newSize < oldSize)
throw new KafkaException(String.format(
"Size of FileRecords %s has been truncated during write: old size %d, new size %d",
file.getAbsolutePath(), oldSize, newSize));
long position = start + offset;
int count = Math.min(length, oldSize);
final long bytesTransferred;
if (destChannel instanceof TransportLayer) {
TransportLayer tl = (TransportLayer) destChannel;
bytesTransferred = tl.transferFrom(channel, position, count);
} else {
bytesTransferred = channel.transferTo(position, count, destChannel);
}
return bytesTransferred;
}
图中没有画出来的还有leader与follower之间的同步,这与consumer是同理的:只要follower处在ISR【同步的replica,相对的就有out of sync replica,也就是跟不上同步节奏的replica】中,就也能够通过零拷贝机制将数据从leader所在的broker page cache传输到follower所在的broker。
同时,page cache中的数据会随着内核中flusher线程的调度以及对sync()/fsync()的调用写回到磁盘,就算进程崩溃,也不用担心数据丢失。另外,如果consumer要消费的消息不在page cache里,才会去磁盘读取,并且会顺便预读出一些相邻的块放入page cache,以方便下一次读取。
如果Kafka producer的生产速率与consumer的消费速率相差不大,那么就能几乎只靠对broker page cache的读写完成整个生产-消费过程,磁盘访问非常少。这个结论俗称为“读写空中接力”。并且Kafka持久化消息到各个topic的partition文件时,是只追加的顺序写,充分利用了磁盘顺序访问快的特性,效率高。
2.2 redis AOF对page cache的利用
当 Redis 开启 AOF 时,需要配置 AOF 的刷盘策略。 基于性能和数据安全的平衡,你肯定会采用 append fsync everysec 这种方案。 这种方案的工作模式为,Redis 的后台线程每间隔 1 秒,就把 AOF page cache 的数据,刷到磁盘(fsync)上。 这种方案的优势在于,把 AOF 刷盘的耗时操作,放到了后台线程中去执行,避免了对主线程的影响。
AOF everysec 真的不会阻塞主线程吗? Redis 后台线程在执行 AOF page cache 刷盘(fysnc)时,如果此时磁盘 IO 负载过高,那么调用 fsync 就会被阻塞住。 此时,主线程仍然接收写请求进来,那么此时的主线程会先判断,上一次后台线程是否已刷盘成功。 如何判断呢? 后台线程在刷盘成功后,都会记录刷盘的时间。 主线程会根据这个时间来判断,距离上一次刷盘已经过去多久了。整个流程是这样的:
- 主线程在写 AOF page cache(write系统调用)前,先检查后台 fsync 是否已完成?
- fsync 已完成,主线程直接写 AOF page cache
- fsync 未完成,则检查距离上次 fsync 过去多久?
- 如果距离上次 fysnc 成功在 2 秒内,那么主线程会直接返回,不写 AOF page cache
- 如果距离上次 fysnc 成功超过了 2 秒,那主线程会强制写 AOF page cache(write系统调用)
由于磁盘 IO 负载过高,此时,后台线程 fynsc 会发生阻塞,那主线程在写 AOF page cache 时,也会发生阻塞等待(操作同一个 fd,fsync 和 write 是互斥的,一方必须等另一方成功才可以继续执行,否则阻塞等待)
通过分析可以发现,即使你配置的 AOF 刷盘策略是 appendfsync everysec,也依旧会有阻塞主线程的风险。 其实,产生这个问题的重点在于,磁盘 IO 负载过高导致 fynsc 阻塞,进而导致主线程写 AOF page cache 也发生阻塞。 所以,你一定要保证磁盘有充足的 IO 资源,避免这个问题。
3 page cache相关的参数
page cache中的数据会随着内核中flusher线程的调度写回磁盘。与它相关的有以下4个参数,必要时可以调整。
- /proc/sys/vm/dirty_writeback_centisecs:flush检查的周期。单位为0.01秒,默认值500,即5秒。每次检查都会按照以下三个参数控制的逻辑来处理。
- /proc/sys/vm/dirty_expire_centisecs:如果page cache中的页被标记为dirty的时间超过了这个值,就会被直接刷到磁盘。单位为0.01秒。默认值3000,即半分钟。
- /proc/sys/vm/dirty_background_ratio:如果dirty page的总大小占空闲内存量的比例超过了该值,就会在后台调度flusher线程异步写磁盘,不会阻塞当前的write()操作。默认值为10%。
- /proc/sys/vm/dirty_ratio:如果dirty page的总大小占总内存量的比例超过了该值,就会阻塞所有进程的write()操作,并且强制每个进程将自己的文件写入磁盘。默认值为20%。
由此可见,调整空间比较灵活的是参数2、3,而尽量不要达到参数4的阈值,代价太大了。
使用sysctl -a | frep dirty命令可以查看默认配置
参考
Page Cache(页缓存) Linux文件系统(五)—三大缓冲区之buffer块缓冲区 【kafka】源码分析-Producer过程全解 Kafka 源码解析:Server 端的运行过程 KAFKA进阶:【十一】总结一下,KAFKA的高并发、高吞吐等特性? 颠覆认知——Redis会遇到的15个“坑”,你踩过几个?
|