3、数据血缘的解析原理:SQL解析、ETL脚本解析、存储过程解析
数据血缘解析,说白了就是让机器读懂你的数据加工逻辑。我刚开始做数据治理时,总觉得这玩意儿玄乎——不就是把SQL跑一遍吗?后来踩了坑才明白,解析的深度和精度,直接决定了血缘图谱的可用性。
这一章,我们聊聊三种最常见的解析场景:SQL解析、ETL脚本解析、存储过程解析。每种都有各自的脾气,咱们一个一个来拆。
3.1 SQL解析:血缘解析的基石
SQL是数据世界里的通用语言。你想想看,不管是Hive、Spark SQL还是传统数据库,最终都要落到SQL上。所以,SQL解析是血缘解析的基本功。
3.1.1 解析的核心步骤
一条SQL语句,从文本变成血缘关系,大致要经过这么几步:
- 词法分析:把SQL拆成一个个。比如
SELECT、FROM、WHERE、表名、字段名。 - 语法分析:根据SQL语法规则,生成抽象语法树(AST)。
- 语义分析:解析AST中的表、字段、函数,建立引用关系。
- 血缘提取:从AST中提取输入表→输出表、输入字段→输出字段的映射。
核心要点:血缘解析的本质,就是建立「数据从哪里来,到哪里去」的映射关系。AST是中间桥梁,但真正干活的是语义分析这一步。
3.1.2 常见的SQL模式与解析策略
我在项目中遇到过最头疼的问题,就是SQL写法千奇百怪。同一个血缘关系,不同写法解析难度天差地别。
| SQL模式 | 示例 | 解析难度 | 常见陷阱 |
|---|---|---|---|
| 简单SELECT | SELECT a, b FROM t1 |
低 | 字段别名混淆 |
| JOIN | SELECT t1.a, t2.b FROM t1 JOIN t2 ON ... |
中 | 多表字段来源追溯 |
| 子查询 | SELECT * FROM (SELECT ... ) sub |
高 | 嵌套层级深,上下文丢失 |
| CTE (WITH子句) | WITH cte AS (...) SELECT ... FROM cte |
中高 | CTE递归引用 |
| 聚合+窗口函数 | SELECT ROW_NUMBER() OVER(...) FROM t |
中 | 窗口函数内部字段解析 |
我的经验:解析子查询时,一定要维护一个「上下文栈」。每进入一层子查询,就压栈;退出时弹栈。否则字段的引用关系会乱套。我曾经因为这个bug,查了整整两天。
3.1.3 代码示例:基于AST的字段级血缘提取
这里我用一个简化的例子,展示如何从AST中提取字段级血缘。实际生产环境会用Antlr、Calcite等成熟框架,但原理是一样的。
// 伪代码:从AST提取字段级血缘
class FieldLineageExtractor {
// 输入:AST根节点
// 输出:字段映射关系 {输出字段 → 输入字段列表}
Map<String, List<FieldRef>> extract(ASTNode root) {
Map<String, List<FieldRef>> lineage = new HashMap<>();
// 1. 遍历SELECT列表
for (SelectItem item : root.getSelectList()) {
String outputField = item.getAlias(); // 输出字段名
List<FieldRef> inputFields = new ArrayList<>();
// 2. 解析每个SELECT项的表达式
resolveExpression(item.getExpr(), inputFields);
// 3. 建立映射
lineage.put(outputField, inputFields);
}
return lineage;
}
// 递归解析表达式,收集引用的字段
void resolveExpression(Expr expr, List<FieldRef> fields) {
if (expr instanceof ColumnRef) {
// 直接字段引用
fields.add(new FieldRef(expr.getTable(), expr.getColumn()));
} else if (expr instanceof FunctionCall) {
// 函数调用,递归解析参数
for (Expr arg : expr.getArgs()) {
resolveExpression(arg, fields);
}
} else if (expr instanceof Subquery) {
// 子查询,递归进入
extract(expr.getQueryAST());
}
// ... 其他表达式类型
}
}
注意:字段别名是解析中的大坑。很多SQL会写 SELECT a AS x, b + c AS y,如果后续引用 y,你必须能追溯到 b 和 c。别名只是「标签」,不是「源头」。
3.2 ETL脚本解析:多语言混编的挑战
ETL脚本比纯SQL复杂得多。你想想看,一个典型的ETL任务可能包含:
- Shell脚本调用SQL
- Python脚本做数据清洗
- Spark任务用Scala或PySpark
- 甚至还有Perl、Java的遗留代码
解析ETL脚本,本质上是在做跨语言的血缘追踪。
3.2.1 解析策略:分层+模式匹配
我个人习惯把ETL脚本解析分成三层:
- 脚本结构层:识别脚本的整体框架。比如Shell中的函数定义、循环、条件分支。
- 数据操作层:定位所有读写数据的操作。比如
hive -e "SELECT ..."、spark-submit --class ...、pd.read_csv()。 - 血缘提取层:对每个数据操作,用对应的解析器提取血缘。
关键思路:不要试图用一个解析器搞定所有语言。正确的做法是:先用「脚本解析器」拆出数据操作片段,再分发给「SQL解析器」「Python解析器」等专用模块。
3.2.2 实战:解析Shell+SQL混编脚本
我曾经接手过一个老项目,ETL脚本全是Shell里嵌SQL,长这样:
#!/bin/bash
# 步骤1:抽取原始数据
hive -e "
INSERT OVERWRITE TABLE dwd.orders
SELECT order_id, user_id, amount, create_time
FROM ods.orders_raw
WHERE dt = '${yesterday}'
"
# 步骤2:数据清洗
python3 clean_data.py --input dwd.orders --output dws.orders_clean
# 步骤3:聚合计算
spark-submit --class com.etl.Aggregation agg.jar \
--input dws.orders_clean \
--output ads.order_summary
解析这种脚本,我的做法是:
- 正则匹配:识别
hive -e、python3、spark-submit等关键字。 - 提取参数:从命令行参数中解析输入输出路径或表名。
- 递归解析:对
hive -e后面的SQL字符串,调用SQL解析器。 - 拼接血缘:把每一步的输入输出串联起来,形成完整链路。
避坑指南:我曾经遇到一个脚本,SQL里用了变量 ${table_name},正则匹配死活找不到表名。后来发现变量是在脚本开头定义的。所以,解析ETL脚本时,一定要先做变量展开,把 ${var} 替换成实际值,再去做血缘提取。
3.3 存储过程解析:动态SQL与游标的噩梦
存储过程是血缘解析的「地狱模式」。为什么?因为存储过程里有:
- 动态SQL:字符串拼接出来的SQL,运行时才确定表名和字段名。
- 游标循环:逐行处理数据,血缘关系在循环中不断变化。
- 条件分支:IF-ELSE、CASE WHEN,不同分支走不同的数据路径。
- 临时表:创建、插入、删除,生命周期只在会话内。
3.3.1 解析策略:静态分析+运行时采样
纯静态解析存储过程,基本不可能100%准确。我的建议是双管齐下:
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 静态分析 | 解析存储过程的AST,提取所有可能的SQL语句 | 覆盖所有代码路径 | 动态SQL无法解析,临时表关系难确定 |
| 运行时采样 | 在测试环境执行存储过程,捕获实际执行的SQL | 准确度高,能处理动态SQL | 只能覆盖部分分支,有数据污染风险 |
我的建议:先用静态分析画出「可能」的血缘关系,再用运行时采样验证和补充。两者结合,准确率能到90%以上。
3.3.2 动态SQL的解析技巧
动态SQL是存储过程里最头疼的问题。比如这段代码:
CREATE PROCEDURE dynamic_query(IN table_name VARCHAR(100))
BEGIN
SET @sql = CONCAT('SELECT * FROM ', table_name, ' WHERE dt = ''2024-01-01''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END;
静态分析时,你根本不知道 table_name 是什么。怎么办?
- 参数推断:如果存储过程有固定调用方,可以从调用代码中推断参数值。
- 模式匹配:分析
CONCAT函数的参数,提取出表名的「模式」。比如CONCAT('ods_', table_name),可以推断出表名以ods_开头。 - 运行时捕获:在测试环境执行,用数据库的审计日志或
general_log捕获实际SQL。
注意:千万不要在生产环境开启 general_log 来捕获SQL!我曾经见过有人这么干,结果日志把磁盘写满了,数据库直接挂了。血的教训。
3.4 三种解析方式的对比与选型
说了这么多,咱们来总结一下。三种解析方式各有优劣,选型时要根据实际情况来:
| 维度 | SQL解析 | ETL脚本解析 | 存储过程解析 |
|---|---|---|---|
| 解析难度 | 低-中 | 中-高 | 高-极高 |
| 准确率 | 高(95%+) | 中(80-90%) | 低-中(60-85%) |
| 动态SQL支持 | 不支持 | 部分支持 | 需运行时采样 |
| 跨语言支持 | 单语言 | 多语言 | 单语言(SQL) |
| 适用场景 | 数据仓库、报表 | 数据管道、调度任务 | 传统数据库、遗留系统 |
选型建议:如果你的数据链路以Hive/Spark SQL为主,优先做好SQL解析。如果ETL脚本五花八门,先统一脚本规范,再考虑解析。至于存储过程……嗯,能迁移就迁移吧,实在不行再上运行时采样。
3.5 本章小结:解析原理的核心脉络
为了让你更直观地理解三种解析方式的关系,我画了一张图:
从这张图你可以看到,三种解析方式不是互斥的,而是互补的。SQL解析打基础,ETL脚本解析扩展范围,存储过程解析补全死角。实际生产环境中,我通常会把三者组合起来,构建一个完整的血缘解析引擎。