《Java 消息中间件·Kafka 内核与客户端》15.1 监控指标与告警

Kafka 的 MBean 有近千个,真正常看的只有十二个。这一节把它们分成 broker、客户端、业务三层,给出本机实测的 JMX 采集步骤——包括镜像里没有 jcmd、JMX_PORT 会被客户端继承、JmxTool 的包名在 4.x 改过这三个坑,并用一次 5 万条的生产与消费演示数字怎么变、阈值怎么定、告警怎么去抖。

近千个 MBean,日常只看十二个

4.3.0 的一个 broker 暴露的 MBean 对象有近千个。本机对着单节点数过一次:

total mbean objects: 992

这不是说其中 980 个没用,而是说:监控系统必须先做减法。如果一上来就把所有 MBean 都采下来,结果通常是指标面板密密麻麻、告警规则几百条,真正出问题时反而没人看。

这一节的做法是:先按「出问题的时候我会先看哪个」筛出十二个,再讲怎么把它们采出来、阈值怎么定、告警怎么不吵。

十二个指标,分三层

层指标MBean它回答什么问题
broker消息速率BrokerTopicMetrics:MessagesInPerSec现在有没有流量
broker入/出字节BrokerTopicMetrics:BytesInPerSec / BytesOutPerSec带宽是否饱和
broker生产失败BrokerTopicMetrics:FailedProduceRequestsPerSec写入是不是在被拒
broker副本不足分区ReplicaManager:UnderReplicatedPartitions冗余还够不够
broker低于最小 ISRReplicaManager:UnderMinIsrPartitionCount写入是否已到临界
brokerISR 收缩ReplicaManager:IsrShrinksPerSec有没有节点反复掉队
broker请求队列RequestChannel:RequestQueueSize处理不过来了吗
broker处理线程空闲率KafkaRequestHandlerPool:RequestHandlerAvgIdlePercent还有多少余量
brokercontroller 数KafkaController:ActiveControllerCount集群有没有主
broker离线分区KafkaController:OfflinePartitionsCount有没有分区不可写
客户端缓冲可用字节buffer-available-bytes生产者是否被背压
业务消费积压kafka-consumer-groups.sh 的 LAG消费者跟不跟得上

最后一行不在 JMX 里,必须单独说。LAG 是消费组维度的概念,broker 不认识「积压」这件事,只能由外面拿 LOG-END-OFFSET 减 CURRENT-OFFSET 算出来。这是整张表里唯一一个不能靠 JMX 拿到的指标,也是最重要的一个。

把指标取出来:JMX 是唯一的路

Kafka 没有内置的 HTTP 指标端点(那需要额外的 exporter 组件,本书不引入)。在只有 kafka-clients 和 JDK 的约束下,读 broker 指标的方式只有 JMX。

第一件要知道的事:apache/kafka 镜像里没有 jcmd、jps、jconsole。它的 /opt/java/openjdk/bin/ 只有这些:

java jfr jrunscript jwebserver keytool rmiregistry

这是一个精简过的 JRE。能用 jfr,但没有 attach 工具。所以「容器起来之后再动态开 JMX」这条路走不通,必须在启动时就打开。

第二件事:用镜像自带的 JMX_PORT 环境变量起 broker。

docker run -d --name mq-f1 -p 19251:19092 \
  -e KAFKA_NODE_ID=1 -e KAFKA_PROCESS_ROLES=broker,controller \
  -e KAFKA_LISTENERS='PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093,EXTERNAL://0.0.0.0:19092' \
  -e KAFKA_ADVERTISED_LISTENERS='PLAINTEXT://127.0.0.1:9092,EXTERNAL://127.0.0.1:19251' \
  -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP='CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT' \
  -e KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
  -e KAFKA_CONTROLLER_QUORUM_VOTERS='1@127.0.0.1:9093' \
  -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \
  -e JMX_PORT=9999 \
  apache/kafka:4.3.0

