关于Kafka broker IO的讨论 - huxihx - 博客园
Apache Kafka是大量使用磁盘和页缓存(page cache)的,特别是对page cache的应用被视为是Kafka实现高吞吐量的重要因素之一。实际场景中用户调整page cache的手段并不太多,更多的还是通过管理好broker端的IO来间接影响page cache从而实现高吞吐量。我们今天就来讨论一下broker端的各种IO操作。
开始之前,还是简单介绍一下page cache:page cache是内核使用的最主要的磁盘缓存(disk cache)之一——实际上Linux中还有其他类型的磁盘缓存,如dentry cache、inode cache等。通常情况下Linux内核在读写磁盘时都会访问page cache。当用户进程打算读取磁盘上文件的数据时,内核会首先查看待读取数据所在的page是否在page cache中,如果存在自然命中page cache,直接返回数据即可,避免了物理磁盘读操作;反之内核会向page cache添加一个新的page并发起物理磁盘读操作将数据从磁盘读取到新加page中,之后再返回给用户进程。Linux内核总是会将系统中所有的空闲内存全部当做page cache来用,而page cache中的所有page数据将一直保存在page cache中直到内核根据特定的算法替换掉它们中的某些page——一个比较朴素的算法就是LRU。同样地,在写入page数据到磁盘之前,内核也会检查对应的page是否在page cache中,如果不存在则添加新page并将待写入数据填充到该page中,此时真正的磁盘写还尚未开始,通常都是隔几秒之后才真正写入到底层块设备上——即这是一个延迟写入操作。理论上来说,在这几秒之内的间隔中,用户进程甚至还允许修改这些待写入的数据——当然对于Kafka而言,它的写入操作本质上是append-only的,故没有这样的使用场景。
针对Kafka而言,我平时看到对page cache的调优主要集中在下面这3种上:
- 设置合理(主要是偏小)的Java Heap size:很多文章都提到了这种调优方法。正常情况下(如没有downConvert的情形)Kafka对于JVM堆的需求并不是特别大。设置过大的堆完全是一种浪费甚至是“拖后腿”。业界对该值的设定有比较一致的共识,即6~10GB大小的JVM堆是一个比较合理的数值。鉴于目前服务器的硬件配置都非常好,内存动辄都是32GB甚至是64、128GB的,这样的JVM设置可以为内核预留出一个非常大的page cache空间。这对于改善broker端的IO性能非常有帮助
- 调节内核的文件预取(prefetch):文件预取是指将数据从磁盘读取到page cache中,防止出现缺页中断(page fault)而阻塞。调节预取的命令是blockdev --setra XXX
- 设置“脏页”落盘频率(vm.dirty_ratio):主要控制"脏页“被冲刷(flush)到磁盘的频率——当然还有个dirty_background_ratio,大家可以google它们的区别。在我看来,前者类似是一个hard limit,而后者更像个soft limit
除了上面这几种,我更想讨论一下broker端自己的使用场景会对page cache造成什么影响进而反过来影响broker性能。目前broker端的IO主要集中在以下几种:
- Producer发送的PRODUCE请求
- ISR副本/非滞后consumer发送的FETCH请求
- 滞后consumer发送的FECTH请求
- 老版本consumer发送的FETCH请求
- Broker端的log compaction操作
一、Producer发送的PRODUCE请求
Producer发送消息给broker,broker端写入到底层物理磁盘,这是Kafka broker端最主要的磁盘写操作了。真正的写入操作是异步的,就像之前说的,broker只是将数据直接写入到page cache中。何时写回到磁盘由操作系统决定,Kafka不关心。显然,当prodcuer持续发送数据时,page cache中会不断缓存当前发送的消息序列。这些数据何时会被访问呢?有三个可能的时机:
- ISR副本拉取:当leader broker成功地写入了一条消息后,follower broker会从leader处拉取该条消息,如果是ISR的follower副本,通常能够很快速地拉取这条新写入消息,那么此时这条消息依然保存在leader broker页缓存的概率就会很大,可以保证直接命中。再结合sendfile系统调用提供的Zero Copy特性内核就能直接将该数据从page cache中输送到Socket buffer上从而快速地发送给follower,避免不必要的数据拷贝
- Broker端compaction:当写入消息成功一段时间后log cleaner可能会立即开启工作,故compaction也有可能会触碰到这条消息。当然这种几率比较小,因为log cleaner不会对active日志段进行操作,而写入的消息有较大几率依然保存在active日志段上
- Consumer读取:对于非滞后consumer(nonlagging consumer)而言,它们会立即读取到这条消息。和ISR副本拉取情况相同,这些consumer的性能也会比较好,因为可以直接命中page cache
二、ISR副本/非滞后consumer发送的FETCH请求
Broker端最重要的读操作! ISR副本和非滞后consumer都几乎是“实时”地读取page cache中的数据,而不需要发起缓慢的物理磁盘读操作。再加上上面说的Zero Copy技术既实现了快速的数据读取,也避免了对磁盘的访问,从而将磁盘资源保存下来用于写入操作。由此可见,这是最理想的情况,在实际使用过程中我们应该尽量让所有consumer都变成non-lagging的。对于这种Broker IO模式而言,此时的Kafka已经有点类似于Redis了。
三、滞后consumer发送的FECTH请求
真实场景下这种consumer一定存在的,不管是从头开始读取的consumer还是长时间追不上producer进度的consumer,它们都属于这类消费者。它们要读取的数据有大概率是不在page cache中的,所以broker端所在机器的内核必然要首先从磁盘读取数据加载到page cache中之后才能将结果返还给consumer。这还不是最重要的,最重要的是这种consumer的存在“污染”了当前broker所在机器的page cache,而且本来可以服务于写操作的磁盘现在要读取数据了。平时应用中,我们经常发现我们的consumer无缘无故地性能变差了,除了查找自己应用的问题之外,有时候诊断一下有没有lagging consumer间接“捣乱”也是必要的。
四、老版本consumer发送的FETCH请求
老版本consumer识别的数据格式与broker端不同,因此和走Zero Copy路径的consumer不同的是,此时broker不能直接将数据(可能命中page cache也可能从磁盘中读取)直接返回给consumer,而是必须要先进行数据格式转换,即所谓的downConvert——一旦需要执行downConvert,此broker就失去了Zero Copy的好处,因为broker需要将数据从磁盘或page cache拷贝到JVM的堆上进行处理。显然这势必推高堆占用率从而间接地减少了page cache的可用空间。更糟的是,当broker JVM线程处理完downConvert之后,还需要把处理后的数据拷贝到内核空间(不是page cache。因为失去了Zero Copy,所以必须先拷贝到内核空间然后才能发送给Socket buffer),再一次地压缩了page cache的空间。如果数据量很大,那么这种场景极易造成JVM堆溢出(OOM)。值得一提的是, KIP-283解决了容易出现OOM的问题,但依然不能使得downConvert场景继续“享受”Zero Copy。
五、Broker端的log compaction操作
Compaction操作定期处理日志段上的数据,执行基于key的压实操作。在compact期间,broker需要读取整个日志段,在JVM堆上构建映射表,因此也会挤占page cache的空间,另外compact会将处理结果写回到日志段中。Compaction是定时运行的操作,在频率上并不如上面4个来的频繁。
综合比较上面这5种IO,我们希望broker端的磁盘尽量为producer写入服务,而page cache尽量为non-lagging consumer服务——这应该是能获取clients端最大吞吐量的必要条件。但在实际应用中,我们的确也观测到了因为lagging consumer或downConvert甚至是compaction导致其他clients被影响的实例。究其原因就是因为所有IO对page cache的影响是无差别的。producer持续写入保证了page cache中不断充满最新的数据,但只有存在一个auto.offset.reset=earliest的consumer,就有可能瞬间把page cache修改得面目全非,即使这个consumer只是一个一次性的测试consumer。从根本上来说,我们应该区分不同clients或进程对page cache访问的优先级。
实际上,Linux的open系统调用提供了O_DIRECT的方式来禁用page cache,因此我在想能否为Kafka clients提供这样的选择,即可以指定某些clients或Kafka线程以O_DIRECT方式来访问Linux的VFS。如果支持这个功能的话,那么像这种一次性的auto.offset.reset=earliest的consumer抑或是阶段性的compact完全可以采用这种方式从而完全避免对page cache的“污染”。不过令人遗憾的是,Java直到Java 10才加入了对O_DIRECT的支持(https://bugs.openjdk.java.net/browse/JDK-8164900)。也许在未来Kafka不再支持Java 9时这会是一个不错的KIP提议吧。