做实时计算这几年,“窗口”这个词几乎每天都绕不开。数据流本身是无限的,但业务问题通常是有边界的:我要看每分钟的PV、每小时的订单额、最近10条数据的平均时长,这些都需要在无限流上切出有限的一段。Flink的Table API和SQL把窗口能力做成了声明式的,官方把窗口明确分成两大类:Group Windows(分组窗口)和Over Windows(Over窗口)。这篇笔记是我系列笔记里的第16篇,主要把我在实际项目里用Flink Table窗口的总结整理出来,包括两类窗口分别怎么用、和DataStream API的窗口有什么差别、Event Time/Processing Time字段怎么配合,以及哪些坑我踩过。适合已经把窗口从DataStream API搬到SQL的开发者,也适合正在用Table API写窗口但偶尔报错、搞不清语义的人,读完可以直接照着示例改。
1. 窗口的本质:为什么Table API要分Group Windows和Over Windows
1.1 流计算里“无限”和“有限”的矛盾
流计算的数据是持续到达的,理论上没有终点,所以任何聚合都不能直接在整条流上做。窗口做的事情很简单:把无限的数据流按时间或行数切成有限的分片,每个分片内部做聚合,然后随着时间推进不断输出结果。你可以把窗口理解成一把“尺子”,每隔一段量一次。
但同样是切数据,业务需求有两种完全不同的形态。第一种是“把一堆数据凑成一整块再算”,例如统计过去5分钟的总成交额,算完把这块数据“消费”掉,输出一条结果。第二种是“每一行经过时,都能看到以它为中心的一个邻域”,例如每条订单进来,我都想知道这单之前的10单平均价格,或者最近1小时累计成交额,输出结果时每一行都保留,同时附上这个邻域里算出来的值。Flink Table API和SQL分别用Group Windows和Over Windows来对应这两类需求。
1.2 Group Windows和Over Windows的分工
Group Windows就是大家更熟悉的滚动窗口、滑动窗口、会话窗口。它的特点是:窗口内数据先被收集,再整体聚合,每个窗口(或每个窗口内每个分组)最终输出一条结果,窗口之间有明确的划分和边界。Over Windows则来自SQL标准里的OVER子句,它不会把数据“切成块”,而是为每一行计算一个基于它之前或之后若干行的聚合值,输出行数与输入行数保持一致。
两类窗口适用场景完全不同,我整理了一个对比,写代码前先想清楚自己到底要哪种:
| 对比维度 | Group Windows | Over Windows |
|---|---|---|
| 输出行为 | 每个窗口/分组输出一条聚合结果 | 每一行都输出,附带聚合值 |
| 数据是否保留 | 窗口结束即关闭,块内数据不保留 | 所有原始行都保留,逐行计算 |
| 典型场景 | 离线化统计:分钟级PV、小时级GMV | 移动累计:最近N条均值、累计金额 |
| SQL对应 | GROUP BY + 窗口函数 | OVER + 聚合函数 |
| 窗口类型 | Tumble / Slide / Session | ROWS / RANGE 两种边界 |
| 对时间字段的要求 | 聚合前切分,窗口字段可参与GROUP BY | 必须ORDER BY时间字段,否则没意义 |
这个表基本就是你选择窗口方案的判断依据。想统计时间段内的总量,用Group;想看每一行的移动趋势,用Over。两者没有谁替代谁的问题,实际项目里经常组合使用。
1.3 从DataStream API迁移到Table API,窗口到底变了什么
如果你之前写过DataStream API,可能会觉得Table API的窗口“长得不太一样”。DataStream里先keyBy再timeWindow,然后写apply或process函数;Table API里变成了一段SQL或链式表达式:声明窗口、声明分组字段、声明聚合函数,剩下的交给优化器。这是从命令式到声明式的转变,命令式写“怎么实现”,声明式写“要什么结果”。
Table API的窗口不直接操作DataStream,而是作用在“动态表”上。动态表会随时间不断变化,窗口聚合的输出本身也是一张动态表,它会在窗口结束时追加新行,或者在结果发生变化时更新旧行。很多人在这个点绕不过去:为什么一个GROUP BY TUMBLE(...)在流模式下会有更新语义?因为只要分组键不是时间本身,同一个分组可能在不同窗口重复出现,Flink需要区分这是追加还是更新。这一点在选Sink的时候特别重要,后面的实操案例里我会专门提。
2. Group Windows实战:滚动、滑动、会话窗口的正确写法
2.1 Tumble滚动窗口:每N时间切一刀
滚动窗口是最简单的窗口,窗口之间不重叠,每条数据只会属于一个窗口。比如“每分钟统计一次访问量”,就是典型的Tumble窗口。Table API里用TUMBLE(time_attr, INTERVAL '1' MINUTE)来声明,其中第一个参数必须是时间字段,第二个参数是窗口长度。
SQL写法:
SELECT TUMBLE_START(ts, INTERVAL '1' MINUTE) AS win_start, COUNT(*) AS pv FROM user_actions GROUP BY TUMBLE(ts, INTERVAL '1' MINUTE);Table API DSL写法(Java 1.13+风格示意):
Table result = tableEnv .sqlQuery( "SELECT TUMBLE_START(ts, INTERVAL '1' MINUTE) AS win_start, " + "COUNT(*) AS pv FROM user_actions " + "GROUP BY TUMBLE(ts, INTERVAL '1' MINUTE)");这里最关键的是窗口辅助函数。TUMBLE_START返回窗口开始时间,TUMBLE_END返回窗口结束时间(注意是开区间末尾),TUMBLE_ROWTIME和TUMBLE_PROCTIME分别返回可下推的Rowtime和Proctime属性,可用于后续窗口连接或生成时间列。很多人问为什么一定要GROUP BY TUMBLE(ts, ...)而不能只GROUP BY TUMBLE_START(ts, ...),因为窗口本身才是分组键,辅助函数只是从分组键里取属性出来。
2.2 Slide滑动窗口:固定长度加固定滑动步长
滑动窗口用来做“每5分钟看过去1小时”这类重叠统计。窗口长度和滑动步长可以不一样,数据可能同时属于多个窗口。声明方式比Tumble多一个参数:HOP(time_attr, slide_interval, size_interval),第一个是滑动步长,第二个是窗口长度。注意顺序,习惯上容易写反。
SQL写法:
SELECT HOP_START(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS win_start, HOP_END(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS win_end, COUNT(DISTINCT user_id) AS uv FROM user_actions GROUP BY HOP(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR);滑动窗口的语义需要特别注意:不是先按长度切好再等步长触发,而是按步长生成一系列起点,窗口终点的确定方式是按步长推进的。实际效果就是每5分钟会输出一次结果,每次结果覆盖过去1小时的数据。窗口重叠意味着同一条数据可能被计入多个窗口,这是业务想要的,不是Bug。
2.3 Session会话窗口:按活跃间隔切分
会话窗口不依赖固定长度,而是依赖“活跃间隔”。连续两条数据的时间差超过指定GAP,就认为上一个会话结束、新会话开始。它特别适合做用户连续行为分析、在线状态划分。
SQL写法:
SELECT SESSION_START(ts, INTERVAL '10' MINUTE) AS session_start, SESSION_END(ts, INTERVAL '10' MINUTE) AS session_end, user_id, COUNT(*) AS action_cnt FROM user_actions GROUP BY SESSION(ts, INTERVAL '10' MINUTE), user_id;会话窗口在事件时间下比较“挑剔”:它需要更精确的状态管理,因为会话边界要到数据不再出现才能确定,所以延迟数据对会话窗口的影响比滚动窗口更大。实际项目里如果数据迟到乱序严重,会话窗口的结果容易出现“会话被拆开”或者“迟迟不能关闭”的现象,需要配合合理的水位线和空闲分区处理来缓解。
2.4 分组窗口的几个容易忽略的语义细节
分组窗口虽然好写,但语义细节有几个容易踩。
第一,GROUP BY里必须包含窗口字段,这是语法强制的。如果SELECT里只写了TUMBLE_START(...)而GROUP BY里只有普通分组字段,Flink会直接报错。如果你只想按窗口聚合、不需要其他分组字段,那GROUP BY就只写窗口函数。
第二,窗口辅助函数里的参数是时间字段,不是窗口别名。标准SQL里有些数据库支持GROUP BY TUMBLE(...) AS t然后SELECT t.start,但Flink的语法习惯是直接TUMBLE_START(ts, ...),别混着用。
第三,窗口时间的开闭区间。Flink的窗口和DataStream一样,左闭右开。[win_start, win_end),这也是为什么TUMBLE_END看起来是“下一秒/下一分钟”而不是窗口最后一行的时间。
第四,聚合函数的选择。窗口内支持COUNT、SUM、AVG、MAX、MIN、DISTINCT聚合,在SQL里可以直接写。如果是Table API,表达式写法也支持,但COUNT(DISTINCT field)在流模式下状态开销会明显大很多,做UV类指标时要做好状态清理的心理准备。
3. Over Windows实战:ROWS与RANGE边界怎么选
3.1 Over窗口到底在算什么
Over窗口不会重排你的输出结构,它只是在你每一行旁边,通过窗口函数算出一个“站在这一行看邻域”的值。典型问题包括:每条订单对应的近10单平均价格、每个用户从登录到当前时刻的累计消费、每条日志前面5条日志的状态。
这么说吧,Group Windows像“期末成绩单”,学期结束统一统计,出一次成绩;Over Windows更像“记账本”,每花一笔钱,余额立刻重新计算。两种思路各有归宿,但Over窗口在实时场景下有个优势:不需要等窗口结束,数据到达即可算,输出延迟更低。
3.2 ROWS与RANGE:两种边界方式的一次说清
Over窗口的边界用ROWS和RANGE定义。ROWS按物理行数算,比如“前10行到当前行”,不关心时间跨度;RANGE按时间范围算,比如“当前时间往前15分钟”,要求排序字段必须和时间相关。
SQL标准里的写法大家都见过:
-- 最近10条(包含当前行在内共11条) SUM(amount) OVER ( PARTITION BY user_id ORDER BY ts ROWS BETWEEN 10 PRECEDING AND CURRENT ROW ) -- 最近15分钟 SUM(amount) OVER ( PARTITION BY user_id ORDER BY ts RANGE BETWEEN INTERVAL '15' MINUTE PRECEDING AND CURRENT ROW )这里有个常见数错个数的点:ROWS BETWEEN 10 PRECEDING AND CURRENT ROW包含了当前行和之前10行,一共11行。如果只想看“不包括当前行、纯前10行”,要写ROWS BETWEEN 10 PRECEDING AND 1 PRECEDING。Flink对FOLLOWING的支持比较受限,当前行之后通常只支持到CURRENT ROW,如果你需要窗口一直延伸到当前行之后,大概率要自己用连接去实现。
3.3 SQL和Table API两种写法
SQL写法如上,Table API也可以写,核心是over()和.as()。旧版本和新版本的API差别不小,我以常见的Flink 1.13~1.17中间版本为例:
Over w = Over .partitionBy("user_id") .orderBy("ts") .preceding(lit(10).rows()) .as("sum_amt"); Table result = table .window(w) .select("user_id, ts, amount, sum_amt");RANGE写法把preceding(lit(10).rows())换成preceding(lit(15).minutes())即可。注意Table API里Over的别名as("sum_amt")是给窗口命名的,不是给聚合结果命名的,所以preceding(10.rows())和.as("sum_amt")一起用,select里引用的是窗口别名。新手常在这里写错,报错信息又不太直观。
3.4 Over窗口的三个硬性要求
第一,必须ORDER BY一个时间字段。Flink要求Over窗口的排序字段必须是时间属性(TIMESTAMP(3)类型的Event Time或Processing Time字段),否则会报不支持。这是因为Over窗口在流上需要按序处理,没有时间顺序就没法界定“之前”和“之后”。
第二,RANGE边界的时间间隔必须跟排序字段类型匹配。排序字段是TIMESTAMP(3),间隔就要用INTERVAL;排序字段是数字(比如秒时间戳),部分版本的RANGE会要求用数字边界。这个类型不匹配的报错往往很晚才出现,写DDL时就把时间字段统一成TIMESTAMP(3)是最省事的。
第三,Over窗口的PARTITION BY不是必须的,但几乎总是建议写。不写等价于在全表上做Over,每个全局分区自成一路,数据倾斜和复杂度都会放大。大部分业务都是“按用户、按商品、按设备”分区的,所以别省这一行。
4. 时间字段是窗口的地基:Event Time和Processing Time声明
4.1 建表DDL里声明Rowtime和Watermark
不管用哪种窗口,前提都是表里有一个时间字段。直接读Kafka或文件时,原始数据里通常只有业务时间戳,需要显式声明成Event Time,并定义水位线。Flink DDL里常见写法:
CREATE TABLE user_actions ( user_id STRING, action_time STRING, amount DOUBLE, ts AS TO_TIMESTAMP(action_time), WATERMARK FOR ts AS ts - INTERVAL '5' SECOND, PRIMARY KEY (user_id) NOT ENFORCED ) WITH ( 'connector' = 'kafka', 'topic' = 'user_actions', 'properties.bootstrap.servers' = 'localhost:9092', 'format' = 'json', 'properties.group.id' = 'flink-window-note' );这里最关键的是ts AS TO_TIMESTAMP(action_time)把字符串转成TIMESTAMP(3),然后WATERMARK FOR ts AS ts - INTERVAL '5' SECOND声明允许5秒乱序。如果你没有WATERMARK,Event Time窗口基本不会按预期触发,因为Flink不知道什么时候“认为”一个窗口的数据齐了。
4.2 用Proctime做Processing Time窗口
有些场景不需要业务时间,只要系统当前时间,比如“每分钟统计目前收到的数据量”,可以直接声明Proctime字段:
CREATE TABLE live_actions ( user_id STRING, action STRING, proc_time AS PROCTIME() ) WITH ( 'connector' = 'kafka', 'topic' = 'live_actions', 'format' = 'json' );proc_time AS PROCTIME()是生成列,不需要来源字段。之后不管是TUMBLE(proc_time, INTERVAL '1' MINUTE)还是OVER (ORDER BY proc_time ...)都可以直接用。Processing Time窗口简单、无乱序问题,但结果不代表真实业务时间,只能用于“当下这一刻”的统计。
4.3 时间字段选错了会怎样
我在项目里见过几次看起来逻辑没错、但结果就是不对的情况,最后都查到了时间字段上。
- 把
STRING类型时间字段放进TUMBLE,Flink会报“Window time field must be of type TIMESTAMP”之类错误,这种还算友好。 - 用了Event Time但没声明Watermark,窗口永远不触发,数据全积压,日志里没有明显报错,这是最隐蔽的坑。
- 建表时声明了
ts AS TO_TIMESTAMP(...),但原始时间戳是13位毫秒数,需要TO_TIMESTAMP_LTZ(ts / 1000, 3),不然把毫秒当秒转,时间直接错乱。
所以我的习惯是:在SELECT里先输出一下时间字段,肉眼确认时间对不对,再做窗口聚合。这一步能省下很多排查工时。
5. 模拟项目:把几种窗口组合到一张表上做用户行为分析
5.1 场景设计与模拟数据
为了把前面几章串起来,我用一个叫“模拟项目X”的例子:假设业务方要给运营看实时大屏,需要三块指标:每分钟页面访问量、每5分钟看过去1小时活跃用户数、每条访问记录旁边附上该用户最近10条平均浏览时长和最近15分钟累计消费金额。
输入是一张用户行为表user_actions,字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| user_id | STRING | 用户ID |
| action_time | STRING | 发生时间,如2024-06-01 10:30:00 |
| action | STRING | 浏览/下单/支付 |
| duration | BIGINT | 本条访问时长(秒) |
| amount | DOUBLE | 订单金额,浏览时为0 |
DDL里把action_time转成ts TIMESTAMP(3),Watermark延迟5秒。为了测试,我先往Kafka里塞100条模拟数据:10个用户,时间从10:00到10:59,浏览为主,部分下单支付。这种数据量不大,但足够验证三种窗口的语义。
5.2 窗口聚合:分钟级PV和5分钟窗口活跃用户
分钟级PV直接用Tumble窗口:
SELECT TUMBLE_START(ts, INTERVAL '1' MINUTE) AS win_start, COUNT(*) AS pv FROM user_actions GROUP BY TUMBLE(ts, INTERVAL '1' MINUTE);每5分钟看过去1小时活跃用户数用Hop窗口:
SELECT HOP_START(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS win_start, HOP_END(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR) AS win_end, COUNT(DISTINCT user_id) AS active_users FROM user_actions GROUP BY HOP(ts, INTERVAL '5' MINUTE, INTERVAL '1' HOUR);这个查询在流模式下会有状态更新:滑动窗口每5分钟输出一次,但同一个窗口还没真正结束时,可能因为迟到数据发生更新。如果Sink是打印到控制台,能看到+I和-U/+U两种消息,+I是新增,-U/+U是更新,说明Flink认为这个结果是可更新的。选Sink前必须确认它支持更新模式,这是模拟项目里最容易被忽略的。
5.3 Over聚合:最近10条平均浏览时长和15分钟累计金额
Over窗口适合逐行计算。这里我想给每条访问记录附上两个值:该用户最近10条访问的平均时长,以及最近15分钟累计金额。两个查询可以写成一条SQL,用子查询或直接叠加Over窗口:
SELECT user_id, action_time, duration, amount, AVG(duration) OVER ( PARTITION BY user_id ORDER BY ts ROWS BETWEEN 10 PRECEDING AND CURRENT ROW ) AS avg_dur_10, SUM(amount) OVER ( PARTITION BY user_id ORDER BY ts RANGE BETWEEN INTERVAL '15' MINUTE PRECEDING AND CURRENT ROW ) AS sum_amt_15m FROM user_actions;这条SQL跑起来后,每条输入都会实时算出一个结果,输出行数等于输入行数。注意两个Over窗口共用了同样的PARTITION BY user_id ORDER BY ts,但边界不同,一个ROWS一个RANGE。Flink允许同一条SQL里出现多个Over窗口,但排序字段要一致才高效,否则可能增加额外排序。这里的sum_amt_15m用的是RANGE,只统计当前时间之前15分钟内的数据,忽略时间范围外的行,这跟“最近10条”是完全不同的口径。
5.4 结果落到外部存储
把查询结果写到外部存储时,要考虑更新模式。Group Windows的结果如果不带分组键,通常可以当作append-only;如果带分组键或用了Hop窗口,结果会带更新,适合用支持Upsert的Sink,比如MySQL用primary key的方式。Over窗口的结果也是逐行追加的,用Kafka或打印基本都能接。
一个稳妥的做法是给结果表里增加一个唯一键。例如Tumble窗口结果可以用TUMBLE_START(ts, INTERVAL '1' MINUTE)作为主键,Hop窗口结果用win_start和win_end组合键,这样Sink端能正确处理更新。我见过纯粹因为没定义主键,导致MySQL结果表出现重复行的案例,排查起来很费劲。
6. 常见报错、排查实录与避坑技巧
6.1 高频报错速查表
下面这些报错和问题是我在实际使用中反复碰到、或确认过同行的真实案例,整理成速查表:
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| 窗口函数报“Window time field must be TIMESTAMP” | 时间字段是STRING或BIGINT没转换 | DDL里通过生成列转TO_TIMESTAMP(...)或TO_TIMESTAMP_LTZ(...) |
| Event Time窗口不触发,结果一直为空 | 没声明Watermark,或Watermark计算有误 | 检查DDL的WATERMARK FOR ts AS ...,并观察Watermark是否推进 |
| GROUP BY报“Column not found” | 窗口字段没有同时出现在GROUP BY里 | 确认GROUP BY TUMBLE(ts, ...)中包含整个窗口表达式 |
| Over窗口报“ORDER BY must be time attribute” | ORDER BY普通字段而非时间字段 | 把排序字段换成Event Time/Processing Time字段 |
| RANGE窗口报类型不匹配 | 排序字段是TIMESTAMP但边界用了数字,或反之 | 统一用INTERVAL N MINUTE配TIMESTAMP(3) |
| 结果出现重复行 | Sink不支持更新模式且没设主键 | 结果表加唯一键,或用Upsert Sink |
| 滑动窗口结果数量明显偏大 | 把滑动步长和窗口长度写反了 | 检查HOP(time, slide, size),slide在前size在后 |
6.2 窗口不触发、结果少数据的排查思路
这类问题排查起来最费劲,我的固定排查顺序是这样的。
第一,先看时间字段。把ts直接放进SELECT打印出来,确认是时间格式、年份月份正确。如果看到时间整体偏移几小时,多半是时区或毫秒转秒的问题。
第二,看Watermark有没有推进。SQL客户端里可以用SHOW WATERMARKS或者直接观察输出延迟。如果Watermark一直停在初始值,检查上游数据源的时间字段是否真的在新数据里变大,有没有出现长时间没有数据的分区(空闲分区会导致Watermark不推进)。
第三,看窗口时间粒度。Flink窗口默认基于UTC毫秒计算,DDL里时间已经转成TIMESTAMP(3),但如果客户端配置的table.local-time-zone不对,窗口边界可能和你本地时间对不上。窗口结果看似“晚了一小时”或“早了一小时”,先检查时区。
第四,看流的更新语义。有些查询结果正常但写进Sink后数据对不上,原因在Sink不支持更新。把Sink换成print或console看日志,通常能一眼发现-U和+U消息,这说明结果在更新。
6.3 我自己常用的几条避坑习惯
最后说几个长期实践沉淀下来的操作习惯,不算什么高深东西,但确实帮我少踩很多坑。
第一,小数据优先。写任何窗口SQL前,先用一条静态或限量数据在SQL Client里跑一遍,确认窗口边界和辅助函数的值符合预期。不要一上来就接Kafka全量流,排错成本高到让你怀疑人生。
第二,把时间字段统一成一个约定。全项目都要求TIMESTAMP(3),原始字符串是yyyy-MM-dd HH:mm:ss就用TO_TIMESTAMP,是13位毫秒就用TO_TIMESTAMP_LTZ(ts / 1000, 3),避免每个任务各写各的时间转换。
第三,优先用SQL而不是Table API DSL。Table API的Over窗口链式写法在不同版本之间变化大,SQL表达窗口语义更标准,后面维护的人看到SQL至少不用翻版本文档。
第四,窗口别嵌套使用。Flink不支持在一个窗口结果上直接再开另一个Group Window,如果必须做“窗口之上再开窗口”,一般先落中间结果到消息队列,再起一个任务消费,这是主流做法。
我自己在实际使用中的体会是,Group Windows和Over Windows在Table API里只是语法上的分类,本质都依赖时间字段。窗口边界、时间类型、更新模式这三个点弄清楚了,大部分问题都能自己定位。最后分享一个小技巧:调试窗口SQL时,把窗口辅助函数如TUMBLE_START、TUMBLE_ROWTIME一起SELECT出来,既能确认窗口边界,又能直接看到每条结果对应的时间范围,这个输出比任何日志都好用。