☰
基于Calcite的SQL血缘解析实践:从表级到字段级的完整方案
2026/10/15 15:38:23 网站建设 项目流程

1. 先聊清楚:血缘解析到底在解决什么痛点

1.1 数据地图上最缺的不是表清单,而是"线"

做数据治理的朋友应该都有同感:很多团队花大力气把表、字段、指标都登记进元数据系统,数据地图上密密麻麻全是点,业务方用起来却觉得没什么价值。为什么?因为地图上只有"点",没有"线"。你知道仓库里有一张订单明细表、一张每日汇总宽表,但订单明细到底是怎么变成宽表的、中间经历了哪些加工节点、宽表里的金额字段是由哪几个源字段拼出来的,地图上完全是空白。血缘解析要补的就是这条"线"。

把血缘落到真实业务里,一般有三个场景是离了它就跑不动的:

  • 影响分析:基础表要改字段类型、删字段或者换数源,你得第一时间知道哪些下游表、报表、指标、数据接口会被波及。没有血缘,就只能靠人工翻上游任务,翻到一半就乱。
  • 数据质量溯源:日报表某个数字对不上,排查要一层层往回追:从应用层报表追到汇总宽表,从宽表追到明细层,再从明细层追到贴源层。没有血缘,追查一次全链路可能要耗掉一个下午。
  • 合规审计:身份证号、手机号这类敏感字段的流动路径必须能说清楚——从哪张源表来、经过哪些加工、最终落到哪张表。审计问起来,你要拿得出链路证据。

这三个场景有个共同点:一旦没有血缘,就只能靠人肉记忆。而一个中大规模数仓里SQL任务少说上千条,靠人肉记忆根本存活不了。

1.2 表级血缘和字段级血缘,差的不只是粒度

血缘解析通常分两个粒度来做:

  • 表级血缘:描述"表到表"的依赖关系,比如ods_order_detail → dwd_order_detail → dws_sales_summary。这种血缘只要解析出 FROM、JOIN、子查询里出现的表,再对应到目标表就能得到。
  • 字段级血缘:描述"字段到字段"的链路,比如目标表dws_sales_summary.amount,其实是ods_order_detail.pay_amount和ods_order_detail.coupon_amount相减后再求和得到的。字段级血缘才真正值钱,也真正难做。

我见过不少团队立项时说的是"我要做数据血缘",结果交付的是表级血缘。不是说表级血缘没用,而是到了真正的数据质量追溯和影响分析场景,表级血缘根本不足以回答"这个字段的改动影响了谁"这个问题。我自己做下来的体感是:表级血缘如果一切顺利,一两天就能跑通;字段级血缘做了几周还在不断补 case。后面几节会详细展开字段级血缘里那些绕不过去的细节。

2. 为什么是 Calcite:解析引擎选型的纠结与取舍

2.1 正则硬拆 SQL?两周后我劝你放弃

一开始我也动过自己写的念头,毕竟需求听起来简单:不就是把 SQL 文本拆一拆,找找 FROM、JOIN、INSERT INTO 后面的表名吗?用正则看起来能覆盖七八成。但等我把线上真实 SQL 样本拉出来跑一遍,崩溃的速度比想象中快得多:

  • CASE WHEN嵌套五层,括号匹配直接让人怀疑人生;
  • 子查询套子查询,别名引用错位,正则根本判断不了"这个 user_id 到底属于哪一层";
  • UNION ALL两个分支字段顺序不一样,正则拆出来的表名跟字段对不上号;
  • 函数套函数,字段名出现在参数中间,比如DATE_FORMAT(create_time, 'yyyy-MM-dd'),你要拿 create_time 得先做括号配平。

正则方案发展到后来越写越不可维护。光"识别字段出现的所有位置"这一件事,就够写出几百行奇怪代码。更别说方言差异:窗口函数、MERGE INTO、LATERAL VIEW 这些写法,正则完全没有系统性的覆盖手段。这是我想劝退所有打算用正则硬拆 SQL 的人的第一条理由。第二个理由更现实:血缘解析只是整个数据治理平台里的一小块,你不可能投入几个月去维护一套脆弱的解析器,它必须站在一个成熟解析引擎的肩膀上。

