写 Flink 笔记写到第 16 篇,终于轮到窗口了。窗口这个概念,搞流处理的人都不陌生,DataStream API 里 TumblingEventTimeWindows、SlidingEventTimeWindows 这些类,很多人张口就能背出来。但一旦切到 Table API / SQL 这条链路,你会发现写法完全换了体系:没有 new 窗口对象这一步,取而代之的是一段看似简单、报错时却让人摸不着头脑的 SQL 语法。这篇笔记就把 Table 上的 Group Windows 和 Over Windows 两类窗口全部过一遍,包括它们各自的适用场景、声明语法、时间属性绑定关系,以及我实际跑任务时踩过的一些坑,希望对正在学 Table API 的人有用。
1. 先把概念立住:Table API里的窗口和DataStream API的窗口差别在哪
1.1 声明式写法和命令式写法的思维转换
DataStream API 写窗口,本质上是一个"组装"的过程:你要选一个 WindowAssigner(滚动、滑动、会话),决定要不要配 Trigger,要不要配 Evictor,要不要设置 allowedLateness,最后再 apply 一个窗口函数。每个手写作业的都会有体会:东西都能跑,但代码量不小,而且各种配置之间的相互作用很容易把人绕晕。
Table API / SQL 这边完全是另一套逻辑。窗口不再是一个"对象",而是 SQL 语法的一部分。你只需要在GROUP BY后面写上TUMBLE(ts, INTERVAL '1' HOUR),或者在OVER (...)子句里声明窗口框架,剩下的窗口分配、触发计算、结果输出全部交给引擎。
这种声明式的写法表达效率很高,但代价就是细节被藏起来了。DataStream 里你能看到 Trigger 什么时候触发、Evictor 怎么清理数据,Table 层这些概念全部隐去,一旦窗口结果不符合预期,你只能从时间属性、水位线这些"地基"往回倒推。所以我的建议是:学 Table 窗口之前,把 DataStream 的窗口语义搞懂,这样你至少知道 SQL 背后大概发生了什么。
1.2 Group Window 与 Over Window:一个负责"分桶聚合",一个负责"逐行计算"
Table 上的窗口分两大类,名字容易混,但职责完全不同。
Group Window 又叫分组窗口,本质是把无限流按时间切成一段一段,每段一个桶,桶内做聚合,每条数据最终只会落进一个(或多个,滑动窗口除外)桶,一个桶输出一行结果。典型的输出是"每分钟的订单金额"、"每小时的活跃用户数"。
Over Window 则是标准的 SQL 开窗函数语义。每一行输入都对应一个窗口框架,这个框架是"从某一行到当前行"的一个区间,你可以基于这个区间计算累计值、移动平均、排序序号。输入一行,输出一行,结果行数不会减少。
举一个生活化的类比:Group Window 像是按班级统计平均分,一个班一行结果;Over Window 像是每个学生回头看前面 10 个同学的成绩算平均,全班 50 人,平均分就有 50 个值。一个是横向压缩数据,一个是纵向逐行计算。
| 对比项 | Group Window | Over Window |
|---|---|---|
| 输出行数 | 每个窗口一行聚合结果 | 每行输入对应一行输出 |
| 典型场景 | 分钟级指标统计、会话分析 | 累计值、移动平均、TopN |
| SQL 入口 | GROUP BY TUMBLE/HOP/SESSION(...) | SELECT ... OVER (...) |
| 窗口形状 | 按时间划分的桶 | 每行一个独立区间 |
| 能否跨多条数据 | 可以聚合任意条数 | 只能限定在一个框架内 |
搞不清这两个概念,后面所有语法学习都会出问题,所以先把它们放在一起记牢。
2. Group Windows实战:TUMBLE、HOP、SESSION三种窗口的写法与边界
2.1 TUMBLE滚动窗口:最纯粹的固定粒度切片
滚动窗口是最好理解的一种:固定大小、互不重叠、每条数据只属于一个窗口。比如按小时统计,1 点到 2 点一个窗口,2 点到 3 点一个窗口,界限分明。
SELECT user_id, TUMBLE_START(ts, INTERVAL '1' HOUR) AS win_start, TUMBLE_END(ts, INTERVAL '1' HOUR) AS win_end, SUM(amount) AS total_amount FROM orders GROUP BY TUMBLE(ts, INTERVAL '1' HOUR), user_id;这里有两个关键点。第一,TUMBLE()的第一个参数必须是时间属性,也就是在 DDL 里声明过的rowtime(事件时间)或者proctime(处理时间),直接传普通时间戳字段会报错,后面第 6 节我会展开说。第二,GROUP BY里只能放窗口函数和维度字段,如果你想把amount这种非维度字段塞进 SELECT 里而不做聚合,SQL 校验直接就把你拦下来。
滚动窗口适合"固定周期报表"这类场景,比如每隔 5 分钟统计一次接口调用量。它的语义干净,不会出现一条数据被重复计入多个窗口的问题,做趋势监控图非常合适。
2.2 HOP滑动窗口:size和slide两个参数谁在前谁在后
滑动窗口比滚动窗口多一个参数:一个是窗口大小(size),一个是滑动步长(slide)。窗口大小决定了单个桶的时间跨度,滑动步长决定了窗口多久"滑动"一次。当 slide 小于 size 时,相邻窗口会重叠,一条数据会落入多个窗口。
SELECT user_id, HOP_START(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS win_start, HOP_END(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS win_end, SUM(amount) AS total_amount FROM orders GROUP BY HOP(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR), user_id;注意HOP()的签名顺序:HOP(time_attr, slide_interval, size_interval),先滑动步长,后窗口大小。这个顺序我至少记反过三次,每次写完看到结果不对才回去翻文档。如果你的 SQL 里写成了HOP(ts, INTERVAL '1' HOUR, INTERVAL '5' MINUTE),语法不会报错,但窗口大小是 5 分钟、滑动步长是 1 小时,语义完全错了,而且结果从表面看很难发现异常。
滑动窗口最常见的场景是"近 1 小时销售额"这种滚动统计:窗口覆盖最近 60 分钟,每 5 分钟刷新一次。因为窗口重叠,一条数据会同时存在于多个窗口的计算中,这在业务上完全合理——同一笔订单,在 10:00 和 10:05 两个时刻看来,都属于"最近一小时"。
2.3 SESSION会话窗口:用不活动间隙自动切分
会话窗口是三种 Group Window 里最特别的一个,它不按固定大小切分,而是按"不活动时间间隔"(gap)来切。只要连续两条数据之间的间隔超过 gap,就认为前一个会话结束,新开一个窗口。
SELECT user_id, SESSION_START(ts, INTERVAL '10' MINUTE) AS win_start, SESSION_END(ts, INTERVAL '10' MINUTE) AS win_end, COUNT(*) AS click_cnt FROM user_clicks GROUP BY SESSION(ts, INTERVAL '10' MINUTE), user_id;这个 gap 是全局静态配置的,不会根据数据密度自适应。10 分钟的 gap 意味着:用户如果每 9 分钟点一次,系统会认为这是一个持续会话;一旦超过 10 分钟没动作,会话关闭。
会话窗口非常契合"用户活跃时段分析":同一个用户可能一天内有上午、下午、晚上三个会话,每个会话单独统计时长和次数,比固定窗口更贴近人的行为规律。需要提醒的是,会话窗口的计算成本比滚动和滑动要高一些,因为它要维护"会话边界"的状态,数据量大时要留意内存和状态大小。
2.4 窗口辅助函数:窗口起止时间、行时间与处理时间
用 Group Window 做聚合时,SELECT 里通常不只想要聚合值,还想要窗口的边界信息。Flink 提供了一系列辅助函数,每种窗口都有配套的一套:
| 窗口类型 | 起始函数 | 结束函数 | 行时间函数 | 处理时间函数 |
|---|---|---|---|---|
| TUMBLE | TUMBLE_START | TUMBLE_END | TUMBLE_ROWTIME | TUMBLE_PROCTIME |
| HOP | HOP_START | HOP_END | HOP_ROWTIME | HOP_PROCTIME |
| SESSION | SESSION_START | SESSION_END | SESSION_ROWTIME | SESSION_PROCTIME |
起点和终点好理解,就是窗口左闭右开的边界。这里重点说TUMBLE_ROWTIME这类函数。它的返回值是"窗口结束时间减 1 毫秒",也就是窗口内最后一条数据理论上可能达到的时间点,而且它具备时间属性,可以继续参与后续的GROUP BY开窗、OVER排序等操作。这意味着你可以做"二次开窗":先按 1 小时聚合出结果,再把这个聚合结果当成新的流,按天继续聚合。这在做多级汇总时非常有用。
TUMBLE_PROCTIME则是把窗口的结束时刻映射为处理时间属性,适合不需要事件时间语义的后续计算。做实时大屏时,我通常会用ROWTIME版本,因为它保留了事件时间语义,下游再做窗口时的一致性更好。
3. Over Windows:ROWS和RANGE两种边界定义才是核心
3.1 ROWS BETWEEN:按物理行数圈定的窗口
Over Window 的核心是窗口框架(frame)的定义。最直觉的一种定义方式是 ROWS BETWEEN,直接数行数。
SELECT user_id, ts, amount, SUM(amount) OVER ( PARTITION BY user_id ORDER BY ts ROWS BETWEEN 5 PRECEDING AND CURRENT ROW ) AS recent_5_sum FROM orders;这段 SQL 的含义是:对每个用户,按时间排序后,每一行取"当前行和前面 5 行"这 6 行做求和。它只关心物理行数,完全不关心这 5 行跨了多长时间。如果前 5 行是 1 秒内产生的,那recent_5_sum就是 1 秒的局部值;如果这 5 行跨了 1 小时,那它又变成了 1 小时的局部值。
这里有一个容易忽略的细节:ROWS 框架按"物理行"计算,而流式场景下如果两条数据的时间戳完全相同,它们的物理排序是不确定的,结果可能和你预期不符。所以做流式 ROWS 窗口时,最好保证排序键有足够的区分度,或者在业务上能接受这种不确定性。
ROWS 窗口最典型的应用是"最近 N 条记录平均值"这类算法,比如对传感器数据做 5 点移动平均平滑。
3.2 RANGE BETWEEN:按时间间隔圈定的窗口
RANGE 框架不看行数,看的是排序键的值范围。
SELECT user_id, ts, amount, SUM(amount) OVER ( PARTITION BY user_id ORDER BY ts RANGE BETWEEN INTERVAL '1' HOUR PRECEDING AND CURRENT ROW ) AS one_hour_cumulative FROM orders;它的语义是:对每一行,取"当前行时间戳往前推 1 小时"这个时间段内所有行参与聚合。注意,这个范围内有多少行不重要,重要的是时间边界。同一行数据的计算结果里,包含的是这一小时内发生的所有订单,无论中间有多少条。
RANGE 框架有一个重要约束:在流处理模式下,ORDER BY的字段必须是时间属性(rowtime或proctime),因为引擎需要依赖时间语义来界定"往前推 1 小时"这个逻辑边界。如果对普通字段做 RANGE,引擎没法在无限流上确定范围,校验阶段就会报错。
RANGE 窗口特别适合做累计值,比如"用户截至当前的累计消费金额"、"过去 1 小时活跃设备数变化趋势"。它的结果是实时的:每来一条数据,当前行的累计值就更新一次,非常直观。
3.3 PARTITION BY、ORDER BY 与窗口框架的约束关系
Over Window 的完整定义由三部分组成:PARTITION BY 决定分区(相当于把整个流按用户切分成多个子流)、ORDER BY 决定行在分区内的排列顺序、窗口框架决定每行往前/往后看多远。
PARTITION BY 是可选的。不加的话,整个流就是一个大分区,比如全站累计销售额。
窗口框架的边界可以是:
UNBOUNDED PRECEDING:从分区第一行开始n PRECEDING:往前 n 行 / n 个时间单位CURRENT ROW:当前行n FOLLOWING:往后 n 行(流处理中较少用)
用UNBOUNDED PRECEDING AND CURRENT ROW就是最常见的"累计到当前"语义。这个框架下,每来一条数据都要重算一遍所有累加值,数据量大的分区要关注性能。
Over Window 还有一个高频用途是 TopN:用ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)给每个分区内的行编号,外层再过滤掉编号大于 N 的行。这在 Table API 里是"实时榜单"的标准写法,和 Group Window 的桶式聚合完全是两种思路,我会选择性地把两者结合起来用。
4. 时间属性是窗口的地基:事件时间、处理时间与水位线的声明细节
4.1 DDL里如何声明rowtime和proctime
不管用 Group Window 还是 Over Window,只要是事件时间语义,你必须先在表的 DDL 里声明rowtime。最标准的做法是在建表语句里加上WATERMARK子句:
CREATE TABLE orders ( order_id BIGINT, user_id BIGINT, amount DECIMAL(10, 2), ts TIMESTAMP(3), WATERMARK FOR ts AS ts - INTERVAL '5' SECOND ) WITH ( 'connector' = 'datagen' );这个WATERMARK FOR ts AS ts - INTERVAL '5' SECOND的意思是:当前水位线等于最新观测到的ts减去 5 秒,允许数据最多迟到 5 秒。声明之后,ts就成了时间属性rowtime,窗口函数才能拿它做参数。
处理时间就简单得多,加一列虚拟列即可:
CREATE TABLE orders ( ... proc_time AS PROCTIME() ) WITH (...);注意rowtime必须精确到毫秒,也就是TIMESTAMP(3)类型。如果你从 Kafka 读的是字符串时间字段,在建表时就得先转成时间戳类型。这一步做不好,后面窗口开不起来。
4.2 水位线如何影响窗口触发与迟到数据
事件时间语义下,Group Window 的触发条件是"水位线越过窗口结束时间"。什么意思?一个 [10:00, 11:00) 的滚动窗口,只有当水位线推进到 11:00 之后,窗口才会触发计算并输出结果。如果源端数据一直不产生新事件,或者乱序特别严重,水位线推不动,窗口就永远不出结果。这种现象在测试时最坑:你明明发了数据,Console 就是一片空白。
处理时间语义则没有这个等待过程,窗口按系统时钟到点就触发,适合对精确性要求不高的场景。
迟到数据的问题也要注意。窗口触发后,如果有迟到数据到达且水位线还没超过"窗口结束时间 + 允许延迟",Flink 还会把这条数据纳入窗口重新计算。Table API 里这个允许延迟的量主要由WATERMARK表达式隐含决定。你在 DDL 里写ts - INTERVAL '5' SECOND,相当于给了 5 秒的容忍期;写成ts - INTERVAL '1' MINUTE,容忍期就是 1 分钟。实际生产里要根据数据乱序程度来定,设小了结果偏少,设大了窗口延迟关闭,实时性变差。
4.3 时区问题:TIMESTAMP_LTZ 与窗口期边界的一个提醒
不少人在用 Kafka 消息里的epoch毫秒时间戳建表时,习惯直接转成TIMESTAMP(3)。这样做有个隐患:TIMESTAMP不带时区信息,如果上游的时间实际是 UTC,而你业务上按东八区算窗口边界,那么窗口的起止时间会整体偏移 8 小时。
更稳妥的做法是用TIMESTAMP_LTZ(3),让 Flink 结合会话时区来解释时间值。建表时字段类型用TIMESTAMP_LTZ(3),配合作业的table.local-time-zone配置,窗口边界才会落在你预期的当地时区上。
这个坑不是语法层面的,结果层面才能发现,一旦中招,排查成本很高。我建议从一开始就统一约定:上游时间用带时区的格式或 epoch 毫秒,下游建表用TIMESTAMP_LTZ,避免窗口边界跟着时区漂移。
5. 一个能直接跑通的Demo:订单表上的Group Window与Over Window
5.1 建表语句与数据准备
这一节给一套完整的可运行示例。我用datagen连接器模拟订单数据源,这样不用搭外部系统,直接在 Flink SQL Client 里就能跑。
CREATE TABLE orders ( order_id BIGINT, user_id BIGINT, amount DECIMAL(10, 2), ts TIMESTAMP(3), WATERMARK FOR ts AS ts - INTERVAL '5' SECOND ) WITH ( 'connector' = 'datagen', 'rows-per-second' = '10', 'fields.order_id.kind' = 'sequence', 'fields.order_id.start' = '1', 'fields.user_id.kind' = 'random', 'fields.user_id.min' = '1', 'fields.user_id.max' = '10', 'fields.amount.kind' = 'random', 'fields.amount.min' = '1', 'fields.amount.max' = '100' );这个配置会每秒生成 10 条订单,用户 ID 在 1 到 10 之间随机,金额在 1 到 100 之间随机,时间戳由datagen自动带。
5.2 GROUP BY TUMBLE 的完整查询
先看滚动窗口的完整用法:
SELECT user_id, TUMBLE_START(ts, INTERVAL '1' MINUTE) AS win_start, TUMBLE_END(ts, INTERVAL '1' MINUTE) AS win_end, SUM(amount) AS total_amount, COUNT(*) AS order_cnt FROM orders GROUP BY TUMBLE(ts, INTERVAL '1' MINUTE), user_id;跑起来后,每分钟每个用户会输出一行,字段包括窗口起止时间和聚合结果。你可以试着把TUMBLE换成HOP、SESSION,观察输出行数和语义变化。
这里有个实践技巧:把TUMBLE_START和TUMBLE_END放到 SELECT 里,不只为了给业务看,更是定位问题的好帮手。当窗口结果看起来不对时,先看窗口边界是否正确:边界如果和预期差 8 小时,那是时区问题;边界如果出现 5 分钟的小碎块,那是 HOP 参数顺序写反了。
5.3 OVER RANGE 的完整查询
Over Window 的查询长这样:
SELECT user_id, ts, amount, SUM(amount) OVER ( PARTITION BY user_id ORDER BY ts RANGE BETWEEN INTERVAL '1' MINUTE PRECEDING AND CURRENT ROW ) AS amount_last_1min, COUNT(*) OVER ( PARTITION BY user_id ORDER BY ts RANGE BETWEEN INTERVAL '1' MINUTE PRECEDING AND CURRENT ROW ) AS order_cnt_last_1min FROM orders;每一行输出都会携带"该用户最近 1 分钟内的累计金额和订单数"。随着时间推进,同一行的结果可能因为后续迟到数据而发生变化,这正是事件时间累计值的正常表现。
想看 ROWS 效果,把RANGE BETWEEN INTERVAL '1' MINUTE PRECEDING改成ROWS BETWEEN 5 PRECEDING即可。对比两套查询的输出,能直观体会到这两种边界定义的差异。
5.4 新旧版语法切换说明
这里要专门提醒版本问题。上面示例里的GROUP BY TUMBLE(ts, ...)是 1.13 之前就有的经典写法,很多生产作业还在用。但在新版本里,官方把 Group Window 的推荐语法改成了窗口表值函数(Window TVF),也就是TABLE(TUMBLE(TABLE orders, DESCRIPTOR(ts), INTERVAL '1' MINUTE))这种写法:
SELECT window_start, window_end, user_id, SUM(amount) AS total_amount FROM TABLE( TUMBLE(TABLE orders, DESCRIPTOR(ts), INTERVAL '1' MINUTE) ) GROUP BY window_start, window_end, user_id;TVF 版本的好处是窗口边界直接变成window_start、window_end字段,语义更清晰,更重要的是它支持窗口嵌套和后续继续基于窗口结果做计算,老语法做不到这些。
如果你刚起步,直接学 TVF 语法;如果维护老作业,至少要知道两种语法的区别,否则在网上搜解决方案时很容易被不同版本的回答带偏。
6. 我实际踩过的坑:Table窗口报错与结果偏差的排查思路
6.1 坑一:没声明时间属性就直接开窗
有段时间我图省事,直接在 CREATE TABLE 里把 Kafka 消息的字符串时间字段转成TIMESTAMP(3),然后就拿它去开窗:
SELECT * FROM orders GROUP BY TUMBLE(ts, INTERVAL '1' HOUR);结果报错提示大概是:"Cannot apply TUMBLE to arguments of type TUMBLE(TIMESTAMP, INTERVAL)"。排查思路是:先确认ts到底是不是时间属性。时间属性和普通时间戳字段在 DDL 里的地位完全不同,TUMBLE只认时间属性。解决办法是给ts补上WATERMARK FOR ts AS ...声明,或者在新版语法里改用DESCRIPTOR(ts)+ 表属性配合。这个问题很基础,但几乎每个新手都会遇到一次。
6.2 坑二:窗口聚合里混入普通字段导致结果错乱
写聚合查询时,想在结果里带上订单金额字段:
SELECT user_id, amount, SUM(amount) FROM orders GROUP BY TUMBLE(ts, INTERVAL '1' HOUR), user_id;这个语句在 SQL 校验阶段就会报错:amount既不在 GROUP BY 里,也不是聚合函数。原因很好理解:Group Window 把多行聚合成一行,amount这个原始字段根本没有对应关系,引擎不知道该输出哪一行。
它的排查链路也简单:报错信息会直接指出非法列。但有一种隐蔽的情况是会"合法但错误"——如果你把amount也加进 GROUP BY,SQL 能过,但窗口被按金额维度进一步拆碎,每个用户每小时会输出几十行结果,完全偏离了设计意图。所以我后来养成了习惯:写 Group Window 的 SELECT 时,先问自己每个非聚合字段是不是都在 GROUP BY 里,并且确实是想作为维度拆分。
6.3 坑三:事件时间窗口"迟迟不出结果"的水位线之谜
最让人抓狂的坑是:SQL 没报错,数据也进了 Kafka,窗口就是不出结果。第一次遇到时我查了很久,最后发现问题出在水位线。
现象是这样的:本地测试往 Kafka 灌了一批带过去时间戳的数据,然后看窗口输出,一直为空。原因是WATERMARK FOR ts AS ts - INTERVAL '5' SECOND计算的是"已观测到的最大时间戳减 5 秒",如果这批数据的时间戳比当前系统时间早很多,比如早了一天,那水位线一直停留在过去,永远推不到任何窗口的结束时间,窗口自然不触发。
排查思路分三步:第一,看源端是否持续有新数据;第二,看数据时间戳是否远落后于系统时间;第三,看是否存在长时间没有数据的分区导致水位线不推进。解决办法通常是让测试数据的时间贴近当前时间,或者给 Source 配置空闲超时处理,让空闲分区不拖住整体水位线。这个坑提醒我:事件时间窗口的"触发"依赖的是水位线而不是数据本身,"有数据"不等于"窗口会算"。
6.4 坑四:老语法与新语法混用时的报错
有一次我把老作业的查询拷到新环境里,直接报了一堆 "Aggregate function ... is not supported" 之类的错误。排查发现,问题不在 SQL 本身,而在于新环境把老语法标记为废弃,甚至在某些版本里已经下线,需要用 TVF 语法重写。
这种情况在 Flink 社区里很常见,因为版本迭代跨度大,网上资料新旧混杂。我的处理办法是:先确认当前环境的 Flink 主版本,再决定查文档的方向。老语法确实还能跑,但新作业我一律用 TVF;维护老作业时,也尽量规划一个迁移窗口,因为老语法不会一直保留。混用两种写法、依赖各自的辅助函数,是最容易出边界问题的地方。
一些个人的体会
窗口这块的学习曲线比较陡,我自己的经验是"文档读三遍不如自己造数据跑一遍"。尤其是 HOP 的 slide/size 顺序、ROWS 和 RANGE 的语义差异、水位线对触发的影响,这些光看是记不住的,一定要动手写几个 query 对比输出。造数据时故意让时间戳乱序、插入一些迟到数据,观察窗口结果怎么变,比你背十遍语法都有用。
另外,建议在你的笔记里专门留一节记录"窗口结果异常时的排查顺序":先看时间属性声明,再看水位线推进,接着看窗口边界计算,最后才看 SQL 本身的逻辑。这个顺序能帮你少走很多弯路。下一篇文章我打算写窗口状态大小和性能调优的方向,窗口虽然好用,但状态管理才是生产环境里真正见功夫的地方。