ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flink空闲数据源Watermark卡顿机制与源码解析

Flink空闲数据源Watermark卡顿机制与源码解析

1. 先把问题摆到台面上:空闲数据源是怎么把水位卡死的

做实时窗口的同学,大概率都在半夜被同一个现象叫醒过:作业跑得好好的,TumblingEventTimeWindows的窗口该在整点出结果,结果数据就是不出来,CPU 闲得发慌,反压也没有,日志一片平静,重启一次能好一会儿,然后又卡住。我第一次遇到这个问题时盯着 Web UI 看了二十分钟,最后发现罪魁祸首是上游某个数据源的分区已经安静了一个多小时——WatermarkStrategy生成的 watermark 停在那条安静的分区上,整个算子的水位就再也推不动了。

这篇文章就来把这件事从头拆一遍:WatermarkStrategy里的空闲数据源处理机制(withIdleness)到底做了什么,源码层面它是怎么实现、怎么传播的,参数该按什么逻辑去算,以及在真实的多分区消费场景里怎么配、怎么验证、怎么排障。适合已经写过 Flink 窗口、但对 watermark 传导链路还停留在"调个 API 就行"阶段的同学,也适合正在排查"窗口不触发"这类问题的值班同学。

先把结论抛出来给你垫底:空闲数据源不是 bug,是语义。事件时间语义下,算子不知道"没数据"和"数据还没来"的区别,所以它必须等。withIdleness做的事情,本质上是给数据源加了一个"我确定我这段时间不会再有数据了,你们别等我"的超时声明,让这个声明以WatermarkStatus的形式沿数据流向下游广播,最终在多输入合流的地方被"忽略"。理解了这句话,后面的源码和参数就都是填空题了。

1.1 一个分区安静了,整条链路的窗口都跟着停

想象一个典型的场景:上游是一个多分区的消息队列,业务按用户 ID 哈希写进去,下游 Flink 用KafkaSource消费,事件时间 + 5 分钟滚动窗口。白天流量均匀,每个分区都在滴答滴答进数据,水位一路稳定推进,窗口按时输出。到了夜里,某个分区对应的一批用户不活跃了,那个分区彻底没数据,另外几个分区还在正常写。

这时候会发生什么?KafkaSource在 Source 端会为每个分区(split)维护独立的WatermarkGenerator,每个分区的生成器各自根据自己收到的事件时间推进自己的 watermark。合流的时候,算子拿到的是所有分区中的最小值(因为要保证没有更早的数据还可能出现)。那个安静的分区的 watermark 就停在它最后一条数据的时间戳上,永不前进,于是整体的最小水位也不前进。

这里有个非常容易误判的地方:很多人以为是"窗口不触发",其实更准确的说法是"水位不推进 → 窗口的触发条件watermark >= window.maxTimestamp永远不满足"。两者的区别在于,如果是水位推进了但结果不对,那是算子逻辑问题;如果是水位压根没动,那问题一定在源头或者合流环节。分清这一点,排查方向立刻就收窄了一半。

另外,这个问题有个恶性的特征:它不会报错,不会反压,不会影响吞吐,只影响延迟。监控上你能看到的唯一异常是端到端的输出延迟曲线突然拉长,或者下游某个算子的currentInputWatermark指标变成一条水平线。这就是为什么这类问题经常被拖很久才被发现。

1.2 三种"没数据"要分清楚:空、闲、慢

在动手改配置之前,我强烈建议先把以下三种情况区分开,因为它们的处理方式完全不同,混在一起调就是瞎调。

第一种是"真的空了":这个分区对应的数据源在可预见的将来都不会再产生数据。比如按设备 ID 分区,某批设备已经全部下线;或者按小时切分的数据源,凌晨那个小时本来就没业务。这种是withIdleness设计时最主要的场景,让空闲分区主动"让路"。

第二种是"暂时闲了":比如某批用户晚上不活跃,早上又会回来。这种也是withIdleness的适用场景,但要特别注意恢复时的水位回拉——分区从 idle 变回 active 后,它需要先发出一个新的 watermark,整体最小水位才能继续推进,这中间会有一个短暂停顿,如果超时时间设得太激进,会导致下游频繁出现"顿一下"的现象。

第三种是"数据慢了":分区并不是没数据,只是数据的事件时间落后很多(比如补历史数据、上游时钟漂移、某个数据源批量重放)。这种情况withIdleness完全无效,因为onEvent一直在被调用,生成器认为这个源很活跃,只是它自己算出来的 watermark 很低。这种要靠乱序容忍度(forBoundedOutOfOrderness)、水位对齐(watermark alignment)或者业务侧的时钟校正来解决。

