ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flink SQL建表实战:从TableEnvironment到DDL与时间属性

Flink SQL建表实战:从TableEnvironment到DDL与时间属性

聊到Flink的Table API和SQL,很多人第一反应是“我连DataStream都没玩明白,怎么就要去写SQL了”。但真用一段时间你会发现,Flink Table API和SQL的价值是把流处理从“手写算子”变成“声明式查询”,而建表就是这套体系的入口。这篇是Flink学习笔记的第12篇,核心就一件事:怎么把表环境创建出来,怎么把表真正建起来。适合刚接触Flink SQL、或者在建表时报了一堆看不懂异常的同学。全文以当前稳定版Flink API为主,老版本设计不同的地方我会专门标出来。

1. 先看整体:Table API和SQL在Flink里的定位与设计逻辑

1.1 为什么Flink非要推一个“表”模型

Flink最初为人熟知的编程方式是DataStream API,写起来就像一条流水线:map、filter、keyBy、window一路连下去,每个步骤都得自己控制。后来社区发现,大多数真实业务场景其实是“数据进来,过滤一下,分组聚合一下,再写出去”,这种逻辑用SQL表达更简单,于是就有了Table API和SQL。

Table模型本质上是把无界流或有界数据描述成“一张表”,每张表有明确的Schema(字段名、字段类型),数据源由Connector提供。你可以类比成工厂里的两条生产路线:DataStream是手工流水线,每件产品逐一经过你的操作;Table是先把订单和物料单交给排产系统,由系统优化后统一执行。后者少了很多手写模板代码,还白得了一整套查询优化器。

在建表之前,必须先有表环境。表环境就是所有表对象的“容器”,它管着Catalog(元数据)、配置项、执行计划。流批一体之后,一套环境同时支持流处理和批处理,这是Flink SQL体系最核心的设计目标。

1.2 表环境到底是个什么东西

TableEnvironment在Flink中承担三类工作:

  • 注册表:包括临时表和永久表,保存表的Schema、连接器信息。
  • 执行SQL:调用executeSql、sqlQuery等方法,把SQL转成Flink作业。
  • 管理配置:并行度、状态后端、时区、SQL方言等都挂在这个环境对象上。

用生活类比,TableEnvironment就像一个数据库实例。你在这个实例里建库、建表、跑查询,所有元数据都在实例内部维护。如果你新建了一个TableEnvironment,之前注册的表不会自动出现,要做元数据迁移就得靠Catalog。

还有一个概念容易混淆:临时表和永久表。临时表只在当前会话有效,进程一结束就没了;永久表则依赖外部Catalog存储元数据。本地开发和测试阶段,绝大多数情况用临时表就够了。

1.3 三种表环境怎么选

Flink历史上提供过几种环境创建方式,容易让人犯迷糊:

环境类适用场景是否支持DataStream互转
TableEnvironment纯SQL开发,离线批处理为主不支持
StreamTableEnvironment流处理 + SQL / Table API支持
BatchTableEnvironment旧版离线批处理专用老版本支持,新版本已废弃

在新版本中,BatchTableEnvironment已经并入了TableEnvironment,通过EnvironmentSettings.inBatchMode()切换批处理模式。所以现在的选择其实很简单:

  • 如果用纯SQL做批任务,创建TableEnvironment即可。
  • 如果要做流处理,或者需要和DataStream API混编,必须用StreamTableEnvironment。

坑也就出在这里。很多人用TableEnvironment时想调用tableEnv.fromDataStream(),编译直接报错,因为TableEnvironment根本没有这个方法。反过来,用StreamTableEnvironment跑批处理也是可以的,只不过通过设置ExecutionEnvironment为批模式来体现。

2. 创建表环境的具体写法与关键配置

2.1 新版本API:EnvironmentSettings与StreamTableEnvironment

Flink不同版本的建环境API差异比较大。1.13之前是EnvironmentSettings.Builder()这种写法,1.14之后推荐用EnvironmentSettings的静态工厂方法。当前稳定版推荐这样写:

import org.apache.flink.table.api.EnvironmentSettings; import org.apache.flink.table.api.TableEnvironment; EnvironmentSettings settings = EnvironmentSettings.inStreamingMode(); TableEnvironment tableEnv = TableEnvironment.create(settings);

