SQL解析与血缘提取:从文本到关系图谱

说实话,很多做数据治理的同行,一听到「SQL解析」就觉得头大。觉得那是编译器工程师的事,跟自己没关系。

我以前也这么想。直到有一次,公司要做一个全链路血缘系统,我拿着正则表达式去匹配SQL里的表名和字段……结果呢?CTE嵌套、子查询别名、Insert+Select混合写法,正则根本扛不住。那段时间我天天被业务方追着问:「这个字段到底从哪来的?」

后来我明白了:想做好血缘,必须先搞定SQL解析。而解析的核心,就是语法树。

核心观点:血缘提取的本质,是把SQL文本转换成结构化语法树,再从中抽取出表与表、字段与字段之间的依赖关系。正则只能处理「看起来规整」的SQL,语法树才能处理「所有合法」的SQL。

4.1 SQL语法树解析原理

SQL对计算机来说,就是一串字符。想让计算机「理解」它,得先做两件事:词法分析和语法分析。

词法分析,说白了就是把SQL拆成一个个最小的单元——。

比如 SELECT a FROM t WHERE b > 1,拆开后就是:SELECT(关键字)、a(标识符)、FROM(关键字)、t(标识符)、WHERE(关键字)、b(标识符)、>(操作符)、1(数字字面量)。

语法分析呢,就是把串按照SQL语法规则,组装成一棵树——抽象语法树(AST)。

我习惯把AST想象成「SQL的骨架」。每个节点代表一个语法结构,比如Select语句、From子句、Join条件、函数调用。树的结构,直接反映了SQL的逻辑层次。

举个例子,下面这条SQL:

SELECT t1.id, t2.name
FROM table_a t1
JOIN table_b t2 ON t1.key = t2.key
WHERE t1.status = 'active'

它的AST大概长这样:

SQL抽象语法树(AST)结构示例 Statement SelectStmt SelectList FromClause WhereClause ColumnRef t1.id ColumnRef t2.name JoinClause table_a JOIN table_b Comparison t1.status = 'active' t1.key t2.key

有了这棵树,血缘提取就变成了「遍历树节点,找到所有表引用和字段引用」的问题。你想想看,这比正则优雅了多少?

我的经验:刚开始做AST解析时,别想着一步到位。先解析最简单的SELECT,再逐步支持JOIN、子查询、CTE。每加一种语法,就加一个Visitor方法。这样迭代,心里踏实。

4.2 Antlr4在血缘解析中的应用

说到解析器生成器,Antlr4是目前业界用得最多的。Hive、Spark、Presto的SQL解析,底层都用了它。

Antlr4的工作流程很简单:你给它一份语法文件(.g4),它帮你生成词法分析器和语法分析器。然后你写一个Visitor,遍历AST,提取你需要的信息。

我常用的做法是:

  1. 准备语法文件——直接用社区维护的SQL语法,比如 MySqlParser.g4 或 HiveSqlParser.g4。别自己从头写,坑太多。
  2. 生成解析器——用Antlr4工具把.g4文件转成Java/Python/Go代码。
  3. 写Visitor提取血缘——重写 visitTableSource、visitSelectItem、visitInsertStatement 等方法,把表名、字段名、别名收集起来。

下面是一个简化版的Visitor示例,用来提取SELECT中的表名和字段:

public class LineageVisitor extends MySqlParserBaseVisitor<Void> {

    private List<TableRef> tables = new ArrayList<>();
    private List<ColumnRef> columns = new ArrayList<>();

    @Override
    public Void visitTableSource(MySqlParser.TableSourceContext ctx) {
        String tableName = ctx.tableName().getText();
        String alias = ctx.alias() != null ? ctx.alias().getText() : null;
        tables.add(new TableRef(tableName, alias));
        return super.visitTableSource(ctx);
    }

    @Override
    public Void visitSelectItem(MySqlParser.SelectItemContext ctx) {
        if (ctx.expression() != null) {
            // 提取字段引用,比如 t1.id、name
            String expr = ctx.expression().getText();
            columns.add(new ColumnRef(expr));
        }
        return super.visitSelectItem(ctx);
    }