我之前踩过一个坑:有个任务用withIdleness调了半天没效果,最后发现那个"安静"的分区其实每 30 秒会来一条心跳式的脏数据,onEvent一直在触发,idle 判定永远进不去。参数设成 60 秒当然没用,因为判定逻辑看的是"有没有记录进来",不是"事件时间有没有推进"。这个细节在 1.2 后面还会展开。

1.3 为什么不能靠"发个 Long.MAX_VALUE 的 watermark"糊弄过去

社区里有个流传挺广的土办法:写个自定义WatermarkGenerator,检测到一段时间没数据就emitWatermark(new Watermark(Long.MAX_VALUE))。这招在单源、无合流、下游没有 join 的场景下确实"看起来能跑",但它是个定时炸弹。

原因很直接:Long.MAX_VALUE意味着"我认为所有时间戳小于无穷大的数据都不会再来了",一旦这个水位传到下游窗口并触发,窗口就被关闭、状态被清理。如果那个以为"空了"的分区五分钟后忽然来了一条正常数据,它会被判定为迟到数据直接丢弃(除非你配了allowedLateness和侧输出)。更严重的是interval join、cogroup这类需要保留两侧状态的算子,一个Long.MAX_VALUE会让它把整侧状态清空,代价是灾难性的。

而withIdleness用的是另一套机制:它发出去的不是一个假的、巨大的水位,而是一个状态标记(WatermarkStatus.IDLE)。下游收到这个标记后,做的是"暂时把这个通道从最小值的计算里剔除",水位本身保持不变。等它重新活跃,再从剔除状态里恢复,水位继续按正常逻辑推进。这是一个"临时让路"而不是"宣告结束"的语义,这两者的差别就是安全与危险的分界线。

理解了这一点,你就明白为什么withIdleness的源码实现要那么绕——它需要在"生成水位"和"标记状态"两条路径上分别做包装,还要保证状态标记和真实水位在同一个周期性回调里被正确发出。接下来的两章就来拆这个实现。

2. WatermarkStrategy 的接口结构:空闲处理被放在了哪一层

要看懂withIdleness,得先搞清楚WatermarkStrategy这个接口到底由几部分拼起来。很多人以为它就是个"生成水位的策略",实际上它是个复合工厂,负责生产两个东西:时间戳分配器(TimestampAssigner)和水位生成器(WatermarkGenerator)。空闲处理并不在这两者里,它是通过装饰器的方式包在生成器外面的第三个东西。

2.1 三件套:TimestampAssigner、WatermarkGenerator、Idleness 装饰

WatermarkStrategy<T>接口继承了两个 Supplier:TimestampAssignerSupplier<T>和WatermarkGeneratorSupplier<T>,分别对应createTimestampAssigner(Context)和createWatermarkGenerator(Context)两个方法。其中createWatermarkGenerator在 Source 端被调用,为每个数据源(准确说是每个 split / 每个并行子任务的每个输入)创建一个生成器实例。

关键在于:这个接口上挂了一堆default方法,比如withTimestampAssigner、withIdleness、withWatermarkAlignment。这些方法的实现方式都是返回一个"包装了当前 strategy 的新 strategy"。这种写法在 Java 里叫装饰器模式,好处是不侵入原有实现,坏处是一旦包装顺序错了或者包装了多次,行为会变得不好推断。

withIdleness的具体实现非常短:

default WatermarkStrategy<T> withIdleness(Duration idleTimeout) { return new WatermarkStrategyWithIdleness<>(this, idleTimeout); }

WatermarkStrategyWithIdleness只覆写了一个方法:

@Override public WatermarkGenerator<T> createWatermarkGenerator(WatermarkGeneratorSupplier.Context context) { return new IdlenessAwareWatermarkGenerator<>( idlenessTimeout, baseStrategy.createWatermarkGenerator(context)); }

两段代码加起来不到二十行,但已经把整个机制的核心交代清楚了:真正的空闲判定不在WatermarkStrategy上,而是在它产出的IdlenessAwareWatermarkGenerator上。策略对象本身只是个工厂,生命周期的实际管理者是生成器。

这个设计选择是有道理的。WatermarkStrategy是配置期的对象,序列化后分发到各个 Task;而生成器是运行期的对象,每个数据源实例一份,可以有自己的状态(最近一次收数据的时间、当前是否 idle)。把状态放在生成器里,天然就实现了"每个分区独立判定",不需要额外的并发控制。

2.2 withIdleness 的装饰器实现与调用顺序

因为withIdleness是装饰器,所以它和链式调用里的其它方法是有顺序关系的,虽然大多数情况下顺序不影响结果,但有个别组合会出问题,值得说清楚。

