资讯详情

Flink Table API/SQL窗口详解:Group Window与Over Window实战

📅 2026/10/19 4:38:10 | 华诺云谱 👁 阅读
Flink Table API/SQL窗口详解:Group Window与Over Window实战
写 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 WindowOver Window输出行数每个窗口一行聚合结果每行输入对应一行输出典型场景分钟级指标统计、会话分析累计值、移动平均、TopNSQL 入口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 提供了一系列辅助函数每种窗口都有配套的一套窗口类型起始函数结束函数行时间函数处理时间函数TUMBLETUMBLE_STARTTUMBLE_ENDTUMBLE_ROWTIMETUMBLE_PROCTIMEHOPHOP_STARTHOP_ENDHOP_ROWTIMEHOP_PROCTIMESESSIONSESSION_STARTSESSION_ENDSESSION_ROWTIMESESSION_PROCTIME起点和终点好理解就是窗口左闭右开的边界。这里重点说TUMBLE_ROWTIME这类函数。它的返回值是窗口结束时间减 1 毫秒也就是窗口内最后一条数据理论上可能达到的时间点而且它具备时间属性可以继续参与后续的GROUP BY开窗、OVER排序等操作。这意味着你可以做二次开窗先按 1 小时聚合出结果再把这个聚合结果当成新的流按天继续聚合。这在做多级汇总时非常有用。TUMBLE_PROCTIME则是把窗口的结束时刻映射为处理时间属性适合不需要事件时间语义的后续计算。做实时大屏时我通常会用ROWTIME版本因为它保留了事件时间语义下游再做窗口时的一致性更好。3. Over WindowsROWS和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 Window5.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 BYSQL 能过但窗口被按金额维度进一步拆碎每个用户每小时会输出几十行结果完全偏离了设计意图。所以我后来养成了习惯写 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 本身的逻辑。这个顺序能帮你少走很多弯路。下一篇文章我打算写窗口状态大小和性能调优的方向窗口虽然好用但状态管理才是生产环境里真正见功夫的地方。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