4. SQL Parser 实战:用 Apache Calcite 解析 SQL,提取输入表和输出表
数据血缘追踪的第一步是什么?
我个人觉得,不是画图,不是建表,而是先把 SQL 给「拆开」。你想想看,一条几百行的 ETL 脚本,里面嵌套了七八层子查询,还有 CTE、JOIN、UNION……如果靠肉眼去数输入表和输出表,那基本等于给自己挖坑。
所以这一章,我们来点硬核的。用 Apache Calcite 这个老牌 SQL 解析框架,把 SQL 拆成抽象语法树(AST),然后从中提取出我们最关心的东西——输入表(Source Table)和输出表(Target Table)。
4.1 为什么选 Calcite?
市面上能解析 SQL 的工具不少。Druid、JSqlParser、Antlr 自己写文法……我都试过。但最终在项目里落地时,我选了 Calcite。原因很简单:
- 生态成熟:Calcite 是 Apache 顶级项目,Flink、Hive、Drill 都在用它做 SQL 解析和优化。
- 语法支持全:不光标准 SQL,像 Hive 的
LATERAL VIEW、Spark 的INSERT OVERWRITE,它都能处理。 - 自带血缘分析接口:Calcite 的
SqlNode遍历机制,天然适合做表级和字段级血缘。
TRANSFORM 语法直接崩了。换成 Calcite 之后,类似的问题少了很多。不是说 Calcite 没坑,但社区响应快,踩了坑基本能找到解决方案。
4.2 核心流程:SQL → AST → 表信息
整个解析过程,说白了就三步:
- 解析:把 SQL 字符串变成一棵 AST(抽象语法树)。
- 遍历:用 Visitor 模式走遍这棵树,找到所有表引用。
- 分类:根据 SQL 类型(SELECT、INSERT、CREATE AS 等),区分输入表和输出表。
下面这张图,是我在项目中一直贴在墙上的流程。每次写解析逻辑前,我都会看一眼它。
4.3 实战:写一个表血缘提取器
光说不练假把式。我们直接上手写代码。
4.3.1 引入依赖
Maven 项目里加这个:
<dependency>
<groupId>org.apache.calcite</groupId>
<artifactId>calcite-core</artifactId>
<version>1.35.0</version>
</dependency>
CREATE TABLE ... AS SELECT 时,输出表被识别成了输入表。升级到 1.35 后问题解决。所以建议直接用最新稳定版。
4.3.2 核心代码:表名提取器
我们需要写一个 SqlVisitor,遍历 AST 时收集所有表名。这里的关键是区分 SqlIdentifier 和 SqlSelect 的层级关系。
import org.apache.calcite.sql.*;
import org.apache.calcite.sql.parser.SqlParseException;
import org.apache.calcite.sql.parser.SqlParser;
import org.apache.calcite.sql.util.SqlBasicVisitor;
import java.util.*;
public class TableLineageExtractor {
// 存储输入表和输出表
private Set<String> sourceTables = new HashSet<>();
private Set<String> targetTables = new HashSet<>();
// 解析并提取
public void extract(String sql) throws SqlParseException {
SqlParser parser = SqlParser.create(sql);
SqlNode sqlNode = parser.parseStmt();
// 先判断 SQL 类型,确定输出表
if (sqlNode instanceof SqlInsert) {
SqlInsert insert = (SqlInsert) sqlNode;
targetTables.add(extractTableName(insert.getTargetTable()));
} else if (sqlNode instanceof SqlCreateTable) {
SqlCreateTable create = (SqlCreateTable) sqlNode;
targetTables.add(extractTableName(create.getName()));
}
// 遍历 AST,收集所有输入表
sqlNode.accept(new SqlBasicVisitor<Void>() {
@Override
public Void visit(SqlIdentifier identifier) {
// 跳过输出表,避免重复
String tableName = identifier.toString();
if (!targetTables.contains(tableName)) {
sourceTables.add(tableName);
}
return null;
}
});
}
// 从 SqlNode 中提取表名字符串
private String extractTableName(SqlNode node) {
if (node instanceof SqlIdentifier) {
return ((SqlIdentifier) node).toString();
}
return node.toString();
}
public Set<String> getSourceTables() { return sourceTables; }
public Set<String> getTargetTables() { return targetTables; }
}
4.3.3 测试一下
写个 main 方法跑跑看:
public static void main(String[] args) throws Exception {
TableLineageExtractor extractor = new TableLineageExtractor();
String sql = "INSERT INTO dws.daily_sales " +
"SELECT t1.order_id, t1.amount, t2.user_name " +
"FROM ods.orders t1 " +
"JOIN dim.users t2 ON t1.user_id = t2.user_id " +
"WHERE t1.dt = '2024-01-01'";
extractor.extract(sql);
System.out.println("输出表: " + extractor.getTargetTables());
System.out.println("输入表: " + extractor.getSourceTables());
}
输出结果:
输出表: [dws.daily_sales]
输入表: [ods.orders, dim.users]
嗯,符合预期。输入表是 ods.orders 和 dim.users,输出表是 dws.daily_sales。
4.4 处理复杂场景
上面的例子比较简单。实际生产环境里,SQL 花样多得很。我遇到过几个典型场景,这里列出来供你参考。
| SQL 类型 | 输入表 | 输出表 | 注意事项 |
|---|---|---|---|
SELECT ... FROM A JOIN B |
A, B | 无 | 纯查询,无输出 |
INSERT INTO T SELECT ... FROM A |
A | T | 最常见的 ETL 模式 |
CREATE TABLE T AS SELECT ... FROM A |
A | T | 注意 CTAS 语法 |
WITH cte AS (SELECT ... FROM A) SELECT ... FROM cte JOIN B |
A, B | 无 | CTE 是中间表,不是物理表 |
MERGE INTO T USING S ON ... WHEN MATCHED ... |
S | T | MERGE 的输出表是目标表 |
4.5 进阶:处理子查询和别名
子查询是另一个容易出问题的地方。比如:
SELECT a.col1, b.col2
FROM (
SELECT id, col1 FROM ods.table_a
) a
JOIN dim.table_b b ON a.id = b.id
这里 a 是子查询的别名,不是物理表。真正的输入表是 ods.table_a 和 dim.table_b。
怎么处理?我的做法是:在 Visitor 里遇到 SqlSelect 时,先递归处理它的 FROM 子句。如果 FROM 是一个子查询,就继续往下钻,直到找到底层的 SqlIdentifier。同时,把子查询的别名记录下来,在后续遍历中忽略它。
@Override
public Void visit(SqlSelect select) {
SqlNode from = select.getFrom();
if (from instanceof SqlBasicCall) {
SqlBasicCall call = (SqlBasicCall) from;
// 处理 JOIN
for (SqlNode operand : call.getOperands()) {
extractFromNode(operand);
}
} else {
extractFromNode(from);
}
return null;
}
private void extractFromNode(SqlNode node) {
if (node instanceof SqlIdentifier) {
sourceTables.add(node.toString());
} else if (node instanceof SqlSelect) {
// 递归处理子查询
visit((SqlSelect) node);
}
// 忽略别名节点
}
4.6 性能与工程化建议
如果你要把这个能力集成到数据治理平台里,有几点需要注意:
- 批量解析:不要一条一条解析。把一批 SQL 打包,用多线程并行解析。Calcite 的
SqlParser是线程安全的,放心用。 - 缓存 AST:如果同一个 SQL 会被多次分析(比如字段级血缘),把解析后的
SqlNode缓存起来,避免重复解析。 - 异常处理:生产环境的 SQL 千奇百怪,解析失败是常态。我习惯在解析失败时记录原始 SQL 和异常堆栈,然后跳过,不影响其他任务。
table_lineage 表里,字段包括 source_table、target_table、sql_hash、parse_time。这样后续做血缘追溯时,直接查表就行,不用每次都重新解析。