先说结论:withIdleness包住的是"调用它那一刻的 base strategy 所生成的生成器"。所以:

// 写法 A WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5)) .withTimestampAssigner((e, ts) -> e.getEventTime()) .withIdleness(Duration.ofMinutes(1)); // 写法 B WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5)) .withIdleness(Duration.ofMinutes(1)) .withTimestampAssigner((e, ts) -> e.getEventTime());

这两种写法生成的生成器都是IdlenessAwareWatermarkGenerator包着BoundedOutOfOrdernessWatermarks,因为withTimestampAssigner只是把时间戳分配器替换掉,不改变生成器的包装结构。所以这两种写法的运行时行为是一致的。

但如果你调用了两次withIdleness,结果就是两层包装,外层先判定,内层再判定,判定阈值以最外层为准。这不报错,但会让源码调试时看到嵌套的生成器对象,第一次见容易懵。我在 review 别人代码时见过withIdleness出现在三个不同的工具方法里被重复调用的案例,虽然不影响正确性,但排查问题时确实增加了心智负担。

还有一个真正需要注意的顺序问题:如果你自己实现了WatermarkStrategy接口(不是用内置工厂方法),并且在自己的createWatermarkGenerator里返回了一个"自己管理周期性发射"的生成器,那withIdleness包装之后,你的onPeriodicEmit只在非 idle 时才会被调用。也就是说,如果你在onPeriodicEmit里还干了别的事情(比如写指标、打日志、清理状态),这些逻辑在 idle 期间会被跳过。这个副作用官方文档里没写,但源码逻辑就是这样,值得记一笔。

2.3 idlenessTimeout 到底该设多少:三个约束条件

参数取值是这类机制里最容易拍脑袋的地方,我给一个可以照着套的判断框架:idlenessTimeout要同时满足三个约束。

约束一:大于正常分区的最长静默间隔。你得知道业务上"正常"的分区最长可能多久不来一条数据。比如促销期间每个分区每秒几百条,凌晨可能几分钟一条,那 5 分钟这个值就能覆盖绝大多数正常波动。这个数据不要靠猜,直接从现有任务的输入侧埋点统计,或者拉一段历史数据算每个分区相邻两条记录的最大间隔。

约束二:小于你能接受的最大延迟增量。假设窗口长度是 5 分钟,你希望最坏情况下窗口延后不超过 2 分钟出结果,那idlenessTimeout就得控制在 2 分钟以内。因为从"最后一次数据到达"到"标记为 idle",需要经过idlenessTimeout这段时间,再加上水位传播和窗口触发的时间。粗略的估算公式是:

最坏输出延迟 ≈ idlenessTimeout + autoWatermarkInterval + 一次网络传播耗时

按默认pipeline.auto-watermark-interval = 200ms算,传播耗时通常在毫秒级,所以主要成本就是idlenessTimeout本身。

约束三:和窗口长度、乱序容忍度形成合理比例。一个经验法则是idlenessTimeout <= 窗口长度 / 3,同时大于maxOutOfOrderness * 2。为什么要大于乱序容忍度的两倍?因为乱序容忍度本身已经预留了一段时间给迟到数据,如果 idle 判定比这个还激进,等于把"数据迟到"和"数据不来"混为一谈,会让本该等待的场景提前放行,增加数据丢失风险(虽然 idle 不会丢数据,但会导致水位推进得比预期快,迟到数据的窗口可能已经被触发了)。

举个具体的例子:5 分钟滚动窗口、maxOutOfOrderness = 10 秒、业务最低流量下每个分区至少 30 秒一条记录。那么合理区间是(20 秒, 100 秒),取 60 秒比较稳。反过来,如果是 1 小时的大窗口、乱序容忍 1 分钟,那idlenessTimeout可以放到 10 分钟甚至更久,反正窗口本身就要等一小时。

3. 源码拆解:一个 idle 标记从产生到生效的完整旅程

前面说了withIdleness生成的是一个包装器,现在把镜头推进到运行期,看这个包装器里的判定逻辑,以及标记发出后经过了哪些环节。这里我以 Flink 1.14 到 1.17 这一段的代码结构为主线,不同小版本在类名、时间基准的取法上有过调整,我会在关键处标注,你对着自己手上的版本看就行。

3.1 IdlenessAwareWatermarkGenerator:判定逻辑与两个状态位

IdlenessAwareWatermarkGenerator的骨架大致是这样(去掉序列化 ID 和空值检查的精简版):