    // 调用入口
    public void parse(String sql) {
        MySqlLexer lexer = new MySqlLexer(CharStreams.fromString(sql));
        CommonTokenStream tokens = new CommonTokenStream(lexer);
        MySqlParser parser = new MySqlParser(tokens);
        ParseTree tree = parser.sqlStatement();
        this.visit(tree);
    }
}

这段代码看着简单,但实际项目中要处理的情况多得多。比如:

  • 别名解析——SELECT a.id FROM t1 a,你得知道 a.id 实际对应 t1.id。
  • 函数包裹——SELECT MAX(a.price),字段是 a.price,不是 MAX(...)。
  • 子查询——FROM (SELECT ...) AS sub,你得递归进入子查询内部。

我曾经踩过的坑:Antlr4生成的解析器默认是大小写敏感的。但SQL本身不敏感。我一开始没注意,结果 SELECT 和 select 解析出来的树结构不一样。后来在语法文件里加了 caseInsensitive=true 才搞定。

4.3 常见SQL的血缘提取实战

理论说完了,咱们上手看看三种最常见的SQL怎么写血缘提取逻辑。

4.3.1 SELECT查询的血缘

SELECT是最基础的。血缘关系就是:输出字段 ← 输入字段。

比如:

SELECT 
    user_id,
    user_name,
    create_time AS reg_time
FROM user_profile
WHERE status = 1

提取结果:

输出字段 来源表 来源字段 转换逻辑
user_id user_profile user_id 直接映射
user_name user_profile user_name 直接映射
reg_time user_profile create_time 别名映射

做法很简单:遍历SelectList里的每个字段,去FromClause里找对应的表。如果有别名,用别名做中间映射。

4.3.2 INSERT的血缘

INSERT比SELECT多了一层「目标表」的信息。血缘关系是:目标表字段 ← 查询输出字段。

看这个例子:

INSERT INTO dws_user_daily (user_id, name, reg_date)
SELECT 
    id,
    full_name,
    create_time
FROM ods_user
WHERE dt = '2024-01-01'

提取逻辑:

  1. 先解析INSERT子句,拿到目标表名 dws_user_daily 和字段列表 [user_id, name, reg_date]。
  2. 再解析SELECT子句,拿到输出字段 [id, full_name, create_time] 和来源表 ods_user。
  3. 按位置一一对应:user_id ← id、name ← full_name、reg_date ← create_time。

注意:INSERT的血缘提取,位置对应是关键。如果目标表字段列表省略了(比如 INSERT INTO t SELECT * FROM s),你得去查元数据,拿到目标表的字段顺序。

4.3.3 CTE(公用表表达式)的血缘

CTE是血缘提取里最容易出问题的。因为它有「多层嵌套」和「引用传递」。

比如:

WITH 
active_users AS (
    SELECT user_id, name FROM users WHERE status = 'active'
),
orders_summary AS (
    SELECT user_id, COUNT(*) AS order_cnt
    FROM orders
    GROUP BY user_id
)
SELECT 
    a.user_id,
    a.name,
    o.order_cnt
FROM active_users a
JOIN orders_summary o ON a.user_id = o.user_id

这里CTE active_users 引用了 users 表,orders_summary 引用了 orders 表。最终查询又引用了这两个CTE。

我的做法是:

  • 先解析所有CTE定义,把每个CTE的名字和它内部的表/字段依赖存起来。
  • 再解析主查询,遇到CTE名字时,去查之前存好的依赖。
  • 递归展开——如果CTE内部又引用了其他CTE,继续往下查。

最终的血缘关系应该是:

输出字段 最终来源表 最终来源字段 经过的CTE
user_id users user_id active_users
name users name active_users
order_cnt orders COUNT(*) orders_summary

我的习惯:处理CTE时,我会建一个「CTE上下文栈」。每进入一层CTE,就把当前CTE的名字和别名映射压栈;退出时弹栈。这样不管嵌套多深,都能正确解析字段归属。

4.4 小结

SQL解析和血缘提取,说白了就是「把文本变成树,再从树上摘果子」。Antlr4帮我们搞定了「变树」这一步,剩下的「摘果子」工作,靠的是对SQL语法的理解和细心的Visitor编写。

我个人觉得,这块最难的不是技术,而是耐心。因为SQL的写法千奇百怪,你永远不知道业务方会写出什么「创意SQL」。但只要你的解析器基于语法树,而不是正则,大部分情况都能兜住。