第31讲 | JVM 与操作系统层调优:Broker 堆、GC、文件描述符、Swap 与页缓存

导读:Kafka 的性能有一半写在 JVM 参数里,另一半写在内核参数里;本讲给出生产环境可直接套用的 JVM 参数与 sysctl 配置,并解释每一条背后的"为什么"。

本讲目标

  1. 理解 Kafka "依赖页缓存而非 JVM 堆缓存"的设计哲学,以及它如何决定堆的大小
  2. 掌握 Broker JVM 堆的估算方法与 G1 / ZGC 的选择依据
  3. 掌握操作系统层的关键配置:文件描述符、vm.swappinessvm.max_map_count、页缓存预留、透明大页
  4. 给出一套生产可用的 JVM 启动参数与 sysctl 模板
  5. 学会用 GC 日志与系统指标验证调优效果

一、先想清楚:Broker 的堆里到底放了什么?

调堆之前必须知道堆里是什么。Kafka Broker 运行时(KRaft 模式)堆内的主要对象:

对象说明与什么正相关
元数据缓存各 topic/partition 的 leader、ISR 信息集群分区总数
网络请求/响应缓冲请求解码后的对象、压缩/解压临时缓冲客户端并发与消息大小
索引抽象.index/.timeindexmmap 映射,映射区域计入地址空间但不计入堆分区数 × 索引大小
日志压缩缓冲Log Compaction 的重写缓冲(cleaner.io.buffer.load压实 topic 的分区活跃段大小
KRaft 元数据日志metadata log 的快照与状态机集群规模

注意一个关键事实:消息本体不进堆。生产写入走页缓存 + 顺序写文件;消费读取优先命中页缓存。所以 Broker 堆不需要"装下消息",只需要装下元数据、请求缓冲和索引控制结构——这决定了 Broker 的堆通常远小于同规格的数据库或缓存中间件。

推导出堆大小的经验法则

堆大小 ≈ 基础开销(1GB) + 分区相关开销(每万分区约 0.5~1GB) + 请求缓冲余量(1~2GB)
单机磁盘容量越大、分区越多 → 堆越大;但一般不超过机器内存的 1/4 ~ 1/3

典型值:6GB~8GB 堆 + 保留 50% 以上物理内存给页缓存。例如 64GB 内存的 Broker 机器:堆 6~8GB,页缓存预留 40GB+,其余给操作系统与其他进程。

反面案例:为什么把堆调到 32GB 反而更慢?

  1. 页缓存被压缩到不足 20GB,热数据无法常驻内存,消费读穿透到磁盘
  2. 堆越大,GC 全量阶段(无论 G1 还是 ZGC 的标记阶段)扫描的对象越多,停顿或 GC 线程 CPU 占用越高
  3. 压缩指针失效:JVM 通过 32 位压缩指针(+UseCompressedOops)引用对象,前提是堆 ≤ 32GB(严格说是 8GB 对齐下约 32GB)。堆一旦超过 32GB,指针膨胀为 64 位,同样对象多耗约 20%~50% 内存,等于"花钱买亏"。因此堆要么 ≤ 31GB,要么一步跨到 48GB+ 才划算——而 Broker 根本不需要那么大

二、GC 选择:G1 与 ZGC

2.1 G1(Kafka 3.x + JDK17 的默认推荐)

G1 把堆划分为 Region,优先回收垃圾最多的区域,兼顾吞吐与停顿。Broker 的堆不大(6~8GB)、分配速率中等(请求缓冲是短命对象,绝大多数在 Young 区就被回收),G1 完全够用,停顿通常在 10~30ms。

关键参数及理由:

  • -XX:+UseG1GC:启用 G1(JDK17 默认即为 G1,显式写出便于排查)
  • -XX:MaxGCPauseMillis=20:目标停顿。注意这是软目标,G1 会动态调整 Young 区大小来逼近它;设得过小(如 5ms)会让 Young 区过小、GC 频率暴涨,反而劣化吞吐
  • -XX:InitiatingHeapOccupancyPercent=35(IHOP):老年代占用达到 35% 就启动并发标记周期。Kafka 请求缓冲产生的临时对象多,老年代涨得快时早启动标记更安全;JDK17 默认自适应 IHOP,一般保持默认即可
  • -XX:G1HeapRegionSize=4M:分区数非常多(堆内元数据碎对象多)时可设为 4M,减少 humongous 对象(大于半个 Region 的对象直接进老年代,是 G1 停顿异常的常见元凶——大请求缓冲就是典型 humongous 来源)

2.2 ZGC:什么时候值得换?

ZGC(JDK17 中已支持分代前的版本,-XX:+UseZGC)的染色指针与读屏障使绝大多数 GC 工作并发执行,停顿 <1ms 且停顿时间不随堆大小增长。适合:

  • 堆需要 >16GB(超大分区数集群、或 Broker 与其他组件混部)
  • 对 Broker 请求 P99 延迟极度敏感(实时风控链路)
  • 观察 GC 日志发现 G1 的混合回收停顿频繁超过 50ms

代价:读屏障带来约 5%~10% 的稳态吞吐损耗,且 ZGC 会用更多内存(染色指针需要更多地址空间,配合大页效果更好)。结论:默认 G1,P99 敏感或大堆场景评估 ZGC,切换前用真实流量灰度对比。

2.3 必开:GC 日志

没有 GC 日志的调优是盲调。JDK17 统一日志语法:

-Xlog:gc*:file=/var/log/kafka/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

观察三个指标:Young GC 频率(正常每秒数次的短回收可接受)、单次停顿时长(G1 目标 20ms 内)、Full GC(出现即报警,通常是 humongous 或元数据泄漏)。

三、操作系统层调优

3.1 文件描述符(ulimit -n)

每个分区副本的活跃 segment 至少 3 个文件(.log/.index/.timeindex),加上 socket、mmap,粗算:

fd 需求 ≈ 分区副本数 × 3 + 连接数 + 余量

单机 5000 分区副本就是 15000+ fd。多数发行版默认 1024(软限制),Broker 启动后几分钟内就会 Too many open files。配置方法:

# /etc/security/limits.conf 或 /etc/security/limits.d/kafka.conf
kafka  soft  nofile  1000000
kafka  hard  nofile  1000000
# systemd 服务则用 service 单元内的 LimitNOFILE=1000000

验证:cat /proc/$(pidof java)/limits | grep "open files"

3.2 Swap:关掉,或者接近关掉

页缓存和堆一旦被换出到 swap,读写消息就变成磁盘随机读,性能断崖式下跌。Kafka 官方建议彻底禁用 swap(swapoff -a),但很多运维规范要求保留 swap 兜底,此时:

vm.swappiness = 1    # 内核几乎不主动换出,仅极端内存压力下兜底

注意 vm.swappiness=1 与 0 的区别:0 在部分旧内核上会触发 OOM killer 更激进(对 cgroup 场景),1 是"尽量不换但允许"的安全值。同时给 Broker 机器设置合理的内存超卖策略,避免与其他大内存进程混部。

3.3 vm.max_map_count:mmap 的隐形上限

Kafka 的索引文件用 mmap 映射(log.index.size.max.bytes 默认 10MB/索引),每个索引文件占用一个 mmap 区域。vm.max_map_count 默认 65530,单机超过约 2 万个分区副本(每副本 2 个索引映射)就会报 mmap failed

vm.max_map_count = 262144    # 生产建议值

3.4 页缓存预留:Kafka 性能的命脉

Kafka 高吞吐的秘密不在堆里,而在页缓存 + 顺序 IO

  • 写入:消息写入 mmap/普通文件写时先进页缓存,内核异步 flush 落盘(Kafka 默认不做 fsync,靠副本冗余保证持久性)
  • 读取:消费追上生产进度时,读的正是页缓存里的热页,一次磁盘都不用碰

因此内存分配的优先级是:页缓存 > 堆 > 其他。规划原则:

Broker 机器内存 = JVM 堆(6~8G) + 操作系统与其他(4~6G) + 页缓存(其余全部,越大越好)
页缓存建议 ≥ 单 Broker 磁盘上的"热数据窗口"(如最近 1 小时的写入量)

例如热窗口 30GB(50MB/s × 600s),页缓存至少给 35GB。监控页缓存命中:pcstat(查看文件在页缓存中的比例)或对比 sar -d 的磁盘读吞吐与消费端拉取吞吐——若消费拉取 40MB/s 而磁盘读 40MB/s,说明全部穿透,页缓存不够或消费滞后太远。

3.5 透明大页(THP):必须关闭

内核的透明大页整理(compaction)会引入随机毫秒级停顿,与 Kafka 的低延迟诉求冲突,dmesg 里出现 compaction stalls 就是证据:

echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

3.6 网络与调度相关

  • net.core.somaxconn:listen backlog 上限,Kafka socket server 默认 50,但内核默认 128/4096 不一,统一调大
  • net.ipv4.tcp_max_syn_backlognet.core.netdev_max_backlog:高连接速率场景
  • 文件系统:XFS 是 Kafka 官方推荐(ext4 亦可);挂载 noatime 避免 atime 更新带来的额外写

实战案例(Java):调优前后对比与验证脚本

场景:3 台 Broker(64GB 内存 / 4TB NVMe SSD / 万兆网卡),每台承载约 4000 分区副本,偶发消费延迟抖动与 Too many open files。下面给出完整的生产配置模板与验证手段。

1)生产可用 JVM 参数(KRaft Broker,JDK17)

