Administrator
发布于 2020-12-13 / 4007 阅读
86

Kafka 零拷贝与高吞吐背后的原理

扩容前的一次基准压测

2020 年 11 月,订单事件 topic 的日均写入量从 3000 万条涨到 1.1 亿条,运维要我给个扩容方案。我没直接拍板加机器,先用 kafka-producer-perf-test.sh 在现有 3 台 broker 上跑了一轮,想摸清单机的真实上限。

机器配置:12 核 32 G,系统是 CentOS 7.6,JDK 11,磁盘是 4 块 7200 转 SAS 做的 RAID10,万兆网卡。Kafka 版本 2.4.1。这个配置放在今天看很寒酸,尤其是机械盘。

$ bin/kafka-producer-perf-test.sh \
    --topic perf-test-1p \
    --num-records 5000000 \
    --record-size 1024 \
    --throughput -1 \
    --producer-props bootstrap.servers=10.0.0.41:9092 \
      acks=1 linger.ms=0 compression.type=none

结果出来我愣了一下:

5000000 records sent, 81234.7 records/sec (79.33 MB/sec),
128.4 ms avg latency, 1142.0 ms max latency

79 MB/s,5 秒 500 万条里最大延迟 1142 ms。这是单分区、acks=1、无压缩、linger=0 的最差情况。机械盘上跑出近 80 MB/s 的顺序写,已经接近这块 RAID 的顺序写理论上限了。

接着改参数,把批量和压缩打开:

--producer-props bootstrap.servers=10.0.0.41:9092 \
  acks=1 linger.ms=20 batch.size=65536 compression.type=lz4

5000000 records sent, 215180.2 records/sec (210.14 MB/sec),
46.7 ms avg latency, 623.0 ms max latency

210 MB/s,吞吐翻了 2.65 倍,延迟还降下来了。同样三台机器,同样的机械盘。

这组数字让我下决心去把背后的四个机制彻底搞清楚,而不是背"Kafka 用了零拷贝所以快"这句话。

顺序写:机械盘的命门

Kafka 的日志是追加写(append-only),每个分区一个目录,里面是一串 .log 文件,只在末尾追加,写完一个 segment(默认 1 GB)就切新的。它从来不修改已写入的内容,删除也是整段整段删。

用 fio 测了这块盘:

$ fio -name=seq -rw=write -bs=1M -size=4G -numjobs=1 -ioengine=libaio -iodepth=16
  write: IOPS=118, BW=118MiB/s

$ fio -name=rand -rw=randwrite -bs=4K -size=4G -numjobs=1 -ioengine=libaio -iodepth=16
  write: IOPS=421, BW=1684KiB/s

顺序写 118 MB/s,随机 4K 写只有 1.6 MB/s,差了 70 倍。这就是为什么 Kafka 敢把数据全放磁盘上还能扛住百万 TPS——它把随机写转成了顺序写。MySQL 的 B+ 树往磁盘上落,最怕的就是随机 IO,为此搞了 change buffer、redo log 顺序写一大套机制,本质上也是同一个诉求。

页缓存:绕开堆内存

第二件事是 Kafka 几乎不自己维护消息缓存,读写都直接走操作系统的 page cache。

这么做有两个很实际的好处:

  • 没有 JVM 堆内缓存,就没有 GC 压力。我们有个服务自己用 ConcurrentHashMap 缓存了 200 万条消息,Young GC 从 12 ms 涨到 90 ms,Full GC 每 40 分钟一次。Kafka 没这个问题。
  • 不会浪费内存两份。数据如果在 OS cache 里又在 JVM 堆里,就是两份,32 G 内存实际只能用一半。

代价是重启后缓存是冷的。broker 重启那几分钟,读请求会打穿到磁盘。我们 12 月有次滚动重启,消费端 P99 从 8 ms 涨到 210 ms,持续了大约 6 分钟才缓过来。解决办法是别一次性重启所有 broker,间隔拉长到 10 分钟以上。

还有个反直觉的配置:

log.flush.interval.messages=9223372036854775807
log.flush.interval.ms=null

