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 语法
选型时我还认真调研过另外两个方向。趁这个机会把对比结论放出来,给做同样需求的人一个参考。
| 维度 | Calcite | JSqlParser | 自研 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字段级血缘要做到三件事:
- 目标表
amount的来源要落到ods_order_detail.pay_amount和ods_order_detail.coupon_amount两个字段,同时记录运算链SUM(pay_amount - coupon_amount),使用者一眼能看出中间做了一次减法和一次聚合。 order_date虽然经过了DATE_FORMAT函数,但血缘链路仍然要能追溯到order_time。- 表达式里的
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。解析时必须维护一个"输出列 → 输入表达式"的映射关系,逐层合并:
- 对子查询
sub,记录sub.total_amount ← ods_pay.amount,且附带SUM聚合信息; - 对外层 select,记录
tgt.total_amount ← sub.total_amount; - 两层映射做一次替换合并,最终得到
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 变更,就触发一次解析,并对结果做差异比对,只把有变化的部分写入血缘库。
增量策略上有两个关键点:
- 防重:同一 SQL 被调度系统重复触发时不能生成重复边。我用
md5(sql 内容 + 目标表)生成一个血缘任务指纹作为唯一键,重复提交直接跳过。 - 版本标记:每次解析打上
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 的文档确实不算友好,很多细节是要靠读源码才能明白的,但相比从零写一个解析器,它已经是最值得投入的方向了。希望这篇记录能帮你在同样的路上少踩几个坑。