Flink 1.13 一出来,很多人在 Web UI 里发现背压页面跟以前长得不一样了:原先那种“背压时间比例”的数字不见了,变成了一组看起来很陌生的指标,比如outPoolUsage、inPoolUsage,还有个布尔状态的isBackPressured。我当时第一反应也是懵,后来专门把这套新指标的源码翻了一遍,越看越觉得这次重构算是把背压监控从“猜”变成了“看”。这篇文章就顺着源码,把 1.13 新背压监控指标到底是怎么算的、由哪些类支撑、适合怎么用,一次讲透。
先说一个反直觉的结论:新版背压监控不是为了让你看到“有没有背压”,而是为了让你看到“背压到底卡在哪一侧”。outPoolUsage高,说明瓶颈很可能在下游消费;inPoolUsage高,说明任务输入端堆积。这两个指标配合isBackPressured,基本能把一个反直觉的“全链路反压”问题拆成可定位的上游问题、自身问题或下游问题。适合正在做 Flink 性能调优、任务稳定性治理,或者准备把旧版监控指标迁移到新版的同学参考。
1. 从“任务可用性”到“池占用率”:1.13 为什么要重构背压监控
老版本(1.12 及以前)里,我们监控背压几乎必看backPressureTimePerSecond。这个指标的含义很直观:在一秒内,任务因为输出缓冲池不可用而等待的时间占比。RecordWriter每次写出记录后,会去判断缓冲池是否还有可用 buffer,如果不可用,就把这段阻塞时间累计起来,最终算出一个比例。
问题在于,这个指标在实际生产里非常容易失真。它统计的是“缓冲池不可用的时间比例”,而缓冲池是否可用,受网络内存配置影响极大。你把taskmanager.network.memory配大一点,池基本一直可用,backPressureTimePerSecond常年是 0,但下游 Sink 明明已经卡成狗。反过来,池配小了,任务稍微抖动一下,指标马上冲到 100%,制造大量无效告警。
更麻烦的是,这个指标是时间维度的均值,它回答不了“背压有多严重”这个问题。两个任务同样是 50% 的背压时间,一个可能是每秒钟短暂阻塞 500 毫秒,另一个可能是稳定阻塞再恢复,本质完全不同。而且老指标只盯着输出侧,输入侧的压力完全没有独立指标,排查问题全靠猜。
1.13 的做法是换个衡量维度:不再看“时间比例”,而是直接看“缓冲池内存占用率”。输出缓冲池的已用内存占总内存的比例,就是outPoolUsage;输入缓冲池的对应比例就是inPoolUsage。水位高就是池子快满了,水位低就是池子很空。这比时间比例稳定得多,也直观得多。
同时,背压判定也从“缓冲池当时是否可写”升级成“任务线程是否真正阻塞在了缓冲池相关调用上”。这一步是在TaskExecutor层面用线程采样完成的,对应 1.13 里新增的几个核心类,后面我逐个拆。
老指标在 1.13 里被彻底移除,这点要特别注意。如果你之前的监控大盘还挂着backPressureTimePerSecond,升级以后拿不到数据,告警也会失效。别想着兼容,直接按新指标体系重建。
2. 三个核心指标的准确含义与 Web UI 分级
1.13 的新背压监控指标实际上是一组,不只是单个数字。核心是下面这三个:
| 指标名 | 类型 | 反映什么 | 主要数据来源 |
|---|---|---|---|
task.outPoolUsage | float,范围 0~1 | 输出缓冲池的内存占用率,越高说明下游消费越慢 | 输出ResultPartition对应的LocalBufferPool |
task.inPoolUsage | float,范围 0~1 | 输入缓冲池的内存占用率,越高说明输入端堆积越明显 | 输入 gate 对应的LocalBufferPool |
task.isBackPressured | boolean | 当前任务是否处于反压状态 | HermesBackPressureSampler线程采样结果 |
2.1 输出池指标 outPoolUsage
outPoolUsage是排查背压时最先要看的值。它的载体是任务输出端绑定的网络缓冲池。RecordWriter往下游发送数据时,需要从池里取可用 buffer 组装数据。如果下游算子消费慢、网络通道拥堵,或者接收端不回收 buffer,池里的内存就会被占满,后面的请求只能阻塞等待。
用生活里的话说,outPoolUsage就是你家水箱的水位。水位持续接近满,说明出水口堵了,这时候反压的根因在下游。水位一直在中高位波动,说明下游能力基本匹配但偶尔消化不过来,可能是周期性抖动。
2.2 输入池指标 inPoolUsage
inPoolUsage对应输入端缓冲池。输入数据到达后先进入这个池,再由算子消费。如果池使用率很高,说明数据到的速度大于算子处理速度,上游生产非常猛,或者算子自身处理遇到瓶颈。
这里有个常见的误判:看到inPoolUsage高就直接认定“上游太快”。实际上还有一种可能是算子线程被别的逻辑占住了,比如等待外部系统响应、正在做很重的序列化或状态读写,导致消费停滞,池自然就堆起来了。所以inPoolUsage高,只能说明输入端有压力,要结合isBackPressured和算子线程状态一起看。
2.3 布尔指标 isBackPressured
isBackPressured是 1.13 新引入的判定结果,它不是拿池使用率算出来的,而是通过周期性采样任务线程的真实状态得到的。如果任务线程在采样窗口内频繁出现在WAITING或TIMED_WAITING状态,并且阻塞位置指向网络缓冲池分配相关的方法,例如等待获取可用 buffer,就会判定当前处于反压状态。
这个布尔值的意义在于过滤掉“池水位高但并没有真正阻塞”的假信号。有些任务虽然池使用率很高,但刚好每次都等到了 buffer,并没有阻塞;而另一些任务池使用率只有 0.5,却因为某个关键 buffer 分片申请失败频繁挂起。只看池使用率会漏掉后者,isBackPressured就是对这类情况的有效补充。
2.4 Web UI 的反压级别划分规则
1.13 Web UI 背压页面不再展示复杂的数字,而是把inPoolUsage和outPoolUsage映射成四个档位,分别是OK、Low、High、Severe。分级用的是一个很直接的阈值逻辑:
// 以池使用率 x 为例,Web UI 层的分级标准大致如下 // x <= 0.1 -> OK // 0.1 < x <= 0.5 -> Low // 0.5 < x <= 0.9 -> High // x > 0.9 -> Severe这四个档位对应的业务含义是:
| 池使用率区间 | 级别 | 建议处理思路 |
|---|---|---|
| 0 ~ 0.1 | OK | 池子基本空闲,无需处理 |
| 0.1 ~ 0.5 | Low | 正常波动,可能偶发小抖动,继续观察 |
| 0.5 ~ 0.9 | High | 池持续处于高水位,需要检查上下游能力 |
| 0.9 ~ 1.0 | Severe | 池接近打满,基本可以断定存在反压,需要优先处理 |
这里要特别强调一个细节:UI 的档位和isBackPressured是两套逻辑。UI 分级只看池使用率,isBackPressured走的是线程采样。所以你会看到一种情况:outPoolUsage已经到 0.7(High),但isBackPressured仍然是 false。这不是指标坏了,而是任务线程在采样窗口里恰好没有阻塞。两类指标要综合判断,不要只盯一个。
3. 源码链路:任务初始化、线程采样与反压判定
看完指标定义,接下来看源码。1.13 把背压逻辑从原来分散在 writer、reader 里的实现,抽到了runtime模块的 taskexecutor 和 taskmanager 相关包里,结构清楚了很多。
3.1 Task 初始化时挂接背压追踪器
以Task类为入口。任务启动时,执行环境会构造输入和输出缓冲池,然后把这些池交给背压追踪器。1.13 的源码里,Task在初始化阶段会创建一个BackPressureTracker对象,并把输入池、输出池以及 TaskManager 级别的追踪器都传进去。关键路径大概是这样的:
// 伪代码:Task 中创建背压追踪器的关键路径 inputBufferPool = taskEnvironment.getInputBufferPool(); outputBufferPool = taskEnvironment.getOutputBufferPool(); backPressureTracker = new BackPressureTracker( taskExecutorBackPressureTracker, // TaskManager 级别的全局追踪器 taskMetricGroup, inputBufferPool, outputBufferPool);这里最值得注意的设计是两层结构:每个Task本地有一个BackPressureTracker,负责计算输入/输出池使用率;而 TaskManager 级别另有一个TaskExecutorBackPressureTracker,负责对每个执行实例的isBackPressured状态做全局登记。两个追踪器之间通过回调连接,Task本地追踪器采样到最新状态后,会更新到 TaskManager 级别的追踪器上。
3.2 TaskExecutorBackPressureTracker 的职责
TaskExecutorBackPressureTracker在 1.13 里的定位是“背压状态的登记中心”。它按ExecutionAttemptID维度记录每个任务执行实例的背压状态。任务启动时注册,任务结束时注销,TaskManager 上报心跳或状态给 JobManager 时,会从这里取状态。
这个类本身不负责采样,它只负责结果的下发和缓存。这样设计的价值在于:TaskManager 一个进程里可能同时跑多个任务,如果每个任务各自独立做线程采样再各自上报,既浪费资源,也没法保证一致性。统一到 TaskManager 层面管理,采样调度和结果汇总就都能收敛到一个地方。
3.3 HermesBackPressureSampler 的采样循环
真正干采样活的是HermesBackPressureSampler。它是一个常驻线程,内部用循环采样机制,在指定的持续时间内不断抓取任务线程的状态。
采样流程大致如下:
- 任务启动时,把要监控的执行线程注册给采样器。
- 采样器进入循环,每隔固定间隔获取一次目标线程的
ThreadInfo。 - 对每个采样点做一次分类,判断线程是否处于“可能反压”的状态。
- 采样持续时间结束后,按反压采样点数量占总采样点的比例,得出最终的
isBackPressured结果。
核心循环的伪代码如下:
// HermesBackPressureSampler#run 的核心采样逻辑(伪代码) while (running) { long windowStart = System.currentTimeMillis(); while (System.currentTimeMillis() - windowStart < sampleDurationMillis) { for (Map.Entry<CompletableFuture<Boolean>, Thread> entry : tasks.entrySet()) { Thread taskThread = entry.getValue(); ThreadInfo threadInfo = threadService.getThreadInfo(taskThread); boolean threadBackPressured = isBackPressuredByThreadInfo(threadInfo); totalSampleCount++; if (threadBackPressured) { backPressuredSampleCount++; } } Thread.sleep(sampleIntervalMillis); } // 用反压采样占比判定最终状态 boolean finalBackPressured = backPressuredSampleCount * 1.0 / totalSampleCount > threshold; // 把结果回写 CompletionFuture }这里threadService是底层的线程状态采样服务,它封装了对ThreadMXBean的调用。isBackPressuredByThreadInfo是判定一个采样点是否算反压的关键:线程状态为WAITING或TIMED_WAITING,且当前栈帧涉及到缓冲池或网络通道的关键阻塞方法时,才会计为一次反压采样点。
这个判定方式比老版本高明在哪?老版本只能靠缓冲池不可用时间来猜,新版本是直接看到了线程“挂在哪个方法上”。线程是 Waiting 在LocalBufferPool的缓冲区申请上,还是 Waiting 在外部 RPC 响应上,代码层面一清二楚,分类准确性高很多。
3.4 BackPressureSampleService 的调度边界
采样任务并不是无限制的。BackPressureSampleService负责控制采样并发窗口,避免频繁采样任务把 TaskManager 的 CPU 打满。它的设计是:每个任务在同一时间只允许一个采样请求进行中,后到的请求直接跳过,而不是排队累积。这个取舍很务实,因为背压状态本身是一个缓变信号,晚几秒看到结果影响不大,但采样线程太多会直接影响作业性能。
3.5 从 TaskManager 到 Web UI 的链路
isBackPressured的结果最终要通过 TaskManager 和 JobManager 之间的状态交互传到 Web UI 层。TaskManager 定期上报任务背压状态,JobManager 侧聚合后通过 REST API 输出给前端。前端拿到数据后,再结合inPoolUsage、outPoolUsage做四档分级展示。
这条链路里有一个值得注意的点:isBackPressured是瞬时布尔值,它不计算“反压比例”。所以你在 UI 上看到的背压状态,是最近一个采样窗口的结果,而不是过去一分钟内反压多久。想拿连续监控数据,还得靠 Prometheus 这类外置系统定时抓指标做时序曲线。
4. 缓冲池使用率的计算逻辑与 LocalBufferPool 细节
outPoolUsage和inPoolUsage的底层计算对象是LocalBufferPool。这个名字听起来很底层,但监控指标用的其实是它对外提供的内存统计能力。
4.1 池使用率的直接计算公式
1.13 里,池使用率的计算不再依赖“剩余多少个 buffer”,而是直接按内存字节数来算。BufferPool提供了两个关键方法:
getTotalMemory():返回缓冲池当前可以使用的总内存大小。getAvailableMemory():返回当前可用的内存大小。
计算逻辑简单直接:
// 1.13 BackPressureTracker 中池使用率计算的核心逻辑 public double calculatePoolUsage(BufferPool bufferPool) { long totalMemory = bufferPool.getTotalMemory(); if (totalMemory == 0) { return 0.0; } long usedMemory = totalMemory - bufferPool.getAvailableMemory(); return (double) usedMemory / totalMemory; }用文字描述就是:(总内存 - 可用内存)/ 总内存。结果取值范围在 0 到 1 之间,0 表示池完全空闲,1 表示池已经被占满。
4.2 为什么按内存字节数而不是按 buffer 个数
有人可能会问:以前不就数 buffer 个数吗?为啥新版要按内存字节算?原因是 Flink 支持可变长 buffer,一个MemorySegment可以被拆成多个逻辑块使用,直接数个数会遇到碎片化导致的数量虚高。按内存字节统计更接近真实资源占用,尤其是处理大记录或高并发小记录混合的场景,字节口径明显更准确。
另外,getTotalMemory并不是一个固定的最大值。LocalBufferPool的可用内存会在池初始容量和最大容量之间动态浮动,它会从共享的内存管理器申请额外 buffer。所以看到使用率虚高时,先确认总内存是不是被动态扩大了,避免误判。
4.3 池使用率指标与 isBackPressured 为什么可能不一致
池使用率和反压判定走的是两条不同的数据通道。池使用率是纯内存统计,每毫秒都能读到一个瞬时值;isBackPressured是采样窗口的聚合结果。两者天然存在时间差。
举一个很典型的场景:outPoolUsage冲到 0.95,但采样窗口恰好刚刚结束,isBackPressured还是 false。等下一次采样完,可能池水位已经回落到 0.3,isBackPressured 又是 false。这时候如果你只看布尔指标,就会漏掉一次明显的反压抖动。反过来,池使用率只有 0.2,但任务线程因为等待 buffer 回补而短暂阻塞,采样器反而可能判为 true。
所以我的习惯是:压测时两个指标一起看,isBackPressured用来确认“是否真堵了”,池使用率用来看“堵到什么程度”。单看任何一个都容易得出片面结论。
4.4 池类型差异对指标的影响
inPoolUsage和outPoolUsage各自绑定不同的池。输入池由输入 gate 持有,输出池由ResultPartition持有。在多并行度场景下,每个子任务都有自己独立的池,所以单看一个任务实例的池使用率不够,要看整个算子所有并发的分布。
另外要注意一种特殊情况:使用批量淘汰或持久化存储时,输出并不完全走网络缓冲池,池使用率可能长期偏低,但实际任务因为磁盘 IO 慢而反压。这时候池指标不是失效,而是它本身只覆盖网络层。遇到这种场景,要结合算子内部指标(比如 Sink 的 flush 耗时)一起分析。
5. 上线以后:指标联调、误报判断与参数调整经验
源码拆完,最后聊点实际用的经验。下面这些是我在测试环境压测和线上故障排查中总结的,不一定每条都符合你的场景,但排查思路可以参考。
5.1 第一步:先定方向,看输出还是看输入
遇到背压告警,我一般按下面的顺序定位:
- 先看
outPoolUsage,大于 0.5 就往下游方向查。 - 再看
isBackPressured,如果输出池高但布尔值是 false,不急着动,观察 1 分钟趋势。 - 如果
outPoolUsage长期在 0.8 以上,基本可以断定瓶颈在下游。重点排查下游算子的并发度、Sink 的外部系统延迟、分区倾斜等问题。 - 如果
outPoolUsage不高,但inPoolUsage很高,说明瓶颈在任务自身或输入侧。检查算子的处理逻辑、状态后端的读写耗时,以及上游算子的发送速率。 - 两个池都高且
isBackPressured为 true,说明整个链路都满了,先查最下游的瓶颈点,而不是在下游前面加并行度。
这五步走完,能覆盖 80% 的背压排查场景。
5.2 用 Prometheus 抓指标做趋势分析
Web UI 上的指标是瞬时的,想看趋势必须外接监控。在 1.13 里,inPoolUsage和outPoolUsage会暴露成任务级的 Gauge 指标,指标名类似flink_taskmanager_job_task_inPoolUsage和flink_taskmanager_job_task_outPoolUsage。把它们接入 Prometheus 之后,配合isBackPressured做告警规则,比看 UI 要靠谱得多。
建议至少做两条曲线:一条是池使用率的均值,一条是 p95 值。均值低不代表没有抖动,p95 高才是真正影响 SLA 的信号。告警阈值可以参考 0.5 开始 warn,0.8 以上 page,但具体数值必须结合你自己的网络内存配置来调,不要直接抄默认值。
5.3 调整网络内存参数时,别忽略池使用率联动
很多调优手段都会影响池使用率。比如把taskmanager.memory.network.max调大,池的总内存变大,outPoolUsage的数值自然会下降。但这里有一个陷阱:池变大了,任务能囤积更多未发送数据,背压信号会被“缓冲”掉,isBackPressured更难触发,但下游的真实消费能力并没有变化。延迟反而可能因为缓冲加深而升高。
所以调网络内存时,要把延迟指标一起看。如果池使用率降到 0.3 以下,但端到端延迟涨了,说明缓冲加得太大了,数据在链路里排队的时间变长。1.13 的池使用率指标能帮你肉眼看到这种“缓冲效果”,这是老版本很难做到的。
5.4 小心池使用率恒为 0 的假象
池使用率等于 0 不代表绝对没有反压。存在几种情况会导致池一直很空:
第一种,任务吞吐量极低,比如 source 只来了一条数据,池自然空。第二种,任务消费的数据不需要经过网络缓冲池,直接走内存或磁盘通道。第三种,池使用率计算时总内存为 0,代码直接返回了 0.0。
我在一次排障里碰到过第三种情况。当时任务启动阶段,池还没有初始化完成,指标一直显示 0,导致告警完全没触发。后来增加了一个启动后延迟采集的逻辑,才恢复正常。所以接入监控时,切记要把任务启动初期的数据过滤掉,或者等待 30 秒后再开始记录。
5.5 跨版本对比数值没有意义
最后说一个容易踩的坑:不要拿 1.13 的outPoolUsage和 1.12 的backPressureTimePerSecond做对比。两者的口径、采样方式、计算逻辑完全不同,数值不可比。如果你要升级,建议先把旧监控大盘停掉,重新定基线。很多团队升级后面对一堆陌生的指标数值,会误以为作业变快了或变慢了,其实只是度量方式变了。
我在实际项目里的做法是:升级后先用一个星期收集新指标基线,再根据基线和历史压测经验重新梳理告警阈值。基线阶段不做任何告警动作,等曲线稳定了再开启自动告警。这周“裸奔”看似风险高,实际非常值得,后面省下的误报排障时间远超过这一周的成本。
1.13 这套背压监控体系,设计思路确实比老版本清晰很多。池使用率回答了“堵到什么程度”,线程采样回答了“到底堵没堵”,两者互补,基本能覆盖反压排查的主流场景。虽然isBackPressured这个布尔指标在精细化监控上还不够细腻,采样窗口也会带来一定的延迟,但比起 1.12 之前那个“要么 0 要么 100%”的背压时间指标,已经是质变。新版本上线后,建议大家都去翻一翻TaskExecutorBackPressureTracker和HermesBackPressureSampler这两个类的实现,读完之后对背压的理解会完全不一样。