3、数据血缘的解析原理:SQL解析、ETL脚本解析、存储过程解析

数据血缘解析,说白了就是让机器读懂你的数据加工逻辑。我刚开始做数据治理时,总觉得这玩意儿玄乎——不就是把SQL跑一遍吗?后来踩了坑才明白,解析的深度和精度,直接决定了血缘图谱的可用性。

这一章,我们聊聊三种最常见的解析场景:SQL解析、ETL脚本解析、存储过程解析。每种都有各自的脾气,咱们一个一个来拆。

3.1 SQL解析:血缘解析的基石

SQL是数据世界里的通用语言。你想想看,不管是Hive、Spark SQL还是传统数据库,最终都要落到SQL上。所以,SQL解析是血缘解析的基本功。

3.1.1 解析的核心步骤

一条SQL语句,从文本变成血缘关系,大致要经过这么几步:

  1. 词法分析:把SQL拆成一个个。比如 SELECT、FROM、WHERE、表名、字段名。
  2. 语法分析:根据SQL语法规则,生成抽象语法树(AST)。
  3. 语义分析:解析AST中的表、字段、函数,建立引用关系。
  4. 血缘提取:从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脚本解析分成三层:

  1. 脚本结构层:识别脚本的整体框架。比如Shell中的函数定义、循环、条件分支。
  2. 数据操作层:定位所有读写数据的操作。比如 hive -e "SELECT ..."、spark-submit --class ...、pd.read_csv()。
  3. 血缘提取层:对每个数据操作,用对应的解析器提取血缘。

关键思路:不要试图用一个解析器搞定所有语言。正确的做法是:先用「脚本解析器」拆出数据操作片段,再分发给「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

解析这种脚本,我的做法是:

  1. 正则匹配:识别 hive -e、python3、spark-submit 等关键字。
  2. 提取参数:从命令行参数中解析输入输出路径或表名。
  3. 递归解析:对 hive -e 后面的SQL字符串,调用SQL解析器。
  4. 拼接血缘:把每一步的输入输出串联起来,形成完整链路。

避坑指南:我曾经遇到一个脚本,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 是什么。怎么办?

  1. 参数推断:如果存储过程有固定调用方,可以从调用代码中推断参数值。
  2. 模式匹配:分析 CONCAT 函数的参数,提取出表名的「模式」。比如 CONCAT('ods_', table_name),可以推断出表名以 ods_ 开头。
  3. 运行时捕获:在测试环境执行,用数据库的审计日志或 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脚本解析 分层+模式匹配+跨语言 存储过程解析 静态分析+运行时采样 字段级血缘 表级+字段级血缘 近似血缘(需验证) 完整数据血缘图谱 说明:三种解析方式互补,实际生产环境通常组合使用,才能构建完整的血缘图谱

从这张图你可以看到,三种解析方式不是互斥的,而是互补的。SQL解析打基础,ETL脚本解析扩展范围,存储过程解析补全死角。实际生产环境中,我通常会把三者组合起来,构建一个完整的血缘解析引擎。