1. Web UI 之外的战场:Flink 监控到底要盯哪些东西
Flink 的 Monitoring 这块内容,官方文档写得其实挺克制——它把 Metrics、Reporter、REST API、日志这几块并列摆出来,每个参数都列了,但很少告诉你“什么时候该看哪个、看到多少算异常”。真正上手之后你会发现,Web UI 上那几个跳动的数字只够应付演示环境,一旦作业跑到几十个 TaskManager、几百个 subtask,光靠刷新页面根本定位不了问题,你会需要一套能留痕、能对比、能告警的监控体系。
这篇笔记就是围绕这块内容展开的:Flink 的 Metrics 体系怎么组织、Reporter 怎么配、哪些指标真正值得盯、出问题时按什么顺序排查。适合已经能跑起一个 Flink 作业、但遇到“延迟在涨、Checkpoint 在超时、CPU 不高但就是慢”这类问题时不知道从哪下手的同学;也适合正在给团队搭监控看板、需要把 Flink 指标接进现有 Prometheus 体系的人。文中涉及的配置以 1.14 到 1.18 这个版本区间为主,个别版本差异我会单独标出来。
2. Metric 体系拆解:类型、命名与 Scope
官方文档里 Metrics 这一节的第一屏信息量其实很大——它定义了 Metric 的类型、Scope 的拼接规则、以及每个 Reporter 如何消费这些指标。很多人配完 Reporter 能出数就停了,但后面所有“指标对不上”“看板画不出来”“Prometheus 内存爆了”的问题,根子几乎都在这一节。
2.1 Counter、Gauge、Meter、Histogram 分别用来表达什么
Flink 的 Metric 只有四类基础类型,理解它们的语义差别比记住名字重要得多。
- Counter:只增不减的累计值,比如
numRecordsIn、numBytesOut。它本身没有时间维度,要看速率必须自己做rate()或者increase()。 - Gauge:瞬时值,某一刻的快照,比如堆内存使用量、当前水位线、当前并行度。Gauge 是最容易误用的类型——很多人把“每秒处理条数”做成 Gauge,结果上游一抖动,曲线就像心电图。
- Meter:平均速率,Flink 内部用 EWMA(指数加权移动平均)算出来的,
numRecordsInPerSecond就是 Meter。它已经帮你把“每秒多少”算好了,但代价是有平滑延迟,突发的尖峰会被吃掉一部分。 - Histogram:分布统计,能拿到 count、min、max、mean,以及配置了
HistogramStatistics之后的 p50/p75/p95/p99。延迟类指标基本都是 Histogram。
这里有个实操上的经验:排障时优先看 Counter 算出来的速率,而不是 Meter。Meter 的 EWMA 在流量骤降的时候会拖一条长尾巴,容易让你误判“问题还在持续”。而在做容量规划、看长期趋势时,Meter 反而更省事,因为不用写 PromQL 聚合。
2.2 Scope 的拼接规则与“指标名为什么这么长”
Flink 的每个指标都有一个完整路径,由 Scope 一段段拼起来。默认的 Scope 格式大概是这样(不同小版本措辞略有出入,但结构一致):
| 配置项 | 默认形态 | 含义 |
|---|---|---|
metrics.scope.jm | <host>.jobmanager | JobManager 全局指标 |
metrics.scope.jm.job | <host>.jobmanager.<job_name> | JobManager 按作业维度 |
metrics.scope.tm | <host>.taskmanager.<tm_id> | TaskManager 全局指标 |
metrics.scope.task | ...<job_name>.<task_name>.<subtask_index> | Task 级指标 |
metrics.scope.operator | ...<job_name>.<operator_name>.<subtask_index> | 算子级指标 |
所以你看到的flink_taskmanager_job_task_operator_numRecordsOutPerSecond,其实是被 Prometheus Reporter 把点号换成下划线、再加了flink_前缀之后的结果。理解这一点很关键:指标名字长不是设计失误,而是它把维度信息编码进了名字里。
注意:Scope 里的
<host>在容器化环境里通常是 Pod 名,每次重启都会变。这意味着同一个算子重启十次,Prometheus 里就会多出十个不同的序列。这是容器环境下指标基数失控最常见的原因之一。
处理办法有两个方向:一是直接把metrics.scope.task和metrics.scope.operator里的<host>去掉,改成在采集端统一打标签;二是确认你用的 Reporter 版本是否把 Scope 变量转成了 label(1.13 之后的 Prometheus Reporter 在这块行为有过调整,最靠谱的做法是配好之后curl一次/metrics接口,直接看实际输出的名字和标签长什么样,不要凭记忆写查询)。
2.3 自定义 Metric 的正确写法与线程安全陷阱
框架自带的指标覆盖不到业务语义,比如“解析失败了多少条”“当前积压队列有多深”,这类东西只能自己埋点。写法本身不复杂,但坑集中在生命周期管理上。
public class OrderParseFunction extends RichFlatMapFunction<String, Order> { private transient Counter badRecord; private transient Meter parseRate; @Override public void open(Configuration parameters) { MetricGroup group = getRuntimeContext() .getMetricGroup() .addGroup("orders", "parser"); badRecord = group.counter("badRecord"); parseRate = group.meter("parseRate", new MeterView(60)); } @Override public void flatMap(String value, Collector<Order> out) { Order order = tryParse(value); if (order == null) { badRecord.inc(); return; } parseRate.markEvent(); out.collect(order); } }几个必须注意的点:
getRuntimeContext().getMetricGroup()一定要在open()里调一次存成成员变量,不要写在flatMap里。前者只是一次组装,后者是每条记录一次链路查找,在千万级 QPS 的场景下这部分开销能占到几个百分点,属于典型的“埋点把作业拖慢”。
addGroup()的层级不要随手乱加。addGroup("orders", "parser")会在指标路径里插入两段,如果你还传了动态变量(比如addGroup("topic", topicName)),那就等于给每个 topic 都开一组指标——topic 数量上百的时候,指标序列数直接翻百倍。
多线程场景要特别小心。Flink 的 Counter 实现理论上是线程安全的(底层用了原子操作),但你自己组合出来的“先读 Gauge 再判断再写”的逻辑不是原子的。如果业务里有独立的回调线程在往 Metric 里写数据,老老实实用一个AtomicLong当数据源,Gauge 只做读,不要在里面做任何计算。
提示:自定义指标名建议用驼峰且不加下划线,Reporter 转换规则各版本略有差异,纯驼峰在 Prometheus 里转成下划线后不会出现连续双下划线这种难看的名字。
2.4 指标基数:真正把 Prometheus 打爆的东西
这一条官方文档不会明说,但踩过的人都懂:Flink 的算子级指标是一个组合爆炸源。假设你有 50 个算子、每个算子 40 个并行度、每个算子带 20 个指标,那就是 50 × 40 × 20 = 4 万个时间序列;如果 Scope 里还带着动态的<host>,重启几次就是十几万。
Prometheus 单实例在几十万序列的时候就开始吃力了,查询变慢、内存见涨,最后往往是监控系统自己先挂。控制基数的几个手段,按性价比排序:
第一,调整 Scope。把metrics.scope.operator里的<subtask_index>去掉,粒度就从“每个并行子任务”降到“每个算子”,序列数直接除以并行度。代价是你失去了发现单点倾斜的能力——所以这个改动只建议用在对“是否倾斜”不敏感的场景。
第二,用 Reporter 的过滤器。Prometheus Reporter 提供metrics.reporter.prom.filter.includes和filter.excludes,前者是白名单、后者是黑名单,语义是包含任意一条 includes 且不匹配任何 excludes 才上报。排障期可以把 includes 设成几个关键指标前缀,长期再放开。
metrics.reporter.prom.filter.includes: '*.numRecordsInPerSecond,*.numRecordsOutPerSecond,*.backPressuredTimeMsPerSecond,*.lastCheckpointDuration'第三,把 JVM 那堆指标单独评估。Status.JVM.ClassLoader、Status.JVM.GarbageCollector这些是 TaskManager 级的,数量不多,但Status.JVM.GarbageCollector会按收集器名字展开成好几条,G1 下一台机器就有三四条,几百台就是上千条。数量不算大,但如果你在做多集群汇总,这部分可以按需取舍。
3. Reporter 实操:从配置文件到 Prometheus 抓取链路
Reporter 是 Metrics 体系里唯一“往外发”的组件。官方文档列了十来种,实际工作中常用的就那么三四个,选错的话后面调优会很别扭。
3.1 Reporter 选型的三个判断维度
我的判断逻辑基本是三问:数据要不要留痕、由谁去拉、集群规模多大。
如果只是本地开发调一个作业,Slf4jReporter最省事,它把指标按固定间隔打到日志里,配合grep就能看,不用起任何外部服务。缺点是日志会被撑爆,绝对不能在生产环境默认打开。
如果团队已经有 Prometheus + Grafana 这套东西,那PrometheusReporter基本是唯一答案。它是 pull 模式——Flink 在 JobManager 和各 TaskManager 上各起一个 HTTP 端口,Prometheus 定期来抓。好处是 Flink 侧不需要知道 Prometheus 在哪,扩缩容时只要目标列表跟着变就行。
如果是传统的 JMX 监控体系,或者你想临时用 JConsole 连上去看一眼,JMXReporter更顺手,它暴露的是标准 MBean,任何 JMX 客户端都能连。
至于 Graphite、InfluxDB、StatsD 这些 push 模式的 Reporter,现在新项目里用得少了。它们的问题是 Flink 需要主动往外推,网络分区的时候会积压、会丢数据,而且推的频率和 Prometheus 的抓取频率在语义上不对等,跨系统对齐的时候容易扯皮。
3.2 PrometheusReporter 的完整配置与多实例端口分配
以 1.15 之后的配置方式为准(用factory.class,1.13/1.14 用的是metrics.reporter.prom.class,直接写 Reporter 类名):
metrics.reporter.prom.factory.class: org.apache.flink.metrics.prometheus.PrometheusReporterFactory metrics.reporter.prom.port: 9249 metrics.reporter.prom.filter.includes: '*.numRecordsInPerSecond,*.numRecordsOutPerSecond,*.backPressuredTimeMsPerSecond,*.busyTimeMsPerSecond,*.lastCheckpointDuration,*.lastCheckpointSize'这里最容易翻车的是端口。默认端口是 9249,而flink-conf.yaml是全局配置,JobManager 和所有 TaskManager 会读到同一份。如果一台机器上跑了多个 TaskManager 进程(standalone 部署经常这样),第二个进程启动时会因为端口占用直接失败,而且报错是在 TaskManager 启动阶段,很容易被当成别的问题。
解决办法是把端口写成一个区间:
metrics.reporter.prom.port: 9249-9260Flink 会在区间内找一个可用端口绑定。但区间方案只解决了“起得来”的问题,没解决“Prometheus 怎么知道它在哪个端口”的问题。所以生产上更推荐的做法是每台机器只跑一个 TaskManager,或者用容器编排时把端口固定映射出来。
Prometheus 侧的抓取配置:
scrape_configs: - job_name: 'flink' scrape_interval: 15s static_configs: - targets: - 'jm-host:9249' - 'tm-host-1:9249' - 'tm-host-2:9249'容器环境下不要手写 targets,用基于 Pod 的服务发现。但要注意:Flink 不会自动给自己的 Pod 打上prometheus.io/scrape这类注解,需要你在部署模板里手动加上,否则服务发现扫不到任何东西,你会对着空白的 Grafana 发呆半小时。
3.3 JMX 与 Slf4j:适合调试期和兜底期的两种方案
JMX Reporter 的配置极简:
metrics.reporter.jmx.factory.class: org.apache.flink.metrics.jmx.JMXReporterFactory metrics.reporter.jmx.port: 9020-9040端口同样建议写区间。它最大的价值在于临时深挖——Prometheus 里的指标是被过滤过的,你想临时看一个没上报的指标,用 JConsole 或jcmd连上去,MBean树里能翻到全量的东西。我有过好几次是靠 JMX 里翻出一个没被 Reporter 上报的指标,才定位到问题的。
Slf4j Reporter 的关键参数是间隔和日志级别:
metrics.reporter.slf4j.factory.class: org.apache.flink.metrics.slf4j.Slf4jReporterFactory metrics.reporter.slf4j.interval: 60 SECONDS在log4j.properties里把对应的 logger 单独设成 INFO 并输出到独立文件,否则它会和业务日志搅在一起。这个方案适合短时间(几小时)的密集观测,不适合长期开着。
3.4 采集间隔与抓取间隔的对齐计算
这一块是配置里最容易被忽略的部分,但直接影响你看到的数据准不准。
Flink 侧有两个时间参数需要区分清楚:
metrics.reporter.<name>.interval:Reporter 的推送间隔,默认 10 秒。注意对 PrometheusReporter 基本无效,因为它不推送。metrics.fetcher.update-interval:指标缓存的刷新间隔,默认 10000 毫秒。这个参数决定 Web UI 和 REST/metrics接口看到的值多久更新一次。
Prometheus 侧是scrape_interval。这三个数字的配合有个简单的判断标准:抓取间隔应该小于等于刷新间隔。如果你把scrape_interval设成 5 秒而update-interval是 10 秒,那 Prometheus 会抓到两份一模一样的数据,rate()算出来的曲线会出现台阶状的锯齿,看着像业务抖动,其实是采集配置的问题。
反过来,scrape_interval设得太长(比如 60 秒),短时间的背压尖峰会被完全漏掉。比较平衡的取值是scrape_interval: 15s,配合update-interval: 10000ms。数据量和灵敏度兼顾,一个背压周期一般也会持续几分钟以上,15 秒的采样率足够还原出形状。
还有一个参数值得单独说:metrics.latency.interval,默认是 0,也就是端到端延迟指标默认关闭。打开它需要设置成一个非零的毫秒数(比如 60000),代价是 Flink 会周期性往数据流里注入 LatencyMarker,这些 marker 会占用网络带宽、也会影响吞吐。在高峰值吞吐的作业上,我一般只在排查阶段临时打开,抓完数据就关掉,不建议长期开启。
4. 关键指标清单与阈值推导
这一节是我认为整篇文档里最值得反复看的部分,因为它决定了你后面画的看板有没有意义。指标不在多,在于每个都对应一个明确的故障假设。
4.1 Checkpoint 与状态相关指标
Checkpoint 是 Flink 作业的“体检报告”,几乎所有状态类问题最后都会在这里露馅。核心的几个指标都在 Job 维度:
| 指标名 | 类型 | 关注点 |
|---|---|---|
lastCheckpointDuration | Gauge | 最近一次成功的耗时 |
lastCheckpointSize | Gauge | 最近一次的状态大小 |
lastCheckpointFullSize | Gauge | 含增量文件的总大小 |
numberOfCompletedCheckpoints | Counter | 成功次数 |
numberOfFailedCheckpoints | Counter | 失败次数 |
lastCheckpointRestoreTimestamp | Gauge | 上次恢复的时间点 |
阈值不是拍出来的,可以这样推:设 Checkpoint 间隔为interval,超时时间为checkpointTimeout。一次健康的 Checkpoint,耗时应该稳定在interval的 50% 以内,也就是如果 3 分钟做一次,耗时最好别超过 90 秒。因为 Flink 的调度逻辑是“上一次做完、等minPauseBetweenCheckpoints之后再开始下一次”,实际的有效间隔约等于:
有效间隔 ≈ max(checkpointInterval, lastCheckpointDuration + minPauseBetweenCheckpoints)这条公式直接解释了很多人困惑的现象:明明配了 1 分钟一次,为什么看板上是 3 分钟一次?因为 Checkpoint 本身就做得太久,间隔被撑开了,配置根本没有生效。
lastCheckpointSize的突增也很说明问题。状态大小在一天内涨了 3 倍,通常意味着要么有状态泄漏(比如ListState只加不清),要么是 key 的基数在膨胀。这时候光看总量不够,得看算子级别的状态分布,找出是哪个算子的状态在吃内存。
注意:开增量 Checkpoint 的时候,
lastCheckpointSize反映的是本次新增的数据量,lastCheckpointFullSize才是全量。两个数字在增量模式下能差一个数量级,别拿lastCheckpointSize去评估状态总量。
4.2 背压、吞吐与网络缓冲
背压指标从 1.13 开始换了一套命名,主要是backPressuredTimeMsPerSecond、idleTimeMsPerSecond、busyTimeMsPerSecond这三个,单位是“每秒中有多少毫秒处于该状态”。这三个值加起来大致等于 1000(会有细微误差),所以判断逻辑非常直观:
| 指标取值 | 含义 | 建议动作 |
|---|---|---|
backPressuredTimeMsPerSecond> 500 | 该算子有一半以上时间在背压 | 重点排查下游 |
busyTimeMsPerSecond接近 1000 且无背压 | 算子满负荷但能跟上 | 关注是否接近瓶颈 |
idleTimeMsPerSecond> 800 | 上游供数不足 | 往数据源方向查 |
| 三者都偏低(总和远小于 1000) | 指标异常或作业在等外部 IO | 查线程栈 |
如果是 1.13 之前的版本,指标名是backPressuredTimeMs、idleTimeMs这种累计毫秒数,需要自己做差值。这也是为什么我说一定要先 curl 一次实际接口,版本之间的名字差异真的会让人白写一堆 PromQL。
吞吐指标相对简单:numRecordsInPerSecond、numRecordsOutPerSecond、numBytesInPerSecond、numBytesOutPerSecond。它们的价值在于对比——上游numRecordsOut和下游numRecordsIn出现持续缺口,要么是网络丢数据(这种情况很少),要么是中间有个算子在过滤或者聚合,得对着算子链去看。
网络缓冲这块需要额外开一个开关:
taskmanager.network.detailed-metrics: true打开之后才会有inPoolUsage、outPoolUsage、floatingBuffersUsage、exclusiveBuffersUsage这些指标。默认关闭是有原因的——它要额外统计每条连接的缓冲占用,对性能有轻微影响。但排“吞吐上不去、延迟忽高忽低”这类问题时,这几个指标几乎是唯一能直接指向网络层的证据。
outPoolUsage长期在 0.8 以上,说明下游消费不动,本地输出缓冲快满了,这是背压的物理证据。floatingBuffersUsage高而exclusiveBuffersUsage低,说明流量分布不均,某些 channel 吃掉了大部分浮动缓冲——这时候该往数据倾斜方向想。
4.3 JVM、GC 与线程
这部分指标默认就有,不需要额外配置,但很多人看了不知道重点在哪。我一般只盯四个:
flink_taskmanager_Status_JVM_Memory_Heap_Used除以Heap_Max,得到堆使用率。但要注意这个值包含了 Flink 的 Managed Memory 之外的区域,而 Managed Memory 是 off-heap 的,所以堆使用率不能直接等同于“Flink 用了多少内存”。
GC 时间用rate()算增长量:过去 5 分钟里 GC 占用了多少时间。经验阈值是 GC 时间占比超过 10% 就要警惕,超过 25% 基本可以确定 JVM 是瓶颈之一。同时要看 GC 次数的增速,年轻代频繁回收但耗时短问题不大,老年代回收次数多且耗时长,那就是真的有内存压力了。
线程数Status.JVM.Threads.Count是个被低估的指标。它突然涨到几千,通常是线程泄漏——比如某个Sink每次open都创建一个连接池但close里没关。作业跑几天之后 OOM 或者响应变慢,回头看这个指标是一条持续上升的直线,基本可以确诊。
CPU 相关指标需要打开metrics.system-resource: true,采集间隔由metrics.system-resource-interval控制,默认 2 秒。这是通过操作系统的系统调用采的,不同系统上能拿到的字段略有差异。
4.4 能直接用的 PromQL 与告警规则
光有指标没用,得能写出查询。下面几条是我在看板上长期保留的:
# 各算子背压情况,只看超过 500ms/s 的 max by (job_name, task_name) ( flink_taskmanager_job_task_backPressuredTimeMsPerSecond ) > 500 # Checkpoint 耗时与间隔的比值,超过 0.5 就该看 flink_jobmanager_job_lastCheckpointDuration / 180000 # TaskManager 堆使用率 flink_taskmanager_Status_JVM_Memory_Heap_Used / flink_taskmanager_Status_JVM_Memory_Heap_Max # 老年代 GC 时间占比(5 分钟窗口) rate(flink_taskmanager_Status_JVM_GarbageCollector_G1_Old_Generation_Time[5m]) # Checkpoint 失败次数增长 increase(flink_jobmanager_job_numberOfFailedCheckpoints[30m]) > 0告警规则我配置得比较克制,只留三条硬性的:
groups: - name: flink-job rules: - alert: FlinkCheckpointFailing expr: increase(flink_jobmanager_job_numberOfFailedCheckpoints[30m]) > 2 for: 5m labels: severity: warning annotations: summary: "作业 Checkpoint 连续失败" - alert: FlinkBackpressureHigh expr: max by (job_name, task_name) (flink_taskmanager_job_task_backPressuredTimeMsPerSecond) > 600 for: 10m labels: severity: warning annotations: summary: "算子 {{ $labels.task_name }} 持续背压" - alert: FlinkHeapPressure expr: flink_taskmanager_Status_JVM_Memory_Heap_Used / flink_taskmanager_Status_JVM_Memory_Heap_Max > 0.85 for: 15m labels: severity: warning annotations: summary: "TaskManager 堆使用率持续偏高"for的取值很重要。背压告警如果设成 1 分钟,每次数据高峰都会响,久而久之就没人看了。10 分钟能过滤掉绝大多数瞬时抖动,剩下的基本都是真问题。
4.5 阈值不是拍脑袋:几个可复用的推导方法
“多少算高”这个问题没有通用答案,但有几个推导思路可以复用。
延迟类指标用分位数反推。如果你的业务 SLA 是 p99 在 500ms 以内,那就把Histogram的 p99 曲线拉出来,看它平时的波动区间。如果平时就在 400ms 上下浮动,那留给你的余量只有 100ms,任何超过这个幅度的抬升都该告警;如果平时是 150ms,那 400ms 就已经是明显异常了。
资源类指标看趋势而不是绝对值。堆使用率 70% 本身不是问题,问题是它是不是在缓慢爬升。用deriv()算一下 1 小时窗口的斜率,持续为正且在加速,比一个静态阈值有用得多。
吞吐类指标看缺口。上游的numRecordsOutPerSecond和下游的numRecordsInPerSecond做减法,缺口持续存在说明链路里有个环节在丢或者卡。
5. 背压与延迟的定位流程实录
指标告诉你“有问题”,但不会告诉你“问题在哪”。这一节说流程。
5.1 第一步永远是分辨背压和数据倾斜
背压和数据倾斜在指标上长得非常像:都是部分 subtask 慢、整体吞吐上不去。但处理方式完全相反——背压要往下游找瓶颈,倾斜要调 keyBy 或者加盐。
区分方法很简单:看同一个算子的不同 subtask 的numRecordsInPerSecond。如果各 subtask 的输入速率差异在 10% 以内,但backPressuredTimeMsPerSecond差异很大,那是背压沿着链路传上来了,得继续往下游看;如果输入速率本身就是几倍的关系(比如某一个 subtask 吃了 60% 的数据),那就是倾斜。
倾斜还有个更直接的证据:numRecordsOut的分布在时间上高度不均匀,某些 subtask 的曲线是尖峰状而另一些是平的。这时候去查 keyBy 的字段,十有八九是某个默认值或者空值占了大头。
5.2 线程栈采样与火焰图的正确打开方式
确认了是背压,下一个问题就是“卡在哪个方法里”。Flink 在 1.13 之后把 Web UI 的背压页面从“输出缓冲占用估算”改成了“线程栈采样”,这个改动含义很深——它承认了前者只能告诉你有没有背压,不能告诉你卡在哪。
手工采样的方式是直接打线程栈:
# 连续采 5 次,每次间隔 2 秒 for i in $(seq 1 5); do jstack <taskmanager_pid> > /tmp/dump_$i.txt sleep 2 done然后统计同一个堆栈出现在多少次采样里。出现在 3 次以上的,基本就是卡住的那个位置。这个方法的原理很简单:如果一段代码在 10 秒内被抓到 4 次,那它大概率占了 80% 以上的 CPU 时间。
较新版本(1.16 之后)提供了火焰图,通过 REST 接口触发:
curl -s "http://jm-host:8081/jobs/<jobId>/vertices/<vertexId>/flamegraph" > flamegraph.html火焰图的好处是能一眼看出调用栈的宽度分布,比人工统计栈方便得多。但要注意它采样的是当前瞬间,如果问题有周期性,得在问题发生时抓,抓早了看不到东西。
5.3 一个端到端的排查过程
说一个我印象比较深的场景(细节做了脱敏和简化)。某订单处理作业,逻辑是 Kafka 进、几条keyBy、最后写外部存储。现象是:吞吐从稳定期的 8 万条/秒掉到 3 万,延迟从 50ms 涨到 2 秒,但 CPU 使用率只有 40%,堆内存也正常。
第一步看背压指标,发现链路上倒数第二个算子backPressuredTimeMsPerSecond长期在 700 以上,最后一个算子(Sink)的busyTimeMsPerSecond接近 900,idleTimeMsPerSecond接近 0。这说明 Sink 一直在忙,但忙得没效率。
第二步看网络指标,outPoolUsage在 Sink 的前一个算子那里长期 0.9 以上,floatingBuffersUsage高,exclusiveBuffersUsage低。到这里可以确认:是 Sink 消费不过来导致的背压,而且通道之间的流量分布不均。
第三步上火焰图,Sink 的调用栈里有超过 60% 的宽度集中在同步 HTTP 调用上。到这里原因就清楚了——Sink 用的是同步批写,批大小 100 条,每批一次网络往返。上游吞吐一上来,每次往返的时间没变,但批次数变多,整体就卡住了。
后续的处理是把同步写改成异步批量提交,同时给 Sink 单独提高了并行度。改完吞吐回到 7 万以上,outPoolUsage降到 0.3 左右。整个过程里最有价值的信息其实来自第二步的网络缓冲指标——它明确指向了“下游消费能力”这个方向,让后面找火焰图有了目标,而不是满屏乱翻。
5.4 四种常见误判
把 GC 停顿当成背压。两者都会让busyTime看起来正常但吞吐下降。区别是 GC 停顿在指标上表现为周期性的“断崖”,而且GarbageCollector.*.Time会有同步的尖峰。如果背压指标平稳、GC 时间有规律的尖峰,先调 GC 参数。
把下游限流当成上游瓶颈。外部系统做了限流的时候,Sink 会一直重试,看起来像是 Sink 自己慢。这时候要看 Sink 的失败率指标和重试次数,如果重试在涨,那就是对端在拒绝,扩并行度反而会加剧问题。
把 Checkpoint 影响当成业务抖动。每次 Checkpoint 都让吞吐掉一截,在曲线上看是周期性的锯齿。判断方法是把lastCheckpointDuration的时间点和吞吐下降的时间点对齐,如果严丝合缝,那就是同步阶段的阻塞或者状态太大导致的写放大。
把指标缺失当成作业健康。这个坑我在容器环境下踩过好几次:Prometheus 抓不到数据,Grafana 上是一条平线,看起来“很平稳”,实际上是采集链路断了。所以一定要配一个“指标缺失”的告警:up{job="flink"} == 0,或者absent(flink_jobmanager_job_lastCheckpointDuration)。
6. 常见问题速查与踩坑记录
6.1 指标缺失:先查这几处
指标不出来,按这个顺序查,能解决八成问题。
配置有没有生效。Flink 读的是conf/flink-conf.yaml,容器环境下是FLINK_PROPERTIES环境变量或者--config传的目录。改了配置文件但没重启进程,配置不会自动重载,这个低级错误我见过不止一次。
端口通不通。在 Flink 所在的机器上先curl localhost:9249/metrics,如果不通就别去看 Prometheus 了。通了再到 Prometheus 那台机器上curl,如果这边不通就是网络策略的问题。
过滤器有没有把指标全排掉。filter.includes写了一个不存在的指标前缀,结果就是一条数据都没有。这个参数不会报错,只会静默地什么都不给。
指标名对不对。版本之间名字会变,比如背压指标在 1.13 前后完全是两套命名。写 PromQL 之前直接去/metrics输出里grep一下,比查文档快。
6.2 指标看着对但用不了:语义层面的坑
有些问题不是“没有数据”,而是“数据不是你以为的那个意思”。
numRecordsIn是 Counter,直接画出来是一条单调上升的线,没有任何诊断价值。必须rate()或者increase()。
numRecordsInPerSecond是 Meter,它已经在 Flink 侧做了平滑。再套一层rate()会得到完全错误的数值——对已经算好的速率再求变化率,结果没有物理意义。
lastCheckpointDuration是 Gauge,反映的是“最近一次”,不是平均值。想看趋势得自己在外面存历史,或者用avg_over_time()做窗口聚合。用 Gauge 判断“是否在恶化”很容易被单次异常值误导。
Histogram 类指标在 Prometheus 里会展开成_count、_sum和若干_bucket序列,算平均值要用_sum / _count,算分位数要用histogram_quantile()套在_bucket上。直接把_count当次数用没问题,但当延迟看就错了。
6.3 监控自身的开销
这一条很少被提起,但规模上去之后是真实存在的成本。
指标采集本身要遍历所有 MetricGroup、做字符串拼接、序列化。TaskManager 数量上百、指标全开的时候,这部分开销能占到几个百分点,表现为“什么都不改,加了个监控之后吞吐掉了 2%”。
控制手段有三个:调大metrics.fetcher.update-interval(比如从 10 秒到 30 秒),减少 Repoter 的指标数量(用过滤器),以及关闭不必要的高频指标。taskmanager.network.detailed-metrics就是个典型——排查期打开,平时关掉。
还有一个隐性成本在 Prometheus 那边。序列数上去之后,Prometheus 的内存和查询延迟都会涨,Grafana 打开一个看板要等十几秒。这种体验上的退化最终会导致没人看监控,那监控就白做了。
6.4 速查表
| 现象 | 优先查的指标 | 常见原因 |
|---|---|---|
| 吞吐下降、CPU 不高 | backPressuredTimeMsPerSecond、outPoolUsage | 下游消费慢、同步 IO 阻塞 |
| Checkpoint 频繁超时 | lastCheckpointDuration、lastCheckpointSize | 状态过大、同步阶段阻塞、后端写入慢 |
| 部分 subtask 特别忙 | 各 subtask 的numRecordsInPerSecond分布 | 数据倾斜、key 分布不均 |
| 延迟抖动、周期性变慢 | Status.JVM.GarbageCollector.*.Time | GC 停顿、堆内存不足 |
| 内存持续上涨 | Heap_Used斜率、Threads.Count | 状态泄漏、线程泄漏、连接池未关闭 |
| 指标完全空白 | up{job="flink"}、Reporter 端口 | 配置未生效、网络策略、过滤器写错 |
| 作业重启后指标断档 | Scope 里的<host>、<tm_id> | 容器重建导致序列名变化 |
我在实际使用中最大的体会是:这套监控体系真正的门槛不在配置,而在“知道该看哪个”。配置部分照抄一遍就能跑,但同样是面对backPressuredTimeMsPerSecond这条曲线,有经验的人三分钟就能判断往下游查还是往上排查,没经验的人会盯着它看一天。所以每次排完故障,我都习惯把当时的关键指标和判断依据记下来,攒上十几条之后会发现,绝大多数问题都落在上面那张表的范围里,剩下的才是真正的新问题。