Flink SQL 从编写到提交运行的全过程解析

0 阅读6分钟

一、引言

Flink SQL 作为声明式 API,其从用户编写 SQL 语句到最终物理执行,需要经历一套独立的编译流水线:SQL 解析 → 逻辑计划 → 优化计划 → 物理计划 → Transformation → StreamGraph → JobGraph → ExecutionGraph → 物理执行图。

相比 DataStream API 直接构建 Transformation 的方式,Flink SQL 在前端多了一整套基于 Apache Calcite 的查询优化体系,但在 StreamGraph 之后两者殊途同归、共享同一套运行时,本文将完整拆解 Flink SQL 的编译执行链路。

二、全局视角:Flink SQL 的完整编译链路

三、第一阶段:SQL 解析(Parsing)

Flink SQL 使用Apache Calcite的 Parser 模块,将 SQL 文本解析为抽象语法树(AST),表示为SqlNode树结构。

// 简化的内部调用链
TableEnvironment.executeSql("INSERT INTO ...")
  → Parser.parse(sqlString)
    → Calcite SqlParser.parseStmt()
      → 生成 SqlNode 树

Flink 对 Calcite 的标准 SQL 语法进行了扩展,支持 Flink 特有的语法,如:

  • CREATE TABLE ... WITH (...) — connector 属性
  • TUMBLE / HOP / SESSION — 窗口 TVF
  • MATCH_RECOGNIZE — CEP 模式匹配
  • STATEMENT SET — 多 Sink 语句

核心数据结构:

SqlNode 树示例(SELECT user_id, COUNT(*) FROM orders GROUP BY user_id):

SqlSelect
├── selectList: [SqlIdentifier(user_id), SqlBasicCall(COUNT, *)]
├── from: SqlIdentifier(orders)
├── where: null
├── groupBy: SqlNodeList[SqlIdentifier(user_id)]
└── ...

该阶段的作用:

  • 语法正确性检查(拼写错误、语法结构错误等在此阶段报错)
  • 将文本 SQL 转化为结构化的内存表示,为后续语义分析做准备

四、第二阶段:语义校验(Validation)

Validator 利用Catalog(元数据中心)对 SqlNode 进行语义校验:

┌─────────────────────────────────────────────────────┐
│                    Validator 校验内容                  │
├─────────────────────────────────────────────────────┤
│ 1. 表是否存在(查询 Catalog)                         │
│ 2. 列名是否存在、类型是否匹配                         │
│ 3. 函数是否已注册、参数类型是否正确                    │
│ 4. 窗口定义是否合法                                   │
│ 5. INSERT 目标表的 Schema 与 SELECT 是否兼容          │
│ 6. 数据类型的隐式转换和强制转换合法性                  │
└─────────────────────────────────────────────────────┘

Catalog 体系:

┌─────────────────────────────────────────┐
│              CatalogManager              │
│  ┌─────────────────────────────────┐    │
│  │     Catalog (如 HiveCatalog)     │    │
│  │  ┌───────────────────────────┐  │    │
│  │  │   Database                 │  │    │
│  │  │  ┌─────────────────────┐  │  │    │
│  │  │  │  Table (Schema 定义) │  │  │    │
│  │  │  │  - 列名、类型        │  │  │    │
│  │  │  │  - 主键、水位线      │  │  │    │
│  │  │  │  - Connector 属性    │  │  │    │
│  │  │  └─────────────────────┘  │  │    │
│  │  └───────────────────────────┘  │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

