Flink SQL 流批一体:从 SQL 到执行计划,优化器如何工作?
Flink SQL 的"流批一体"经常被误解为"同一套 SQL 在流和批上跑出相同的结果"。这个理解只对了一半:语义统一不等于执行统一 。这篇文章拆解 Blink Planner 的编译链路,用两个理论锚点(增量视图维护、Exchange 算子)解释流和批为什么必然在物理层分叉,并用四个带完整 EXPLAIN 输出的对照实验 回答三个问题:同一条聚合 SQL 的执行计划如何分叉、
COUNT(DISTINCT)在倾斜负载下慢一个数量级的结构性根源到底是什么(本文给出可代入参数的状态基数模型与推演对比表)、以及两阶段聚合、Split Distinct、Lookup Hint 各自怎么开启、怎么在 EXPLAIN 和运行时指标上验证生效。

引言:流批一体,一体的是什么?
一个常见的困惑:同一个 GROUP BY 聚合,在批模式下几秒跑完,在流模式下却延迟高、状态膨胀、还倾斜。
问题不在 SQL 本身,而在流和批对"聚合"的执行语义根本不同。批模式下数据有界,优化器可以选择全量排序或哈希聚合,一次性输出结果;流模式下数据无界,聚合必须是增量 的------每来一条记录就要更新结果,并维护状态应对乱序、迟到与回撤。语义上,流聚合和批聚合都返回"正确的结果";物理执行上,它们是两套完全不同的算子。
Blink Planner 的设计哲学是"批是流的特例 ":逻辑优化规则尽可能复用,物理计划层对流和批保留两套规则集。"一体"发生在 SQL 层和逻辑计划层 ,物理计划和执行层 分叉。理解这个分叉,是读懂 Flink SQL 执行计划的前提。
本文的方法论约定:每个结论都配三样证据------(1) 可以一分钟复现的 EXPLAIN 输出;(2) 可以代入自己参数的量化模型或推演对比;(3) 运行时指标的验证路径。优化器行为不是靠信仰理解的,是靠观察。
一、前置知识:编译链路与两个理论锚点
1.1 从 SQL 到 JobGraph:流水线与 EXPLAIN 的三段结构
SQL 文本 → SqlParser → SqlNode(AST)
→ SqlValidator → RelNode(逻辑计划)
→ 逻辑优化(Blink 规则管线)→ 优化后的 RelNode
→ 物理转换(流/批分叉)→ 物理计划
→ CodeGen → StreamTransformation → JobGraph
- Calcite 负责解析、验证与关系代数表示:SQL 翻译为关系代数树,谓词下推、投影裁剪、表达式折叠等规则在此基础上操作;
- Blink Planner 负责优化程序、物理转换与 CodeGen :逻辑优化是确定性的规则管线 (按固定规则序列执行,而非代价搜索;代价模型仅在 join reorder 等可选功能中参与)。这个"确定性"很重要:在统计信息不变的前提下,同样的 SQL + 同样的配置,EXPLAIN 输出完全可复现------这是后文一切对照实验的方法论基础;
- 流和批的分叉发生在物理转换 :流模式转换为流物理节点,批模式转换为批物理节点,各自选择不同算子实现。
EXPLAIN 的输出分三段,各回答一个问题:
| 段名 | 回答的问题 | 判读关注点 |
| :--- | :--- | :--- |
| Abstract Syntax Tree | SQL 被解析成什么 | 逻辑结构是否符合预期(LogicalAggregate、LogicalTableScan等) |
| Optimized Physical Plan | 优化器最终选了什么算子 | 判读优化器行为,主要看这段 :节点个数、Exchange位置与distribution、changelogMode|
| Optimized Execution Plan | 物理计划如何映射到运行时 | 并行度、sourceSplit等运行时属性 |
关键性质:EXPLAIN 不触发执行------零副作用、秒级返回、不需要任何外部系统在线。改一个配置、照一次片子、对比、再改。
1.2 为什么需要 Blink Planner
旧 Planner 将批 SQL 和流 SQL 分别翻译为 DataSet API 与 DataStream API,两套 API 在 Runtime 层也隔离,逻辑优化无法复用。Blink 的改造:批作为流的有界特例,统一用 Stream Transformation 承载执行,批的"全量到齐再计算"用阻塞交换实现。这个设计使"流批一体"从口号变成架构,也预告了 1.4 节的分叉本质。
1.3 理论锚点一:流式聚合 = 增量视图维护(IVM)
把流表看作随时间变化的关系 R(t),连续查询就是在 R 的变更序列上维护一个物化视图 :每次输入增量 ΔR,算子输出视图变更 ΔV(这就是 changelog:+I 插入、−U/+U 回撤与更新),而 keyed state 就是物化视图本身。这个视角一举解释三件事:
- 流聚合为什么必须有状态------状态不是实现细节,是视图的本体;
- 为什么输出是 changelog------ΔV 是增量维护的输出形态;
- 状态大小由什么决定 ------
|state| = |视图|。
第三点有一个数据库界的经典结论(Blakeley, Larson, Tompa, "Efficiently Updating Materialized Views", SIGMOD 1986):
- 可合并的聚合 (SUM/COUNT/MAX/MIN/AVG 均可分解为 SUM/COUNT 的组合):累加器是可压缩的 ------COUNT 只需一个计数器,SUM 只需一个累加值,无需保留任何原始记录,因此状态 O(组数);
- DISTINCT 类聚合不可自维护 :要正确维护
COUNT(DISTINCT x),视图必须能回答"x 之前来过没有"------这要求保留底层 `(分组键, 去重键) 集合本身 ,状态 O(组数 × 去重键数),无法压缩 。
这一个结论预告了后文的全部分野:两阶段聚合救得了前者、救不了后者;Split Distinct 是后者的专门解法。
1.4 理论锚点二:Exchange 算子与阻塞/流水线之分
Graefe 在 Volcano 系统(Goetz Graefe, "Volcano---An Extensible and Parallel Query Evaluation System", SIGMOD 1989)中提出的两个概念影响至今:
- Exchange 算子 :并行数据库的数据重分布节点------EXPLAIN 里的
Exchange(distribution=[hash[...]])就是它,Calcite 正是 Volcano/Cascades 血统的后裔; - 执行模型的阻塞/流水线之分 :批执行允许阻塞交换 (上游全部物化再交给下游------排序、去重、全量哈希因此可行);流执行只允许流水线交换 (数据边到边走,任何"等数据到齐"的算子都不可行)。
"批是流的特例"的准确含义是:批 = 允许阻塞的流。流被加上"永不阻塞"的约束,排序聚合从物理选项中消失,增量聚合登场。两阶段聚合(Local-Global 哈希聚合)是并行数据库与 MapReduce combiner 的同一招式,流批都做------差异在于流上的局部层必须回答"局部结果何时发射"(答案就是 mini-batch),批上直接物化即可。
本节小结 :编译链路是"Calcite 解析验证 → Blink 确定性规则优化 → 物理转换分叉 → CodeGen"。判读优化器看
Optimized Physical Plan段。两个理论锚点:流聚合 = 增量视图维护(状态即视图、累加器可压缩性决定状态复杂度);流批之别 = 流水线约束与阻塞许可之别。
二、核心剖析:优化器在流和批之间做了什么
2.1 共享的逻辑优化:下推是一场"能力协商"
谓词下推、投影裁剪、表达式折叠对流批同时生效。但下推不是优化器单方面的决定,而是与 Source 能力的协商 :连接器实现了 SupportsProjectionPushDown、SupportsFilterPushDown 等接口,才能"接住"对应的下推规则。
EXPLAIN 判读(修正一个常见误读):
- scan 节点
fields=[user_id]→ 投影已下推(列裁剪生效); - 谓词被 Source 接住 → 吸收进 scan 节点(显示
filters=[...]属性,或上层不再出现 Filter/Calc); - 谓词没被接住 → 保留为上层
Calc(where=[...])节点; fetch=[...]→ 对应的是 LIMIT 下推 ,与过滤下推无关。
这解释了一个高频困惑:为什么我的 WHERE 没推下去------不是优化器笨,是 connector 没实现对应能力。实验 3 会让你亲眼看到一场"推了一半"的协商。
2.2 流批分叉的物理决策:聚合与 Join 策略全景
聚合的分叉:
| 模式 | 聚合策略 | 状态 | 输出形态 |
|---|---|---|---|
| 流 | 增量聚合(GroupAggregate,默认单阶段) |
必须维护(状态即视图) | changelog(+I / −U / +U) |
| 批 | 全量哈希聚合(HashAggregate)或排序聚合 |
无长生命周期状态,中间结果物化 | 一次性结果 |
| Join 的可用策略(修正"只能选 Regular Join 或 Lookup Join"的说法): | |||
| 模式 | 可用 Join 策略 | 状态约束 | |
| :--- | :--- | :--- | |
| 流 | Regular Join、Lookup Join、Interval Join、Temporal Join | 无界两侧状态无限增长,需 TTL 治理 | |
| 批 | HashJoin、SortMergeJoin、NestedLoopJoin | 无状态,可阻塞 | |
| 流 Join 的核心约束是无界两侧的状态无限增长------这决定了 TTL 的必要性(4.4 节展开),也是 Lookup Join 成为维表场景标配的原因(3.5 节)。 |
2.3 两阶段聚合:combiner 的流式重生,以及它救不了 COUNT(DISTINCT) 的原因
流聚合默认单阶段:GROUP BY shop_id 时所有同 shop 数据经 hash[shop_id] Shuffle 到同一算子。两阶段拆成 LocalAgg (Shuffle 前局部预聚合)+ GlobalAgg:
- 对 SUM/COUNT 等可合并聚合 :局部累加器可合并(1.3 节的累加器可压缩性),Shuffle 量从原始行数压缩到"分区 × 批内组数",全局层状态 O(组数)------倾斜治理有效;
- 对 COUNT(DISTINCT) :局部聚合的"累加器"就是去重键集合本身(不可自维护),局部只能去重"同分区同批内的重复",压不了基数 。
真正的瓶颈要用 1.3 节的状态公式看清楚。设头部商家的 distinct user 为 5000 万、并行度 16: - 单阶段 :5000 万个
(shop_id, user_id)状态条目经hash[shop_id]全部驻留在同一个 subtask (估算每条 64--128 字节,即单点 GB 级状态),且该 subtask 承接全部流量------这就是"流模式比批慢一个数量级"的结构性来源:批可以物化去重一次成型,流必须为每个状态条目做增量维护; - 两阶段 :Shuffle 量或许有压缩,但状态基数分毫未动 ------
hash[shop_id]仍然把 5000 万条押在同一个 subtask。慢依旧。
结论一句话:SUM/PV 倾斜用两阶段;UV(DISTINCT)倾斜必须换键------Split Distinct。
2.4 Split Distinct:用换键分散状态基数
改写思路(与 Flink 自动改写等价):
- 第一层 按
(shop_id, MOD(HASH_CODE(user_id), 1024))分组做COUNT(DISTINCT user_id)------桶键是 user_id 的确定性函数 ,任一 user 恒落同桶,因此桶内 distinct 之和 = 总 distinct(正确性由此保证); - 第二层 按
shop_id汇总各桶计数。
关键在两次 Shuffle 的键:
- 第一次 按
hash[shop_id, bucket]:5000 万状态条目被 1024 个桶打散到多个 subtask------理想均匀分布下最大单点状态从 5000 万条降到约5000万/16 ≈ 312万条(这是完美均匀分布的上限估算,实际取决于哈希分布),均衡性提升约等于并行度倍数; - 第二次 按
hash[shop_id]看似又回到单点,但此时每个商家每批只输入bucket_num(1024)行,第二层状态 O(组数)------已被压缩三个数量级,不构成瓶颈。
代价 (没有免费的打散):多一层算子与一次额外 Shuffle;第二层每商家每批固定消费 1024 行(bucket_num不能盲目调大);端到端延迟略增。
优化前后对比(理论推演,读者可代入自己的参数验证方向) :
| 维度 | 单阶段 | 两阶段 | Split Distinct 开启后 |
| :--- | :--- | :--- | :--- |
| 最大单点状态条目 | 5000 万 | 5000 万(不变) | ≈312 万(理想均匀,÷16) |
| 瓶颈 subtask 的 busyTime | 逼近 100% | 逼近 100%(不变) | 与其他 subtask 趋同 |
| 第二层输入行数/批/商家 | --- | --- | 固定 1024(bucket_num) |
| Shuffle 次数 | 1 | 1 | 2 |
| 端到端延迟 | 基线 | 基线 + mini-batch allow-latency | 基线 + allow-latency + 一层开销 |
注意两阶段那一列:状态与瓶颈完全没动 ------这就是"两阶段救不了 DISTINCT"的量化呈现,也是第 3 章实验 2 要用 EXPLAIN 亲手验证的结论。
开启方式(原文缺失的关键一环):
sql
SET 'table.optimizer.distinct-agg.split.enabled' = 'true'; -- 总开关(缺它一切免谈)
SET 'table.optimizer.distinct-agg.split.bucket-num' = '1024'; -- 桶数,默认 1024
本节小结 :逻辑层下推是优化器与 Source 能力的协商;物理层分叉的根源是流水线约束与视图维护语义。倾斜治理的分野由累加器可压缩性决定:两阶段救 Shuffle 量与可合并聚合,Split Distinct 用换键救 DISTINCT 的状态基数------上一节的推演表就是三个方案的量化对比。
三、手把手实操:四个实验,先推演、再 EXPLAIN、后验证
3.1 环境准备
环境依赖:JDK 11,Flink 1.18.1,Maven 3.6+。
xml
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-api-java-bridge</artifactId>
<version>1.18.1</version>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-planner-loader</artifactId>
<version>1.18.1</version>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-clients</artifactId>
<version>1.18.1</version>
</dependency>
三个说明:① datagen 内置于 table 模块 ,无需额外连接器依赖;② 实验 1--3 只做 EXPLAIN,不执行、不连任何外部系统 ,秒级完成;③ 不用 IDE 的话,SQL Client(bin/sql-client.sh embedded)等价可复现------SET '...' 后直接 EXPLAIN SELECT ...。
本文所有 EXPLAIN 输出在 1.18.1 上整理排版(省略不影响判读的个别属性;属性名随小版本可能有差异)。判读信号一律使用稳定特征:聚合节点个数、Exchange 的位置与 distribution、changelogMode。
3.2 实验 1:单阶段 vs 两阶段,再对照批模式
java
import org.apache.flink.table.api.*;
import org.apache.flink.table.api.config.ExecutionConfigOptions;
import org.apache.flink.table.api.config.OptimizerConfigOptions;
import java.time.Duration;
public class AggPhaseLab {
public static void main(String[] args) {
TableEnvironment tEnv = TableEnvironment.create(
EnvironmentSettings.newInstance().inStreamingMode().build());
tEnv.executeSql(
"CREATE TABLE orders (shop_id STRING, user_id STRING, amount DECIMAL(10,2)) " +
"WITH ('connector' = 'datagen', 'rows-per-second' = '1000')");
// 先用可合并聚合做阶段对照(DISTINCT 在实验 2 单独观察,避免变量混杂)
String sql = "SELECT shop_id, COUNT(user_id) AS pv, SUM(amount) AS gmv " +
"FROM orders GROUP BY shop_id";
// ---- 1) 显式单阶段:对照基线(不依赖版本默认值,确保可复现) ----
tEnv.getConfig().set(OptimizerConfigOptions.TABLE_OPTIMIZER_AGG_PHASE_STRATEGY,
OptimizerConfigOptions.AggregatePhaseStrategy.ONE_PHASE);
System.out.println("========== 1. ONE_PHASE ==========");
System.out.println(tEnv.explainSql(sql));
// ---- 2) 两阶段:需 mini-batch 攒批配合 ----
tEnv.getConfig().set(ExecutionConfigOptions.TABLE_EXEC_MINI_BATCH_ENABLED, true);
tEnv.getConfig().set(ExecutionConfigOptions.TABLE_EXEC_MINI_BATCH_ALLOW_LATENCY,
Duration.ofSeconds(5));
tEnv.getConfig().set(ExecutionConfigOptions.TABLE_EXEC_MINI_BATCH_SIZE, 5000L);
tEnv.getConfig().set(OptimizerConfigOptions.TABLE_OPTIMIZER_AGG_PHASE_STRATEGY,
OptimizerConfigOptions.AggregatePhaseStrategy.TWO_PHASE);
System.out.println("========== 2. TWO_PHASE ==========");
System.out.println(tEnv.explainSql(sql));
}
}
输出 1(ONE_PHASE,展示完整三段结构):
========== 1. ONE_PHASE ==========
== Abstract Syntax Tree ==
LogicalAggregate(group=[{0}], pv=[COUNT($1)], gmv=[SUM($2)])
+- LogicalTableScan(table=[[default_catalog, default_database, orders]])
== Optimized Physical Plan ==
GroupAggregate(groupBy=[shop_id], select=[COUNT(user_id) AS pv, SUM(amount) AS gmv])
+- Exchange(distribution=[hash[shop_id]])
+- TableSourceScan(table=[[default_catalog, default_database, orders]], fields=[shop_id, user_id, amount])
== Optimized Execution Plan ==
GroupAggregate(groupBy=[shop_id], ...)
+- Exchange(distribution=[hash[shop_id]], shuffle=HASH)
+- TableSourceScan(..., sourceSplit=[...])
输出 2(TWO_PHASE,只列 Physical Plan 段):
== Optimized Physical Plan ==
GroupAggregate(groupBy=[shop_id], select=[pv, gmv])
+- Exchange(distribution=[hash[shop_id]])
+- LocalGroupAggregate(groupBy=[shop_id], select=[PARTIAL_COUNT(user_id), PARTIAL_SUM(amount)])
+- TableSourceScan(table=[[default_catalog, default_database, orders]], fields=[shop_id, user_id, amount])
判读(两个稳定信号) :聚合节点从一个变两个;Exchange 从 scan 之上移到了 LocalAgg 之上 ------先局部压缩、再 Shuffle,正是 combiner 的计划形态。
批模式对照(同一条 SQL,只改运行模式与源的有界性):
java
// 批模式环境:注意源必须给 number-of-rows(有界),否则建表即报错
TableEnvironment bEnv = TableEnvironment.create(
EnvironmentSettings.newInstance().inBatchMode().build());
bEnv.executeSql("CREATE TABLE orders (shop_id STRING, user_id STRING, amount DECIMAL(10,2)) " +
"WITH ('connector' = 'datagen', 'number-of-rows' = '100000')");
// 同一条聚合 SQL
String batchSql = "SELECT shop_id, COUNT(user_id) AS pv, SUM(amount) AS gmv " +
"FROM orders GROUP BY shop_id";
System.out.println(bEnv.explainSql(batchSql));
== Optimized Physical Plan ==
HashAggregate(isFinal=[true], groupBy=[shop_id], select=[pv, gmv])
+- Exchange(distribution=[hash[shop_id]])
+- HashAggregate(isFinal=[false], groupBy=[shop_id], select=[shop_id, PARTIAL_COUNT(user_id), PARTIAL_SUM(amount)])
+- TableSourceScan(table=[[default_catalog, default_database, orders]], fields=[shop_id, user_id, amount])
三个稳健差异:
| 差异 | 流(TWO_PHASE) | 批(默认) |
|---|---|---|
| 聚合节点名 | GroupAggregate + LocalGroupAggregate |
HashAggregate(isFinal=[true/false]) |
| 局部层发射时机 | 由 mini-batch 的 allow-latency / size 控制 |
上游物化完成即发射 |
| 输出语义 | changelog(+I/-U/+U) |
一次性结果 |
| 注意一个反直觉的事实:批也在做两阶段 ------Local-Global 本就是并行数据库的传统艺能。流批的真正差异不在"分不分阶段",而在局部结果何时、以什么形态发射。 | ||
两个坑 :批模式源必须给 number-of-rows(有界),否则建表即报"无界源不支持批模式";实验必须显式 ONE_PHASE/TWO_PHASE,因为默认 AUTO 的选择随 mini-batch 开关变化。 |
3.3 实验 2:COUNT(DISTINCT) 与 Split Distinct
java
import org.apache.flink.table.api.*;
import org.apache.flink.table.api.config.ExecutionConfigOptions;
import org.apache.flink.table.api.config.OptimizerConfigOptions;
import java.time.Duration;
public class SplitDistinctLab {
public static void main(String[] args) {
TableEnvironment tEnv = TableEnvironment.create(
EnvironmentSettings.newInstance().inStreamingMode().build());
tEnv.executeSql("CREATE TABLE orders (shop_id STRING, user_id STRING, amount DECIMAL(10,2)) " +
"WITH ('connector' = 'datagen', 'rows-per-second' = '1000')");
String sql = "SELECT shop_id, COUNT(DISTINCT user_id) AS uv, SUM(amount) AS gmv " +
"FROM orders GROUP BY shop_id";
tEnv.getConfig().set(ExecutionConfigOptions.TABLE_EXEC_MINI_BATCH_ENABLED, true);
tEnv.getConfig().set(ExecutionConfigOptions.TABLE_EXEC_MINI_BATCH_ALLOW_LATENCY, Duration.ofSeconds(5));
tEnv.getConfig().set(ExecutionConfigOptions.TABLE_EXEC_MINI_BATCH_SIZE, 5000L);
// 1) 未开启 Split Distinct(显式单阶段,得到干净的基线)
tEnv.getConfig().set(OptimizerConfigOptions.TABLE_OPTIMIZER_AGG_PHASE_STRATEGY,
OptimizerConfigOptions.AggregatePhaseStrategy.ONE_PHASE);
System.out.println("========== 未开启 ==========");
System.out.println(tEnv.explainSql(sql));
// 2) 开启 Split Distinct(总开关!)
tEnv.getConfig().set(OptimizerConfigOptions.TABLE_OPTIMIZER_DISTINCT_AGG_SPLIT_ENABLED, true);
System.out.println("========== 开启 Split Distinct ==========");
System.out.println(tEnv.explainSql(sql, ExplainDetail.CHANGELOG));
}
}
输出 1(未开启):
== Optimized Physical Plan ==
GroupAggregate(groupBy=[shop_id], select=[COUNT(DISTINCT user_id) AS uv, SUM(amount) AS gmv])
+- Exchange(distribution=[hash[shop_id]])
+- TableSourceScan(table=[[default_catalog, default_database, orders]], fields=[shop_id, user_id, amount])
输出 2(开启 Split Distinct + CHANGELOG 详情;若同时开启 TWO_PHASE,可能叠加一层 LocalGroupAggregate,此处为聚焦改写结构省略------判读信号不受影响):
== Optimized Physical Plan ==
GroupAggregate(groupBy=[shop_id], select=[uv, gmv], changelogMode=[U])
+- Exchange(distribution=[hash[shop_id]], changelogMode=[U])
+- GroupAggregate(groupBy=[shop_id, bucket], select=[uv, gmv])
+- Calc(select=[shop_id, user_id, MOD(HASH_CODE(user_id), 1024) AS bucket, amount])
+- Exchange(distribution=[hash[shop_id, bucket]], changelogMode=[I])
+- TableSourceScan(table=[[...orders]], fields=[shop_id, user_id, amount], changelogMode=[I])
判读(三个稳定信号,对照 2.4 节的推演):
- 第二层 是最顶上的
GroupAggregate(groupBy=[shop_id])------按商家汇总; - 第一层 是中间的
GroupAggregate(groupBy=[shop_id, bucket])------分桶去重;Calc节点出现MOD(HASH_CODE(user_id), 1024)表达式; - 两次 Exchange 的键不同 :第一次
hash[shop_id, bucket](状态基数在这一层被分散),第二次hash[shop_id](数据已被压缩三个数量级,不构成瓶颈)。
changelogMode 是什么 :注解里 I = insert-only,U = update。scan 是[I](append-only 源),聚合后变成[U](update 流)------这一行就是"流批语义分叉"的可视化 :批模式没有这个维度。
手动等价改写(理解机制的透镜,也是自动优化不可用时的 fallback):
sql
-- 第一层:按 (shop_id, bucket) 分桶去重
SELECT shop_id, MOD(HASH_CODE(user_id), 1024) AS bucket,
COUNT(DISTINCT user_id) AS uv_bucket, SUM(amount) AS gmv_bucket
FROM orders
GROUP BY shop_id, MOD(HASH_CODE(user_id), 1024);
-- 第二层:按 shop_id 汇总
SELECT shop_id, SUM(uv_bucket) AS uv, SUM(gmv_bucket) AS gmv
FROM ( -- 上面第一层的结果 -- )
GROUP BY shop_id;
正确性一句话:桶键是 user_id 的确定性函数 → 每个 distinct user 恒落同桶 → 桶内 distinct 之和 = 总 distinct。
运行时验证 (EXPLAIN 只证明"计划变了",不证明"性能好了";datagen 的默认分布是均匀的,要观察倾斜需先构造倾斜数据------把热点 key 的生成权重调高,或改用 fields.#.kind='sequence' 限定取值域):
bash
# REST API 获取各 subtask 的指标(替换占位符:jobId、vertexId、subtaskIndex)
curl "http://<jobmanager>:8081/jobs/<jobId>/vertices/<vertexId>/subtasks/<subtaskIndex>/metrics?get=0.numRecordsInPerSecond,0.busyTimeMsPerSecond,0.numRecordsOutPerSecond"
或在 WebUI → 聚合算子所在 Vertex → Metrics 页签添加:
flink_taskmanager_job_task_operator_numRecordsInPerSecond
flink_taskmanager_job_task_operator_busyTimeMsPerSecond
flink_taskmanager_job_task_operator_numRecordsOutPerSecond
优化前的特征 (对应 2.4 节推演表第二列):个别 subtask 的 busyTimeMsPerSecond 逼近 1000、其余接近 0;单点 state 远大于其余。开启后 :各 subtask 指标应趋于均衡。计划证据(EXPLAIN)+ 指标证据(subtask 均衡性),才是完整的因果链。
3.4 实验 3:观察一场"推了一半"的下推协商
java
// 复用 3.2 的 tEnv 与 orders 表
System.out.println(tEnv.explainSql(
"SELECT shop_id FROM orders WHERE user_id = 'u1'"));
== Optimized Physical Plan ==
Calc(select=[shop_id], where=[=(user_id, 'u1')])
+- TableSourceScan(table=[[default_catalog, default_database, orders]], fields=[shop_id, user_id])
判读 :fields=[shop_id, user_id]------投影下推成功(user_id 因过滤需要被保留);但 where 留在上层 Calc------过滤下推失败 。原因不是优化器偷懒,而是 datagen 没有实现过滤下推能力接口 (2.1 节的协商机制)。换成 JDBC 等支持谓词下推的连接器,where 会被吸收进 scan 节点。这个 10 秒的实验,是排查"为什么我的过滤没推下去"的最短路径。
3.5 实验 4:Lookup Join------修正版 Hint 与吞吐上限模型
场景:流表关联外部维表。表定义(EXPLAIN 不连库,但 JDBC connector 依赖需在 classpath,需额外引入 flink-connector-jdbc):
sql
CREATE TABLE orders (order_id STRING, product_id STRING, amount DECIMAL(10,2),
proctime AS PROCTIME())
WITH ('connector' = 'datagen', 'rows-per-second' = '1000');
CREATE TABLE products (id STRING, product_name STRING, category STRING)
WITH ('connector' = 'jdbc',
'url' = 'jdbc:mysql://mysql:3306/dim', 'table-name' = 'products',
'username' = 'dim_user', 'password' = '***');
同步基线(默认):
sql
SELECT o.order_id, p.product_name
FROM orders AS o
JOIN products FOR SYSTEM_TIME AS OF o.proctime AS p ON o.product_id = p.id;
EXPLAIN 中体现为 LookupJoin 节点挂接在主流的 TableSourceScan 之上(维表不作为独立扫描节点出现------它按 key 现查)。
吞吐上限模型 :同步模式下,吞吐上限 = 1 / 单次查询延迟。若外部查询 P99 为 10ms,同步 Lookup 的最大吞吐仅约 100 条/秒 ------这就是维表 Join 成为流作业瓶颈的数学原因。
修正版 Hint (原文的 'cache'='LRU' 与 'shuffle'='true' 均非 1.18 合法选项,照抄会报错):
sql
-- 异步模式:吞吐 = capacity / 延迟(理论上限)
-- 注意:async 要求连接器支持异步查询;JDBC 目前不支持
SELECT /*+ LOOKUP('table' = 'products', 'async' = 'true', 'capacity' = '200',
'timeout' = '30s', 'output-mode' = 'allow_unordered') */
o.order_id, p.product_name
FROM orders AS o
JOIN products FOR SYSTEM_TIME AS OF o.proctime AS p ON o.product_id = p.id;
-- 本地缓存:cache 合法取值为 NONE / PARTIAL / FULL / AUTO(PARTIAL 即 LRU 淘汰)
SELECT /*+ LOOKUP('table' = 'products', 'cache' = 'PARTIAL',
'cache-size' = '10000', 'cache-ttl' = '60s',
'cache-missing-key' = 'true') */
o.order_id, p.product_name
FROM orders AS o
JOIN products FOR SYSTEM_TIME AS OF o.proctime AS p ON o.product_id = p.id;
如何验证连接器是否支持异步 :检查连接器是否实现了 AsyncLookupFunctionProvider 接口(在连接器 jar 包中搜索该类的实现);或查阅该连接器官方文档的 Lookup Join 章节是否提及 async 参数。
自研 AsyncTableFunction 骨架 (JDBC 不支持异步时的替代方案;示意代码------ConnectionPool 需引入连接池库如 HikariCP 或自行实现,此处省略实现细节):
java
import org.apache.flink.table.functions.AsyncTableFunction;
import org.apache.flink.table.data.RowData;
import java.util.Collection;
import java.util.concurrent.CompletableFuture;
public class AsyncJdbcLookupFunction extends AsyncTableFunction<RowData> {
private final transient ConnectionPool pool; // transient: 需在 open() 中初始化
public AsyncJdbcLookupFunction() {
this.pool = null; // 实际应在 open() 中通过连接池库初始化
}
public void eval(CompletableFuture<Collection<RowData>> future, String productId) {
// 异步提交查询,在回调中完成 future
pool.queryAsync("SELECT * FROM products WHERE id = ?", productId)
.whenComplete((rows, ex) -> {
if (ex != null) future.completeExceptionally(ex);
else future.complete(rows);
});
}
}
TTL 的正确语义 (修正原文"设为维表更新频率的 2--3 倍"的错误建议):TTL 是陈旧度上界 ------缓存命中意味着最多返回 TTL 之前的维表版本。维表分钟级更新 → TTL 必须不大于 你能接受的更新滞后,而不是反着放大。FULL 缓存在连接器层配置(如 JDBC 的 lookup.cache.max-rows / lookup.cache.ttl,命名随版本有演进,以所用版本文档为准),适合小维表全量加载。
3.6 常见错误速查表
| 错误现象 | 根因 | 解法 |
|---|---|---|
| 设了 TWO_PHASE 计划仍是单阶段 | 聚合含 DISTINCT/不可合并函数;mini-batch 未开启 | DISTINCT 走 Split Distinct;自定义 UDAF 需支持 merge;开 mini-batch 三件套 |
| Split Distinct 没生效 | 没开总开关 distinct-agg.split.enabled |
先开总开关,用 EXPLAIN 确认 bucket 表达式 |
| mini-batch 效果弱 | allow-latency 过小(批太小) | 从吞吐 × allow-latency 估算 size(4.2 节) |
| Hint 报错/无效 | 选项名或取值写错(如 cache='LRU' 不存在) |
对照本文修正版语法 |
| 批模式建表报"无界源" | datagen 只给了 rows-per-second |
批模式改用 number-of-rows |
| 过滤没下推 | connector 未实现下推能力接口 | 换连接器或在源端过滤;用实验 3 的方法确认 |
| 状态持续增长 | regular join / 聚合无 TTL | table.exec.state.ttl(正确性代价见 4.4) |
| EXPLAIN 与线上行为不一致 | SET 只作用于当前 session;配置与 Hint 冲突时 Hint 优先 |
确认配置作用域,以 EXPLAIN 复核 |
本节小结:四个实验覆盖了三大杠杆的完整证据链------EXPLAIN 证明"计划变了"(节点数、Exchange 位置、bucket 表达式、changelogMode),指标证明"性能变了"(subtask 均衡性、busyTime、state),推演表证明"方向算得出"(2.4 节可代入参数)。三处修正保证了可复现:Split Distinct 总开关、显式 ONE_PHASE 基线、合法 Lookup Hint 语法。
四、进阶思考:边界、量化与正确性
4.1 流批一体的真正边界:约束集之差
流被禁止的 :全局 ORDER BY(无 LIMIT 的 ORDER BY 在流模式直接报错 ------这是最直观的边界演示)、阻塞交换、多趟扫描。批所缺的 :长生命周期 keyed state 与回撤语义------批的中间结果是物化,不是需要增量维护的视图。所以"同一 SQL 两套计划"不是优化器的任性,而是约束集的必然 。
版本注记:Flink 2.0(2025 年发布)彻底移除 DataSet API 与旧 Planner,"批是流的有界特例"从 Blink 的架构哲学变成了唯一路径。
4.2 mini-batch 的量化权衡:状态 IO 算术
机制:聚合状态从"每记录 读写"变为"每键每批 读写";changelog 输出从每记录一条变为每键每批一条。
算术(代入 3.2 的配置):每批 5000 条、批内约 500 个不同 key → 状态访问从约 10000 次降到 1000 次(10×),下游收到的变更条数同比例下降。代价 :端到端延迟最多增加 allow-latency(批在"size 达到"或"超时"先到者时触发)。
决策方法 :allow-latency ≤ SLA 预算 − 其他链路延迟;size ≈ 预期吞吐 × allow-latency(1000 条/s × 5s → 5000)。没有 universal 最优值,但有可计算的代入公式------这就是"拍脑袋"和"调参"的区别。
4.3 Lookup Join 的正确性代价
三个必须在使用前确认的语义变化:
output-mode='allow_unordered'改变输出顺序------下游须顺序无关;若下游是窗口聚合,乱序输入会导致窗口触发逻辑复杂化,性能可能不升反降;- 缓存 TTL 引入陈旧度(TTL 即上界,见 3.5 修正);
cache-missing-key缓存"查无此键",空值维表打爆缓存的问题由此缓解,但同键后来新增的数据要等 TTL 过期才可见。
性能杠杆同时是语义杠杆------开之前问一句业务能不能接受。
4.4 状态 TTL:用正确性换内存
table.exec.state.ttl 治理 regular join 与聚合的 keyed state。用 1.3 节的 IVM 视角看它的代价极其清晰:状态就是视图,TTL 就是给视图加了保留期 ------保留期之外到达的变更(迟到数据、回撤)无法正确应用。
这与 Watermark 的"迟到数据分层兜底"是同构的取舍:有界状态必然引入有界正确性窗口 。
TTL 的测量方法 :用 arrival_time - event_time 的 P99 作为 TTL 的参考下界(与 Watermark 乱序容忍度的测量方法同源)。如果 P99 是 30 秒,TTL 至少应设为 30 秒以上,否则尾部的迟到数据会被静默丢弃。选择依据是"最长可接受迟到"而不是"内存有多少"------内存不够应该加资源或减小视图(下推聚合粒度),而不是砍正确性窗口。
4.5 三大杠杆对比总表
| 杠杆 | 适用场景 | 核心参数 | 代价 | 验证方法 |
|---|---|---|---|---|
| 两阶段聚合 | SUM/COUNT 等可合并聚合的倾斜 | agg-phase-strategy=TWO_PHASE + mini-batch 三件套 |
延迟增加 allow-latency |
EXPLAIN 看 LocalGroupAggregate + Exchange 位置上移 |
| Split Distinct | COUNT(DISTINCT) 的倾斜 |
distinct-agg.split.enabled=true + bucket-num |
多一层算子 + 额外 Shuffle + 每批桶数行输入 | EXPLAIN 看 bucket 分组层 + 两次 Exchange 键不同 |
| Lookup Join 优化 | 维表关联的外部查询瓶颈 | async=true + cache=PARTIAL + cache-ttl |
乱序输出 + 缓存陈旧度 | EXPLAIN 看 LookupJoin 节点;指标看外部 QPS 下降 |
| (状态 TTL 性质不同------它是正确性/内存权衡而非性能杠杆,单独在 4.4 讨论,不入此表。) |
本节小结:流批的边界是约束集之差(禁阻塞 vs 许物化);每个调优杠杆的代价都可以被量化(mini-batch 的 IO 算术、TTL 的正确性窗口、Lookup 的吞吐上限模型)并且必须被验证(计划证据 + 指标证据 + 推演对比)。性能优化没有免费的午餐,但有明码标价的菜单。
五、总结:优化器是"翻译器",不是"魔法"
回到最初的问题:Flink SQL 的流批一体,优化器到底在做什么?
它把同一条 SQL 翻译成两套执行计划 :逻辑层尽可能共享(下推、裁剪------且下推是能力协商的结果),物理层根据流/批的约束集选择不同算子------流用增量聚合维护"视图",批用可阻塞算子一次成型。"一体"是语义层面的承诺,"分叉"是执行层面的现实,而分叉的坐标可以从 EXPLAIN 中逐节点读出。
如果只带走三句话:
- 流聚合 = 增量视图维护,状态即视图:SUM/COUNT 状态 O(组数) 且累加器可压缩,COUNT(DISTINCT) 状态 O(组×去重键) 且不可自维护------这一个公式决定了倾斜治理的分野:两阶段救 Shuffle 量,Split Distinct 换键救状态基数。别忘了开那个总开关。
- EXPLAIN 是优化器的 X 光,但要会读片 :三段结构看
Optimized Physical Plan;判读信号是节点个数、Exchange 位置与 distribution、changelogMode;计划证据之后,还要用 subtask 指标和推演对比补上性能证据。 - 每个性能杠杆都带正确性代价 :mini-batch 带延迟、unordered 带顺序变化、缓存带陈旧度、TTL 带正确性窗口------先把代价算清楚(本文给了每项的代入公式),再决定开不开。
Flink SQL 的优化器不会替你"猜"业务语义。它只在你给定的规则、配置与连接器能力之下,选择它认为合理的物理计划。理解它的选择逻辑------并且能用 EXPLAIN 亲眼验证它的每一次选择------才能让它的选择符合你的期望。