扩容前的一次基准压测
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 | 页缓存 → 用户态 buffer | CPU |
| 3 | 用户态 buffer → socket 缓冲区 | CPU |
| 4 | socket 缓冲区 → 网卡 | 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/write | 81% | 620 MB/s |
零拷贝失效的几种情况,这个比原理更有用:
- 开了 SSL。数据要加密,必须在用户态过一遍,sendfile 直接失效。我们的公网 Kafka 集群就因为这个,吞吐只有内网集群的 40%。
- 消息格式需要转换。老版本 consumer 拉新格式的消息时,broker 要解压缩、降版本、再压缩,会退化成普通拷贝。
- 用了
interceptor或TransactionalId相关的过滤逻辑,需要读消息内容。
批量压缩:最容易被低估的一招
压测数据里 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 MB | 1.0 : 1 | 11% |
| gzip | 151 MB | 6.8 : 1 | 78% |
| snappy | 287 MB | 3.6 : 1 | 29% |
| lz4 | 250 MB | 4.1 : 1 | 24% |
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 还有余量。省下来的预算后来用在了加副本数上,那才是真正提升可靠性的地方。