启动后 9999 就在监听(本机实测,/proc/net/tcp 里能看到):

tcp        0      0 :::9999                 :::*                    LISTEN
tcp        0      0 :::9092                 :::*                    LISTEN

坑一:客户端 JVM 会继承 JMX_PORT

这是本机踩得最久的一个。容器里执行任何 Kafka 命令行工具时,docker exec 会把容器的环境变量(包括 JMX_PORT=9999)带进新进程。Kafka 的启动脚本看到 JMX_PORT 就给自己也开一个 JMX,于是两个 JVM 抢同一个端口:

Caused by: java.net.BindException: Address in use
    at java.base/sun.nio.ch.Net.bind0(Native Method)
    ...
    at jdk.management.agent/sun.management.jmxremote.ConnectorBootstrap$PermanentExporter.exportObject(Unknown Source)

报错栈里全是 jmxremote,看不出跟你要执行的那条命令有什么关系。解决办法是在执行前清掉这个变量:

docker exec mq-f1 sh -c 'unset JMX_PORT; /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server 127.0.0.1:9092 --list'

凡是容器开了 JMX 端口,里面所有 CLI 都要加这一句。

坑二:JmxTool 的包名在 4.x 变了

采集工具是 JmxTool,但它不在默认 classpath 上,而且类名换了。老写法:

kafka.tools.JmxTool

在 4.3.0 上报:

Error: Could not find or load main class kafka.tools.JmxTool

它实际在 kafka-tools-4.3.0.jar 里,包名是 org.apache.kafka.tools:

docker exec mq-f1 sh -c 'unset JMX_PORT; java -cp "/opt/kafka/libs/*" \
  org.apache.kafka.tools.JmxTool \
  --jmx-url service:jmx:rmi:///jndi/rmi://127.0.0.1:9999/jmxrmi \
  --object-name "kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec" \
  --attributes Count,OneMinuteRate --one-time true'

输出是两行,第一行是表头,第二行是值:

Trying to connect to JMX url: service:jmx:rmi:///jndi/rmi://127.0.0.1:9999/jmxrmi
"time","kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec:Count","...:OneMinuteRate"
1793248915456,0,0.0

第一列是采集时刻的毫秒时间戳,不是指标值。解析的时候很容易把它当成第一个指标。

坑三:一次只能查一个 objectName

直觉上会想用逗号把多个 --object-name 串起来一次采完:

--object-name "kafka.server:type=A,name=X,kafka.server:type=B,name=Y"

这样会失败。因为 JmxTool 用逗号分隔多个查询,而 ObjectName 内部本身就有逗号(type=...,name=...),两套语法撞在一起,解析直接异常:

java.lang.RuntimeException: Invalid ObjectName
    at javax.management.ObjectName.<init>(Unknown Source)
    at org.apache.kafka.tools.JmxTool$JmxToolOptions.lambda$queries$0(JmxTool.java:411)

结论很朴素:一个 objectName 一次调用。十二个指标就是十二次 JVM 启动,每个一两秒,采一轮十几秒。作为手工排错手段完全够用;要高频采集,应当上专门的 exporter,而不是循环调 JmxTool。

一次完整的采集:从空集群到 5 万条

把十二个指标写成脚本,先采基线。空集群上的原始输出(本机实测,Temurin 25.0.5+8 / Kafka 4.3.0 KRaft 单节点 / 2026-10-29):

MessagesInPerSec.Count      1793248967861,0
BytesInPerSec.Count          1793248968152,0
BytesOutPerSec.Count         1793248968557,0
FailedProduce.Count          1793248969065,0
UnderReplicatedPartitions    1793248969458,0
UnderMinIsrPartitionCount    1793248969930,0
IsrShrinksPerSec.Count       1793248970252,0
PartitionCount               1793248970589,3
ActiveControllerCount        1793248970976,1
OfflinePartitionsCount       1793248971369,0
RequestQueueSize             1793248971877,0
RequestHandlerAvgIdlePct     1793248972377,0.9942522795907361