2.2 Calcite 的核心能力:SqlNode 语法树和关系代数

Calcite 是业界主流的动态数据管理框架,我们主要用到它的两层能力:SQL 解析(parsing)和验证(validation)。解析完成后,血缘提取可以走两条路线。

第一条路线基于SqlNode 语法树。Calcite 内部是用解析器把 SQL 文本变成一棵SqlNode树,树上有SqlSelect、SqlJoin、SqlIdentifier、SqlCall这些节点类型。框架提供了一套 visitor 访问器,比如SqlBasicVisitor,你重写它的visit方法,在遍历到感兴趣的关键节点时收集表和字段信息。这条路实现起来很直接,适合快速跑通表级血缘,但遇到复杂表达式时很多推导要自己做。

第二条路线基于关系代数 RelNode。通过 validator 把语法树校验后,再转成关系代数树,树上是TableScan、Project、Filter、Join、Aggregate这类算子。每个算子都明确描述输入输出的字段索引,字段级血缘在关系代数上的表达更结构化,算是比较正规的算法路线。但这条路有个现实约束:它强依赖 validator,如果 SQL 里引用了 schema 中不存在的表,校验阶段就可能直接报错。真实环境里血缘解析经常要处理一堆历史遗留 SQL,那些表未必都注册在 catalog 里,所以纯靠 RelNode 路线并不现实。

我最终的选择是折中:先用 SqlNode 路线快速跑通表级血缘,字段级血缘则结合两条路线的各自优势——用 SqlNode 做语法层面的字段收集,再借用 validator 在部分表齐全的场景下辅助关系代数推导。具体实现后面第三部分会讲。

2.3 横向对比:Calcite、JSqlParser、自研 Antlr 语法

选型时我还认真调研过另外两个方向。趁这个机会把对比结论放出来,给做同样需求的人一个参考。

维度CalciteJSqlParser自研 Antlr 语法
解析能力强,支持自定义语法扩展中,标准 SQL 为主完全可控,但成本极高
血缘提取支持有 SqlNode / RelNode 双路线需自行遍历需完全自研
方言适配可通过配置和扩展适配主流方言较弱,复杂方言吃力取决于你写多少语法文件
学习成本较高,文档不友好低,API 直观非常高
后期维护成本低,跟随社区演进中,遇到方言缺陷难绕高,语法文件的坑要自己扛

JSqlParser也是 Java 生态里一个很常用的 SQL 解析库,API 简单、开箱即用,小项目做点简单 SQL 解析非常舒服。但我们当时需要覆盖 Hive 风格 SQL 和一批带自定义函数的脚本,JSqlParser 对复杂方言的支持明显吃力,而且它不提供类似 Calcite 的自定义语法扩展能力,遇到解析不了就只能绕。

如果用纯 Antlr 自己写 SQL 语法文件,等于把 Calcite parser 层从零做一遍,工程量至少按月算,还要持续维护。Calcite 的语法文件本身就是大量 SQL 方言的集合,社区长期迭代,性价比完全不在一个量级。最终敲定 Calcite,并不是因为它完美,而是因为它把最难的解析和校验部分做掉了,我们只需要专注在"如何从树里挖出血缘"这件事上。

3. 血缘提取的完整实现链路:从 SQL 文本到字段级血缘

3.1 第一步:用 SqlParser 把 SQL 变成语法树

Calcite 最基础的用法是这样:

import org.apache.calcite.config.Lex; import org.apache.calcite.sql.SqlNode; import org.apache.calcite.sql.parser.SqlParseException; import org.apache.calcite.sql.parser.SqlParser; SqlParser.Config config = SqlParser.config() .withLex(Lex.MYSQL) .withConformance(SqlConformanceEnum.DEFAULT); SqlParser parser = SqlParser.create(sqlText, config); SqlNode sqlNode = parser.parseStmt(sqlText); // 解析完整语句

