Kafka 为什么这么快?这个问题我在技术社区被问过,也被面试官问过,最近一次是在帮一个团队排查消费积压问题时又被抛了出来。很多人能背出“顺序写、零拷贝、批量、分区”这几个关键词,但真要落到“为什么这些机制能让一个消息队列跑出几十万甚至上百万 TPS”的层面,能讲清楚的人不多。
先给个结论:Kafka 的快,本质上是存储模型、网络传输、并发模型和协议设计四个层面协同的结果,任何单独拿出来讲的技术都不是它独有的,但组合在一起就形成了碾压级的吞吐能力。这篇文章不打算做功能清单式的介绍,而是把这条“快”的链路从磁盘一路拆到客户端,讲清楚每一环的设计动机、实现机制和代价边界。适合正在使用 Kafka 但没细究过内部原理的后端开发,也适合想在生产环境调优参数、排查性能瓶颈的人。
1. 先纠正一个先入为主的印象:快不是靠某一块硬件
聊 Kafka 的性能,最常见的误解是“它用的是多快的盘”“加了多少内存”。我见过不少团队,业务吞吐没上来,第一反应是扩容机器、换更好的 SSD,结果集群节点翻了一倍,吞吐还是那个德行。原因很简单:Kafka 的性能天花板从来不在单机硬件上,而在架构设计是否让硬件发挥了本该有的效率。
先看一个反直觉的事实:Kafka 大量使用磁盘,但它的持久化策略默认并不“刷盘”。写入的数据先进操作系统的页缓存,而页缓存的回写由操作系统自行决定。这意味着你表面上往磁盘写了一条消息,实际上它可能只是在内存里走了一圈就返回 ack 了。如果这时候数据被尽快消费掉,好多消息甚至从未落到物理盘上。
这个设计在可靠性上是有争议的,但性能上确实极聪明。它把“持久化”的语义从“写入物理介质”变成了“写入已被操作系统接受的存储抽象”,然后把磁盘的物理写入延迟从请求链路里剥离出去,让单条消息的写入代价无限逼近纯内存操作。很多人在第一次知道这个机制时会担心丢数据,这个担心合理,但前提是你得先理解 Kafka 的可靠性配置是怎么工作的,后面我会专门展开。
还有一层容易被忽略的:Kafka 的“快”更多是设计出来的,而不是堆出来的。单机单盘写顺序文件,机械硬盘也能轻松跑到上百 MB 每秒;而同一块盘做随机小 IO,可能只有几个 MB 每秒。Kafka 把“每条消息一条日志”变成了“每个分区一条顺序追加的日志流”,就是把随机写转换成顺序写的关键。这一层转换,任何没有做日志结构化的消息系统都做不到同样的效果。
所以,讨论 Kafka 为什么快,一定要有全局视角。顺序写解决了“写”的效率,页缓存解决了“读写中间层”的效率,零拷贝解决了“读出来发出去”的效率,分区和批量解决了“并发和数据放大”的效率。下面一条线拆下去。
2. 存储层的两个引擎:顺序写盘和页缓存
2.1 顺序写:顺着磁盘的脾气来
磁盘这玩意儿,最擅长的就是连续读写。机械硬盘靠磁头寻道,随机写一次可能要找半天位置,连续写则是磁头顺着轨道一路写下去,速率可以差两个数量级。即便是 SSD,随机写也会带来写放大、垃圾回收频繁等问题,顺序写依然是更友好的模式,只是差距没那么夸张。Kafka 从设计第一天起就抓住了这一点。
具体到实现上,Kafka 的主题下有若干个分区,每个分区是一个追加日志。这个日志在物理存储上被切分成一个个 LogSegment 文件,消息永远只追加到当前那个处于 active 状态的段文件末尾。你可以把分区想象成一个只能往后翻的账本,写账只允许在最后一页的末尾落笔。
这个设计让 Kafka 的写入路径变得极其简单,不需要像传统数据库那样维护复杂的页结构、索引更新的随机写,也不需要为了更新某条记录去磁盘上寻找老数据。因为消息天然是追加式的:新消息来了,往文件尾一放,完事。
我见过一个团队做过测试,在普通 SATA 盘上,Kafka 单分区顺序写可以稳定跑到 120MB/s 以上,但同样的机器上随机写一个小文件,每秒只能写几 MB。这就是为什么说 Kafaka 不要配过于夸张的硬件也能获得不错吞吐——前提是你给它的是连续 IO,而不是把它当数据库用。
顺序写还有额外的好处:刷盘策略可以非常简单。默认情况下,Kafka 并不会每条消息都 fsync,而是积累到一定量或者间隔一定时间再由操作系统批量回写磁盘。批量回写可以把很多小的随机回写合并成大的顺序回写,进一步把磁盘效率榨干。
2.2 页缓存:一条消息可能根本没碰过磁盘
Kafka 在 JVM 堆外维护缓存吗?不。它直接利用操作系统内核的页缓存(page cache)。生产者写入消息时,数据先写进页缓存就返回成功(取决于 acks 配置),消费者读取时,如果消息还在页缓存里,直接从内核空间拷贝到用户空间,不需要碰磁盘。
这个设计比在 JVM 内维护一个缓存池高明在哪?首先,JVM 堆内存既要留给业务对象,又要做 GC,那么在堆内缓存大量消息会导致频繁的垃圾回收,停顿时间完全不可控。而页缓存位于内核管理的内存区域,不受 GC 影响,Linux 内核会动态地在可用内存中给它分配空间。
其次,页缓存天然具有全局共享特性。同一个机器上的生产者和消费者,如果读写同一批消息,消费者读的是生产者刚写入页缓存的同一份数据,完全不需要二次落盘。实测在热数据场景下,Kafka 的消费吞吐会非常夸张,这就是为什么“写入后立刻消费”这种典型业务场景,Kafka 表现尤其好。
举一个我实际观察到的案例:某业务高峰期,Kafka 集群的节点 CPU 和磁盘 IO 都不高,但吞吐很大,页面缓存命中率接近 99%。查了下监控,消息从生产到消费的平均链路延迟在十几毫秒,而磁盘设备的平均读写利用率不到 20%。也就是说,绝大多数消息到被消费走为止,物理盘压根还没出场。
这里需要提醒一句:别试图手动调小操作系统可用内存来“给 JVM 腾位置”。Kafka 的 Broker 堆内存一般设置 4~6GB 就够用,剩下的物理内存让给页缓存。堆内存设太大会拖慢 GC,反而影响吞吐。很多人刚开始调优时喜欢给 Broker 分配十几个 GB 堆内存,实测下来对吞吐没有正面帮助,甚至因为 Full GC 出现抖动。
3. 零拷贝:数据在进网络之前少走了好几趟路
存储层解决了写的问题,但一个消息从磁盘到消费者手里还要跨过一条漫长的路径。如果按传统方式实现,数据要走好几趟冤枉路。
传统的 read + write 路径是这样的:磁盘数据先被内核读入页缓存,再从页缓存拷贝到用户空间的缓冲区,然后应用把缓冲区数据传给 socket 发送缓冲区,最后网卡从 socket 缓冲区把数据发出去。这里发生了几次拷贝?至少四次,从内核到用户空间算一次,从用户空间再回到内核算一次,加上两次内核内部的复制。
Kafka 的方案是零拷贝。Broker 在响应消费者拉取请求时,直接从文件到 socket 发送数据,中间不在用户空间做任何拷贝,这个能力在 Java 里通过FileChannel.transferTo()调用底层sendfile系统调用完成。我来对比一下两条链路:
| 阶段 | 传统 read + write | Kafka 的 transferTo |
|---|---|---|
| 磁盘到内核缓冲区 | 1 次 DMA 拷贝 | 1 次 DMA 拷贝 |
| 内核缓冲区到用户空间 | 1 次 CPU 拷贝 | 0 |
| 用户空间到 socket 缓冲区 | 1 次 CPU 拷贝 | 0 |
| socket 缓冲区到网卡 | 1 次 DMA 拷贝 | 1 次 DMA 拷贝 |
| 上下文切换 | 4 次用户态/内核态切换 | 2 次 |
从表格能看到,零拷贝把 CPU 参与的拷贝从两次降到了零次,把上下文切换从四次减半。CPU 时间被省下来处理更多请求,这在追求吞吐的场景里非常关键。很多人以为零拷贝是“不用拷贝”,更准确的说法是“避免了多余的用户态拷贝和上下文切换”。
注意一点:mmap也是常见的零拷贝方案,Kafka 在索引文件上用了,但数据文件的读取走的是sendfile而不是 mmap。原因是 mmap 在小文件上性能不错,但大文件映射会引起缺页中断和潜在的锁竞争,sendfile则更稳定地适合日志这类大文件的顺序读取场景。这意味着同是“零拷贝”,选型也得看场景。
页缓存和零拷贝在这里又形成了接力:如果消息还在页缓存中,sendfile直接从页缓存把数据交给网卡;如果已经落到磁盘,则从磁盘 DMA 到页缓存后再发出。无论哪种情况,应用层都完全不需要触碰这份数据。这就是为什么 Kafka 的消费吞吐能远高于普通基于 JVM 的应用服务器。
4. 分区与并行:把吞吐分摊到横向资源上
4.1 单分区是串行的,推力来自分区数乘以消费者数
很多人觉得 Kafka 快是因为它“把消息顺序写盘”,但顺序写有一个固有限制:单分区的写入速率就是一块盘的顺序写速率。如果你只用一个分区,吞吐撑死也就是磁盘的速度,还没法利用多块盘。Kafka 的解法是:把数据流切成多个分区,每个分区在逻辑上独立,物理上可以落在不同磁盘上。
分区带来的是并行度。写入端,生产者可以把消息分发到不同的分区,这些分区的数据最终可以写入不同的磁盘;消费端,同一个消费组里不同的消费者实例可以分别负责不同分区,消费任务被横向拆分。这个模型和数据库的分库分表思路是一脉相通的。
我拿一个测试数据来说明:一个 Broker,单分区,生产端吞吐大约 80MB/s;加了分区数到 8 个,用了多块盘,吞吐就可以轻松到几百 MB/s,前提是生产端能并行发。分区数不够,消费者线程再多也白搭,因为一个分区的消息只允许被同一个消费组内的一个消费者实例拉取。
所以,评估 Kafka 总吞吐时不必把公式想太复杂:理论吞吐约等于“单磁盘顺序写速率 × 磁盘数量 × 可用并行度”,再乘以一个负载不均匀系数。你可以从这个公式倒推,你的集群到底缺的是分区数、磁盘还是消费者线程。
4.2 分区数不是越多越好:并行是有代价的
看到分区可以提高并行度,不少人的第一反应是“那把分区数设大一点”。这个做法初期有效,但分区不是免费的午餐。
每个分区在 Broker 上都有对应的日志文件、索引文件,还有一套副本同步机制。分区数过多,会产生大量小文件,文件句柄数也会暴涨,分区 leader 的选举和副本同步也变得更复杂。客户端这一侧也一样,生产者要为每个分区维护缓冲批次,消费者要为每个分区维护拉取状态,分区太多导致内存占用和调度开销线性上升。
实践中我的建议是:分区数先按“目标吞吐 / 单分区吞吐”估算,再结合消费端的并行能力和业务键分布来定。如果消息有明确的 key,同一 key 的数据必须有序,那只能由一个分区承载,这个分区就是天然的吞吐瓶颈。如果你追求吞吐,就别用会导致极端倾斜的 key。
另外,副本机制也会吃掉吞吐。一个分区的 leader 写入一条消息后,还要把数据同步给 ISR 里的 follower,如果 fan-out 比例是 1:2,那么同步流量就占了总写入流量的两倍。很多人调优时只盯着生产者参数,忽略了副本带来的放大效应,其实在跨机房或跨盘同步时,这部分开销往往比业务写入流量还高。
5. 批量思维:缓存、攒批、压缩和稀疏索引的协同
5.1 单个消息处理是低效的,攒批才是正解
如果一条一条消息单独发送,每一条都要经历网络传输、协议解析、磁盘写入和确认返回,那系统大部分时间都花在固定开销上,数据量再大也快不起来。Kafka 的吞吐之所以能上去,很大程度上因为它到处都在“攒批”。
生产端有batch.size和linger.ms两个参数控制攒批行为:batch.size指定一个批次能达到的最大字节数,linger.ms指定消息在缓冲区里最多等待多久。举个例子:一条消息 1KB,批次目标 64KB,那它需要攒够约 64 条才一起发出去;如果没有攒够但等了 5ms,也会以较小的批次发出去。
攒批的收益在于:网络包的个数大幅降低、压缩率提升(压缩算法对相似结构的消息效果非常好)、服务端处理单个批次的开销摊薄。有人会担心攒批增加延迟,幸运的是,Kafka 的linger.ms默认是 0,也就是说有消息就发,不强制等待。如果你想要极低延迟,保持默认即可;如果你想要极高吞吐,就需要适当调大linger.ms。
复制代码来看一个生产端的典型配置:
Properties props = new Properties(); props.put("bootstrap.servers", "broker1:9092,broker2:9092"); props.put("acks", "all"); props.put("linger.ms", 10); props.put("batch.size", 65536); props.put("buffer.memory", 67108864); props.put("compression.type", "lz4"); props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");这个配置在多数高吞吐场景下都能得到不错的效果。buffer.memory是生产者内存缓冲池的总大小,攒批意味着要用内存换吞吐,这个值不能设得太小,否则消息一多就会被阻塞。我见过生产环境把buffer.memory设成默认 32MB,结果稍微有流量尖峰就出现max.block.ms超时,改到 64MB 之后问题直接消失。
5.2 稀疏索引:用索引覆盖查找,而不是给每条消息编目录
日志是顺序追加的,但消费者随时可能要从任意 offset(偏移量)开始读。如果每次都要从头扫描日志文件,效率就太低了。Kafka 的做法是给每个 LogSegment 维护稀疏索引,索引不是为每条消息建立的,而是每隔一定字节建立一个索引项。
这里解释一下物理结构:每个 LogSegment 目录下有几个文件,.log存消息体,.index存偏移量到物理位置的映射,.timeindex存时间戳到偏移量的映射。.index是稀疏的,默认每 4KB 写入一条索引,也就是说一个字节区间可能对应多条消息。
因为索引是稀疏的,它的内存占用非常小:一条消息本来需要几十字节的定位信息,现在分摊到 4KB 数据量上,内存占用省了几个数量级。消费者从某个 offset 读取时,先二分查找最近的索引项,定位到 log 文件的大致位置,再在这个位置附近顺序扫几条消息就找到目标。由于是顺序文件,局部顺序扫描的成本很低,整体定位非常快。
这个设计的本质是“用少量内存和极小的一次顺序扫描,换掉每消息索引的巨额内存开销”。对大数据量日志场景来说,这是最务实的选择。也因为这个设计,Kafka 可以放心地保留“任意 offset 可读”的能力,不用像别的中间件那样为随机读付出巨大代价。
5.3 压缩与二进制协议:把网络和 CPU 的账算明白
Kafka 在生产者端就支持压缩,压缩发生在批次级别,而不是单条消息级别。这一点很关键:几十条消息攒成一个批次再压缩,压缩率明显比逐条压缩高。常见的压缩算法里,lz4 兼顾速度和压缩率,zstd 压缩率更高但 CPU 开销略大,gzip 压缩率不错但速度慢。
压缩收益直观:假设消息平均 1KB,压缩后变成 400B,带宽消耗下降 60%,磁盘占用也下降 60%。特别是在跨机房、跨地域同步的场景下,压缩节省的带宽成本相当可观。
代价是 CPU。Broker 在接收压缩批次时,一般不会解压,而是直接存储并分发(除非需要重新分配分区),所以压缩的 CPU 开销主要在生产者端。但批次级压缩的 CPU 开销通常远小于带宽节约带来的收益,这对网络带宽敏感的生产环境非常有吸引力。
Kafka 的协议本身也是为高性能设计的。它的请求头非常精简,消息格式尽量扁平,批次结构紧凑,几乎没有冗余的文本字段。相比基于文本协议的消息系统,二进制协议天然少了很多字符串解析开销。协议还支持批量响应,消费者一次拉取请求可以带回大量消息,这个后面展开。
6. 生产者粘性分区与消费者合并提升:两端容易被忽略的细节
6.1 粘性分区:让攒批能力不被负载均衡策略毁掉
Kafka 早年的生产者默认用轮询方式选择分区:第一条消息发给分区 0,第二条发给分区 1,第三条发给分区 2,以此类推。这个策略能保证分区负载均衡,但它对批量发送非常不友好。
设想一下:生产者端要攒一个 64KB 的批次,需要来自同一个分区的消息。轮询模式下,每条消息都被分散到不同分区,每个分区上的消息数量积累得很慢,导致批次迟迟攒不满,要么频繁以小火柴盒大小发送,要么等更久才攒够,吞吐自然上不去。
粘性分区(Sticky Partitioner)就是来回应用户空间到 socket 缓冲区的中间层问题:一个批次内的消息尽量发往同一个分区,即使这些消息在业务上没有 key 或 key 相同也不管。这样批次能更快攒满,发送效率大幅提升。Kafka 官方在自己的基准测试里也提到,粘性分区策略在吞吐上有显著收益,我之前实测过一轮:同样配置下,粘性分区的吞吐比老式轮询能翻好几倍。
粘性分区也不是没有代价。它让短时间内的写入请求集中在少数分区上,分区负载可能出现瞬时倾斜。不过这个倾斜是局部的,从全局时间段来看,不同批次会把流量分散到不同分区,最终负载还是趋于均衡的。 Kafka 0.11 之后的默认分区器就是 Sticky Partitioner,大部分人可能都没意识到自己已经在用这个优化了。
6.2 消费端拉取:一次请求带回一批数据
消费者端的性能提升,来自拉取模型本身的设计。Kafka 消费者不是服务端推消息,而是自己主动拉取。这个拉不是一条一条拉,而是用一次fetch请求拉回一批数据,批次大小由max.partition.fetch.bytes控制(默认 1MB)。
合并收益的核心在fetch.min.bytes和fetch.max.wait.ms这两个参数的配合。fetch.min.bytes允许消费者告诉服务端“累积到一定字节数再返回响应”,fetch.max.wait.ms则是最大等待时间。如果数据量够大,消费者用 1MB 请求就能拉回几千条消息;如果数据量不够,服务端可以稍微等一会儿,等数据积压到一定量再一起返回。
这个设计极大地压低了请求数量。试想如果一条一条拉,每次网络请求都带着完整的协议头和响应开销,消费者线程再多也顶不住。一次拉取 1MB 后,消费者在本地内存里逐条处理,处理完再发起下一次拉取。网络往返次数一下子少了几个数量级。
不过这个参数需要调到一个平衡点。fetch.min.bytes设太大,低流量时消费者会干等,增加端到端延迟;设太小,又回到了高频小请求的老路。我一般建议:延迟敏感的业务保持默认或调小fetch.max.wait.ms,追求吞吐的批处理任务把fetch.min.bytes调到 1MB 以上,让每个拉取周期尽量满载。
消费端还有一个容易被忽略的机制:消费者在成功处理一批消息后,会定期提交消费位点(offset),而不是每处理一条就提交。批量提交减少了与 Broker 的偏移量管理请求数量。配合分区数量与消费线程的匹配,整体消费路径几乎每一环都在做“攒一批、处理一批、提交一批”的批量动作。
7. 快之外的现实:调优实践与常见认知误区
7.1 为什么有人把 Kafka 用成了普通消息队列
讲了那么多原理,必须回到现实:我见过不少团队说“Kafka 根本不快”,仔细排查发现,瓶颈根本不在 Kafka 本身,而在两侧的接入方式。
最常见的场景是生产端把linger.ms设成 0,同时业务每次只发一条消息,并且这些消息随机分散到几十个分区。结果每个批次都攒不满,网络包照发不误,压缩基本无效,吞吐极低。你换了再多机器也没用,因为瓶颈在“攒批”这个行为上根本没有启动。
另一个常见问题是消费端一条一条地处理,而且处理里还有同步外部调用。假设每条消息处理耗时 50ms,单个消费者线程每秒只能处理 20 条,即使 Kafka 把几千条消息拉回来,也只能排队慢慢处理。此时 Kafka 本身再快也没用,瓶颈在消费逻辑。解决办法是把同步调用改异步、批量调用外部接口、或者把这部分逻辑放到专门的处理线程池里。
我还看到过团队把 Kafka 当作传统事务性数据库来用的,比如一条消息写完立刻查回来看有没有读到。Kafka 的消费位点和生产天然有细微延迟,这种场景首先得调整对一致性的预期。Kafka 适合的是高吞吐日志和流式处理,不是强一致性的在线查询。
7.2 真正值得调的参数和几个不该碰的旋钮
讲完原理,给一份我实际调优时最常用的参数参考,这些参数在不同环境中表现稳定,可以直接作为起点:
| 配置项 | 建议值或调整方向 | 说明 |
|---|---|---|
acks=all | 可靠性优先时设为 all | 吞吐会比 acks=1 低一些,但不会相差数量级 |
linger.ms | 10~50ms | 高吞吐场景建议大于 0,对 RT 影响需实测 |
batch.size | 64KB~1MB | 结合消息大小调整,不必无脑调大 |
buffer.memory | 64MB~128MB | 太小会导致生产端阻塞和超时 |
compression.type | lz4 或 zstd | 压缩率高,带宽收益明显 |
fetch.min.bytes | 1KB~1MB | 结合业务延迟要求调整 |
fetch.max.wait.ms | 500~5000ms | 低流量下控制消费者拉取延迟 |
num.io.threads | 8~16 | Broker 处理磁盘 IO 的线程数 |
num.network.threads | 4~8 | Broker 处理网络请求的线程数 |
log.segment.bytes | 1GB 为主 | 决定 LogSegment 大小,影响索引效率和文件滚动频率 |
再列几个容易踩坑、不建议随手改的参数:
log.flush.interval.messages别调太小。默认是按时间间隔刷盘,系统内部会自动批量回写。有人为了数据安全把它设成 1,每次收到消息就强制刷盘,等于把 Kafka 的顺序写优势和页缓存优势全部废掉,吞吐直接暴跌。replica.lag.time.max.ms别设太短。太短会导致节点在正常 GC 或磁盘抖动时被判定为 follower 落后,频繁触发副本重新选举,集群稳定性反而变差。JVM 堆内存别往大了调。Kafka 用大量内存做页缓存,堆内存设过大会影响操作系统可用的页缓存空间,同时导致 GC 压力剧增。堆内对象主要是各种索引和元数据,4~6GB 在绝大多数场景都够用。
max.poll.records别和max.partition.fetch.bytes同时设得太极端。拉取记录数太大但每处理一条又慢,会出现重复 poll 超时踢出消费组成员的问题。这种问题在实时数据分析场景特别常见。
7.3 磁盘选型与硬件配置的优先级
最后给一个硬件配置方向的总结,这个顺序来自我调优多个集群的经验:
| 优先级 | 配置 | 理由 |
|---|---|---|
| 高 | 内存容量(页缓存) | 热数据链路大多靠页缓存,内存越充裕命中率越高 |
| 高 | 磁盘数量 > 单盘速度 | 多盘并行比单块超高速盘更能扩展分区级吞吐 |
| 中 | SSD 或 NVMe(可选) | 主要缩短冷数据读取和刷盘时间,不是吞吐的必要条件 |
| 低 | 夸张的 CPU 核心数 | 多数场景 CPU 不是瓶颈,除非压缩全部在 Broker 侧做 |
一个典型的快速验证法:先在单台机器用官方基准工具测生产吞吐,如果数值理想,说明 Broker 自身没问题,问题大概率在客户端接入或网络。如果单机吞吐都不理想,优先检查磁盘是否在做随机 IO、页缓存比例是否被挤掉、以及副本同步配置是否正常。
如果你在生产环境里看到“Kafka 快不起来”,不用急着怀疑 Kafka 本身。先看数据链路是否在批量运行,再看瓶颈在哪一层。我对 Kafka 性能最深的一个体会是:它的快建立在一整套相互配合的设计上,哪一环断了都会拖慢整体。但只要顺着“顺序写、页缓存、零拷贝、分区并行、批量处理”这条主脉络去排查,绝大多数吞吐问题都能快速定位到具体原因。
最后再分享一个小技巧:调优时不要同时动多个参数。每改一个配置,就压测一轮,记录吞吐和延迟的变化。Kafka 的参数之间有很强的耦合关系,你同时动了五个参数,出了问题根本不知道是谁的锅。我一般从生产端开始调,先确保攒批和压缩真正生效,再看 Broker 的磁盘和网络指标,最后才碰消费者参数。按这个顺序走,调优效率会高得多。