public class IdlenessAwareWatermarkGenerator<T> implements WatermarkGenerator<T> { private final Duration idleTimeout; private final WatermarkGenerator<T> wrappedWatermarkGenerator; private long lastRecordTimeStamp = Long.MIN_VALUE; private boolean isIdle = false; @Override public void onEvent(T event, long eventTimestamp, WatermarkOutput output) { lastRecordTimeStamp = /* 记录最近一次数据到达的时间 */; isIdle = false; wrappedWatermarkGenerator.onEvent(event, eventTimestamp, output); } @Override public void onPeriodicEmit(WatermarkOutput output) { if (isIdleNow()) { isIdle = true; output.markIdle(); } else { isIdle = false; wrappedWatermarkGenerator.onPeriodicEmit(output); } } }

这段代码有三处值得展开。

第一处,判定发生在onPeriodicEmit里,不是onEvent里。这意味着空闲检测的精度受周期性回调的频率限制。onPeriodicEmit的调度周期就是pipeline.auto-watermark-interval(默认 200ms),由 Source 端的ProcessingTimeService注册定时器来驱动。所以判定精度大约是idleTimeout向上取整到 200ms 的倍数,这个误差在分钟级别的超时面前可以忽略,但如果你把idleTimeout设成 500ms 这种量级,那就得把auto-watermark-interval一起调小,否则实际行为和你预期的差很远。

第二处,判定用的是处理时间维度。这是我见过最多人误解的地方。onEvent收到的eventTimestamp是事件时间,而超时判定比较的是"距离最近一次调用的处理时间"。这两个时间轴在网络抖动、上游重放、时钟异常的场景下可能差得非常远。所以会出现在eventTimestamp严重滞后的情况下(比如重放三个月前的数据),源明明一直在进数据,但 idle 判定却可能因为某些时间基准的错位而出现奇怪行为。

提示:这里的时间基准在 Flink 1.16 之后的实现里做过调整,部分版本改为由 Source 侧的ProcessingTimeService提供时间,语义没变(判"处理时间上的静默"),但如果你在调试时发现自己算出来的超时和实际不符,先去IdlenessAwareWatermarkGenerator里确认当前版本的lastRecordTimeStamp是在哪里赋值的,两分钟的事,别靠猜。

第三处,isIdle这个布尔位的作用是避免重复标记。markIdle()被重复调用本身是幂等的,但中间的WatermarkOutput在有多个下游时会走WatermarkOutputMultiplexer,重复标记会触发重复的状态广播,虽然功能上没问题,但会让日志和指标变得嘈杂。代码里维护isIdle状态,就是为了只在状态翻转的那一刻发一次。

还有一个容易被忽略的细节:onEvent里会把isIdle置回false,但不会立刻调用markActive()。真正的markActive()是在下一次onPeriodicEmit走到 else 分支时,由wrappedWatermarkGenerator.onPeriodicEmit(output)之后的流程触发的。所以从"数据回来"到"下游认为这个通道恢复活跃",中间至少隔一个auto-watermark-interval。这就是 1.2 里说的"恢复时有短暂停顿"的来源。

3.2 WatermarkOutput 和 markIdle:状态是怎么被发出去的

WatermarkOutput是生成器唯一的出口,它有两个方法:emitWatermark(Watermark)和markIdle()(早期版本还有markActive(),后来改为内部自动处理)。这个接口的实现有好几层,从内到外依次是:

层级实现类职责
最内层SourceOutputWithWatermarksSource 端每个 split 一个,持有生成器,接收事件
中间层SourceOperator的内部 Output汇总一个 Source 子任务下所有 split 的水位与状态
算子层WatermarkOutputMultiplexer把水位/状态分发给多个下游输出(主输出 + 侧输出)
传输层RecordWriterOutput/CopyingChainingOutput序列化或直接传递Watermark与WatermarkStatus

markIdle()最终会转成WatermarkStatus.IDLE这个对象,沿Output#emitWatermarkStatus(WatermarkStatus)向下游发送。注意这是个独立于 Watermark 的数据类型,在 1.14 之前它叫StreamStatus,值是IDLE/ACTIVE两个枚举。改名之后概念更清晰了,但如果你在翻旧版本的资料,看到StreamStatus别懵,就是同一个东西。

WatermarkOutputMultiplexer在这一层做的事情值得单独说:当一个算子有多个下游输出(比如主输出加上几个侧输出,或者split/side output组合),每个下游需要独立知道上游的 idle 状态。WatermarkOutputMultiplexer内部维护每个下游的WatermarkStatus集合,只有当所有下游都收到过 idle 标记时,它才认为当前通道真的 idle 并向更下游传播。这个设计避免了"一个侧输出分支把主输出分支饿死"的问题。