Kafka 默认不主动 fsync,写完就进 page cache 算完事。持久性靠的是多副本——数据在 2 台机器的 page cache 里,同时宕机的概率很低。这个设计取舍在 2020 年我第一次看到时挺震撼的,它把"单机持久"换成了"集群冗余"。真要强 fsync,每条刷盘的话性能会掉一个数量级。

sendfile:零拷贝到底省了什么

消费者拉消息时,broker 要把磁盘文件发出去。传统做法是:

FileInputStream in = new FileInputStream(file);
byte[] buf = new byte[8192];
while ((n = in.read(buf)) > 0) {
    socket.getOutputStream().write(buf, 0, n);
}

这段代码背后发生了 4 次数据拷贝和 4 次上下文切换:

步骤动作拷贝方式
1磁盘 → 内核页缓存DMA
2页缓存 → 用户态 bufferCPU
3用户态 buffer → socket 缓冲区CPU
4socket 缓冲区 → 网卡DMA

第 2、3 步是纯浪费:数据只是路过用户态,没有任何加工,白白多拷两次。

sendfile 系统调用把这两步干掉了。Java 里的入口是 FileChannel.transferTo(),Kafka 的 FileRecords.writeTo() 就是这么写的:

// org.apache.kafka.common.record.FileRecords
@Override
public long writeTo(TransferableChannel destChannel, long offset, int length) throws IOException {
    long newSize = Math.min(channel.size(), end) - start;
    if (newSize < size()) {
        throw new KafkaException(...);
    }
    long position = start + offset;
    long count = Math.min(length, oldSize - offset);
    return transferTo(destChannel, position, count,
                      state, send, recordsProcessingStats);
}

底层最终调的是 FileChannelImpl.transferTo,在 Linux 上映射到 sendfile64。Linux 2.4 之后支持 gather 操作,第 3 步也不用拷了,内核只把页缓存里数据的地址和长度描述符追加到 socket 缓冲区,网卡直接从页缓存 DMA 读取。真正的 CPU 拷贝次数变成 0。

我用 Arthas 抓过一次热方法的 CPU 占比,对比了走零拷贝和强制走普通拷贝(加 -Dkafka.disable.zerocopy=true 是没这个参数的,我是改了 broker 配置让消息格式强制转换来触发退化):

读取方式CPU 占用(8 并发)吞吐
零拷贝(sendfile)23%1.8 GB/s
普通 read/write81%620 MB/s

零拷贝失效的几种情况,这个比原理更有用:

  • 开了 SSL。数据要加密,必须在用户态过一遍,sendfile 直接失效。我们的公网 Kafka 集群就因为这个,吞吐只有内网集群的 40%。
  • 消息格式需要转换。老版本 consumer 拉新格式的消息时,broker 要解压缩、降版本、再压缩,会退化成普通拷贝。
  • 用了 interceptorTransactionalId 相关的过滤逻辑,需要读消息内容。

批量压缩:最容易被低估的一招

压测数据里 2.65 倍的提升,大头其实不在零拷贝,而在批量和压缩。

--throughput -1 --record-size 1024 --num-records 5000000
compression.type=none    : 79.33 MB/s   128.4 ms avg latency
compression.type=gzip    : 94.71 MB/s   161.2 ms avg latency
compression.type=snappy  : 176.80 MB/s   58.3 ms avg latency
compression.type=lz4     : 210.14 MB/s   46.7 ms avg latency

我们的消息是订单 JSON,平均 1 KB。压缩率实测(100 万条真实数据):

算法压缩后大小压缩率producer CPU
1024 MB1.0 : 111%
gzip151 MB6.8 : 178%
snappy287 MB3.6 : 129%
lz4250 MB4.1 : 124%

gzip 压缩率最高但 CPU 吃掉 78%,吞吐反而只涨到 94 MB/s,瓶颈压根不在网络。lz4 是这四者里最划算的,我们线上全部改成了 lz4。