# kafka-server-start.sh 或 systemd 的 Environment/KAFKA_HEAP_OPTS
KAFKA_HEAP_OPTS="-Xms6g -Xmx6g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=20 \
  -XX:G1HeapRegionSize=4M \
  -XX:MetaspaceSize=96m \
  -XX:MaxMetaspaceSize=256m \
  -XX:InitiatingHeapOccupancyPercent=35 \
  -Xlog:gc*:file=/var/log/kafka/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100M \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/var/log/kafka/ \
  -XX:+ExitOnOutOfMemoryError \
  -Djava.awt.headless=true"

要点:-Xms = -Xmx 避免堆伸缩抖动;6g 堆给 64GB 机器留足页缓存;OOM 时 HeapDump 且进程退出交给 systemd 拉起(避免半死状态)。

ZGC 替代方案(P99 敏感场景灰度验证用):

KAFKA_HEAP_OPTS="-Xms8g -Xmx8g -XX:+UseZGC \
  -XX:+ZGenerational=false \
  -Xlog:gc*:file=/var/log/kafka/gc-%t.log:time,uptime,tags:filecount=10,filesize=100M"

(工程假设:JDK17 的 ZGC 尚无正式分代模式,分代 ZGC 需 JDK21+ 的 -XX:+ZGenerational,此处按 JDK17 保守写法。)