3.3 StatusWatermarkValve:多通道合流时的最小水位裁决

如果说IdlenessAwareWatermarkGenerator是"生产端",那StatusWatermarkValve就是"消费端",真正体现"忽略空闲通道"这个语义的地方在这里。

这个类在org.apache.flink.streaming.runtime.io包下,服务于所有多输入的算子(ConnectedStreams、union、coGroup、join等)。它内部维护一个inputChannelStatus数组,每个元素记录该通道的当前水位和状态。核心方法有三个:

// 收到某个通道的水位 public void inputWatermark(Watermark watermark, int channelIndex) // 收到某个通道的状态变更(IDLE / ACTIVE) public void inputStreamStatus(WatermarkStatus watermarkStatus, int channelIndex) // 内部:在所有活跃通道中找最小值并向下游发射 private void findAndOutputNewMinWatermarkAcrossAlignedChannels()

findAndOutputNewMinWatermarkAcrossAlignedChannels的逻辑很朴素:遍历所有通道,跳过处于 idle 状态的,对剩余的取最小值,如果这个最小值大于上次发射的值,就向下游发射。这就是"空闲通道被忽略"的确切实现——不是把它的水位当成无穷大,而是直接不参与最小值计算。

而inputStreamStatus收到IDLE时做的事情更微妙。它不只是打个标记,还会把该通道的水位重置为一个特殊值(表示"暂时无有效水位"),等到ACTIVE恢复时,通道需要重新发出第一个水位,才能重新参与最小值计算。这个设计保证了恢复过程是安全的:不会因为残留的旧水位把整体水位拉低,也不会因为缺少水位而卡住。代价就是恢复时那一下停顿。

注意:StatusWatermarkValve的判定是按输入通道的,不是按并行子任务。所以在一个keyBy之后的connect场景里,左右两侧各自可能有多个上游子任务,通道数量是上游子任务数之和。排查时如果发现某个算子有 8 个输入通道但只有 2 个在出数据,那最小水位就卡在那 2 个上,剩下的 6 个如果没被 idle 标记,会一直拖着——这就是为什么withIdleness必须在源头配置,而不是在下游补。

调试这个类有个实用技巧:把org.apache.flink.streaming.runtime.io.StatusWatermarkValve的日志级别调到 DEBUG,你能看到"channel X 被标记为 idle""最小水位推进到 Y"这类日志,配合currentInputWatermark指标看,非常直观。生产环境不建议长期开着,但排查期临时调半天完全值得。

3.4 WatermarkOutputMultiplexer:一条 watermark 分发给多个下游输出

这个类在 3.2 里提了一嘴,这里展开一点,因为它是"状态传播不丢失"的关键。

考虑一个算子既有主输出又有侧输出,那么上游发来的一个IDLE标记,需要同时发给主输出和所有侧输出的下游。但如果某个下游还没来得及读取,WatermarkOutputMultiplexer就要缓存住这个状态,等那个下游追上进度再发。它内部用了一个Map<Output, WatermarkStatus>的结构来跟踪"每个下游当前看到的最后一个状态"。

这个机制带来的直接后果是:当所有下游都收到 idle 标记后,算子才会向更下游传播 idle。这意味着在多级链路里,idle 的传播是有延迟的,链路越长,传播越慢。所以如果你的作业有很长的算子链(比如 10 级),不要指望idlenessTimeout设成 1 分钟就能在 1 分钟整的时候看到效果,实际可能是 1 分零几秒。这个误差在分钟级超时下无所谓,但在秒级超时下会让你的调参结论不可靠。

另外补充一个实践要点:如果算子开了side output但没有消费方(比如侧输出数据被下游的sink丢弃了),WatermarkOutputMultiplexer依然会等这个分支的状态,不会有任何优化。我见过一个任务因为侧输出分支的下游算子并行度设得很小导致反压,最终表现为"水位偶发卡顿",查了半天才发现是侧输出那边的问题。这种耦合关系,配withIdleness的时候也得心里有数。

3.5 完整链路串讲

把上面几块串起来,一条 idle 标记从产生到生效的旅程是这样的:

  1. Source 端每个 split 独立持有一个IdlenessAwareWatermarkGenerator,每来一条记录更新lastRecordTimeStamp。
  2. ProcessingTimeService按auto-watermark-interval周期调用onPeriodicEmit,若超时则调output.markIdle()。
  3. SourceOutputWithWatermarks把markIdle()转成WatermarkStatus.IDLE,交给SourceOperator。
  4. SourceOperator汇总本子任务下所有 split 的状态后,通过WatermarkOutputMultiplexer分发到各个下游输出。
  5. RecordWriterOutput把WatermarkStatus序列化后通过网络发往下游子任务。
  6. 下游的StreamTaskNetworkInput收到后,转发给StatusWatermarkValve.inputStreamStatus。
  7. Valve 标记该通道为 idle,并在下一次inputWatermark到来时,用findAndOutputNewMinWatermarkAcrossAlignedChannels在排除该通道的前提下重新计算最小水位。
  8. 新水位继续向下游传播,直到某个窗口算子发现watermark >= window.maxTimestamp,触发计算。