这里有个很多第一次接触 Calcite 的人容易忽略的点:parseQuery只解析查询语句,如果丢给它一条完整的INSERT INTO ... SELECT ...就会出问题。实际在血缘场景里,我们要处理的恰恰是这类写操作语句,目标表藏在外层,数据来源藏在内层 SELECT 里。所以建议直接用parseStmt拿到完整语句的根节点,再用instanceof判断节点类型,是SqlInsert就把getTargetTable()作为目标表,取getSource()作为查询体继续往下钻;是SqlCreateTable这类 DDL,就取它内部的 query 子节点。这样从第一天起就避免"只能解析查询语句"的盲区。

另外注意withLex配置:Lex.MYSQL、Lex.ORACLE、Lex.BIG_QUERY对应不同的语法风格和引号处理。血缘解析要覆盖多方言的话,这一项最好做成按源端配置,而不是全局写死。

3.2 第二步:visitor 遍历中如何区分表名和字段名

拿到SqlNode之后,血缘提取的核心逻辑就在 visitor 遍历里。这里最棘手的问题不是遍历本身,而是区分哪些SqlIdentifier是表名、哪些是字段名。

我采用的策略是分层处理:

  • 在SqlSelect.getFrom()的子树里,SqlIdentifier基本就是表名或者表别名,SqlJoin的 left/right 同理;
  • 在selectList、where、groupBy、having这些表达式子树里,SqlIdentifier基本就是字段名;
  • 遇到嵌套的SqlSelect子查询,递归进入之前,把当前层的别名和实际表名对应关系记录到上下文栈里。

一个简化版的 visitor 骨架如下:

public class LineageVisitor extends SqlBasicVisitor<Void> { private final Deque<AliasContext> aliasStack = new ArrayDeque<>(); private final List<TableNode> sourceTables = new ArrayList<>(); @Override public Void visit(SqlSelect select) { // 先处理 FROM 子树,收集表 if (select.getFrom() != null) { select.getFrom().accept(this); } // selectList 里的 identifier 按字段处理 if (select.getSelectList() != null) { select.getSelectList().accept(this); } // where / groupBy / having 同理 return null; } @Override public Void visit(SqlJoin join) { join.getLeft().accept(this); join.getRight().accept(this); join.getCondition().accept(this); return null; } @Override public Void visit(SqlIdentifier id) { // 根据当前所在的上下文判断是表还是字段 // 完整名称 id.names 可能是 [schema, table] 或 [table, column] return null; } }

SqlIdentifier有个关键方法id.names,它保存的是点分名称的各个部分。比如ods_order_detail.d在 from 里出现时 names 是["ods_order_detail", "d"],在 select 里出现时d.order_time就是["d", "order_time"]。通过命名长度和当前遍历位置,再结合别名栈,就能判断出每个 identifier 在血缘中的角色。这块逻辑是整个解析器里最核心的部分,写的时候建议把所有分支都用真实SQL测试覆盖,光靠说明书是写不出来的。

3.3 第三步:字段级血缘映射的构建与表达式记录

把表和字段都识别出来之后,剩下的是建模。字段级血缘我用的核心结构很简单:

public class ColumnLineage { private String targetTable; // 目标表 private String targetColumn; // 目标字段 private String expression; // 转换表达式,如 "SUM(pay_amount - coupon_amount)" private List<String> sourceRefs; // 来源字段,如 ["pay_amount", "coupon_amount"] private String sqlId; // 关联的SQL任务ID // getter / setter 省略 }

对于一条最朴素的INSERT INTO tgt(a,b) SELECT x,y FROM src,字段映射是直白的:tgt.a ← src.x,tgt.b ← src.y。真实SQL几乎不会这么听话,更多是下面这样:

INSERT INTO dws_sales_summary (order_date, region, amount) SELECT DATE_FORMAT(d.order_time, 'yyyy-MM-dd'), d.city, SUM(d.pay_amount - d.coupon_amount) FROM ods_order_detail d GROUP BY DATE_FORMAT(d.order_time, 'yyyy-MM-dd'), d.city

字段级血缘要做到三件事:

  1. 目标表amount的来源要落到ods_order_detail.pay_amount和ods_order_detail.coupon_amount两个字段,同时记录运算链SUM(pay_amount - coupon_amount),使用者一眼能看出中间做了一次减法和一次聚合。
  2. order_date虽然经过了DATE_FORMAT函数,但血缘链路仍然要能追溯到order_time。
  3. 表达式里的order_time、city、pay_amount、coupon_amount这些标识符都要保留一个"是否带表限定"的信息,方便后面做多表 join 时的字段归属判断。

代码层面的做法是:遍历 selectList 中的每个 select item。如果是简单SqlIdentifier,直接映射;如果是SqlCall(函数调用或表达式),递归遍历它的每个操作数,把所有SqlIdentifier节点收集出来作为来源字段,SqlCall.toString()作为转换表达式文本。

这一步走通,字段级血缘的骨架就算立住了。真正的麻烦在下一节——各种语法结构的处理和边界情况。

4. 字段级血缘的深水区:子查询、CTE、CASE WHEN 怎么处理

4.1 子查询和 CTE 的别名作用域管理

子查询是血缘解析里最消耗脑细胞的场景。看这条:

INSERT INTO tgt SELECT t.user_id, sub.total_amount FROM ( SELECT user_id, SUM(amount) AS total_amount FROM ods_pay WHERE pay_status = 1 GROUP BY user_id ) sub JOIN ods_user t ON sub.user_id = t.user_id

在字段级血缘里,最终要找的是ods_pay.amount怎么变成tgt.total_amount,中间隔着一个子查询别名sub。解析时必须维护一个"输出列 → 输入表达式"的映射关系,逐层合并:

  1. 对子查询sub,记录sub.total_amount ← ods_pay.amount,且附带SUM聚合信息;
  2. 对外层 select,记录tgt.total_amount ← sub.total_amount;
  3. 两层映射做一次替换合并,最终得到tgt.total_amount ← ods_pay.amount (SUM 聚合)。

这个"逐层替换"的过程,本质上就是做一趟代换。实现上建议为子查询单独建立一张临时映射表,子查询遍历完入栈,外层引用时从栈顶查找别名对应的真实来源。注意user_id在子查询内外同时存在,替换时要按限定名完整匹配,不能只比对字段名。

CTE(WITH 子句)处理原理类似,但有两点不同:CTE 可以被多次引用,同一个 CTE 的映射定义要被复用,而不是每引用一次就重新解析一遍;CTE 之间也有依赖关系,比如WITH a AS (...), b AS (SELECT * FROM a),解析 b 时要用到 a 的映射结果,所以遍历顺序上要先解析依赖链底层的 CTE。

4.2 CASE WHEN 和函数嵌套的归因策略

CASE WHEN在血缘归因上有个天然的纠结:条件里出现的字段和取值里出现的字段,都算来源吗?我的经验是都算,但要在血缘里区分语义。

CASE WHEN d.pay_status = 1 THEN d.pay_amount ELSE 0 END

这条表达式的完整血缘应该记录:

  • 来源字段:pay_status(条件字段)、pay_amount(取值字段)、0(常量,可忽略);
  • 表达式文本:CASE WHEN d.pay_status = 1 THEN d.pay_amount ELSE 0 END。

如果血缘模型足够精细,可以把"条件字段"和"取值字段"分别存,等影响分析时给业务方更准确的提示。但很多商业血缘工具的第一版也只是把所有 identifier 收进来,能追溯到列已经比"只知道来源表"强太多了。所以第一版不用过度设计,先把表达式文本和来源字段列表存好,有条件再拆。

函数嵌套的归因相对无脑但很有效:递归遍历SqlCall的每个操作数,把嵌套层层的SqlIdentifier全部收集。NVL(phone, mobile)收集phone、mobile两个;IF(score > 60, 'pass', 'fail')收集score。注意'pass'这种字符字面量不是 identifier,要过滤掉,否则血缘里会出现一串无意义的常量。

4.3 视图展开:把血缘推到基表还是停在视图

真实数仓里,上层应用经常直接查视图而不是物理表。血缘这时候就面临选择:指向视图本身,还是把视图展开到基表。

两种方案各有适用场景,我建议都做,用不同密度的边区分:

