一次CPU sys上涨引发对kafka PageCache的思考

1.CPU sys 上涨背景

配置 机型 A 机型 B
CPU 48C 48C
MEM 8*32G 12*16G
DATA DISK 12*960G SSD 12*4T SSD

线上某个kafka集群由于种种原因,从 24 * 机型 A 置换迁移为 12 * 机型 B。从集群总资源维度看,排除其他客观因素,置换后,CPU总核数少了一半,使用率上升其实也是预期之内的。事实上置换后,集群CPU使用率确实也由原有的 20%提升至 40%,上升了约 1 倍多。但置换后,cpu sys使用率均值约达到了 12%,较为抢眼,系统相关服务却并无异常,令人有些困惑。

这个问题其实并不难解释,先说结论,因为kafka数据操作会优先在PageCache中进行,导致读写磁盘数据时是系统内核线程去操作而非用户应用层面,所以单机数据读写压力上涨后,系统内核线程的繁忙就表现为cpu sys上涨,甚至比cpu user使用还来的明显。

今天就借此和大家探讨下,kafka高吞吐性能的核心之一—PageCache

2.kafka 消息存储

kafka的存储设计和一般的存储设计理念也差不多,都是分缓存,持久化层,缓存数据尽量放内存,持久化数据就会考虑多副本且落盘。一般的应用引擎设计都会考虑自己来实现缓存及写盘这一套逻辑,kafka的不同之处在于他并没有自己在内存中创建缓冲区,然后再实现向磁盘write的一系列方法,而是直接站在巨人们的肩膀上,使用了系统层面的PageCache

基于Linux开源社区一众贡献者的多年打磨迭代,Linux的文件系统早已在PageCache做了大量的优化和填坑,且还会持续优化,这无异于为kafka的缓存模块提供的强大助力。

直接使用内核系统的PageCache:

  • 减少内存开销: Java对象的内存开销(overhead)非常大,往往是对象中存储的数据所占内存的两倍以上
  • 规避 GC 问题:JVM中的内存垃圾回收已经是多年诟病的问题了,随着堆内数据不断增长而变得越来越不明确,回收所花费的代价也会越来越大
  • 简单可靠:内核系统会调用所有的空闲的内存作为PageCache,并在其上做了大量的优化:预读,后写,flush管理等,这些都不再需要应用层操心,全部有系统接管完成

3.kafka 数据读写

3.1.读写接力

Linux系统会把还没应用程序申请走的内存挪给PageCache使用,此时,当写入数据时,会先写入PageCache中,并标记为dirty。读取数据时,会先再PageCache中查询,如果有就快速返回,没有才会去磁盘读取回写到PageCache中。

因此,一般情况,只要生产和消费速率相差不是很远,数据读写都会发生在PageCache中,没有磁盘操作。这比起自己在内存中再维护一份消息数据提供读写,既不会浪费内存,又不用考虑GC,即便kafka应用重启了,数据也还在PageCache中,可以快速读取恢复。

3.2.异步 flush 数据落盘

由于kafka调用的是系统的PageCache,所以这里讲的kafka数据flush其实就是Linux内核的后台异步flush

内核线程pdflush负责将有dirty标记的内存页,发送给 IO 调度层。内核会为每个磁盘起一条pdflush线程,每 5 秒(/proc/sys/vm/dirty_writeback_centisecs)唤醒一次,主要由以下面三个参数来调整:

  • /proc/sys/vm/dirty_expire_centisecs:默认值 30s,page dirty的时间超过这个值,就会刷盘,所以即使意外OS crash,理论最多也就丢这 30s 的数据
  • /proc/sys/vm/dirty_background_ratio:默认值 10%,如果dirty page的总大小超过了可用内存的 10%(即/proc/meminfo 里 MemFree + Cached - Mapped),则会在后台启动pdflush线程刷盘,这个值是个比较重要的调优参数。
  • /proc/sys/vm/dirty_ratio:默认值 30%,如果写入数据过快,超过了pdflush的速率,此时dirty page会迅速积压,当超过可用内存的 30%,则此时所有应用的写操作都会被block,各自去执行flush,因为操作系统认为现在已经来不及写盘了,如果crash会丢过多的数据,会阻塞住不再接纳更多的数据。我们要尽量避免这种情况的发生,长时间的写入阻塞,很容易带来一系列的雪崩问题。在 Redis2.8 以前,Rewrite AOF 就经常导致这个大面积阻塞问题。

3.3.Page Cache 清理策略

当写入的数据逐渐增多,直到内存满了,此时就需要考虑把应用占用的内存数据挪到swap区去,或者开始清理PageCache了。一般来说,我们会通过调整/proc/sys/vm/swappiness的值为 0,来尽量不使用swap。剩下的就来看看PageCache是如何清理的了。

Page Cache的清理策略是改良版的LRU。如果直接用LRU,一些新读取但只用一次的冷数据会占满了LRU的头部。因此将原来一条LRU的队列拆成了两条,一条放新数据,一条放已经访问过好几次的热数据。刚访问的数据放在新LRU队列里,多次访问命中后会升级到旧LRU队列的热数据队列。清理时会从新LRU队列的尾部开始清理,直到清理出足够的内存。

Linux通过配置/proc/sys/vm/min_free_kbytes的值,来优化系统开始回收内存的阈值。

3.4.预读策略

根据清理策略,当消费太慢,堆积的数据过多直到Page Cache被清理掉了,此时就需要读盘了。

系统内核针对这个问题,会有个预读策略,每次读取请求都会尝试预读更多的数据。

  • 首次预读:readahead*size = read_size * 2 or _ 4,首次预读窗口会是读大小的 2~4 倍,可以提升 IO 效率
  • 后续预读:readahead_size *= 2 ,后续预读会逐渐倍增,直到达到最大预读大小

这也是为什么有时候,我们会觉得应用有个”热身状态”,刚开始卡一下后,运行的越来越快,这其中预读策略就起到了一定的 IO 优化效果。

hyperxu wechat
欢迎您扫一扫上面的二维码,订阅我的公众号!
坚持原创技术分享,您的支持将鼓励我继续创作!