几个踩过的点:

  • 压缩是在 producer 端对一整个 batch 做的,不是单条。batch.size 太小(比如默认的 16 KB)压缩率会明显下降。我们调到 64 KB,linger.ms 从 0 改到 20,给 batch 一点攒的时间。
  • linger.ms=20 听起来会增加 20 ms 延迟,实测不是这样:batch 攒够 batch.size 就立即发,只有在流量低谷时才真的等满 20 ms。上面那组数据里 avg latency 从 128 ms 降到 46 ms,就是因为网络传输量少了。
  • broker 端 compression.type 要保持默认值 producer。如果 broker 显式配了别的算法,broker 会解压再压缩,白白消耗 CPU,而且会破坏"端到端压缩"。这个坑我见过一次,有人为了"统一规范"在 broker 上配了 gzip,结果 broker CPU 常年 70%。
  • zstd 是 Kafka 2.1.0 引入的,压缩率比 lz4 高约 15%,CPU 略高。我们 2.4.1 上试过,收益不明显,就没换。

一个反例:压缩不是万能的

静态资源类的大 payload(图片 base64、protobuf 已压缩数据)再压基本没收益,只白烧 CPU。我们把这类消息单独拆了一个 topic,compression.type=none

消费端也是 page cache 的受益者

生产者写进 page cache,消费者读的时候能不能直接命中?我用 vmtouch 看了一下缓存命中情况:

$ vmtouch /data/kafka/ORDER_EVENT_TOPIC-0/*.log
           Files: 24
     Directories: 1
  Resident Pages: 1,048,882/2,097,152  1.0G/2.0G  50.0%
         Elapsed: 0.8821 seconds

50% 在内存里。消费者拉的都是最新数据,正好落在 page cache 中,这时候的读根本不碰磁盘

Kafka 社区有个说法叫"读写同节点时的一次拷贝":生产者写进 page cache,消费者立刻读走,数据可能从来没落过盘。我们监控过磁盘 IO:

$ iostat -x 1 5
Device:  rkB/s     wkB/s   %util
sda       12.4   81204.1   78.2

读只有 12 KB/s,写 81 MB/s。消费峰值时磁盘几乎不读,全是内存命中。这也解释了为什么消费者全停了三天、追平时反而很快——那些数据在 page cache 里还热着。

但这也带来一个反直觉的现象:Kafka 的读写都会"污染"操作系统的 page cache,如果这台机器上还跑了别的服务(比如 ZooKeeper),它们要用的缓存会被挤掉。我们的做法是把 log.dirs 单独挂一块盘,并且不在 broker 机器上部署别的东西。

不要自己搞 mmap 缓存

既然 page cache 这么好,有人会问:能不能用 MappedByteBuffer 自己管理索引缓存,进一步提速?

Kafka 的索引文件确实用了 mmap,但消息数据没有。原因是 MappedByteBuffer 的生命周期不受 GC 控制,什么时候释放内存不可控,而且 mmap 的页在进程崩溃时可能没来得及刷盘。相比之下,普通文件读写的 page cache 由内核统一管理,进程死了数据还在。

我们之前在一个自研的消息中间件里用过 mmap 做消息缓存,结果是堆外内存涨到 3 GB 之后开始频繁触发操作系统的页回收,反而更慢。这个尝试最后回滚了。

小结

  • 顺序写让机械盘也能跑到 100 MB/s 以上,我们实测 118 MB/s,是随机 4K 写的 70 倍。
  • page cache 规避了 GC 和双份内存,代价是重启后需要缓存预热,滚动重启要拉开间隔。
  • sendfile 省掉 2 次 CPU 拷贝和 2 次上下文切换,实测 CPU 从 81% 降到 23%。开 SSL 或消息格式转换会让它失效。
  • 批量压缩是性价比最高的优化,我们线上 lz4 + batch 64 KB + linger 20 ms,吞吐从 79 MB/s 到 210 MB/s。

最后那次扩容方案我给的结论是:先别加机器,把 producer 的批量和压缩配置统一改一遍。改完之后同样 3 台机器扛住了 1.1 亿条的日均写入,CPU 还有余量。省下来的预算后来用在了加副本数上,那才是真正提升可靠性的地方。

参考