1. 为什么 Flink-SQL 成了实时开发的主流姿势
刚接触实时计算那会儿,我写的是 DataStream API,一个简单的过滤聚合逻辑要敲上百行 Java 代码,光是定义 POJO、注册 TypeInformation、处理窗口触发就能耗掉大半天。后来团队接了一个实时报表需求,指标口径一天改三遍,每次改动都要重新编译打包上传,运维同学看我的眼神都不对了。直到我把这套逻辑用 Flink-SQL 重写,原本两百多行的代码压缩成了十几行 SQL,改口径就是改一个字段表达式,重新提交 SQL 任务即可,效率提升不是一点半点。
Flink-SQL 本质上是 Flink 提供的一套声明式流处理接口,它把流和批统一抽象成表(Table),让你用写 SQL 的方式描述"我要什么结果",而不是"我该怎么一步步算"。底层依然跑的是 Flink 的流式引擎,只是把 DataStream 那套算子编排的活儿交给了优化器。它解决的核心问题是:让实时计算的门槛从"会写分布式程序"降到"会写 SQL",同时把大量重复的窗口、状态、水位线处理逻辑标准化,减少手写出错的概率。
这篇文章适合三类人看:一是刚转实时方向、SQL 底子比 Java 好的数据开发;二是被 DataStream 样板代码折磨、想找更高效方案的老手;三是需要快速搭建实时数仓链路的架构同学。我会从整体设计思路讲到具体实操,把踩过的坑和参数调优经验都摊开说,尽量让你看完就能上手跑通一个像样的实时任务。
2. Flink-SQL 整体设计与核心思路拆解
2.1 流批一体的表抽象到底解决了什么
传统做法里,离线用 Hive SQL 跑 T+1,实时用 DataStream 写代码,两套技术栈、两套口径、两拨人维护,最头疼的就是同一指标离线算出来和实时算出来对不上。Flink-SQL 的设计出发点就是把这层割裂抹平:无论输入是有界的历史数据还是无界的实时流,在 SQL 层面都当成一张表来看待,你写的SELECT ... GROUP BY ...既能跑批也能跑流。
这个抽象的关键在于动态表(Dynamic Table)这个概念。普通数据库的表是静态快照,你查一次它返回当前时刻的数据;而 Flink 里的表是随时间不断变化的,每来一条新数据,表就"更新"一次,SQL 查询的结果也随之连续更新。理解这一点非常重要,因为它决定了你写出来的 SQL 在流模式下是持续输出的,而不是查一次就结束。很多人第一次跑流 SQL 发现任务不退出、结果一直在变,就是因为没转过这个弯。
从选型角度看,什么时候该用 SQL、什么时候还得回到 DataStream?我的经验是:标准的过滤、聚合、窗口、维表关联、双流 Join 用 SQL 绰绰有余,而且更稳;但涉及复杂的状态机、自定义的定时器逻辑、需要精细控制 checkpoint 行为的场景,DataStream 仍然不可替代。两者不是替代关系,实际项目里经常是 SQL 做主体链路,个别算子用 UDF 或 DataStream 补充。
2.2 三种运行模式的取舍逻辑
Flink-SQL 落地时有三种常见形态,选错了会直接影响开发效率和运维成本,这里必须掰扯清楚。
第一种是SQL Client 交互模式,适合探索性开发和临时排查。启动一个本地或远程的 SQL Client,敲一句看一句结果,调试验证特别快。但它不适合上生产,因为任务生命周期绑在客户端会话上,你一关窗口任务就没了。
第二种是SQL 嵌入 DataStream 程序,也就是在 Java/Scala 代码里用TableEnvironment执行 SQL 字符串。这种方式兼顾了 SQL 的表达力和代码的工程化能力,可以把 SQL 存在配置中心动态加载,也方便和现有的调度、监控体系集成。中大型项目我一般推荐这种。
第三种是纯 SQL 文件提交,把 SQL 写成.sql文件,通过命令行或 REST 接口提交给 Flink 集群。这种方式最轻量,适合口径相对稳定、由数据团队而非工程团队维护的场景。缺点是调试不方便,出错了得看日志定位。
| 运行模式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| SQL Client | 探索调试、临时验证 | 即时反馈、零代码 | 不适合生产、会话绑定 |
| 嵌入程序 | 中大型生产项目 | 工程化好、可动态化 | 需要 Java 基础 |
| SQL 文件提交 | 口径稳定的报表链路 | 轻量、数据团队可维护 | 调试困难、灵活性低 |
选型的核心判断标准就一条:这套 SQL 由谁来维护、改动频率多高。如果天天改口径,嵌入程序配合配置中心最省心;如果一个月不动一次,SQL 文件提交足够。
2.3 为什么优化器值得信任但也要懂它
Flink-SQL 底层用的是 Apache Calcite 做 SQL 解析和优化,它会自动帮你做谓词下推、列裁剪、算子合并等优化。举个例子,你写SELECT a, b FROM t WHERE a > 10,优化器会自动只读取 a、b 两列,并把过滤条件下推到数据源,减少网络传输。这些优化在批模式下效果显著,在流模式下也能减少状态大小。
但优化器不是万能的,有些写法会让它"看不懂"从而放弃优化。最典型的就是在WHERE里对字段做函数运算,比如WHERE DATE_FORMAT(ts, 'yyyy-MM-dd') = '2024-01-01',这种写法会导致谓词无法下推,全量数据都要拉进来算。正确做法是把函数作用在常量侧:WHERE ts >= '2024-01-01 00:00:00' AND ts < '2024-01-02 00:00:00'。这个细节我在生产环境踩过,一个查询从秒级变成分钟级,排查半天才发现是谓词没下推。
3. 核心语法与实操要点深度解析
3.1 建表语句里的隐藏门道
Flink-SQL 的CREATE TABLE和普通 SQL 长得像,但里面的WITH参数才是灵魂,它决定了数据从哪来、到哪去、怎么解析。以最常见的 Kafka 源表为例:
CREATE TABLE user_behavior ( user_id BIGINT, item_id BIGINT, behavior STRING, ts TIMESTAMP(3), WATERMARK FOR ts AS ts - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'user_behavior', 'properties.bootstrap.servers' = 'localhost:9092', 'properties.group.id' = 'test-group', 'scan.startup.mode' = 'latest-offset', 'format' = 'json' );这里有几个点必须说透。WATERMARK FOR ts AS ts - INTERVAL '5' SECOND这行是流处理特有的,它定义了水位线,告诉 Flink"我认为 ts 小于当前最大时间减 5 秒的数据都已经到齐了"。水位线是触发窗口计算的关键,设太小会导致迟到数据被丢弃,设太大会增加结果延迟。5 秒是个经验值,具体要看你的数据乱序程度,我一般会先观察上游数据的乱序分布再定。
scan.startup.mode决定了任务启动时从哪开始消费。latest-offset从最新位置开始,适合只关心增量;earliest-offset从头开始,适合补历史数据;group-offsets从消费者组提交的位点开始,适合断点续传。生产环境做数据回溯时,这个参数选错会导致要么丢数据要么重复消费,务必确认清楚。
format指定数据格式,JSON 最常用但性能一般,如果追求吞吐可以用 Avro 或 Protobuf。JSON 还有个坑是字段类型不匹配时会直接报错,建议在源表定义时把字段类型和上游对齐,或者用STRING接收后再在查询里转换。
3.2 窗口聚合:TUMBLE、HOP、CUMULATE 怎么选
窗口是流处理的核心,Flink-SQL 提供了三种常用窗口 TVF(表值函数),选错了要么算不准要么资源浪费。
滚动窗口 TUMBLE是最常用的,窗口之间不重叠,每条数据只属于一个窗口。比如每 5 分钟统计一次 PV:
SELECT window_start, window_end, COUNT(*) AS pv FROM TABLE(TUMBLE(TABLE user_behavior, DESCRIPTOR(ts), INTERVAL '5' MINUTE)) GROUP BY window_start, window_end;滑动窗口 HOP的窗口会重叠,一条数据可能属于多个窗口。比如每 1 分钟输出一次最近 5 分钟的数据,用于平滑曲线:
SELECT window_start, window_end, COUNT(*) AS pv FROM TABLE(HOP(TABLE user_behavior, DESCRIPTOR(ts), INTERVAL '1' MINUTE, INTERVAL '5' MINUTE)) GROUP BY window_start, window_end;注意 HOP 的参数顺序是滑动步长在前、窗口大小在后,写反了结果完全不对,这个坑我见过不止一个人踩。
累积窗口 CUMULATE适合"从当天零点累计到现在"这类需求,窗口起点固定、终点逐步扩展。比如大促期间实时累计 GMV:
SELECT window_start, window_end, SUM(amount) AS total_gmv FROM TABLE(CUMULATE(TABLE orders, DESCRIPTOR(ts), INTERVAL '1' HOUR, INTERVAL '1' DAY)) GROUP BY window_start, window_end;| 窗口类型 | 特点 | 典型场景 | 状态大小 |
|---|---|---|---|
| TUMBLE | 不重叠、固定大小 | 固定周期统计 | 小 |
| HOP | 重叠、滑动 | 平滑曲线、移动平均 | 大(窗口数×) |
| CUMULATE | 起点固定、终点扩展 | 累计指标 | 中 |
选窗口的核心是看业务语义:要"每 X 分钟一个独立结果"用 TUMBLE,要"最近 X 分钟的滚动视图"用 HOP,要"从某时刻起的累计值"用 CUMULATE。HOP 因为窗口重叠,状态开销是 TUMBLE 的好几倍,能用 TUMBLE 就别用 HOP。
3.3 双流 Join 的三种姿势与状态管理
实时场景里双流 Join 是绕不开的,Flink-SQL 支持 Regular Join、Interval Join 和 Lookup Join 三种,语义和资源消耗差别很大。
Regular Join就是普通 SQL 的 Join 写法,两条流的数据都会保留在状态里,任何一边来了新数据都可能触发输出。它的好处是语义直观,坏处是状态会无限增长,必须配合 TTL 使用:
SELECT o.order_id, o.amount, u.user_name FROM orders o JOIN users u ON o.user_id = u.user_id;如果不加状态 TTL,跑几天状态就能把内存撑爆。配置方式是在TableEnvironment里设置:
TableConfig config = tableEnv.getConfig(); config.set("table.exec.state.ttl", "24h");Interval Join限定了两条流的时间范围,只有时间差在指定区间内的数据才会关联,状态可以及时清理:
SELECT o.order_id, o.amount, p.pay_time FROM orders o, payments p WHERE o.order_id = p.order_id AND p.pay_time BETWEEN o.order_time AND o.order_time + INTERVAL '30' MINUTE;这种写法适合"下单后 30 分钟内支付"这类有明确时间约束的场景,状态压力比 Regular Join 小得多。
Lookup Join是流表关联维表的标准做法,维表通常是 MySQL、HBase 这类外部存储,每条流数据到来时实时查询维表:
SELECT o.order_id, o.amount, d.product_name FROM orders o JOIN product_dim FOR SYSTEM_TIME AS OF o.proc_time AS d ON o.product_id = d.product_id;Lookup Join 的关键是缓存策略,不加缓存每条数据都查一次维表,维表压力巨大。可以配置:
'lookup.cache' = 'LRU', 'lookup.cache.max-rows' = '10000', 'lookup.cache.ttl' = '10min'LRU 缓存配合 TTL 能大幅降低维表查询量,代价是维表更新有最长 TTL 的延迟。如果维表变更要求实时生效,TTL 就得设短,权衡点在于维表更新频率和查询压力的平衡。
提示:Regular Join 一定要配状态 TTL,否则就是定时炸弹。我见过一个任务跑了三天状态涨到几十 G,最后 OOM 挂掉,排查发现就是忘了设 TTL。
4. 完整实操流程与关键环节实现
4.1 环境准备与依赖配置
先把环境搭起来。我用的是 Flink 1.17,这个版本 SQL 功能比较完善,社区也活跃。下载解压后,把 Kafka connector 的 jar 包放到lib目录:
# 下载 Flink wget https://archive.apache.org/dist/flink/flink-1.17.0/flink-1.17.0-bin-scala_2.12.tgz tar -xzf flink-1.17.0-bin-scala_2.12.tgz cd flink-1.17.0 # 放入 Kafka connector cp flink-sql-connector-kafka-1.17.0.jar lib/ cp flink-connector-jdbc-3.1.0-1.17.jar lib/启动本地集群:
./bin/start-cluster.sh访问http://localhost:8081能看到 Web UI 就说明起来了。这一步的坑在于Scala 版本要和 connector 匹配,2.12 和 2.11 的包不能混用,混了会报NoSuchMethodError,排查起来很费劲。
4.2 从 Kafka 到 MySQL 的端到端链路
我拿一个电商实时 GMV 统计的例子走完整流程。数据从 Kafka 来,按分钟窗口聚合后写入 MySQL。
第一步,建 Kafka 源表:
CREATE TABLE orders ( order_id STRING, user_id BIGINT, product_id BIGINT, amount DECIMAL(10, 2), order_time TIMESTAMP(3), WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND ) WITH ( 'connector' = 'kafka', 'topic' = 'orders', 'properties.bootstrap.servers' = 'localhost:9092', 'properties.group.id' = 'gmv-group', 'scan.startup.mode' = 'latest-offset', 'format' = 'json' );第二步,建 MySQL 结果表:
CREATE TABLE gmv_result ( window_start TIMESTAMP(3), window_end TIMESTAMP(3), total_gmv DECIMAL(20, 2), order_count BIGINT, PRIMARY KEY (window_start, window_end) NOT ENFORCED ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://localhost:3306/realtime', 'table-name' = 'gmv_result', 'username' = 'root', 'password' = 'password', 'sink.buffer-flush.max-rows' = '100', 'sink.buffer-flush.interval' = '5s' );注意PRIMARY KEY ... NOT ENFORCED这行,它告诉 Flink 这个主键用于 upsert 模式写入,相同主键的数据会更新而不是追加。如果不声明主键,MySQL sink 会以 append 模式写入,窗口结果重复计算时会产生重复行。
第三步,写聚合逻辑:
INSERT INTO gmv_result SELECT window_start, window_end, SUM(amount) AS total_gmv, COUNT(*) AS order_count FROM TABLE(TUMBLE(TABLE orders, DESCRIPTOR(order_time), INTERVAL '1' MINUTE)) GROUP BY window_start, window_end;把这三段 SQL 存成gmv.sql,用 SQL Client 提交:
./bin/sql-client.sh -f gmv.sql任务跑起来后,往 Kafka 里灌几条测试数据,一分钟内就能在 MySQL 里看到聚合结果。
4.3 参数调优与资源规划
任务能跑通只是第一步,跑得稳、跑得快才是生产要求。几个关键参数必须调:
并行度决定了任务的并发能力。源表并行度受 Kafka 分区数限制,如果 Kafka 有 6 个分区,源表并行度最多设 6,设多了会有空闲的 subtask。聚合算子的并行度可以独立设置:
-- 在 SQL 里设置 SET 'parallelism.default' = '4';状态后端的选择直接影响大状态任务的稳定性。默认的 HashMapStateBackend 把状态放内存,状态大了容易 OOM。生产环境建议用 RocksDB:
# flink-conf.yaml state.backend: rocksdb state.backend.incremental: true state.checkpoints.dir: hdfs:///flink/checkpointsRocksDB 把状态存磁盘,支持增量 checkpoint,代价是读写性能比内存慢一些。状态小于 1G 用内存后端,大于 1G 果断上 RocksDB。
Checkpoint 间隔要在容错和性能之间平衡。间隔太短,频繁做快照影响吞吐;间隔太长,故障恢复时要重放的数据多。一般设 1 到 3 分钟:
execution.checkpointing.interval: 60s execution.checkpointing.timeout: 10min execution.checkpointing.min-pause: 30smin-pause这个参数容易被忽略,它保证两次 checkpoint 之间至少间隔这么久,防止前一次还没做完后一次就开始了,导致 checkpoint 堆积。
| 参数 | 推荐值 | 作用 | 调优方向 |
|---|---|---|---|
| parallelism.default | 按分区数 | 并发度 | 源表受分区限制 |
| state.backend | rocksdb | 状态存储 | 大状态必选 |
| checkpointing.interval | 60s | 快照间隔 | 容错与性能平衡 |
| table.exec.state.ttl | 24h | 状态过期 | 防状态膨胀 |
4.4 上线前的验证清单
任务上线前我有一套固定动作,能挡掉大部分低级错误。第一,用有界数据源验证逻辑,把 Kafka 源换成datagen或文件源,跑一遍看结果对不对,避免直接上生产流调试。第二,检查状态 TTL 是否配置,所有 Join 和聚合都要确认。第三,压测峰值流量,用生产 1.5 倍的速率灌数据,观察反压和 checkpoint 耗时。第四,验证故障恢复,手动 kill 一个 TaskManager,看任务能否从 checkpoint 恢复且结果不丢不重。
注意:验证故障恢复时,如果 sink 是 MySQL 且用了 upsert 模式,重复消费不会产生重复数据;但如果 sink 是 append 模式(比如写 Kafka),就必须保证 exactly-once 语义,否则恢复后会重复写入。
5. 常见问题与排查技巧实录
5.1 任务启动就报错?先看这三类
第一类:ClassNotFoundException 或 NoSuchMethodError。九成是依赖版本不匹配。Flink 主版本、connector 版本、Scala 版本三者必须对齐。排查方法是在lib目录下ls一遍,看有没有重复的 jar 或者版本号对不上的。我遇到过一次同时放了 1.16 和 1.17 的 connector,任务启动随机报错,删掉旧版本就好了。
第二类:Table not found。SQL 里引用的表名和CREATE TABLE定义的不一致,或者建表和查询不在同一个 SQL 会话里。SQL Client 里每个会话是独立的,重启客户端后之前建的表就没了,需要用CREATE TABLE重新注册或者用CREATE TEMPORARY TABLE配合 catalog 持久化。
第三类:Watermark 相关报错。常见的是The watermark can not be generated,一般是时间字段类型不对。水位线字段必须是TIMESTAMP(3)或TIMESTAMP_LTZ(3),如果是STRING类型得先用TO_TIMESTAMP转换。
5.2 结果不准?从水位线和状态两头查
结果不准是最难排查的,因为任务不报错,只是数据对不上。我的排查顺序是:先看水位线,再看状态,最后看 sink 语义。
水位线问题表现为迟到数据被丢弃。如果上游数据乱序严重,5 秒的水位线延迟不够,晚到的数据会被判定为迟到而丢弃。排查方法是观察 Flink Web UI 里各算子的numLateRecordsDropped指标,如果这个值持续增长,说明水位线设小了,需要调大延迟。
状态问题表现为结果重复或缺失。Regular Join 没设 TTL 会导致状态无限增长最终 OOM;窗口聚合的状态如果 checkpoint 失败恢复后可能重复计算。排查方法是看 checkpoint 大小是否持续增长,以及恢复后结果是否和恢复前一致。
Sink 语义问题表现为下游数据重复。如果 sink 不是幂等的,故障恢复后重放的数据会重复写入。解决办法是给 sink 表声明主键走 upsert 模式,或者在下游做去重。
| 现象 | 可能原因 | 排查指标 | 解决方向 |
|---|---|---|---|
| 数据偏少 | 水位线延迟小 | numLateRecordsDropped | 调大水位线延迟 |
| 状态暴涨 | 未设 TTL | checkpoint size | 配置 state.ttl |
| 结果重复 | sink 非幂等 | 恢复前后对比 | 声明主键 upsert |
| 任务卡住 | 反压 | busyTime / backPressure | 加并行度或优化 SQL |
5.3 性能上不去?反压定位四步法
反压是流任务最常见的性能问题,表现为上游算子处理快、下游处理慢,数据在中间堆积。Flink Web UI 里能看到反压指标,但定位到具体算子需要四步。
第一步,看 Web UI 的反压面板,找到标红(HIGH)的算子,那就是瓶颈所在。第二步,看该算子的 busyTime 和 backPressuredTime,如果 busyTime 接近 100%,说明是 CPU 瓶颈,需要优化计算逻辑或加并行度。第三步,看 checkpoint 耗时,如果 checkpoint 时间很长,说明状态太大,需要检查是否有不必要的状态保留。第四步,看数据倾斜,如果某个 subtask 的吞吐远低于其他,说明 key 分布不均,需要加盐打散。
我遇到过一个典型案例:双流 Join 任务反压严重,排查发现是某个热门商品的 order_id 特别多,导致单个 subtask 负载过高。解决办法是在 Join key 上加随机前缀打散,聚合后再去掉前缀,代价是多一次聚合但负载均衡了。
5.4 那些文档里不会写的避坑经验
坑一:SQL Client 的会话隔离。SQL Client 每次启动都是新会话,之前建的表、设的参数全没了。生产环境别用 SQL Client 跑常驻任务,用 SQL 文件提交或者嵌入程序。
坑二:时间属性的类型陷阱。PROCTIME()是处理时间,EVENT_TIME是事件时间,两者不能混用。窗口聚合必须用事件时间字段,用处理时间会导致结果不可重现。
坑三:Decimal 精度丢失。金额字段用DECIMAL(10, 2)定义,如果上游传的是DOUBLE,转换时会有精度误差。金融场景务必全程用 DECIMAL。
坑四:Kafka 分区数和并行度的关系。源表并行度超过 Kafka 分区数时,多余的 subtask 会空闲,浪费资源。反过来分区数超过并行度,单个 subtask 要消费多个分区,可能成为瓶颈。
坑五:Checkpoint 存储路径权限。用 HDFS 做 checkpoint 存储时,Flink 进程必须有写权限,否则 checkpoint 一直失败但任务不报错,直到故障恢复时才发现没有可用 checkpoint。
6. 进阶玩法与扩展方向
6.1 自定义 UDF 补齐 SQL 的表达力
内置函数不够用时,可以写 UDF。Flink 支持 Scalar Function、Table Function、Aggregate Function 三种。最常用的是 Scalar Function,一进一出:
public class MaskPhone extends ScalarFunction { public String eval(String phone) { if (phone == null || phone.length() < 11) { return phone; } return phone.substring(0, 3) + "****" + phone.substring(7); } }注册后在 SQL 里直接用:
CREATE FUNCTION mask_phone AS 'com.example.MaskPhone'; SELECT mask_phone(phone) FROM users;UDF 的坑在于序列化,如果 UDF 里引用了不可序列化的对象,任务提交时会报错。建议 UDF 保持无状态,需要的外部配置通过open()方法初始化。
6.2 维表关联的缓存与异步优化
Lookup Join 的性能瓶颈通常在维表查询。除了前面说的 LRU 缓存,还可以用异步查询提升吞吐。同步查询时每条数据都要等维表返回,异步查询可以并发发起多个请求:
// 异步 Lookup Function 示例 public class AsyncDimFunction extends AsyncTableFunction<Row> { public void eval(CompletableFuture<Collection<Row>> future, Long productId) { // 异步查询维表,完成后 future.complete(result) } }异步查询能把维表 QPS 提升数倍,代价是代码复杂度上升,需要处理超时和异常。维表 QPS 压力大且能接受一定延迟的场景,异步 + 缓存组合是最优解。
6.3 实时数仓分层中的 SQL 编排
在实时数仓里,Flink-SQL 通常承担 ODS 到 DWD、DWD 到 DWS 的加工。分层的好处是每层职责清晰,ODS 做数据清洗和标准化,DWD 做明细宽表,DWS 做轻度聚合。层与层之间通过 Kafka 或 Paimon 这类流式存储衔接,每层都是一个独立的 Flink-SQL 任务。
这种架构的关键是口径一致性,同一指标在 DWD 和 DWS 里的计算逻辑必须一致,否则对不上账。我的做法是把公共的计算逻辑抽成视图(View),各层引用同一个视图定义,改口径只改一处。Flink-SQL 支持CREATE VIEW,用好了能省很多事。
CREATE VIEW valid_orders AS SELECT * FROM orders WHERE amount > 0 AND status = 'PAID'; -- DWD 层引用 INSERT INTO dwd_orders SELECT * FROM valid_orders; -- DWS 层引用 INSERT INTO dws_gmv SELECT window_start, SUM(amount) FROM TABLE(TUMBLE(TABLE valid_orders, DESCRIPTOR(order_time), INTERVAL '1' MINUTE)) GROUP BY window_start, window_end;我个人在实际操作中的体会是,Flink-SQL 最大的价值不是让你少写代码,而是让实时计算的逻辑变得可读、可审查、可复用。以前 DataStream 写的任务,换个人接手要花半天理解算子编排;现在一段 SQL 摆在那,业务同学都能看懂口径对不对。最后再分享一个小技巧:调试复杂 SQL 时,把中间结果INSERT INTO一个 print sink 或者临时 Kafka topic,分段验证每一步的输出,比盯着最终结果猜哪里出错高效得多。