这一组数字是全 0 加两个常数,正是「健康的空集群」该有的样子:ActiveControllerCount=1(有且只有一个 controller),PartitionCount=3(建的 topic 有三个分区),RequestHandlerAvgIdlePercent=0.994(处理线程 99.4% 的时间在空闲)。

然后打 5 万条进去:

docker exec mq-f1 sh -c 'unset JMX_PORT; /opt/kafka/bin/kafka-producer-perf-test.sh \
  --topic f1.demo --num-records 50000 --record-size 512 --throughput -1 \
  --producer-props bootstrap.servers=127.0.0.1:9092 acks=1'
50000 records sent, 11839.924224 records/sec (5.78 MB/sec), 1471.39 ms avg latency, ...

这条命令会顺带打一行提示:Option --producer-props has been deprecated and will be removed in a future version. Use --command-property instead. 4.3.0 里这个参数还能用,但已经是弃用状态,写脚本时应当换成 --command-property。

(单次运行,随调度波动,不作基准。本机 load 常年在十几到二十几之间,这个吞吐数字没有参考价值。)

再采一次:

MessagesInPerSec.Count      1793248988399,50000
BytesInPerSec.Count         1793248988655,26148454
BytesOutPerSec.Count        1793248988855,0
RequestHandlerAvgIdlePct    1793248990593,0.9938321088574491

三个变化值得注意:

  1. MessagesInPerSec 从 0 变成 50000,与发送条数完全对上。
  2. BytesInPerSec 变成 26,148,454,约 24.9 MB。5 万条 × 512 字节是 25.6 MB,差额来自批次头和记录格式开销。
  3. BytesOutPerSec 还是 0——因为没有消费者。这一条直接回答了「我这套集群的读流量是多少」。

RequestHandlerAvgIdlePercent 几乎没动(0.9938),说明这点流量对 broker 来说微不足道。空闲率是判断「还有多少余量」最直接的数字,比任何 QPS 都直观。

最后消费一遍,BytesOutPerSec 变成 26,410,501,与写入量同量级。

一个必须学会区分:broker 级和 topic 级

上面的 MessagesInPerSec 显示 50000。但消费完再采一次,它变成了:

MessagesInPerSec.Count      1793249012093,50006

多出来的 6 条不是业务消息。消费组提交 offset 时,Kafka 会往内部 topic __consumer_offsets 写记录,这些记录也被 broker 级的计数器统计进去。

用带 topic= 标签的 objectName 分别取,一目了然:

--object-name 'kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec,topic=f1.demo'
--object-name 'kafka.server:type=BrokerTopicMetrics,name=MessagesInPerSec,topic=__consumer_offsets'
f1.demo              1793249040358,50000
__consumer_offsets   1793249042741,6

50000 + 6 = 50006,严丝合缝。

这就是为什么监控面板上「集群总消息量」经常会莫名其妙地跳。 事务 topic、消费者位点 topic、controller 的内部 topic 都在计数里。做容量规划要用 topic 级指标;判断「集群整体还在不在干活」才用 broker 级。

延迟要看百分位,不要看均值

同一个采集方式可以取请求延迟。RequestMetrics 下按请求类型分组:

--object-name "kafka.network:type=RequestMetrics,name=TotalTimeMs,request=Produce" \
--attributes 99thPercentile,Mean,Count

本机在一次 5 万条的生产之后取到:

99thPercentile=28.2  Mean=6.02  Count=539

三件事要一起读:

  • Count=539 不是 50000。这个计数器统计的是请求数,不是消息数,一个请求里可以装很多条消息(批次)。
  • Mean=6.02ms 看着很好,但 99thPercentile=28.2ms 是均值的近五倍。长尾被均值完全掩盖了。
  • 延迟告警必须基于百分位。用均值定阈值,会漏掉那些「1% 的请求慢到超时」的事故。

阈值怎么定