这条链路上任何一环出问题,表现都是"水位不动"。反过来说,只要你能把这条链路在脑子里画出来,排查时就只需要一段一段地缩小范围:源头有没有发?传输有没有丢?合流有没有算对?

4. 动手实现:多分区消费场景下的完整配置与验证

理论说完了,来点能直接抄的。下面给一个多分区消息队列消费 + 事件时间窗口 + 空闲处理的完整写法,然后把 SQL 的等价配置、验证手段、参数选择都过一遍。

4.1 一份可复制的 DataStream 写法

假设数据结构是一个简单的订单事件,有orderId、amount和eventTime三个字段,从上游多分区数据源消费,按orderId聚合 5 分钟窗口:

public class IdleSourceDemo { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); // 关键配置一:周期性水位发射间隔,默认 200ms // 调小可以让 idle 判定更及时,但会增加心跳包数量 env.getConfig().setAutoWatermarkInterval(100L); // 关键配置二:checkpoint,保证水位状态可恢复 env.enableCheckpointing(60_000L); DataStream<OrderEvent> source = env .fromSource(buildSource(), WatermarkStrategy.noWatermarks(), "order-source") .uid("order-source"); // 关键配置三:事件时间 + 乱序容忍 + 空闲处理 DataStream<OrderEvent> withWatermark = source.assignTimestampsAndWatermarks( WatermarkStrategy .<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(10)) .withTimestampAssigner((event, ts) -> event.getEventTime()) .withIdleness(Duration.ofMinutes(1))); withWatermark .keyBy(OrderEvent::getOrderId) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .allowedLateness(Time.seconds(30)) .sideOutputLateData(lateTag) .aggregate(new AmountAggregate()) .print(); env.execute("idle-source-demo"); } }

这份代码里有三个地方是刻意安排的,值得说明。

setAutoWatermarkInterval(100L)调小到 100ms,是因为我把idlenessTimeout想控制在分钟级,100ms 的判定粒度完全够用,同时心跳频率翻倍带来的网络开销在中小规模作业里可以忽略。但如果你有几百个并行度、几千个 split,心跳本身也是成本,这时候 200ms 默认值更合适,别盲目调小。

.allowedLateness(Time.seconds(30))和.sideOutputLateData()是配套的保险。虽然withIdleness是安全机制,但现实里"标记 idle 之后数据又回来了"是常态,窗口关闭后到达的数据默认会被丢弃,加上允许延迟和侧输出,至少能把迟到数据捞出来做补偿。这套组合拳是我在线上环境的标配,缺一个心里都不踏实。

.uid("order-source")这个细节容易被忽略。Source 端的WatermarkStatus是不进 checkpoint 的(因为它是个瞬时状态),恢复之后需要重新判定。但如果没设 uid,任务重启时算子状态可能对不上,恢复后水位从零开始推进,加上 idle 判定,表现可能是"重启后一段时间窗口全乱"。所以凡是涉及状态和水位的算子,uid 一定要写。

4.2 用 Table API / SQL 时的等价配置

如果你用的是 SQL,withIdleness在 DataStream API 里是链式调用,在 SQL 里就变成了一个配置项:

-- 会话级别开启,单位毫秒,-1 表示关闭(默认) SET 'table.exec.source.idle-timeout' = '60000'; -- 也可以同步调整自动水位间隔 SET 'pipeline.auto-watermark-interval' = '100';

这个配置项的语义和withIdleness完全一致,作用于当前作业的所有source。这里有个需要权衡的点:DataStream API 可以对不同 source 配置不同的超时,而 SQL 是全局的。如果作业里有数据密度差异很大的多个 source(比如一个高频订单流、一个低频配置流),全局配 60 秒对高频流其实无所谓(它根本不会 idle),但对低频流可能刚好卡在边界上。这种情况我一般会把全局值往大了配,然后对确实需要及时性的那个流单独用 DataStream API 处理,混着用完全合法。

还有一点:SQL 场景下常常搭配TUMBLE窗口和GROUP BY使用,如果发现窗口结果延迟,除了idle-timeout,还要检查sql-client或作业配置里的table.exec.emit.early-fire.enabled这类提前触发选项是不是开着,别把两件事混在一起排查。