批处理则把inStreamingMode换成inBatchMode:

EnvironmentSettings settings = EnvironmentSettings.inBatchMode(); TableEnvironment tableEnv = TableEnvironment.create(settings);

如果你需要和DataStream API混编,核心写法是:

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.table.api.bridge.java.StreamTableEnvironment; StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

这个StreamTableEnvironment就是流处理场景下最常用的环境。它内部持有StreamExecutionEnvironment,所以并行度、重启策略、状态后端这些配置既可以在env上设,也可以通过tableEnv的配置项去影响SQL作业。我在实际项目中几乎都用这种方法,因为很多脏活累活还是得靠DataStream算子收尾。

注意,纯TableEnvironment创建时不需要StreamExecutionEnvironment,但一旦你用了StreamTableEnvironment,底层作业还是要走StreamExecutionEnvironment,所以env上配置的并行度会直接影响SQL任务。

2.2 环境上的核心配置:并行度、状态、时区

创建完表环境后,第一件事通常就是设置并行度和时区。并行度好理解,直接决定下游并发数。时区问题则容易被忽略,但它对时间字段影响很大。比如:

tableEnv.getConfig().setLocalTimeZone(ZoneId.of("Asia/Shanghai"));

假如你处理的是TIMESTAMP_LTZ类型字段,没设置时区,Flink默认用UTC,查询出来的时间会和本地时间差8小时。这个我在处理日志数据时踩过,当时所有窗口统计都偏了,排查到最后才发现是时区没配。

状态后端的配置在新版本中通常这样写:

Configuration configuration = new Configuration(); configuration.setString("execution.state.backend", "rocksdb"); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration);

需要注意,有些配置项在env和tableEnv两边都能写,但生效优先级可能不同。我建议:能用table配置项表达的,尽量统一写在tableEnv.getConfig()里;需要做checkpoint、重启策略的,写在env里。这样排查参数时思路清晰,不用两边猜。

2.3 依赖不齐,环境建得再对也会翻车

有个场景特别典型:IDE里写了个纯TableEnvironment,run的时候抛NoClassDefFoundError,提示缺少org.apache.flink.table.api.TableEnvironment。这不是代码问题,是依赖没引全。Flink Table API相关依赖至少需要:

<dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-api-java</artifactId> <version>${flink.version}</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-runtime</artifactId> <version>${flink.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-planner-loader</artifactId> <version>${flink.version}</version> <scope>provided</scope> </dependency>

如果你用StreamTableEnvironment,还要额外引入bridge包:

<dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-api-java-bridge</artifactId> <version>${flink.version}</version> </dependency>

很多人会漏掉flink-table-planner-loader。没有它,环境创建成功,但执行executeSql时直接报“SQL validation failed”或者找不到执行器。依赖这东西,不是等到运行报错才去补,最好一开始就按官方模板配齐。

3. 创建表:四种主要方式与DDL完整拆解

3.1 方式一:用SQL DDL在Catalog中建表(最常用)

建表最标准的方式是SQL DDL,语法和传统数据库很接近:

CREATE TABLE [IF NOT EXISTS] table_name ( column_name data_type [COMMENT '注释'] [约束...], ... ) WITH ( 'key' = 'value' );

一个最简例子,用Flink内置的Datagen连接器生成数据:

CREATE TABLE source_table ( id BIGINT, name STRING, price DECIMAL(10, 2), ts TIMESTAMP_LTZ(3), proctime AS PROCTIME() ) WITH ( 'connector' = 'datagen', 'fields.id.kind' = 'sequence', 'fields.id.start' = '1', 'fields.id.end' = '1000', 'fields.name.kind' = 'random', 'fields.name.length' = '6', 'rows-per-second' = '5' );

这里有几个关键点:

  • WITH里的connector决定了这张表的数据从哪来,或者写到哪去。Datagen是测试专用的模拟数据源,不连外部系统,非常推荐用来练手。
  • 字段类型必须写在Schema里,Flink不会因为你给了个JSON配置帮你猜类型。
  • proctime AS PROCTIME()声明了一个处理时间字段,作用是给无时间字段的数据流提供时间语义,后面细说。

如果对接文件系统,比如读CSV:

CREATE TABLE csv_source ( id INT, name STRING, dt STRING ) WITH ( 'connector' = 'filesystem', 'path' = 'file:///tmp/flink_demo/data', 'format' = 'csv' );

注意filesystem连接器必须配format,否则建表直接报错。这是新手最容易踩的第一个坑。

再看写结果表的例子:

CREATE TABLE sink_table ( id BIGINT, total_price DECIMAL(20, 2) ) WITH ( 'connector' = 'print' );

print连接器是Flink内置的,结果直接打到标准输出,调试神器。建表时只需要定义好字段名和类型,不需要关心底层实现。

3.2 方式二:从DataStream转成表

如果代码里已经有DataStream,可以通过StreamTableEnvironment把它注册成表:

StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); DataStream<Row> stream = ...; // 方式一:注册成临时视图 tableEnv.createTemporaryView("my_view", stream); // 方式二:直接得到Table对象 Table table = tableEnv.fromDataStream(stream);