绝对值阈值在 Kafka 上几乎一定错。同一套指标,在小集群和中等集群上的「正常值」差几个数量级。可行的做法是把阈值分成三类。

类型适用指标定法
零容忍FailedProduceRequestsPerSec、OfflinePartitionsCount、ActiveControllerCount≠1只要出现就告警
持续偏离UnderReplicatedPartitions、UnderMinIsrPartitionCount持续超过 N 个采集周期
相对基线MessagesInPerSec、RequestQueueSize、99thPercentile与同时间段的基线比,看倍数

第一类不需要阈值,因为它们本该恒为某个值。OfflinePartitionsCount 大于 0 意味着有分区根本不可写,没有任何「短暂可接受」的说法。

第二类需要去抖,原因在第 14.1 节已经用实验说明过:ISR 收缩在三种路径下的耗时完全不同——优雅关闭是 2 秒内主动注销,进程冻结要等 broker.session.timeout.ms(默认 9 秒),复制慢则要等 replica.lag.time.max.ms(默认 30 秒)。一次正常的滚动重启就会让 UnderReplicatedPartitions 短暂大于 0。如果见到非零就告警,每次发版都是一轮告警风暴。

第三类必须看相对值。本机的 RequestHandlerAvgIdlePercent 在空集群上是 0.994,在打满时可以是 0.2、0.05。没有一个通用数字能同时覆盖这两种场景。

告警分级与去抖

级别触发条件期望动作
P1OfflinePartitionsCount > 0 持续 1 分钟;ActiveControllerCount ≠ 1 持续 1 分钟立即介入,可能无法写入
P2UnderMinIsrPartitionCount > 0;FailedProduceRequestsPerSec > 0 持续 2 分钟15 分钟内处理,写入正在被拒
P3UnderReplicatedPartitions > 0 持续 5 分钟;RequestQueueSize 超过基线 3 倍当天处理,冗余或延迟在退化

去抖统一用「连续 N 个采集周期」而不是「一次采样超过阈值」。周期取 30 秒比较合适:比 ISR 收缩最慢的那条路径(30 秒)短,又不至于把正常的替换节点抖动算进去。

有一类告警必须配「抑制」。滚动重启期间 UnderReplicatedPartitions 一定会非零,这个时间窗里应当临时抑制 P3 的副本告警,否则真正的问题会淹没在正常的噪音里。抑制不等于关闭——窗口结束后要有一次强制复查。

三个常见的告警错误

错误一:用均值。 前面已经算过,均值 6ms 而 P99 是 28ms。事故往往就发生在被均值抹平的那 1% 里。

错误二:只看 broker,不看消费组。 broker 的所有指标都健康,LAG 却已经涨到几百万——这在线上的发生频率高于任何一种 broker 故障。原因通常是消费者那边变慢了(下游数据库慢了、某条消息处理超时重试),broker 完全无辜。LAG 必须单独采集,且必须按消费组告警,不能只看总量。

错误三:把 broker 级计数当业务量。 前面 50006 那个例子,如果按 broker 级做容量预测,会系统性地高估。内部 topic 的写入量随消费组数量增长,在大集群上能占到不可忽略的比例。

从指标到判据

监控这件事的终点不是「有个面板」,而是「出问题时能在五分钟内回答三个问题」:

  1. 现在能不能写?(FailedProduceRequestsPerSec、UnderMinIsrPartitionCount)
  2. 数据有没有危险?(UnderReplicatedPartitions、OfflinePartitionsCount)
  3. 消费者跟不跟得上?(LAG 及其斜率)

前两个问题由 broker 指标回答,第三个必须由消费组数据回答。把这十二个指标按这三个问题组织,面板上就不再是一堆曲线,而是一份可以直接照着念的判断清单。

下一节讲版本升级:哪些改动可以撤回,哪些不能。

阅读导航:上一节 14.3 数据不一致的取证 | 下一节 15.2 版本升级与兼容性

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收