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 优化效果。