这里要特别提醒:DataStream的泛型如果不是Row,而是case class或POJO,Flink会按照字段名自动推断Schema。比如有一个Person类,字段是name、age,那么映射出来的表字段也自动叫name、age。但如果是元组类型,默认字段名是f0、f1这种,业务上不够可读,建议尽早转成Row并在fromDataStream时指定字段:

Table table = tableEnv.fromDataStream(stream, "name, age");

从DataStream转表有个隐含限制:一条流只能被注册成一张表,如果多次createTemporaryView同一DataStream,可能报错或者行为诡异。实际项目里如果需要多个视图,最好重新构造流。

3.3 方式三:注册外部Catalog和永久表

本地开发用临时表没问题,但生产环境里建议用Catalog统一管理元数据。Flink内置了GenericInMemoryCatalog,另外有JDBC、Hive等Catalog实现。注册方式很简单:

Catalog catalog = new GenericInMemoryCatalog("my_catalog", "default_database"); tableEnv.registerCatalog("my_catalog", catalog); tableEnv.useCatalog("my_catalog");

之后建表写全限定名:

CREATE TABLE my_catalog.default_database.my_table (...);

Catalog的价值在于:多个作业共用一套元数据定义,避免每个作业都重复写一遍带一堆WITH参数的建表语句。用过的人应该深有体会,几十行WITH参数写五遍,谁都不想碰。

3.4 时间属性和计算列:必须在建表阶段定义好

这部分是Flink SQL建表最核心的难点。时间属性分两种:

事件时间(Event Time):数据本身携带的时间,比如日志里的事件发生时间。

处理时间(Processing Time):数据到达Flink系统的时间,也就是机器当前时间。

事件时间在DDL里通过WATERMARK声明:

CREATE TABLE order_table ( order_id BIGINT, amount DECIMAL(10, 2), order_time TIMESTAMP_LTZ(3), WATERMARK FOR order_time AS order_time - INTERVAL '5' SECOND ) WITH ( 'connector' = 'datagen' );

WATERMARK FOR order_time表示用order_time字段作为事件时间,允许5秒乱序延迟。这里的INTERVAL '5' SECOND是允许数据迟到的最长时间,要根据业务容忍度来调,写小了会丢数据,写大了窗口结果出得慢。

处理时间则需要计算列:

CREATE TABLE log_table ( event STRING, proc_time AS PROCTIME() ) WITH (...);

proc_time AS PROCTIME()这一句就是定义处理时间列。这个字段并不来自底层数据,而是Flink自动生成,所以叫“计算列”。

有个很关键的规则:时间属性只能在建表语句里定义,到了查询阶段你不能随便在SELECT里加一个WATERMARK。如果你拿到一张没有时间属性的表,查询里想开窗口,系统会直接报错。所以建表前就想清楚:这张表要按事件时间算窗口吗?如果用事件时间,源数据里要有对应时间字段。

4. 实操演示:让一张模拟流表跑起来

4.1 准备环境依赖

先给出一份经本地测试可用的pom依赖模板(这里用1.17版本做演示,其他版本注意版本对齐):