  • 指向视图本身:链路短、清晰,适合影响分析的初步评估,能快速看出"改这个视图影响哪些下游"。
  • 展开到基表:链路长但完整,适合数据质量溯源,能一路追到真正的物理 ODS 层。

Calcite 在 schema 中注册了视图时,validator 阶段会把视图定义展开成对应的 RelNode。这个展开过程可以复用来做物理血缘推导。但注意:展开是有代价的,视图里面再套视图、再带子查询时,展开链路会非常深,性能和数据准确性都要压测。折中做法是默认血缘指向视图,用户点击下钻时再动态展开一层,而不是一次性把全部视图都展开成基表。

5. 跑通解析之后才遇到的五个真实坑

5.1 同名不同表的字段冲突

这是字段级血缘里最经典的坑。两张表 join,都有user_id,目标表也有user_id。如果不带表名解析,来源字段会同时出现两个user_id,使用者根本不知道来自哪张表。

我的解决方案是:提取来源时保留完整限定名,血缘记录里写ods_order.user_id和ods_user.user_id,通过 select 项里的限定信息来确定到底取了哪一边。对于 select 里没带表别名、但 from 里只出现一张表的场景,要能根据当前作用域补全表名,这就又回到 4.1 节那个别名栈的作用了。这里强调一句:血缘里的字段名一定要带表名,否则下游根本没法用。

5.2 窗口函数的 PARTITION BY 字段会被默认 visitor 漏掉

窗口函数是字段血缘的一个"黑洞"。看这句:

SUM(amount) OVER (PARTITION BY user_id ORDER BY order_time)

amount是值来源字段没问题,但user_id和order_time同样参与了数据加工——一个是分组维度,一个是排序维度。如果不显式处理,默认 visitor 在遇到SqlOver节点时不会钻进它的窗口定义里去,PARTITION BY user_id里的user_id会静默丢失。

踩过一次之后,我在 visitor 里显式加了visit(SqlOver)的重写,把getPartitionList()和getOrderList()里的字段也当作来源字段收进血缘。同时,在血缘结构里给这些字段打上"窗口分区"或"窗口排序"的标签,至少不要让它们和普通值来源混在一起,给下游造成误导。

5.3 UNION ALL 各分支列数不一致

业务 SQL 里UNION ALL各分支字段顺序不一致、甚至个别分支多一个字段的情况并不少见。字段级血缘遇到UNION ALL时,要注意它是按位置对齐的,不是按字段名对齐的。也就是说第一个分支的第三个 select item 和第二个分支的第三个 select item 属于同一逻辑列,即使它们名字不一样。

一旦某个分支调整了 select item 顺序,按名字对齐的工具就会产生错位血缘,这是非常隐蔽的错误。我的实践是:按每个分支 selectList 的 index 对齐,同一 index 的来源字段合并成多个可选来源,同时在血缘边上标注"来源于 UNION 第 N 分支"。

5.4 MERGE INTO 的三个分支血缘要分开处理

MERGE INTO 在数仓同步场景里太常见了。它同时涉及目标表、源表和匹配条件,血缘提取时要区分三个分支:

  • INSERT分支:value 表达式里出现的源端字段;
  • UPDATE分支:SET 赋值表达式里出现的源端字段;
  • MATCHED/NOT MATCHED条件:ON 条件里出现的字段,属于关联键血缘。

刚开始我只解析了常规INSERT ... SELECT,上线后某张表的血缘明显对不上,查了半天才发现是 MERGE 的同步任务根本没被解析进去。后来专门为SqlMerge写了一套处理逻辑:目标表从getTargetTable()拿,源表从getSourceTable()拿,三个分支分别提取字段,最后写入血缘模型时带上分支类型标签。这个坑强烈建议一开始就纳入范围,别放到第二期。

5.5 血缘去重:select 字段与 where 条件别混为一谈

同一个源字段可能既出现在 select 列表里,又出现在 where 条件里。朴素收集会在同一对字段之间产生重复边,甚至误导使用者以为 where 里的字段也流入了目标列。

实际处理上,我做了两类血缘的区分:

  • 值来源:来自 select 列表、聚合表达式、窗口函数、CASE WHEN 取值分支,这些字段真正参与生成目标值;
  • 条件来源:来自 where、having、join on 条件,这些字段影响数据范围但不直接进入目标列。

值来源写入正式的字段血缘链路;条件来源可以单独存成一张"过滤血缘"表,供数据质量排查时参考,但不混入主链路。这个细节做和不做,血缘图的准确性差很多。

6. 血缘数据落地:存储模型、增量解析与查询接口

6.1 表级和字段级血缘怎么存

解析引擎跑通了,还得让业务方和下游系统查得到。血缘存储我用了两层模型:

  • 表级血缘:(上游表, 下游表, 解析方式, sql_id, 任务名, 时间戳);
  • 字段级血缘:(目标表, 目标字段, 上游表, 上游字段, 转换表达式, 分支类型, sql_id, 任务名, 时间戳)。

如果团队有条件上图数据库(比如 Neo4j),血缘天然适合用图来表达:表和字段作为节点,血缘关系作为边,查询"从某底表出发找出所有下游影响面"会非常自然。但图数据库的运维成本不低。我当时的做法是先用关系型数据库,字段级血缘主表加(src_table, src_column)索引,再配合物化的全链路扩展表来实现多级下游查询。量级在几万张表、几十万条字段边以内,关系库扛得住。

6.2 增量解析:跟着调度系统跑,用 MD5 去重

血缘解析不是跑一次就结束的,要跟着数仓的任务调度不断更新。我们的接入方式是:每次有新的 SQL 任务上线或现有任务 SQL 变更,就触发一次解析,并对结果做差异比对,只把有变化的部分写入血缘库。

增量策略上有两个关键点:

  1. 防重:同一 SQL 被调度系统重复触发时不能生成重复边。我用md5(sql 内容 + 目标表)生成一个血缘任务指纹作为唯一键,重复提交直接跳过。
  2. 版本标记:每次解析打上effective_date和version,血缘变更时旧版本不物理删除,只是标记失效。这样就能回答"这个字段三个月前的上下游是谁"这类历史追溯问题,对审计特别有用。

6.3 给业务方一个"下游影响分析"的查询入口

血缘数据落库之后的第一个实用出口就是影响分析。举个例子:数仓管理员想评估"如果ods_order_detail.pay_amount字段要变更,会直接影响哪些下游字段",一条 SQL 就能查出来:

SELECT DISTINCT target_table, target_column FROM column_lineage WHERE src_table = 'ods_order_detail' AND src_column = 'pay_amount' AND end_date IS NULL -- 只看当前生效版本 ORDER BY target_table, target_column;

这个查询给出直接依赖关系。如果要看完整下游影响链路,可以递归往下继续查,或者我在离线任务里预先物化一张"血缘扩展表",把每个起点字段到每个下游字段的全路径都展开,查询时直接命中。物化扩展表的好处是查询极快,代价是血缘变更时需要重算受影响路径。这个取舍看场景,查询频繁但变更不频繁的团队,物化方案更合适。

7. 收尾:几个只有做过的才知道的经验

聊到最后,分享几点我个人的体会。

第一,血缘解析这个模块,难度不在"解析 SQL"这一步本身,而在后面那些真实环境的脏数据、方言差异、视图展开、血缘去重。demo 跑通只是开始,真正让工具从演示走向可用,靠的是把业务 SQL 样本拉出来一条条过。建议从一开始就收集全量线上 SQL 作为回归测试集,每修一个 bug 就加一条样例,防止回归。

第二,自研正则方案看起来省事,实则是最费时间的方向。我最初折腾了两周正则,后来切到 Calcite,一周就把第一版表级血缘跑通了。如果你们团队也在做类似的选型,真的不用犹豫太久。

第三,血缘解析要尽量提前把"目标表"的识别做好。很多 SQL 任务是 CTAS 或者 INSERT OVERWRITE,目标表藏在语句结构里,解析器第一版就支持这些,能避免后面大量返工。

如果你们也从零开始,建议的路径是:先表级血缘快速见效,再字段级简单映射,最后啃 UNION、CASE WHEN、窗口函数、MERGE 这些复杂 case。每一步都保留原始 SQL 的上下文,排错时会轻松很多。Calcite 的文档确实不算友好,很多细节是要靠读源码才能明白的,但相比从零写一个解析器,它已经是最值得投入的方向了。希望这篇记录能帮你在同样的路上少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询