1. 搭建前先想清楚这三件事:版本、磁盘和内存布局
1.1 版本选型:KRaft还是传统模式,以及JDK怎么装
很多朋友拿到一台Oracle Linux 8.4的服务器,第一反应是找一个最新版Kafka下载、解压、改配置、启动,跑通之后就开始压测,结果吞吐量惨不忍睹。我在帮人排查这类问题时发现,半数以上的瓶颈不是Kafka本身,而是搭建之前的那几步决策出了问题。所以先把版本、磁盘、内存这三件事讲透,再谈后面怎么调优。
先说版本。Kafka 3.x之前的版本必须搭配ZooKeeper管理元数据,等于集群里有两套系统要维护,ZooKeeper的session超时、节点选举、数据目录膨胀都是隐形的坑。3.x开始引入KRaft模式,把元数据直接交给Kafka自己管理,去掉了ZooKeeper依赖,部署和运维都简化不少。如果你是从零起步,没有存量系统需要兼容,我建议直接上KRaft模式的Kafka 3.x,这也是新集群最省力的路径。KRaft的完整生产可用性在3.3之后已经比较稳,我自己在测试环境跑过小半年,基本没有遇到元数据层面的异常。
补充一个小细节:Oracle Linux 8.4自带的yum仓库里有OpenJDK,但版本偏老,默认通常是Java 8。Kafka 3.x需要JDK 8/11/17,生产环境我推荐装OpenJDK 11,两条命令就能搞定:
sudo dnf install -y java-11-openjdk-devel java -version装完之后把JAVA_HOME指到/usr/lib/jvm/java-11-openjdk,这一步很多人会漏掉。之后再下载Kafka二进制包解压到独立目录,比如/opt/kafka,设置好KAFKA_HOME并加入PATH。Kafka安装本身不需要编译,解压即用,但目录权限要规划好,建议用独立的系统用户来跑,不要直接拿root运行。SELinux在Oracle Linux 8.4默认是Enforcing,Kafka要写自定义目录、监听9092端口,如果发现启动后写不了日志或端口连不上,多半是SELinux拦截,内网测试环境比较快的办法是临时设为permissive,生产环境还是要用semanage配置好端口和目录上下文。
1.2 磁盘与文件系统:吞吐量的物理底座
Kafka的日志写入本质是顺序追加,看起来对随机IO不敏感,很多运维新手因此忽略磁盘选型。但实际压测会发现,在小消息高并发场景下,SATA盘和NVMe盘的吞吐差距可以到两倍以上。原因在于Kafka不是只做顺序写,它还承担分区管理的元数据更新、索引文件的读写、以及消费者回查历史数据时的随机读。一旦page cache命中不了,随机读的压力就全部落到底层磁盘上。
我的建议是:如果有条件,日志目录一定放在独立的NVMe盘上,至少也要是SSD,而且不要和系统盘共用。文件系统选XFS,在Oracle Linux 8.4上用mkfs.xfs格式化即可。相比ext4,XFS在处理大文件和高并发IO时元数据开销更小,这是社区里跑生产几乎成为默认选项的原因。挂载的时候用noatime,减少不必要的写盘:
sudo mkfs.xfs /dev/nvme0n1 sudo mkdir -p /data1/kafka-logs sudo mount -o noatime /dev/nvme0n1 /data1机器上如果有多块数据盘,server.properties里可以用逗号分隔配多个目录,Kafka会自动把新分区分摊到空闲更多的磁盘上:
log.dirs=/data1/kafka-logs,/data2/kafka-logs还有一个经常被忽略的点:磁盘容量估算。公式不复杂:分区总数 × 副本因子 × 单日数据量 × 保留天数。比如每天写入500GB、副本因子3、保留7天,那就是500×3×7约10.5TB,这是纯数据占用,还没算清理机制生效时的临时占用。建议预留20%到30%余量,磁盘快满时Kafka的段清理会频繁触发,吞吐会肉眼可见地掉下来。
1.3 内存与JVM堆:page cache才是Kafka最好的朋友
Kafka高吞吐的底层逻辑很简单:消息写入时先进操作系统page cache,消费者读的时候直接命中内存,磁盘只在刷盘和cache miss时起作用。这意味着Kafka的JVM堆并不是越大越好,堆越大,留给page cache的内存就越少,消费者一旦要读历史数据,全得回源磁盘。官方也建议堆不要超过8GB,超过的部分往往变成GC的负担。
我在搭建时会给每台机器定一个内存分配基线。比如64GB物理内存的节点,JVM堆固定给8GB,Xms和Xmx设成一样,避免运行期动态扩容触发Full GC;其余内存全部留给操作系统做page cache。用free -h观察时,cache那一列始终应该有充足的量,这才是Kafka正常工作该有的内存状态。
JVM参数里还要注意GC收集器的选择。JDK 11默认是G1,Kafka社区跑生产大多也是G1,不需要刻意换成CMS。多机部署时可以统一把堆参数写进KAFKA_HEAP_OPTS环境变量,避免每台机器手工改脚本:
export KAFKA_HEAP_OPTS="-Xms8g -Xmx8g -XX:+UseG1GC"版本、磁盘、内存这三件事定下来,再开始配置broker参数才算有的放矢。
2. Broker端参数校准:吞吐量优化的第一道闸门
2.1 先厘清两个流传很广的误解
网上搜Kafka高性能配置,能看到一堆人直接抄"num.io.threads=32""堆内存=16G"这种清单,实际跑起来反而更慢。先说第一个误解:线程数不是越大越好。Kafka的网络线程和IO线程是分工的,网络线程负责接收和发送请求,IO线程负责真正的业务处理,比如读写日志段。线程太多,CPU上下文切换和锁竞争的成本会淹没线程带来的并发收益。我看到不少集群把num.io.threads调到三四十,结果吞吐没上去,CPU的sys占用倒是先满了。
第二个误解是堆内存越大越稳。上面讲过,Kafka的核心吞吐能力来自page cache,不是JVM堆。堆给到16GB、32GB,平时可能没感觉,一旦触发Full GC,整个broker的吞吐会瞬间掉到谷底。尤其用CMS或者G1处理超大堆时,一次长时间的stop-the-world动辄几秒,线上流数据会直接断流。
那么合理的基准是什么?如果CPU是16核,num.network.threads起步4到6,num.io.threads在8到16之间,配合压测结果微调。我见过的绝大多数场景,默认的3和8换到8和16之后就能看到明显改善,再往上加收益就非常有限了。
2.2 一组可直接上手的核心配置
下面这张表是我在Oracle Linux 8.4上验证过的起点配置,适合16核、64GB内存、NVMe磁盘的常规节点。所谓的起点配置,意思是先按这套跑,再结合压测结果做针对性微调,而不是照着一劳永逸。
| 参数 | 默认值 | 推荐起点值 | 说明 |
|---|---|---|---|
num.network.threads | 3 | 6 | 处理网络请求的线程数,太高会加剧上下文切换 |
num.io.threads | 8 | 16 | 处理写请求和读请求的线程数,与磁盘性能相关 |
socket.send.buffer.bytes | 102400 | 1048576 | 服务端发送缓冲区,高吞吐链路调大有效 |
socket.receive.buffer.bytes | 102400 | 1048576 | 服务端接收缓冲区,配合网络调大 |
socket.request.max.bytes | 104857600 | 104857600 | 单次请求最大字节数,一般不用改 |
num.replica.fetchers | 1 | 4 | 副本拉取线程数,副本流量大时必须调大 |
replica.fetch.max.bytes | 1048576 | 10485760 | 每个副本拉取线程单次拉取的数据量上线 |
log.segment.bytes | 1073741824 | 1073741824 | 单个分段大小,1GB够用,不建议低于512MB |
num.network.threads和num.io.threads的分工,可以类比餐厅的前厅和后厨。前厅服务员只管接单和上菜,后厨只管做菜,两个岗位人数配比不合理,要么门口排队,要么后厨空转。Kafka把网络层和IO层分开,目的就是让不同性质的负载各走各的线程,互不阻塞。调参的核心目标不是把某一项拉满,而是让两个环节的利用率都保持在一个健康区间。
server.properties里改完这些参数后重启broker即可生效。启动之后用jstack看一眼线程状态,如果IO线程大量处于RUNNABLE,说明磁盘吞吐还有余量;如果大量WAITING,说明请求量还没到瓶颈,这时候再调大线程数意义不大。
2.3 副本同步参数:写入吞吐再高,副本跟不上都是白搭
单节点跑高吞吐很容易,三节点集群一压测就暴露问题,原因多半出在副本拉取上。Kafka的副本同步是消费者模式,follower主动从leader拉数据,num.replica.fetchers决定有多少个线程干这件事。默认只有1,副本一多或者消息体量大,follower的同步速度很快被拉爆,ISR频繁收缩,然后leader收到写入请求却凑不齐min.insync.replicas,生产者端就开始报NotEnoughReplicas,吞吐直接腰斩。
所以副本因子大于等于3的集群,num.replica.fetchers在4到6之间是常见的选择,同时把replica.fetch.max.bytes从默认的1MB调到10MB以上。这两个参数的作用是让follower一次能捞更多数据回来,减少同步的往返次数。调完之后观察kafka.server:type=KafkaServer的ISR指标,确保ISR始终是满的,而不是频繁进出。
这里还要顺带提一下min.insync.replicas。它不直接影响吞吐,但和可靠性的关系很大。副本因子3、min.insync.replicas=2是常规配置。如果为了吞吐把这值改成1,磁盘故障时丢数据几乎就是必然事件,这个取舍不建议做。
3. 生产者和消费者调优:把吞吐量的每一分潜力抠出来
3.1 生产者:batch、压缩与可靠性之间的平衡
broker端调好了,客户端端往往藏着更大的吞吐空间。生产者端的绝活是攒批发送,两个核心参数batch.size和linger.ms一起发挥作用。打个比方,一辆公交车不是乘客满座才发车,而是要么坐满了(batch.size达到阈值),要么到点了(linger.ms超时),两者谁先到谁触发发车。默认linger.ms=0意味着到点就发,攒批机制基本不起作用,网络往返次数非常多。
生产环境我常用的起点配置是:
batch.size=262144 linger.ms=5 compression.type=lz4 acks=all buffer.memory=268435456 enable.idempotence=true max.in.flight.requests.per.connection=5batch.size从默认16KB调到256KB,配合linger.ms=5,在吞吐和延迟之间能取一个不错的平衡。如果业务对实时性要求极高,比如毫秒级告警,linger.ms不要超过5,否则攒批的等待时间会吃掉敏感场景的延迟预算。压缩方面,CPU核数富余就选zstd,压缩比高;吞吐优先选lz4,压缩比和CPU开销更均衡。acks=all必须和min.insync.replicas=2配合,这是可靠性的底线,不建议为了压测数字好看降到1或者0。
还有个容易翻车的参数是buffer.memory。它记录的是生产端还没发出去的积压消息占用的内存上限,默认只有32MB。高峰时期写入速率超过broker处理能力时,缓冲区很快被写满,然后生产者开始阻塞,超过max.block.ms就抛TimeoutException。把buffer.memory调到256MB能显著提高抗峰值能力,代价是本地内存占用多一些,通常值得。
3.2 消费者:拉取批量、提交方式与并发边界
消费者端的高吞吐思路和生产端是相反的,不是主动攒数据,而是让服务端一次性给够。fetch.min.bytes默认1,意味着broker只要攒到1字节就能响应,这会造成大量大型空响应往返。调到32KB左右,broker会等一批数据凑够再返回,网络利用率高很多。代价是引入了额外的缓冲等待时间,fetch.max.wait.ms默认500毫秒,如果业务实时性敏感,这个值要往下压,比如100毫秒。
另一个关键参数是max.poll.records,默认500。它决定一次poll()最多返回多少条消息。很多人以为加大这个值吞吐就会上去,但真正制约你的是单条消息的处理时间。处理耗时越长,单次poll周期就越长,一旦超过max.poll.interval.ms,消费者会被判定为死亡,触发重平衡,吞吐反而跳水。正确做法是先估算单条消息的处理耗时,再倒推max.poll.records:处理一条100毫秒,500条要50秒,明显太激进;压到100条,单次poll周期5秒,健康得多。手动提交offset也有讲究,在消息处理完之后再commitSync(),宁可重复处理也不能丢位移。
消费端的并发边界问题值得单独说:消费者组内真正在并行拉取的实例数,上限就是单个topic的分区数。topic只有6个分区,消费者组里加再多实例,也有6个在干活,其余全在闲置等待。遇到消费端吞吐瓶颈,先看分区数够不够,再决定要不要加消费者实例,顺序不能反。
3.3 分区数:并发天花板的这件事要提前算
分区数设计是Kafka吞吐规划里最容易被拍脑袋定下来的事。分区数太少,生产端和消费端的并行度都被锁死;分区数太多,broker端每个分区都要管理自己的索引、LEO、位移,元数据开销会吃掉一部分吞吐。我的经验是,单分区吞吐先按5到10MB/s估算(取决于消息大小和硬件),目标总吞吐除以这个值,再乘以副本因子的余量,就是分区数的量级参考。比如目标50MB/s,消息1KB左右,30到50个分区通常够用。
还要考虑消息顺序性。同一个分区内的消息是有序的,但跨分区就乱了。业务如果要求全局有序,分区数只能设为1,那吞吐上限也就是单分区的上限;如果只要求同一个key有序,按key hash到分区,分区数可以放开。每一类取舍都要提前算清楚,后面再改分区数,要么新建topic迁移,要么做分区重分配,都是不小的工程。
4. 用官方压测脚本验证效果:命令、指标与结果解读
4.1 生产者压测:先让数字代替感觉
参数调完之后,验证效果最快的方式是用Kafka自带的压测脚本,不需要引第三方工具。生产者的脚本是kafka-producer-perf-test.sh,在Kafka解压目录的bin下面。压测前先建一个专用topic,不要在业务topic上压:
bin/kafka-topics.sh --create \ --topic perf-topic \ --partitions 36 \ --replication-factor 3 \ --bootstrap-server 192.168.1.10:9092,192.168.1.11:9092然后跑生产者压测,固定消息大小和数量,控制变量来对比参数:
bin/kafka-producer-perf-test.sh \ --topic perf-topic \ --num-records 1000000 \ --record-size 1024 \ --throughput -1 \ --producer-props \ bootstrap.servers=192.168.1.10:9092,192.168.1.11:9092 \ acks=all \ batch.size=262144 \ linger.ms=5 \ compression.type=lz4脚本输出的核心三列是records/sec、MB/sec和延迟百分位。比如:
1000000 records sent, 112233.0 records/sec (107.0 MB/sec), 8.0 ms avg latency, 15.0 ms 50th, 45.0 ms 95th, 120.0 ms 99th latency107MB/sec就是当前配置下的整体吞吐。我观察的第一眼永远是99th延迟,如果它持续往上走,说明某个环节开始饱和了,这时候平均值和总吞吐再好看也不可信。对比测试时,每次只改一个参数,跑两轮取中位数,避免把环境抖动当成参数收益。
4.2 消费者压测:消费端瓶颈怎么暴露
消费端的脚本是kafka-consumer-perf-test.sh,同样命令也简单:
bin/kafka-consumer-perf-test.sh \ --bootstrap-server 192.168.1.10:9092,192.168.1.11:9092 \ --topic perf-topic \ --messages 1000000 \ --threads 12消费者压测脚本的短板是比较"傻",它只测拉取能力,不包含业务处理逻辑,真实场景里消费者端的瓶颈往往不在Kafka拉取,而在下游处理和序列化。所以如果消费者压测的吞吐远低于生产者压测,先把网络和broker的读路径查一遍,这些环节压掉之后,剩下的差距就是下游处理的锅,回到业务代码里找问题。
数据文件对压测结果的影响也值得提一句:消费者冷读时会命中不了page cache,吞吐会掉得很厉害;热读时几乎全部命中内存,结果会很好看。所以做对比测试时,先确认消费者压测前一批数据是否已在page cache里,否则两次压测的结果不具备可比性。
4.3 用JMX指标确认瓶颈所在
压测数据只告诉你"不行",没告诉你"哪里不行"。定位瓶颈我用JMX指标。Kafka的JMX端口可以在kafka-server-start.sh里通过JMX_PORT打开,然后用jconsole或者监控系统连接。优先看三个指标:BrokerTopicMetrics里的BytesInPerSec和BytesOutPerSec,这是真实的流量进出;RequestChannel的RequestQueueSize,如果长期不为0,说明请求处理线程跟不上了;KafkaRequestHandlerPool里的平均空闲比例,低于30%基本可以判定IO线程已经过载。
内存端用jstat -gcutil看GC情况,重点盯Full GC次数和耗时:
jstat -gcutil <broker_pid> 1000正常情况下Full GC应该是个位数或者零,如果FGC持续增长,FGCT累计时间蹭蹭往上走,赶紧回头检查堆大小和GC参数。健康的broker在压测期间GC总耗时占比不超过5%,超过这个值,吞吐稳定性一定出问题。
5. 真实环境踩坑记:一次吞吐量骤降的完整排查链路
5.1 现象:50MB/s降到20MB/s,延迟飙升
前面讲的都是方法论,现在分享一个完整的踩坑案例。前阵子帮一个团队看某跨平台系统的Kafka集群,3节点,Oracle Linux 8.4,硬件是2U服务器加硬件RAID卡,日志目录放在RAID1的SAS盘上。上线第一周吞吐稳定在50MB/s左右,P99延迟10毫秒上下。第二周某天上午开始,生产者压测吞吐掉到20MB/s,P99直接飙到800毫秒,而且不是偶发,一整天都这样。
这个现象第一反应是broker配置被改过,查了一圈没有任何改动,于是按"网络→GC→磁盘→系统参数"的顺序往下排查。这里我想强调:遇到吞吐骤降,不要上来就怀疑参数,先确认到底哪个环节先饱和。
5.2 排查链路:从GC到磁盘再到透明大页
第一步看broker JVM,jstat -gcutil输出FGC一直在涨,FGCT累计时间高得离谱,Full GC几乎每隔几分钟就来一次,每次停顿好几秒。那一刻以为堆内存给小了,查下去发现完全相反——这台机器的堆被团队按网上的"高性能清单"设成了32GB,完整吃掉了物理内存的一半还多。
堆大导致两个连锁反应:一是页表膨胀,内存回收成本高,Full GC频次和停顿时间都失控;二是page cache被严重挤压,free -h里cache只剩不到5GB。消费者读取历史数据时全部落盘,底层SAS盘扛不住随机读,磁盘利用率飙到90%以上。这两点叠加,吞吐掉到20MB/s根本不奇怪。
第二步查磁盘层,iostat -x看到sda的util接近100%,await长期几百毫秒,但奇怪的是磁盘总吞吐量并不高,读写都很碎。仔细翻RAID卡配置,发现写缓存策略是write-through,每次写入都直接穿透到物理磁盘,Kafka那种先写page cache再异步刷盘的节奏被彻底打断。RAID卡的写缓存本应设置成write-back,这一步在服务器出厂时经常被默认成保守的write-through。
第三步查系统内核参数。Oracle Linux 8.4默认开启了透明大页THP,cat /sys/kernel/mm/transparent_hugepage/enabled输出always。THP在内存回收和分配时的抖动,会在Kafka这种大内存高IO的进程上被放大,和JVM GC停顿叠加在一起,雪上加霜。
5.3 修复方案与可复制的检查清单
修复分三步走。第一步把JVM堆从32GB压回8GB,Xms和Xmx保持一致,同时关闭THP,在grub启动参数里追加transparent_hugepage=never,重启后确认生效。第二步把RAID卡写缓存策略改成write-back,让磁盘控制器先吃下写请求,再由它自己决定刷盘节奏。第三步把vm.swappiness从默认值调低到10,减少内核把page cache换出的倾向,sysctl.conf里写入:
vm.swappiness=10 net.core.somaxconn=1024 net.ipv4.tcp_max_syn_backlog=2048然后把broker的num.network.threads和num.io.threads按前面表格调整,重启集群。压测结果很快恢复:吞吐回到51MB/s,P99降到20毫秒以内,GC停顿肉眼可见地消失,连续几天没有再复发。
这次排查之后,我给新集群整理了一份检查清单,每次搭完都会跑一遍:
- THP是否关闭,
transparent_hugepage=never是否写入grub参数 vm.swappiness是否在10以下- JVM堆是否控制在总内存的1/4以内,且不超过8GB
free -h的cache列是否常年保持充足的余量- RAID写缓存策略是否为write-back
- 日志目录是否独立挂载、文件系统是否为XFS
ulimit -n文件描述符是否调大,broker高并发下文件句柄不够会直接抛IOException- 9092端口和自定义日志目录在SELinux下是否被正确放行
这套清单能过滤掉七八成我在生产环境见过的"吞吐莫名其妙上不去"问题。最后再多说一句:Kafka集群优化是一个持续校准的过程,参数表只是起点,真正可靠的做法是把压测脚本固化下来,每次变更先跑一轮对比,让数字替你做决定。