先说明一个现象:很多人第一次接触Kafka,都是从“高性能、高吞吐”这六个字开始的。但真正去翻源码、看文档、甚至自己搭集群压测之后,才会意识到一个问题——Kafka的快,不靠堆硬件,不靠缓存硬扛,也不靠把逻辑写得有多花哨。它的快,是从数据落盘方式、内核利用方式、网络IO方式到并行模型,一整套设计层层叠加的结果。这篇文章就把这套设计剥开,围绕四大核心机制讲清楚:为什么顺序写盘能扛住海量写入、为什么零拷贝能让消费端快得惊人、为什么批量+压缩能把带宽榨干、为什么一个分区一份并行度的模型能横向扩展到几十台机器。适合正在用Kafka但总觉得“性能上差点意思”的开发者,也适合准备面试、想把底层原理串成体系的人。
先说结论:Kafka的吞吐量,本质上不取决于它有多聪明,而取决于它有多“克制”。它克制地只做追加写,克制地把缓存交给操作系统,克制地不在用户态搬运数据,克制地用一个非常朴素的分区模型换取无限并行。这套设计里的每一条,单拎出来都是大学操作系统课上的基础知识点,但组合起来,就成了消息中间件里最能打的那个。
1. 顺序写盘:Kafka的快,从磁盘IO开始
1.1 为什么随机读写那么慢
很多人以为SSD普及之后,磁盘随机IO已经不是瓶颈了。这个想法对一半。SSD的4K随机读写比机械硬盘快了两个数量级,延迟从十几毫秒降到几十微秒,但依然比顺序读写慢一个数量级。SSD的写放大、GC搬移、FTL映射更新,这些机制决定了随机写不但慢,还会带来寿命损耗。
传统消息队列为什么慢?因为它们中的大多数,为了支持“按Key查询”“按Offset删除”“事务性索引”,都选择了B+Tree或者LSM-Tree这类带随机写入的结构。每条消息的写入,都可能触发索引更新、页分裂、磁盘寻址。在机械硬盘时代,一次随机寻址时间是8-10ms,意味着每秒只能做大概100-200次随机写。哪怕你用几百条消息聚成一个事务批量提交,吞吐上限也摆在那里。
Kafka的选择非常极端:它把整个Topic的写入操作,退化成对一个文件的追加写。每条消息到达时,直接写到当前活跃Segment文件的尾部。数据落盘之后,返回给Producer一个Offset。索引文件是异步、批量、顺序生成的。
1.2 磁盘顺序写到底有多快
我实测过一台普通的SATA SSD,顺序写速度大约500MB/s,随机写只有不到50MB/s,差10倍。机械硬盘更夸张,顺序写150MB/s,随机写只有1-2MB/s,差了百倍。Kafka的快,首先就快在它把每一次写入都用在了顺序写这一个方向上。
这里还有一个很多人忽略的点:操作系统层面,磁盘IO调度器对顺序IO的合并和预读是非常激进的。写一条200字节的消息,到了内核块设备层,可能被合并成16KB甚至更大的IO请求。也就是说,Kafka的每次“磁盘写”,实际上写的是一个大块,而不是零碎的小块。这种场景下,磁盘的写入带宽几乎可以打满。
注意一个经典误区:Kafka不是每次都把消息
fsync到磁盘。它默认的log.flush.interval.messages是无限大,log.flush.interval.ms是无限大。数据写入Page Cache就算“写入成功”,刷盘交给了操作系统的pdflush线程。这个设计让单条消息延迟保持在亚毫秒级别,同时通过副本复制保证数据不丢。
1.3 Segment与稀疏索引:让顺序写不影响乱序读
顺序写解决了写的问题,但消息中间件难免要消费。消费者如果每次都要从头扫整个日志文件,那再快的磁盘也扛不住。
Kafka的解法是Segment + 稀疏索引。每个Topic的partition目录下,由多个Segment组成,每个Segment是一个.log文件加两个索引文件:.index(偏移量索引)和.timeindex(时间戳索引)。每个Segment默认1GB(log.segment.bytes),写满就滚动生成新Segment。
索引不是每条消息一个条目,而是每写入一定字节量才记一条(默认log.index.interval.bytes是4KB)。这意味着索引大小只有日志的极小比例,可以常驻内存。查找消息时,先二分定位Segment,再在索引里二分定位到近似位置,最后回到日志文件里做少量顺序扫描。
我当初看这个设计的时候,第一反应是“Kafka的查询效率是不是很低?”后来想明白了:Kafka的核心场景是按Offset顺序消费,它不是给“随机点查某条消息”设计的。一旦消费组把Offset提交在某处,下次基本就是顺序往后读。稀疏索引覆盖的顺序查找场景,效率足够高,代价却比B+Tree小一个量级。
1.4 顺序写模型的代价:不能删、不能改
天下没有免费的午餐。Kafka敢把写操作简化成追加写,就必须接受两个限制:数据不能改、删除也是假删除。
消息一旦写入,就不会再修改。而删除,只是标记一个Segment为“可删除”状态,后台异步清理。这种设计直接避免了随机写和原地更新的锁竞争。消费者读到的数据始终是一个不可变序列,天然支持并发读,不需要悲观锁。
如果你的业务需要“消息发出去还能撤回、还能修改”,那Kafka根本不适合你,请出门右转用数据库。Kafka的设计哲学是:把不必要的能力砍掉,把核心能力压榨到极致。
2. 页缓存与零拷贝:没有缓存胜似缓存
2.1 为什么Kafka不用JVM堆内缓存
如果你写过Java后端,你一定见过各种本地缓存:ConcurrentHashMap、Caffeine、Guava Cache。Kafka作为JVM系中间件,却没有选择用堆内缓存来缓存消息数据,而是完全依赖操作系统的Page Cache。原因有三:
第一,堆内缓存意味着GC压力。几GB的缓存对象,触发Full GC时STW时间非常恐怖。Kafka追求的是毫秒级延迟和几十秒甚至几分钟的长运行稳定性,大规模的堆内缓存是灾难。
第二,操作系统本身的Page Cache管理已经非常成熟。它会根据内存压力动态调整缓存大小,会用LRU策略淘汰冷数据,还有预读、回写等优化。Kafka自己搞一套缓存,既要保证命中率,又要处理淘汰策略,还要应对JVM GC,纯属重复造轮子。
第三,也是最重要的一点:Page Cache天然支持零拷贝。如果Kafka用堆内缓存,消费端读取数据必须从JVM堆拷贝到堆外,再经过Socket发送。绕了一圈,数据在路上被复制了三四次。而利用Page Cache,配合sendfile()系统调用,数据直接在内核态完成磁盘到网卡的发送,整个路径不经过用户态。
2.2 零拷贝:数据怎么从磁盘飞到消费者手里
传统的数据读取路径是这样的:
磁盘 -> Page Cache -> 用户态Buffer堆内 -> Socket Buffer -> 网卡这个过程涉及4次用户态和内核态之间的上下文切换,以及至少2次不必要的内存拷贝。如果数据量是1GB,这些拷贝就是1GB级别的内存搬运,CPU开销巨大。
Kafka利用sendfile()系统调用,路径变成这样:
磁盘 -> Page Cache -> 网卡数据从磁盘读到Page Cache后,DMA引擎直接把数据从内核态发送到网卡。CPU全程不参与数据搬移,只负责控制信息。这就是零拷贝。
在Linux上,Kafka的Producer发送和Consumer拉取都做了这个优化。具体到代码,是Kafka的TransportLayer和FileMessageSet读取机制配合实现的。我经常看一些性能分析文章,把Kafka的“零拷贝”吹得神乎其神,其实它就是sendfile()的合理应用,只是大多数Java应用根本没想到、也没胆量去绕过JVM堆直接走系统调用。
2.3 Page Cache热读为什么这么猛
零拷贝解决了“数据从磁盘到网卡”的路径问题,但真正让Kafka消费延迟低的,还有一层:数据大概率还在Page Cache里,连磁盘都不用读。
假设你的Producer写入100MB消息,这100MB此刻就在Page Cache里躺着。Consumer立刻来消费,命中的是内存,速度是以GB/s计的。这个特性给Kafka带来的消费体验是:写入即读取,延迟只跟网络和反序列化有关。
实操要点:Kafka所在机器的内存一定要给足。我之前负责过一个集群,消息体平均1KB,日写入量几TB,机器内存128GB,跑了大半年还没见过磁盘IO瓶颈。但如果你只给Kafka 8GB内存,那它就要频繁从磁盘拉数据,消费延迟和吞吐直接下滑一个数量级。
这里也解释了一个常见的困惑:为什么Kafka的heap设置通常只需要4-6GB?因为数据不在堆里,堆主要是给业务代码、网络层缓冲、索引结构用的。真正的大头是Page Cache。所以调Kafka的JVM参数,不需要无脑把-Xmx拉满,反而应该把内存留给操作系统。
2.4 页缓存的淘汰策略对延迟的影响
Page Cache也不是无限大的。当写入量超过内存容量,早期数据就会被LRU淘汰。淘汰后,消费者再来读,就需要真正的磁盘IO,延迟从微秒级跳到毫秒级。
怎么缓解?两个思路:
一个是让Consumer追赶生产。消费延迟越小,数据越是“热”的,越不可能被淘汰。另一个是合理设置log.retention.hours,不要无限保留数据。日志越长,Page Cache覆盖的范围越有限,命中率越低。
我见过一个反面案例:某公司Kafka集群保留30天数据,每天写入几百GB,内存只有64GB,消费端偶尔追赶不及时,就要从磁盘拉几小时前的数据,整个消费链路卡顿严重。后来把保留时间缩到3天,同时对消费延迟做了监控,问题才缓解。Kafka不是存储系统,别拿它当数据仓库用。
3. 批量与压缩:一次网络请求干最多的活
3.1 Producer端攒批:谁能等谁就能快
Kafka的Producer不是来一条发一条的。那样的话,每条消息都是一次网络往返,再快也就几万QPS。Kafka的做法是:先把消息放进内存缓冲区(RecordAccumulator),攒够一批再发送。
核心参数有两个:batch.size和linger.ms。
batch.size:默认16KB。Producer会为每个分区维护一个双端队列,消息写入时尽量往现有批次里塞,塞不下就新建一批。linger.ms:默认0。字面意思是“等多久再发”,默认值是0,表示不额外等待,立即发送。但实际上,如果第一批还没攒满,后续消息可以继续填充当前批次,直到满了才发。
所以在高并发场景下,即使linger.ms=0,批次天然就会被填满。每秒百万级消息的输入,每个批次16KB,每个批次几千条消息,网络请求数被压缩了几个数量级。
3.2 RecordBatch:变长整型和增量编码
Kafka的消息格式,从V2版本开始,引入了RecordBatch。每条消息不再是独立的网络包,而是共享一批元数据(如压缩类型、CRC、时间戳等),消息体用变长整型和增量编码来压缩存储。
这个消息格式的关键在于:Kafka是用CPU换带宽。一条消息里的时间戳、Offset、Key等字段,如果每个都存一个固定长度的长整型,那一条10字节的消息可能膨胀到50字节。而变长整型(Varint)+ZigZag编码,可以把这些数字压缩到原来的四分之一甚至更小。
你如果调过Kafka的指标,会发现一个有意思的现象:Topic的磁盘占用和实际消息体大小往往对不上,因为消息格式本身也是影响存储的关键因素。V2版本的RecordBatch,在批量场景下,平均每条消息的额外开销只有不到1字节。
3.3 压缩算法选型:LZ4、ZSTD和GZIP的取舍
Kafka支持在Producer端配置压缩类型:none、gzip、snappy、lz4、zstd。
先看实测倾向:纯文本JSON类的消息,ZSTD压缩率最高,能到5-10倍;LZ4次之,但速度快非常多;GZIP压缩率居中,CPU消耗最高;Snappy现在基本不推荐,性价比不如LZ4。
压缩这件事,本质上是用CPU换带宽和磁盘。如果你的消息是纯文本或JSON,压缩收益非常大。但如果消息已经是压缩过的二进制(比如图片缩略图、已压缩的日志),再压就是浪费CPU。
个人配置经验:生产环境首选LZ4,CPU跑得动,压缩率也不错。如果消息量大且CPU有多余资源,选ZSTD,等级设为3左右就够用了。别上默认最高等级,你会发现Producer的CPU飙到80%以上,吞吐反而下降。
压缩消息存到Broker后,数据是以压缩形态写入Segment的。消费者拉取时,Kafka会在服务端做“压缩检核”,如果消费者指定了和解压匹配的压缩类型,数据可以直接以压缩形态传输。这一实现在2.1版本后有个重要改进:KafkaConsumer在拉取数据时,会传入自己支持的压缩类型,Broker端不再做无效的解压再压缩。
3.4 批量发送对延迟的潜在伤害
批量是把双刃剑。攒批攒得太狠,吞吐上去了,延迟也上去了。linger.ms如果设置成100ms,就意味着消息最多可能等100ms才发出去。对延迟敏感的业务,这个参数必须调低。
我的建议是:延迟敏感和吞吐敏感用两套配置。
- 延迟敏感:
linger.ms=0~5,batch.size=16KB~64KB,acks=1 - 吞吐优先:
linger.ms=20~100,batch.size=1MB,acks=1,开启压缩
另外还有一点:不要为了“攒更多批”而加大batch.size。batch.size只是每个分区的一个上限,不是让你把batch调到1MB之后,消息就一定要攒到1MB才发。它更像是一个“容量上限”和“发送触发高度”。要理解两者区别,需要看Producer端RecordAccumulator的Ready检查逻辑:只要满足“批大小达到batch.size”或者“等待时间超过linger.ms”,该批次就会进入发送队列。
4. 分区并行:一条消息一个副本,一个分区一线程
4.1 并行度从哪来
先问一个问题:一张表存在MySQL里,并行度再高也就是那一条查询路径。Kafka不一样,一个Topic拆成N个分区,每个分区是独立的读写单元。生产端可以并行写不同分区,消费端一个分区只能被一个消费组内的一个消费者消费,但不同分区可以由不同消费者并行读写。
这种“分区即并行单元”的模型,让Kafka的吞吐量和分区数基本成正比。3个分区的Topic和30个分区的Topic,在机器资源充足的情况下,性能差一个数量级。
Partition内数据是有序的,Partition间不保证顺序。这意味着如果你需要全局有序,只能用单分区——那性能就限制了。多数业务对“全局有序”的需求其实是“分区内有序”,这样设计完全没有问题。
4.2 副本不是备份,是“读多写一”
Kafka的副本机制里,每个分区有1个Leader和N个Follower。生产者只写Leader,消费者默认也读Leader。Follower只做一件事:从Leader拉取数据,追平自己的HW(High Watermark)。
为什么不让消费者从Follower读?一方面实现简单,不用处理Leader和Follower之间的数据一致性问题;另一方面,Kafka的“读”依赖Page Cache热点命中,Follower的数据是异步同步的,如果消费者从Follower读,可能读到没提交的数据或者延迟很大的数据。所以Kafka的默认模型是:写Leader、读Leader、Follower纯备份。
你可以开启acks=all,让Producer等待所有ISR(In-Sync Replicas)确认后才返回,换取强一致。但这个配置的代价是写入延迟受最慢副本的制约。我的建议是:跨机房场景选acks=all,其余场景acks=1足够,配合监控确认副本同步正常。
4.3 分区数多少合适
分区数不是越大越好。每个分区都对应一组文件句柄、内存索引和消费线程。分区过多,Broker上元数据膨胀,Controller的开销变大,故障恢复时Leader切换的时间也会变长。分区太少,并行度又上不去。
经验公式(针对几十到几百GB/天的集群规模):
分区数 ≈ Broker数 × 建议单Broker分区数 单Broker分区数建议在 50~200 之间具体还要看消费端的并行能力。如果你的一个Topic每秒写入100MB,消费端只有一台机器,那分区再多也就一台机器的消费能力。分区数是和消费端并行度强相关的。消费端每个线程如果能稳定处理每秒10MB,那100MB/s的Topic,至少需要10个分区、10个消费线程。
避坑提示:分区数只能在建Topic时设定,之后修改要等分区迁移完成,期间读写性能会受严重影响。上线前一定预估好数据规模,宁多勿少。我见过一个案例,上线时Topic只建了12个分区,半年后数据量翻了30倍,消费端怎么加机器都跟不上,最后只能新建Topic迁移数据,折腾了整整一个周末。
4.4 副本数怎么选
副本数最常踩的坑是“为了安全把副本设成3,于是Broker数也只有3,然后发现Leader都挤在同一台机器上”。
其实副本数为3时,Broker数最好超过3,比如5台甚至6台。因为分区的Leader会均匀散落在各Broker上,如果只有3个Broker而副本数为3,每个分区的3个副本刚好占满全部3台机器,某个Broker宕机,分区就没有可用副本能顶上,ISR收缩后数据冗余度下降,这是非常危险的状态。
另外,min.insync.replicas要配合acks=all才生效。如果你设置min.insync.replicas=2但Producer用acks=1,那实际上Produce请求在Leader写入成功后就直接返回了,没有任何副本同步保障。这两者必须联动配置。
5. 性能参数调优实践:一张表看清关键配置
前面四章把Kafka的设计内核讲透了。这一章直接上干货,把生产环境最常用的性能参数整理成表,附上我自己的调优经验。
| 参数 | 默认值 | 调优建议 | 对应设计点 |
|---|---|---|---|
batch.size | 16KB | 32KB~1MB,吞吐优先时增大 | 批量机制 |
linger.ms | 0 | 0默认即可,吞吐优先可设20~100 | 批量机制 |
compression.type | none | 文本类消息设lz4或zstd | 压缩机制 |
acks | all | 同机房ack=1,跨机房ack=all | 副本机制 |
max.in.flight.requests.per.connection | 5 | 保持默认,开启幂等后可调更高 | 网络并行 |
buffer.memory | 32MB | 根据消息峰值调整,64~256MB常见 | 攒批缓冲 |
num.io.threads | 8 | 磁盘IO密集可适当调大 | 服务端线程 |
num.network.threads | 3 | 网络连接多就调大,如8~16 | 网络线程 |
queued.max.requests | 500 | 默认即可 | IO背压 |
log.segment.bytes | 1GB | 大文件场景可调大减少Segment数 | 顺序写盘 |
log.retention.hours | 168 | 根据业务数据量调整 | 日志保留 |
replica.fetch.max.bytes | 1MB | 大流量场景调大,如10MB | 副本拉取 |
有几个容易忽略的点补充说明:
num.io.threads不是线程越多越快,线程切换和锁竞争会吃掉性能。如果你的Broker是SSD盘,8个线程通常足够。log.segment.bytes调大到2GB,可以减少Segment文件数量,让索引更稀疏,内存占用更低。但对删除和压缩不友好。建议保留默认。max.in.flight.requests.per.connection在启用幂等Producer(enable.idempotence=true)时,可以安全地调到10或者更高,因为Kafka会自动处理乱序和去重。
6. 常见性能陷阱与排查实录
6.1 陷阱一:Producer的batch.size调大但吞吐没涨
很多人把batch.size翻了好几倍,发现Producer吞吐纹丝不动。原因在于:batch.size只是“发送阈值”,不是“攒批时间”。在低并发场景下,每条消息间隔远大于linger.ms,批次永远攒不满,batch.size再大也白搭。
真正要调的是linger.ms。举个例子:每秒只有100条消息,每条1KB,那16KB的batch想攒满,平均要等160ms。如果linger.ms=0,Producer会在第一条消息进来后立即发送,batch基本每次都只装1条。调linger.ms=100后,吞吐就能提升约10倍。代价是延迟增加100ms。
6.2 陷阱二:压缩反而变慢
压缩不是只加不减。我遇到过最典型的场景:消息体是几KB的无规律二进制数据,用了ZSTD压缩,CPU占用翻了一倍,磁盘空间一点没省。
判断要不要压缩,先做一次样品测试。拿一段真实消息跑一下gzip -t或者直接看compression.ratio指标。如果压缩率低于80%,说明数据本身熵很高,压不动,就不如不压。另外注意,broker端会为压缩消息保留原始数据的校验信息(CRC),在压缩率很低的情况下,这部分额外开销会吃掉压缩收益。
6.3 陷阱三:用户态缓冲导致零拷贝失效
Kafka的零拷贝有个前提:数据自始至终不被解压到用户态。如果你在Consumer端使用了需要“修改消息内容”的拦截器(Interceptor)或者序列化器,Kafka不得不把压缩数据解压到堆内,零拷贝路径就断了。这之后就回到了传统的多次拷贝。
排查方法:看Consumer的metrics里的io-wait和user时间飙升,同时观察Producer和Consumer之间的网络流量远小于实际吞吐(因为压缩数据被解压后重新传输了)。
注意:不要试图通过Consumer做消息的“清洗”或“补字段”,那会完全破坏端到端的零拷贝优化。需要加工的数据流出Kafka后,交给下游的实时计算框架处理,别在Kafka链路上做。
6.4 陷阱四:分区数翻倍后吞吐反而下降
我见过有人把一个Topic从20分区调到200分区,结果吞吐下降了三成。原因是:分区数变多后,Broker上的文件句柄、内存索引、网络连接全部翻倍,Controller同步元数据的开销随之上升。在机器规格不高的情况下,分区数是资源的大敌。
要理解分区数的上限,可以观察两个指标:Controller的IsrUpdatesPerSec和Broker的NetworkProcessorAvgIdlePercent。如果前者飙升、后者跌倒20%以下,说明分区数已经超过了机器的处理能力。这个时候加机器,而不是继续加分区。
6.5 一个完整的性能排查决策树
最后分享我常用的性能问题排查路径:
- 先看Broker的CPU是否打满。CPU满则看是用户态(压缩编解码、消息格式序列化)还是内核态(拷贝、网络协议栈)。
- CPU不满,看网络流量是否达到网卡上限。如果达到了,考虑压缩、减少副本传输、升级网络。
- 网络也不满,看Page Cache命中率。如果从磁盘读的比例高,考虑加内存或缩小retention。
- 磁盘IO高,看是顺序写还是随机写。随机写大概率是日志清理(Compaction)在作祟,此时关注
log.cleaner.backlog.ms。 - 全链路都快,那就是Consumer消费逻辑太慢。Consumer的poll循环如果绑定了解析和入库,一定要拆出来异步处理。
这个顺序基本覆盖了90%的性能问题。排查时切忌瞎猜,先用uptime、top、vmstat、iostat、nicstat把机器状态拿全,再用Kafka自带的JMX指标做交叉验证。
7. 写在最后:Kafka性能设计的启示
我自己做消息中间件选型和技术方案评审这些年,越来越觉得Kafka这种“四两拨千斤”的设计极具参考价值。它没有发明新概念,也没有堆砌黑科技,只是把计算机系统里最经典的那几条底层规律用到了极致:顺序IO快于随机IO、内核比用户态更擅长搬数据、批量请求永远比单个请求快、并行是吞吐的唯一出路。
如果你把Kafka的性能设计套到自己的系统上,也有立竿见影的启发:日志类数据写入本地文件,能顺序写就别随机写;文件传输能走零拷贝就走零拷贝,别在业务代码里手动复制十几遍;对外提供接口时能批量接受请求就批量接受,并且勇敢地引导上游攒批;需要扩展吞吐先看有没有把并行粒度拆到位。
回到开头那句话:Kafka为什么快?不是因为它做了什么多复杂的事,而是因为它把每个环节都做对了。希望你读完这篇,能把Kafka的性能脑图装进自己脑子里,下次再有人问“Kafka怎么这么快”,你能从磁盘IO讲起,一路讲到系统调用和分区并行,讲得有理有据,讲得让人信服。