2)sysctl 与系统配置模板

# /etc/sysctl.d/99-kafka.conf
vm.swappiness=1
vm.max_map_count=262144
vm.dirty_background_ratio=5
vm.dirty_ratio=60
fs.file-max=2097152
net.core.somaxconn=4096
net.core.netdev_max_backlog=16384
net.ipv4.tcp_max_syn_backlog=8192
net.ipv4.tcp_slow_start_after_idle=0
# 应用并持久化
sysctl --system

# THP 关闭写入 rc.local 或 systemd oneshot 服务
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

# 文件描述符(systemd 部署)
# [Service] 段内:LimitNOFILE=1000000

vm.dirty_ratio 的取值逻辑:允许页缓存脏页累积到 60% 才强制回写,让内核把大量碎写合并成大块顺序刷盘(Kafka 写入本身安全由多副本保证,掉电最多丢未刷盘的少量数据,与 log.flush.interval.messages 的默认"不主动 fsync"策略一致);dirty_background_ratio=5 让后台回写尽早起步、避免一次性刷盘风暴。

3)Java 验证工具:页缓存命中率观察

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * 观察某 topic 分区日志文件的页缓存驻留比例。
 * 思路:mincore 统计驻留页占比。JDK 无直接 API,
 * 这里用简化方案——读取 /proc/meminfo 的 Cached 变化趋势 + 文件大小,
 * 判断页缓存压力(工程假设:以系统级 Cached 趋势近似评估,够用于容量规划)。
 */