该阶段的作用:

  • 注册 UDF(CREATE FUNCTION),使 Validator 能识别自定义函数
  • 配置 Catalog 元数据(CREATE TABLE DDL 或 HiveCatalog 集成)
  • 设置类型系统相关的配置(如 table.exec.legacy-cast-behaviour

五、第三阶段:逻辑计划生成(Logical Planning)

通过 Calcite 的SqlToRelConverter,将校验后的 SqlNode 转换为关系代数表达式树(RelNode)。

SQL:  SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id

逻辑计划(RelNode 树):

LogicalProject(user_id=[$0], cnt=[$1])
└── LogicalAggregate(group=[{0}], cnt=[COUNT()])
    └── LogicalProject(user_id=[$0])
        └── LogicalTableScan(table=[[default_catalog, default_database, orders]])

核心概念:

概念说明
RelNode关系代数节点,如 Project、Filter、Aggregate、Join、Sort 等
RexNode行表达式节点,表示列引用、常量、函数调用等
RelTraitSet节点的物理特性集,如排序顺序(Collation)、数据分布(Distribution)
RelDataType行类型定义,包含列名和数据类型
  • 纯逻辑表达,不包含任何执行策略
  • 与具体引擎无关(标准 Calcite RelNode)
  • 保留了完整的关系代数语义

六、第四阶段:查询优化(Optimization)

这是 Flink SQL 编译链路中最核心的阶段,Flink 使用两套优化器协同工作:

关键优化规则示例:

  • 谓词下推(Filter Push Down)

  • Local-Global 聚合拆分

在流模式下,优化器还需要处理:

考量点说明
Changelog 语义区分 Insert-only、Upsert、Retract 流,选择合适的物理算子
更新类型推导判断每个节点产出的是 +I/-D/+U/-U 中的哪些,决定下游是否需要处理撤回
状态 TTL聚合、Join 等有状态算子的状态清理策略影响结果正确性
Mini-batch 优化攒批触发以减少状态访问和下游更新频率

该阶段的作用:

-- 查看执行计划(用于验证优化效果)
EXPLAIN SELECT ...;

-- Hint 干预 Join 策略
SELECT /*+ BROADCAST(small_table) */ * 
FROM large_table JOIN small_table ON ...;

-- Hint 干预状态 TTL
SELECT /*+ STATE_TTL('orders' = '1d', 'users' = '7d') */ *
FROM orders JOIN users ON ...;


# flink-conf.yaml 或 SET 命令

# Mini-batch 优化(减少状态访问频次)
table.exec.mini-batch.enabled: true
table.exec.mini-batch.allow-latency: 5s
table.exec.mini-batch.size: 1000

# Local-Global 聚合
table.optimizer.agg-phase-strategy: TWO_PHASE

# 数据倾斜优化
table.optimizer.distinct-agg.split.enabled: true

# 状态 TTL
table.exec.state.ttl: 36h

# Join 重排
table.optimizer.join-reorder-enabled: true

最佳实践:

  • 善用 EXPLAIN 查看执行计划:在提交作业前通过 EXPLAIN 确认优化是否生效(谓词是否下推、Join 策略是否合理)。
  • 启用 Mini-batch:对于高 QPS 的聚合/Join 场景,Mini-batch 能显著降低状态访问频率和下游更新压力。
  • 合理设置状态 TTL:流式 Join 和聚合会无限积累状态,务必设置 TTL 避免状态膨胀导致 OOM。
  • 关注 Changelog 模式:如果 Sink 不支持 Retract/Upsert,需要确保上游查询只产出 Insert-only 流,否则运行时报错。

七、第五阶段:物理计划生成(Physical Planning)

优化后的逻辑 RelNode 被转换为 Flink 特有的物理 RelNode(FlinkPhysicalRel),再进一步转为ExecNode

优化后逻辑计划:                         物理计划:
FlinkLogicalAggregate                   StreamExecGroupAggregate
└── FlinkLogicalCalc                    └── StreamExecExchange(hash[user_id])
    └── FlinkLogicalTableSourceScan         └── StreamExecCalc
                                                └── StreamExecTableSourceScan

ExecNode是 Flink SQL 物理层的核心抽象。每个 ExecNode 负责将自身翻译为一组Transformation,这里是 SQL 编译链路与 DataStream 运行时的汇合点。

// ExecNode 接口核心方法
public interface ExecNode<T> {
    /**
     * 将当前节点翻译为 Transformation
     * 这是 SQL 层到 DataStream 运行时的桥梁
     */
    Transformation<T> translateToPlan(Planner planner);
}

每个物理算子内部调用 DataStream 层的基础设施来构建 Transformation:

StreamExecGroupAggregate.translateToPlan()
  → 构建 KeyedProcessOperator(GroupAggFunction)
    → 包装为 OneInputTransformation
      → 设置并行度、资源、uid 等

八、从 Transformation 到执行:与 DataStream 的汇合

从 Transformation 层开始,两条路径完全共享同一套运行时基础设施:

  • 同样的 StreamGraph 构建逻辑
  • 同样的算子链化优化
  • 同样的调度器和 Failover 机制
  • 同样的状态后端和 Checkpoint 协议
  • 同样的网络栈和反压机制

九、一个完整 SQL 作业的图演变示例

-- DDL
CREATE TABLE orders (
    order_id STRING,
    user_id STRING,
    amount DECIMAL(10,2),
    order_time TIMESTAMP(3),
    WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND
) WITH ('connector' = 'kafka', ...);

CREATE TABLE user_order_stats (
    user_id STRING,
    window_start TIMESTAMP(3),
    order_cnt BIGINT,
    total_amount DECIMAL(10,2),
    PRIMARY KEY (user_id, window_start) NOT ENFORCED
) WITH ('connector' = 'jdbc', ...);

-- DML
INSERT INTO user_order_stats
SELECT 
    user_id,
    window_start,
    COUNT(*) AS order_cnt,
    SUM(amount) AS total_amount
FROM TABLE(TUMBLE(TABLE orders, DESCRIPTOR(order_time), INTERVAL '1' MINUTE))
GROUP BY user_id, window_start;

编译链路演变:

1. Parse → SqlNode:
   SqlInsert
   └── SqlSelect(selectList=[user_id, window_start, COUNT(*), SUM(amount)],
                 from=TUMBLE(...), groupBy=[user_id, window_start])

2. Validate → 校验 orders 表存在、列类型匹配、TUMBLE 参数合法

3. Logical Plan → RelNode:
   LogicalSink(user_order_stats)
   └── LogicalProject(user_id, window_start, order_cnt, total_amount)
       └── LogicalWindowAggregate(group=[user_id], window=[TUMBLE(1min)],
                                   aggs=[COUNT(*), SUM(amount)])
           └── LogicalWatermarkAssigner(order_time - 5s)
               └── LogicalTableScan(orders)

4. Optimize → 优化后逻辑计划:
   - 投影下推:只从 Kafka 读取 user_id, amount, order_time 三列
   - 窗口聚合识别:识别为 Window TVF Aggregate,可使用高效窗口算子
   
5. Physical Plan → ExecNode:
   StreamExecSink(user_order_stats)
   └── StreamExecWindowAggregate(group=[user_id], window=[TUMBLE(1min)],
                                  select=[user_id, window_start, COUNT(*), SUM(amount)])
       └── StreamExecExchange(distribution=[hash[user_id]])
           └── StreamExecWindowTableFunction(TUMBLE, time_col=[order_time], size=[1min])
               └── StreamExecWatermarkAssigner(order_time - 5s)
                   └── StreamExecTableSourceScan(orders, fields=[user_id, amount, order_time])

6. Transformation (5 个):
   [KafkaSource][WatermarkAssigner][WindowFunction][Exchange][WindowAgg][JdbcSink]

7. StreamGraph (6 个 StreamNode):
   [Source(p=3)] --Forward--> [Watermark(p=3)] --Forward--> [WindowTVF(p=3)]
       --Hash(user_id)--> [WindowAgg(p=6)] --Forward--> [Sink(p=6)]

8. JobGraph (算子链化后, 3 个 JobVertex):
   [Source→Watermark→WindowTVF Chain(p=3)] --Hash--> [WindowAgg→Sink Chain(p=6)]
   
   ※ Source/Watermark/WindowTVF 链化:相同并行度 + Forward
   ※ WindowAgg/Sink 链化:相同并行度 + Forward
   ※ 中间断开:Hash 分区 + 并行度不同(3→6)

9. ExecutionGraph:
   [3 个 Source Chain EV] --Hash(ALL_TO_ALL)--> [6 个 WindowAgg+Sink EV]
   共 9 个 ExecutionVertex

10. 物理执行图:
    3 个 Task(Source Chain) + 6 个 Task(WindowAgg+Sink) = 9 个 Task
    最少需要 6 个 Slot