4.3 怎么确认水位真的动了:指标、日志与本地复现

配完了不代表生效了。我一般用三层验证,从粗到细。

第一层看指标。在 Web UI 的算子详情页里,找到currentInputWatermark和currentOutputWatermark这两个指标(前者是算子当前输入的最小水位,后者是它向下游发射的水位)。正常情况下水位应该是阶梯式上升的曲线;如果是一条水平线,说明卡住了;如果是锯齿状忽高忽低,那可能是 idle 恢复导致的回拉,属于正常现象。把这两个指标和numRecordsIn放在一起看,能快速判断是"没数据"还是"有数据但水位不推"。

第二层看日志。把org.apache.flink.streaming.runtime.io.StatusWatermarkValve的日志级别调到 DEBUG,正常的输出大概是这样:

Marking channel 3 as idle Recompute min watermark across aligned channels, new min: 1700000120000 Marking channel 3 as active

看到Marking channel X as idle说明生成端的标记已经走过来了;如果长时间没有这条日志,说明问题在更上游(生成器根本没触发markIdle),这时就该去看IdlenessAwareWatermarkGenerator的判定逻辑,确认是不是有"假数据"在持续触发onEvent。

第三层本地复现。这个最有用。用一个SourceFunction或者自定义的Source,模拟两个 split:一个每 500ms 发一条,另一个发 10 条后彻底停发,然后观察下游窗口的输出。本地环境跑一遍,比你在线上一层层加日志快得多。我通常会把这个复现用的最小工程放在一个专门的目录里,改完参数直接main跑,几十秒就能看到结果,比在测试集群上验证效率高一个数量级。

4.4 参数选择与压测验证

参数选择在 2.3 已经给了框架,这里补充一个验证方法,避免你只靠理论计算。

我的做法是:先统计生产环境上游各分区最近 7 天的记录间隔分布,算出 P99 和最大值。然后把idlenessTimeout设在 P99 的两倍左右,并且不超过窗口长度的三分之一。接着做一次压测,人为让一部分分区停止写入,观察窗口输出延迟的变化曲线。

压测时关注的指标是"从数据产生到窗口输出的端到端延迟",而不是吞吐。因为withIdleness影响的就是延迟,吞吐基本不受影响。如果压测结果显示延迟增量明显大于你算出来的idlenessTimeout,那多半是链路传播或者下游反压导致的,回到 3.5 的链路图逐段查。

还有一个小技巧:可以在本地用一个小脚本持续往某个分区写数据、另一个分区只写一次,然后每隔一段时间改一下idlenessTimeout的值,观察窗口输出的时间点变化。多跑几组数据,你对这个参数的"手感"会比看十篇文档都强。我自己就是这么把"60 秒对 5 分钟窗口意味着什么"这件事变成直觉的。

5. 踩坑实录与排查速查表

前面讲了原理和实现,这一章全是踩坑经验。有些坑我在不同项目里反复见过,甚至同一个坑被不同的人踩过三次,值得单独列出来。

5.1 五个高频误用

误用一:以为配了withIdleness就能解决所有水位不推进的问题。这是最常见的误解。withIdleness只处理"某个源长时间没有记录进来"的场景。如果你的问题是"某个源一直在进数据但它的水位很低"(比如重放历史数据、上游事件时间分布不均),withIdleness完全无感。判断方法很简单:去看那个"安静"的源,numRecordsIn到底是 0 还是非 0。0 就是 idle 场景,非 0 就得换思路。

误用二:把idlenessTimeout设得比auto-watermark-interval还小。比如超时设 50ms、发射间隔是 200ms,那判定精度就是 200ms,超时值形同虚设,实际行为是"每 200ms 检查一次,只要这 200ms 没数据就标记 idle"。这种配置在正常流量下会导致频繁的 idle 切换,水位忽高忽低,下游窗口反复触发又回拉,性能和正确性双输。

误用三:在 sink 端或者下游算子"补"withIdleness。withIdleness只对assignTimestampsAndWatermarks生成的生成器有效,你在下游再调一次assignTimestampsAndWatermarks,等于给已经打好时间戳的数据重新分配时间戳和生成水位,语义完全变了,等于人为制造了一个假的 source。这个错误我在 code review 里拦过两次,改法是把配置挪回真正的 source 端。