<properties> <flink.version>1.17.2</flink.version> <scala.binary.version>2.12</scala.binary.version> </properties> <dependencies> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-streaming-java</artifactId> <version>${flink.version}</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-api-java-bridge</artifactId> <version>${flink.version}</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-planner-loader</artifactId> <version>${flink.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-table-runtime</artifactId> <version>${flink.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-connector-datagen</artifactId> <version>${flink.version}</version> </dependency> </dependencies>

注意,本地IDE直接运行Flink任务时,planner和runtime不要标provided,否则运行期类缺失。只有打包放到集群时才建议用provided,避免和集群自带依赖冲突。这个区别我踩过好几次,IDE里要么直接跑不动,要么一堆ClassNotFound。

4.2 完整示例:建表、查询、输出

下面写一个完整DEMO,模拟商品订单统计:

import org.apache.flink.table.api.Table; import org.apache.flink.table.api.bridge.java.StreamTableEnvironment; import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; public class FlinkTableDemo { public static void main(String[] args) { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1); StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env); // 1. 建源表:用Datagen模拟订单数据 tableEnv.executeSql( "CREATE TABLE orders (\n" + " order_id BIGINT,\n" + " product STRING,\n" + " amount DECIMAL(10, 2),\n" + " order_time TIMESTAMP_LTZ(3),\n" + " WATERMARK FOR order_time AS order_time - INTERVAL '3' SECOND\n" + ") WITH (\n" + " 'connector' = 'datagen',\n" + " 'fields.order_id.kind' = 'sequence',\n" + " 'fields.order_id.start' = '1',\n" + " 'fields.order_id.end' = '100000',\n" + " 'fields.product.kind' = 'random',\n" + " 'fields.product.length' = '3',\n" + " 'fields.amount.kind' = 'random',\n" + " 'rows-per-second' = '10'\n" + ")" ); // 2. 建结果表:把统计结果打印到控制台 tableEnv.executeSql( "CREATE TABLE result_print (\n" + " product STRING,\n" + " total_amount DECIMAL(20, 2),\n" + " order_cnt BIGINT\n" + ") WITH (\n" + " 'connector' = 'print'\n" + ")" ); // 3. 查询:按product分组做聚合 Table result = tableEnv.sqlQuery( "SELECT product, SUM(amount) AS total_amount, COUNT(order_id) AS order_cnt " + "FROM orders GROUP BY product" ); // 4. 把结果写入sink result.executeInsert("result_print"); } }

这段代码跑起来后,控制台会输出类似下面的结果:

+I[abc, 320.50, 15] +I[xyz, 480.20, 21]

+I说明这是insert操作,Flink流计算结果默认会带上操作类型。

这里有个细节值得展开:SQL查询和DataStream算子不同,它是动态流式的,GROUP BY会维护状态,每条新数据进来都刷新统计结果。并行度是1,输出顺序还好观察;如果调大并行度,不同分组的输出会交错,这是正常现象。

4.3 用printSchema和EXPLAIN验证建表结果

建表不报错不代表表结构符合预期。Flink提供了两个很实用的方法来校验。

printSchema可以打印表的字段结构:

tableEnv.from("orders").printSchema();

输出类似:

root |-- order_id: BIGINT |-- product: STRING |-- amount: DECIMAL(10, 2) |-- order_time: TIMESTAMP_LTZ(3) |-- watermark: ORDER_TIME AS order_time - INTERVAL '3' SECOND

每次调整建表语句后,我建议都先printSchema看一眼,确认字段类型、时间属性是否和自己设想一致。曾经有位同事建了一张表,时间字段被自动推断成TIMESTAMP(3) WITHOUT TIME ZONE,而源数据用的是UTC毫秒时间戳,结果窗口统计全偏,printSchema一眼就看穿了。

EXPLAIN则能看到SQL优化后的执行计划:

EXPLAIN SELECT product, SUM(amount) FROM orders GROUP BY product

执行计划里能看到Flink的优化过程,包括滤掉哪些列、怎么分组、使用什么算子。初学阶段不急着深究执行计划,但至少确认一下查询没有变成全表扫描之类奇怪计划。

4.4 结果的两种消费方式

上面的例子用了executeInsert把结果写进sink表。有时候我们还想把结果拿回DataStream做进一步处理,比如连接其他数据源、调用外部服务,这时要把Table转回DataStream:

Table resultTable = tableEnv.sqlQuery("SELECT product, SUM(amount) FROM orders GROUP BY product"); DataStream<Row> resultStream = tableEnv.toDataStream(resultTable);

toDataStream返回的是DataStream ,每条数据都是一个Row对象。注意,流模式下toDataStream会追加一个RETRACT或UPDATE操作语义,所以你需要关注Row里的数据,而不是直接认为Row只是简单查询结果。老版本还有一个toAppendStream,只适用于无更新操作的查询,新版本已经明确不推荐,统一用toDataStream更省心。

5. 常见问题与排查技巧实录

5.1 建表环节高频报错速查

下面这张表是我和身边同事实际踩过且解决掉的典型问题,整理出来当速查手册用很顺手:

报错信息(关键词)出现原因解决办法
Could not find a suitable factory for 'connector'WITH里没写connector或写错检查WITH参数,确保connector拼写正确
Table 'xxx' was not found当前Catalog/Database里没有这张表检查是否执行了建表SQL,SHOW TABLES确认
Column 'xxx' is not present in table schemaSELECT字段名和表Schema对不上打印printSchema,逐一对照字段
Watermark should be declared on a TIMESTAMP column事件时间字段不是时间类型检查字段类型,改成TIMESTAMP/TIMESTAMP_LTZ
Only the first field can be an event time attribute定义了多个WATERMARK一张表只能有一个事件时间字段
SQL validation failedDDL语法或类型不合法缩小SQL范围,逐句在CLI执行定位
NoClassDefFoundError: TableEnvironment缺少table API相关依赖引入flink-table-api-java和runtime
LinkageError: planner冲突依赖里同时出现老版本planner和新版loader排除flink-table-planner,统一用loader

第一条最搞笑也最常见。很多人从网上抄了一段建表SQL,复制过来后WITH参数没有完整带过来,或者把别人的自定义参数当固定项,导致connector找不到。奉劝各位:建表SQL一次性写全,接外部系统时至少确认connector、format、path这三项都在。

5.2 时间字段类型推断错乱的问题

Datagen、CSV这类数据源,Flink不会知道你的毫秒数到底是要做日期还是要做时间戳。如果你把事件时间字段声明成BIGINT,然后试图给它加WATERMARK,Flink会直接拒绝,因为BIGINT不是时间类型。

推荐做法是在源数据到达之前就完成类型转换。比如从JSON里读到的是字符串时间,可以在建表时用函数转换,或者先fromDataStream映射成Row,再转表时指定类型。如果数据源是外部消息队列,优先让序列化器在源码阶段转换,避免在SQL里反复做CAST,性能和可读性都好很多。

另一个时区偏8小时的坑之前提过,这里再强调一次:TIMESTAMP_LTZ是带时区语义的,输出显示会随setLocalTimeZone变化。如果你在本地跑任务,看到的结果和线上差8小时,先查时区配置而不是怀疑数据。

5.3 临时表和视图的可见范围

很多人以为建了临时表下一个作业还能用,其实临时表生命周期只在当前TableEnvironment内。同一个JVM里,如果用了多个环境对象,A环境建的临时表在B环境里查不到。生产上如果多个作业需要共享表定义,应该走Catalog或者统一维护一套SQL文件。

视图View本质是一段SQL,不是物理表。查询视图时Flink会展开它的定义再执行。适合把公共口径封装在视图里,比如“有效订单表”底层是复杂过滤条件,上层业务直接查CTE式的视图就行。视图不会存储数据,别指望它帮你做缓存。

5.4 本地调试的几个实用习惯

给并行度设个合理的值。本地跑流任务,并行度过高会看到大量输出交杂,而且状态太多不容易定位数据。建议先设1,逻辑跑通后再调大。这不影响正确性,只是调试体验。

多利用SHOW、DESCRIBE、EXPLAIN三个命令:

SHOW TABLES; DESCRIBE orders; EXPLAIN SELECT * FROM orders;

Flink SQL的报错信息本来就比DataStream友好,再加这三个排查命令,基本能覆盖90%的建表问题。遇到看不懂的异常,先在CLI里把标准写法试通,再搬回代码,效率翻倍。

最后再分享一个我自己常用的习惯:建表语句写好后,先单独跑一个“只查询1条数据”的SQL,用print sink打印出来,确认字段有值、类型正常、时间是我们预期的,然后再往上堆复杂的聚合和窗口逻辑。这样就把“建表问题”和“SQL业务问题”隔离开来,出了错能快速定位是表的问题还是查询的问题。建表这层越稳,后续写出飘逸SQL的容错空间才越大。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进