做流式计算的人,迟早都会被同一个问题卡住:数据是一条一条没完没了流进来的,可你一旦想做求和、计数、去重这类聚合操作,就必须先回答一个问题——统计哪一段数据?这个“哪一段”,就是窗口操作的起点,也是时间维度聚合的核心。
我最早接触流式计算时,最大的思维障碍就是把流当成了“一直在跑的批处理”:反正数据都在那里,我想查哪个范围就查哪个范围。但真实场景根本不是这样。上游数据无穷无尽,存储和计算资源却有限,状态不可能无限膨胀,结果必须按业务节奏持续产出。于是几乎所有流计算引擎,最终都长出了一套窗口机制,用时间把无界的数据流切成一段一段有界的“小批”,再在每一段上完成聚合。
这篇文章我就从窗口的底层逻辑开始,把分类、时间语义、生命周期管理、真实排错和性能调优完整讲一遍。适合正在做实时指标、实时监控、大屏统计这类业务的工程师,也适合刚上手流计算、总搞不清窗口触发时机的人。
1. 数据永远到不齐:流式聚合为什么必须划出窗口
1.1 批处理心智模型在流式数据上的失效
批处理的世界很干净。一张订单表放在那里,数据都齐了,跑一条SELECT SUM(amount) FROM orders WHERE created_at BETWEEN ... GROUP BY ...,结果一次给全。哪怕睡了三个小时再跑,数据还是那些数据,结果不变。
流式处理完全不是这样。订单是一条一条进来的,你这个查询什么时候执行?执行到一半,新的订单又来了,要不要把新订单加进去?如果不加进去,那查询的边界到底在哪里?
所谓无界流,本质上是一个没有终点的数据序列。任何聚合操作都需要一个有限集合作为输入,这是数学上的硬约束。SUM、COUNT、AVG这类算子天然要求“先知道有哪些数据”,但流式数据永远无法告诉你“到这就结束了”。
于是流式计算不得不做一个哲学层面的切割:虽然数据在宏观上没有终点,但我们可以先约定一个时间片段,假设这个片段里的数据会到齐。这个片段,就是窗口。
1.2 无限数据流里的两种破解思路
一种思路是近似聚合。比如超大规模基数统计里常见的 HyperLogLog,不要求精确结果,只维护一个固定大小的估算结构,数据来了就更新一下,永远不用停下来等谁。
另一种思路就是本文的主角:窗口聚合。把无限流按时间切分成有限片段,每个片段内做精确聚合,片段与片段之间互不干扰,窗口结束后完整输出结果。它既保住了聚合的精度,又把无限计算变成了有限计算。
窗口操作之所以是时间维度聚合的标配,是因为绝大多数业务指标都天然绑定时间:最近5分钟的订单量、过去一小时的用户活跃数、今天凌晨开始的支付失败率。如果不用时间把流切开,你就只能维护一个不断增长的全局累加器,那既不是实时指标,也不是可运维的东西。
2. 四种窗口的工程选型:滚动窗、滑动窗、会话窗和全局窗
窗口的分类其实不复杂,复杂的只是选型。选错了,轻则多做无谓计算,重则状态膨胀到任务 OOM。我把四种常见窗口逐个过一遍,重点说清楚它们的边界逻辑和成本差异。
2.1 滚动窗口:最天然的“等分时间轴”
滚动窗口是最好理解的一种:固定长度,比如 5 分钟,从 10:00 到 10:05 是一个窗口,10:05 到 10:10 是下一个窗口,数据不重叠,每个事件只属于一个窗口。
伪代码大致长这样:
stream .keyBy(event -> event.userId) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .aggregate(new OrderAmountAggregate());滚动窗口的优点是边界确定,窗口之间没有交集,结果合并、下游补算都很方便。比如按分钟统计独立访客,用滚动窗口天然合理:每个用户只会在某个分钟窗口里被计一次,不会出现重复计数。
代价是延迟固定。窗口结束之前,结果永远不出来。如果业务要求每 30 秒刷新一次“最近 5 分钟”指标,滚动窗口是做不到的。
2.2 滑动窗口:步长与窗口长度的博弈
滑动窗口是滚动窗口的广义版本,多了一个步长参数。窗口长度和滑动间隔可以不同,比如每 30 秒滑动一次,每次计算最近 5 分钟的数据。
stream .keyBy(event -> event.userId) .window(SlidingEventTimeWindows.of(Time.minutes(5), Time.seconds(30))) .aggregate(new OrderAmountAggregate());滑动窗口最需要警惕的是重叠问题。当滑动间隔小于窗口长度时,同一个事件会被同时放进多个窗口。窗口长度 5 分钟、滑动间隔 30 秒,那么任何一个时刻系统里都并存着 10 个窗口,每一条数据都要被复制进差不多 10 个窗口分别聚合。
这在业务上能换来“高频刷新 + 更长统计周期”的体验,在成本上却可能是灾难。遇到资源吃紧,第一反应应该是检查有没有审计度不合理的滑动窗口。
2.3 会话窗口:以静默期为边界的业务窗口
会话窗口跟上面两种都不一样,它没有固定长度,而是按“不活跃间隙”切分。比如定义 10 分钟不产生数据就算一次会话结束,那么从用户第一次点击开始计时,如果中间超过 10 分钟没有新数据,前面这段聚合成一个会话,后面再来的数据开启新会话。
stream .keyBy(event -> event.userId) .window(EventTimeSessionWindows.withGap(Time.minutes(10))) .aggregate(new SessionDurationAggregate());会话窗口非常适合用户行为分析、访问路径还原、APP 使用时长统计这类场景。它的动态边界也带来了额外的成本:因为不知道窗口什么时候结束,系统必须为每一个 key 维护“最近活跃时间”的状态。key 越多,这种状态的规模越大。
会话窗口还有一个容易踩坑的地方:超长会话。如果某个用户一直保持活跃,窗口会无限延伸,聚合状态持续增大,最终可能拖垮单个子任务。工程上通常会给会话窗口加一个最大长度上限,切掉“僵尸长会话”。
2.4 全局窗口与自定义窗口:留给高阶玩家的自由度
全局窗口把所有同 key 的数据放进同一个窗口,不按时间切分。它本身几乎不产生什么结果,因为窗口没有明确的结束时机。全局窗口的真正价值在于:配合自定义触发器和 Evictor,可以实现各种业务自定义窗口。
实际生产中我也见过有人用全局窗口做“按业务编号累计到一定数量再触发”的窗口,效果尚可,但代价是状态管理完全自己负责,状态清理、触发时机、过期数据处理全都要自己写。没有充分理由,不建议默认走这条路。
四种窗口的对比可以打包成一张表:
| 窗口类型 | 边界确定方式 | 数据重叠 | 状态开销 | 典型场景 |
|---|---|---|---|---|
| 滚动窗口 | 固定长度等分 | 不重叠 | 低 | 分钟级指标、滚动汇总 |
| 滑动窗口 | 固定长度+固定步长 | 可能重叠 | 高,窗口数=长度/步长 | “近5分钟”高频刷新 |
| 会话窗口 | 不活跃间隙 | 不重叠 | 中等,按 key 保存活跃状态 | 用户行为分析、访问时长 |
| 全局窗口 | 无边界,须自定义触发 | 全部 | 取决于触发逻辑 | 自定义业务窗口、批量触发 |
选型时我个人的优先级是:能滚动就不滑动,能固定就不会话。窗口不是越灵活越好,而是越便宜越好。
3. 事件时间与水位线:窗口“什么时候关闭”由谁说了算
窗口的资源开销和结果质量,都取决于一个核心机制:这个窗口到底在什么时间点触发计算。
3.1 三种时间语义,别只选好写的
流计算里时间至少有三种:事件时间、处理时间、摄入时间。
- 事件时间:数据自己携带的时间戳,代表这件事真实发生的时刻。
- 处理时间:数据到达计算引擎并由机器处理时的本地墙钟时间。
- 摄入时间:数据进入引擎后,由引擎在入口统一打上的时间戳。
处理时间实现最简单,窗口按照机器本地时间切分,数据来了就归入当前窗口。问题是结果不稳定:上游延迟一秒,窗口边界就跟着偏移一秒;重放数据时,结果跟第一次跑完全不一样。处理时间适合对准确性要求极低的场景,比如实时展示“当前待处理条数”。
事件时间才是窗口聚合的默认选择。只有用它,才能真正回答“10:00 到 10:05 这五分钟的交易额是多少”。代价是你必须面对乱序:事件明明发生在 10:03,但因为网络抖动或上游重试,10:06 才到引擎。窗口已经关了,这条数据还算不算?
3.2 水位线:窗口的“统一关窗闹钟”
为了处理乱序,引擎引入了水位线。你可以把它理解成一个“不早于某个时刻”的声明:当引擎看到水位线到达 T,就认为事件时间小于等于 T 的数据已经全部到达(或者说不会再有了)。
窗口的触发逻辑因此变得非常简洁:当水位线越过窗口的结束时间,引擎就触发这个窗口的计算。
stream .assignTimestampsAndWatermarks( WatermarkStrategy .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(10)) .withTimestampAssigner((event, ts) -> event.eventTimestamp) );上面的代码声明:允许事件最多迟到 10 秒。引擎会持续观察已进入的数据,取“最大事件时间 - 10 秒”作为当前水位线。假设数据里最大事件时间是 10:05:20,水位线就是 10:05:10。等到某条数据把最大事件时间推到 10:05:30,水位线变成 10:05:20,于是所有在 10:05:20 之前结束的窗口开始计算。
水位线设得越小,窗口触发越早,但丢数据的风险越大;设得越大,容错越强,但结果延迟越高。这个“10 秒”没有万能值,要结合上游延迟分位数来定,建议至少看 P95 和 P99。
3.3 窗口关闭之后:迟到数据的三条出路
水位线越过了窗口边界,窗口被触发并输出一次结果。此时窗口关闭了吗?不完全是。窗口还会为迟到的数据再留一小段缓冲,这就是 allowedLateness 参数。
处理逻辑分三种情况:
- 数据落在窗口内,且在水位线越过窗口结束时间之前到达:正常参与聚合。
- 数据落在窗口内,且在水位线越过窗口结束时间之后、但还没超过 allowedLateness:窗口会再次触发,把增量结果重新输出一遍。
- 数据到达时,事件时间已经落在窗口结束时间 + allowedLateness 之外:引擎把它丢弃,或者送入侧输出流,由业务决定是否补偿。
工程上常见的习惯是:给延迟特别敏感的核心指标开侧输出流,把超时数据留下来做离线对账。不要指望所有迟到数据都能被一个窗口接住,那会让窗口状态无限期存活,内存迟早出问题。
4. 窗口不只是一段计算:状态、触发器和清理的生命周期管理
很多人在理解了窗口的分类和时间语义之后,就觉得万事大吉了。直到某个凌晨,任务 OOM 或者结果对不上账,才意识到窗口背后还挂着状态、触发器和清理策略这三座大山。
4.1 每个窗口都是独立的状态容器
窗口聚合不是“扫一遍算完就扔”。在滑窗和会话窗里,数据是陆续到达的,引擎必须把已经收到的中间结果存在状态里,等触发时刻到来时再汇总输出。
这个状态是分布式的,通常以“key + 窗口标识”为维度存储。只要窗口没触发,它就一直占用存储空间。窗口数量一旦膨胀,状态就跟着膨胀。
一个典型的反面案例:用滑动窗口且滑动间隔=1 秒、窗口长度=1 小时,系统会同时存在 3600 个窗口。如果 key 数量又很大,每个 key 都要维护 3600 份中间状态。数据量稍微上来,状态后端直接告警。
所以窗口选型一定要把“存量窗口数量”算出来:窗口长度除以滑动步长是多少,就近似等于同一时间存活的窗口数量。
4.2 触发器:精确控制窗口何时算
引擎默认的触发规则是最简单的:水位线越过窗口结束时间就触发一次。但真实业务往往不满足于这个规则。
比如你想对近 5 分钟的数据做聚合,但希望每 30 秒先看看中间结果,或者在窗口内数据量超过 1 万条时提前输出一个大致的阈值告警。这时候就要用自定义触发器。
触发器能感知三类信号:数据来了、水位线到了、处理时间到了。你可以在代码里分别定义响应逻辑,返回 CONTINUE(不触发)、FIRE_AND_PURGE(触发并清空窗口)、FIRE(触发但保留窗口)等动作。
还有一个人容易遗忘的算子叫 Evictor。它在聚合前先把窗口里不该出现的数据剔除掉,比如你想只保留窗口里最近 3 分钟的明细,再对剩余数据聚合。Evictor 用起来很灵活,但它会让引擎把窗口内的数据全部缓存住,削弱增量聚合的收益,不能轻易滥用。
4.3 清理策略:比触发更重要的兜底逻辑
窗口生命周期有几个阶段:创建、接收数据、触发、清理。清理是最后一环,也最容易遗漏。
默认情况下,窗口在水位线越过“窗口结束时间 + allowedLateness”之后,会被引擎标记清理。如果你把 allowedLateness 设得很大,或者自定义了触发器不断延期,那么窗口状态就迟迟无法释放。
工程上建议做三件事:
- 明确允许的迟到时间,不要贪大。宁可配合侧输出流做补偿,也不要让主链路状态长期挂载。
- 使用状态过期时间设置,定期清除那些长时间没有更新的窗口状态。
- 在自定义窗口逻辑里,显式调用清理方法,确保在窗口完成使命后释放引用。
你设计的不是窗口该算多久,而是窗口该活多久。后者才是状态管理的关键。
5. 真实排查实录:窗口结果莫名偏少的三个隐蔽原因
理论讲完,来一段实际踩坑记录。那次事故发生在某实时订单统计链路,窗口明明是 5 分钟滚动窗口,结果每天凌晨总是有几分钟的窗口比人工报表少几条数据。我把排查链路完整复述一遍,比直接给结论更有价值。
5.1 现象与第一波猜测
监控图上的曲线很有规律:白天一切正常,凌晨 0 点前后的某个 5 分钟窗口,订单量周期性偏低,偶尔少 1 到 2 条。偏偏这个指标还要用于业务对账,少一条都是大事。
第一反应是上游消息丢了。于是查生产端日志、查中间消息队列的消费位点、查消费端是否有异常重启。一通操作之后发现:数据一条不少地进了引擎,甚至侧输出流里也没有迟到数据。问题明显不是“数据丢没丢”,而是“数据有没有在正确的时间参与正确的窗口”。
5.2 事件时间戳的类型和单位陷阱
后来我把注意力拉回到时间戳上。日志里随手看了一条样例,发现事件时间字段竟然是一个只精确到秒的整数。再往上翻埋点代码,客户端发送的时间戳确实是毫秒,但中间经过某台代理服务做字段映射时,被塞进了一个以秒为单位的字段。
这一下问题全对上了。引擎默认将时间戳按毫秒解析,结果原本 1672502400000 毫秒的真实事件时间,被读成了 1672502400 毫秒,相差了约 19 天。窗口结束时间完全错位,数据被归入错误窗口。
更隐蔽的是它不是全局错,而是只有部分字段类型转换不统一的数据才会错。白天业务量大,正确和错误的数据混在一起,曲线看着还正常;凌晨业务量低,几秒钟才来一条数据,偶尔来一条错误时间戳的就会被算进完全不同的窗口,看起来就像“少了一条”。
修复动作不复杂:在数据接入层做一次强制转换与单位校验,确保事件时间统一为毫秒,并在入口加监控,发现事件时间与前一条数据相差超过阈值就报警。
5.3 空闲分区导致水位线停滞
时间戳单位修好之后,数据对账仍偶发偏移。继续查,发现某台机器上的应用并行度过高,部分下游分区在深夜没有新数据到来。这里涉及一个很隐蔽的机制:如果引擎按每个分区独立推进水位线,那么只要有一个分区长时间没有数据,它就会一直保持着最初的水位线,把整体水位线死死拖住。
水位线被拖住,窗口就不敢触发,本该在 5 分钟出结果的数据迟迟不算完。等到其他分区数据把水位线推过去,窗口才补触发,但这时候业务来看结果,已经晚了好几分钟,和人工统计的口径对不上了。
这类问题的标准解法是给水位线设置空闲超时,标记超过阈值没有数据的空闲分区直接忽略,不让它拖慢全体进度。
5.4 验证与复盘
修完时间戳单位和空闲超时两个问题后,我们做了一轮回放验证:把有问题的历史数据重新灌入同一个任务,用同一套窗口配置重新计算。最终结果和人工报表完全一致,之后几天监控曲线也恢复了正常。
这次事故给我最大的教训是:窗口聚合的结果质量,从来不只是窗口 API 本身的问题,而是完整链路的问题。上游时间戳规范、水位线推进条件、分区数据分布、状态生命周期,任何一环出问题,最终都会以“窗口结果不对”的形式弹出来。
6. 窗口计算的性能账本:状态开销与调优取舍
最后聊聊窗口调优。很多人以为调优就是加并行度,其实窗口聚合的调优要先算清账:窗口数、状态量、聚合方式,三者的乘积决定了你的资源占用。
6.1 增量聚合和全量聚合的选择
窗口内聚合有两种实现思路。增量聚合是每来一条数据,就更新一次中间聚合结果,典型的是aggregate方式。全量聚合则先把窗口内所有数据攒在状态里,窗口触发时一次性迭代全部数据计算,典型的是processWindowFunction。
// 增量聚合:只存中间累加值,状态极小 stream.keyBy(e -> e.key) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .aggregate(new AmountAccumulator(), new WindowResultFunction()); // 全量聚合:窗口内数据全部缓存,触发时遍历 stream.keyBy(e -> e.key) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .process(new ProcessWindowFunction<Event, Result, String, TimeWindow>() { ... });大多数指标场景都应该用增量聚合。它把状态牢牢压在很小的量级,不会随着窗口内的消息数增长而膨胀。只有需要同时获取窗口上下文、或者聚合逻辑高度依赖完整明细时,才用全量聚合。尤其要注意,全量聚合配合大窗口,是 OOM 的标准配方。
6.2 key 分布与并行度不一定能救你
窗口状态是分散在各个 key 上的,如果 key 倾斜严重,比如某几个大店家的订单量占了 80%,那么这些 key 对应的子任务会把 CPU、内存打满,其他子任务空转。此时单纯提高并行度没有意义,瓶颈永远在热点 key 上。
治本的方法是拆分热点 key,或者在业务上允许对热点 key 单独建任务。治标的方法是尽可能让上游在分流时就按用户 ID 均匀散列。
一个容易忽略的点是:窗口数量不受并行度影响,它只由“窗口长度/滑动步长”决定。滑窗的并行度堆得再高,每个 key 要维护的窗口份数还是一样。所以上线前先去算窗口数量,再去决定资源规格。
6.3 状态后端的取舍与监控水位
窗口状态最终落在状态后端里。生产环境建议优先考虑磁盘型状态存储方案,配置好增量检查点,防止窗口状态过大导致内存溢出。状态量大的任务如果非要用纯内存方案,必须提前做好容量评估和紧急扩容预案。
监控层面,至少要看三个指标:窗口延迟、状态大小、背压时长。窗口延迟反映水位线推进是否正常,状态大小直接反映窗口生命周期是不是拖着没清理,背压则说明上游生产速度已经压过了下游消费能力。任何一个指标持续异常,都不是加内存能敷衍过去的,要回到窗口配置本身找原因。
6.4 一次滑窗优化的实际收益
之前维护过一个实时大屏任务,原来是“近 10 分钟交易额”每 30 秒刷新一次,结构上是典型的 20 个并发窗口状态。初期数据量小,一切正常;数据量涨到三倍之后,检查点时间和内存占用双双飙升。
当时的优化路径很直接:把滑窗改成了滚动的 1 分钟窗口加下游实时累加,用一个独立算子维护“近 10 分钟”的内存队列。窗口状态从 20 份缩到 1 份,状态体积下降一个数量级,检查点耗时也恢复到正常水位。
这个方案不是普适的,但它印证了一个道理:窗口操作的关键不在于“用哪个现成算子”,而在于你能不能从窗口的状态开销出发,设计出更适合自己数据形态的切分方式。流式计算的时间维度聚合,从来不是一个 API 调用,而是一套有取舍的工程决策。
最后分享一个我一直在用的习惯:每次上线窗口任务之前,先把“事件时间单位”“水位线延迟”“allowedLateness”“预估状态量”这四个数字写在一张卡片上,评审时逐个过一遍。这四个数字能一致且明确写出来,任务基本稳了一半。