误用四:忽略"假数据"对 idle 判定的破坏。上游如果有心跳记录、空消息、状态同步包混在业务数据里,onEvent会一直被调用,idle 判定永远进不去。这类数据往往时间戳是当前时间或者 0,处理起来更恶心。解决办法是在 source 之后立刻过滤,或者在反序列化阶段就区分开,别让它们跑到水位生成器那里去。我在一个项目里花了半天才发现,那个"安静"的分区每 30 秒来一条null的监控消息。

误用五:多个 source 用同一个WatermarkStrategy实例。WatermarkStrategy是个可序列化的配置对象,本身是线程安全的,多个 source 共用一个实例没问题。但如果你在自定义 strategy 里存了可变状态(比如一个AtomicLong或者一个本地缓存),那就会出现多个并行子任务共享状态的问题——准确说是序列化后被复制成了多份,各自独立,你的"全局统计"根本不全局。这个坑不出错则已,一出就非常难查,建议自定义 strategy 一律保持无状态。

5.2 排查速查表

下面这张表是我这几年积累的"水位不推进"排查清单,按"从现象到原因"的顺序排列,值班时照着走一遍基本能定位。

现象最可能的原因快速验证方法
某算子currentInputWatermark长期不动,numRecordsIn也接近 0上游某个 split 空闲,且未配置 idle 处理检查 source 端是否调用了withIdleness
numRecordsIn正常但水位不动事件时间远落后于处理时间,或者时间戳字段解析错误抽样打印eventTimestamp,和真实时间对比
配了withIdleness但仍不生效判定基准是处理时间,源有假数据触发onEvent在该 split 上加计数器,看是否有心跳类数据
水位推进了但窗口不输出窗口触发条件没满足,或者窗口后的算子反压检查窗口算子的numRecordsOut和下游反压指标
水位忽高忽低、窗口频繁重算idlenessTimeout过小导致频繁 idle/active 切换把超时调大 3 倍再观察一轮
重启后一段时间水位异常算子 uid 缺失导致状态恢复错位检查所有有状态算子的 uid
单个并行子任务水位落后数据倾斜,某个 subtask 的 split 分配不均看各 subtask 的numRecordsIn分布

这张表用的时候要注意:先看数据,再看水位。很多人一上来就盯水位曲线,其实numRecordsIn的信息量更大——它能立刻告诉你是"没数据"还是"有数据但处理有问题",这两条岔路一走错,后面全是白费功夫。

5.3 与其它机制的相互影响

最后说几个withIdleness和别的机制"打架"或者"配合"的场景,这些内容散落在不同的文档里,平时很难串起来。

与水位对齐(watermark alignment)的关系。水位对齐解决的是"某个分区跑得太快,把整体水位拉得太超前,导致状态膨胀"的问题,机制是主动限制快分区的水位推进。而 idle 处理解决的是"某个分区太慢或者不动"的问题。两者在 source 端同时生效,交互起来的效果是:快分区被限速,慢分区超时后被标记 idle,整体水位由剩下的正常分区决定。这个组合在分区间数据速率差异很大的场景下特别有用,我在一个数据倾斜严重(快慢分区速率差 100 倍)的任务里同时开了这两个,效果比单独用任何一个都好。

与allowedLateness的关系。这两者经常被混为一谈,但作用完全相反。allowedLateness是"窗口触发后,我再等一会儿,把迟到数据也算进去",它让窗口的状态多保留一段时间。withIdleness是"这个源暂时不参与水位计算",它让水位能继续推进。一个放宽时间,一个放宽来源,互补而非替代。如果只配allowedLateness不配 idle,会出现的现象是:窗口一直不触发,allowedLateness根本没机会生效,因为触发条件就没满足过。

与并行度调整的关系。调整并行度会导致 split 重新分配,idle 状态需要重新计算。如果任务刚扩缩容完就观察到水位异常,先等一个idlenessTimeout周期再看,别急着改配置。另外,Source 端的并行度如果小于分区数,一个子任务会承担多个分区,这些分区的 idle 判定是各自独立的(每个 split 一个生成器),所以不会出现"一个分区空闲导致整个子任务被标记 idle"的问题,这点可以放心。

与 checkpoint 的关系。WatermarkStatus是瞬时状态,不进 checkpoint。所以任务从 checkpoint 恢复后,所有通道都从 active 状态开始,需要重新等待idlenessTimeout才能再次标记 idle。这意味着恢复后的第一段时间里,水位可能因为某个空闲分区而卡住。如果你的作业经常重启,而重启后窗口迟迟不出结果,这大概率就是原因。缓解办法是在恢复后的一段时间内结合业务侧的补偿逻辑,或者缩短 checkpoint 间隔、减少重启频率。


这个机制我从"照着文档调 API"到"能画出完整链路图",中间走了不少弯路,最深刻

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进