public class PageCacheProbe {
    public static void main(String[] args) throws IOException, InterruptedException {
        String kafkaDataDir = args.length > 0 ? args[0] : "/data/kafka-logs";
        long prevCached = cachedKb();
        long prevTs = System.currentTimeMillis();
        while (true) {
            Thread.sleep(10_000);
            long nowCached = cachedKb();
            long ts = System.currentTimeMillis();
            double mbPerSec = (nowCached - prevCached) / 1024.0 / ((ts - prevTs) / 1000.0);
            long totalKb = totalKb();
            System.out.printf("页缓存 Cached=%.1fGB (占比 %.0f%%), 10s增量=%.1f MB/s%n",
                    nowCached / 1024.0 / 1024.0, 100.0 * nowCached / totalKb, mbPerSec);
            prevCached = nowCached; prevTs = ts;
            // Cached 长期在低位徘徊(如 <20% 物理内存)说明被其他进程挤占,需要扩内存或隔离部署
        }
    }

    static long parseMeminfo(String key) throws IOException {
        for (String line : Files.readAllLines(Path.of("/proc/meminfo"))) {
            if (line.startsWith(key + ":")) {
                return Long.parseLong(line.replaceAll("[^0-9]", ""));
            }
        }
        throw new IllegalStateException("meminfo key not found: " + key);
    }

    static long cachedKb() throws IOException { return parseMeminfo("Cached"); }
    static long totalKb() throws IOException { return parseMeminfo("MemTotal"); }
}

运行:java PageCacheProbe.java /data/kafka-logs(Linux 环境),预期看到 Cached 稳定在物理内存的 50%~70% 且写入速率与业务流量吻合;若 Cached 被压到 20% 以下,先排查同机的大内存进程。

4)调优效果核验

验证项命令 / 指标调优前调优后(典型值)
GC 停顿gc 日志 Pause Young偶发 80ms+稳定 <20ms
fd/proc/<pid>/limits + 当前 fd 数接近 65535 报警<5 万 / 上限 100 万
页缓存命中磁盘读 vs 消费拉取吞吐读=拉取(全穿透)读 << 拉取
抖动`dmesggrep -i “compaction stall”`每日数十条

踩坑提示 / 生产建议

  1. 容器环境的坑:K8s 中 JVM 感知的是 cgroup 限制,务必用 JDK17 的容器感知(默认开启)并显式 -Xmxvm.max_map_count 在宿主机设置,容器内只读
  2. 堆 ≤ 31GB 红线:跨过 32GB 压缩指针失效,Broker 场景没有任何理由超过它
  3. swapoff 要配合内存水位告警:彻底关 swap 后,内存泄漏的最终结局是 OOM killer,比 swap 慢更致命的是进程消失——上内存 85% 告警
  4. GC 日志必须落盘保留:出问题后无法复现,只能靠历史日志;给 /var/log/kafka 单独划空间并 logrotate(用 JVM 自带 filecount 即可)
  5. 混部是大忌:Broker 与 Spark/Flink/ES 等页缓存大户同机,页缓存互相踩踏,所有 JVM 调优都白费
  6. 验证顺序:系统参数(fd/swap/THP)→ 堆与 GC → 业务压测,一次改一类,否则无法归因

本讲小结

  • Kafka 性能命脉是页缓存而非堆:堆 6~8GB 足够,内存优先留给页缓存,且堆不要越过 31GB 压缩指针红线
  • GC 默认 G1(MaxGCPauseMillis=20、大 Region 防 humongous);P99 敏感或大堆灰度 ZGC
  • 系统层四件套:fd 百万级、swappiness=1、max_map_count=262144、THP 关闭;脏页比例调高以合并刷盘
  • 所有调优都要留证据:GC 日志、/proc 指标、压测前后对照表

思考题

  1. 为什么 Kafka 敢默认不主动 fsync(不做 log.flush),而 MySQL 必须依赖 WAL 的实时刷盘?从"数据由什么机制保证不丢"的角度对比多副本复制与单机持久化。
  2. Broker 堆设为 40GB 会遇到哪两个问题?如果业务确实需要超大元数据规模(百万级分区),除了加堆还有别的方向吗?提示:分层与元数据分离(KRaft 元数据主题、Observer 节点)。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