从 0 设计一个列级数据血缘模型:我们踩过的坑与最终方案
最近在做一个企业级 SQL 数据血缘分析平台(支持 28 种 SQL 方言),把血缘模型的设计过程整理出来分享给大家。血缘分析这个领域,说难不难------本质就是"从 A 表的 a 列,算出了 B 表的 b 列";但要做好列级(column-level)血缘,模型设计上有一堆绕不开的取舍。本文不讲如何解析 SQL(那是另一篇的量级),专注聊血缘结果的数据模型该怎么设计。
一、先明确:表级血缘 vs 列级血缘
网上大部分血缘工具(包括很多开源方案)止步于表级血缘 :orders → dws_order_summary,一张箭头图完事。
但对数据治理来说,表级血缘几乎没用。真正的诉求是:
- 这个报表字段
amount_cny的数据源头是哪张表的哪个列?(影响分析) - 我要改
orders.amount的类型,下游会炸掉哪些东西?(变更评估) - 这个字段经过了什么加工逻辑?(
amount * rate?SUM聚合?还是 CASE WHEN 打标?)
所以我们的模型从第一天起就以列级血缘为目标:节点是列,边是列与列之间的数据流动。
二、模型总览:三个核心类
整个血缘模型就三个核心结构,追求"最小完备":
python
LineageGraph # 一次分析的完整结果
├── nodes: list[LineageNode]
├── edges: list[LineageEdge]
└── warnings: list[ParseWarning] # 解析告警(不中断,降级收集)
LineageNode # 一个列节点(或虚拟节点)
├── id: str # 基于 FQN 的唯一标识
├── type: NodeType # COLUMN / LITERAL ...
├── name: str
├── fqn: str # 全限定名
├── group_id: str # 归属的表/CTE/子查询
├── confidence: float # 0.0 ~ 1.0 置信度
└── metadata: dict # transform_expr 等扩展信息
LineageEdge # 一条有向数据流
├── type: EdgeType # DIRECT / TRANSFORM / AGGREGATION / ...
├── transform_expr: str # 加工表达式
└── sql_start / sql_end: int # 在源 SQL 中的位置(用于高亮联动)
数据流的顶层管线是这样的:
scss
SQL 字符串
→ DialectAdapter._preprocess() # 方言级清洗
→ sqlglot.parse() # 统一 AST
→ LineageResolver.resolve() # 核心:AST → 血缘图
→ LineageGraph { nodes, edges, warnings }
→ 存储(Kuzu 图数据库)/ REST API / 前端可视化
下面按"每一个字段为什么存在"来展开。
三、节点设计:为什么没有"表节点"?
第一个反直觉的设计:我们的血缘图里没有 TABLE 类型的节点 ,resolver 只产出 COLUMN 和 LITERAL 两类节点。
表的信息去哪了?放在每个列节点的 group_id 里------group_id 是该列归属的表/CTE/子查询的 FQN。前端渲染时按 group_id 把列聚合成"表卡片",列是卡片里的行。
这样设计的好处:
- 图更纯粹 。所有遍历算法(BFS 找上下游、路径查询)只面对一种节点,不用到处写
if node.type == TABLE的分支。 - 分组是视图,不是数据。同一份血缘数据,可以按表分组展示,也可以按图层平铺展示,模型不用改。
- 虚拟列有地方放 。下面会讲,
COUNT(*)需要凭空造出*列节点,如果表也是节点,这些虚拟节点的归属关系会更绕。
另一个字段是 confidence(0.0~1.0)。SQL 血缘不是非黑即白的------遇到动态 SQL、SELECT * 无元数据可展开、影子表等情况,解析器只能给出"猜测"。与其丢掉这些不确定的结果,不如打上置信度让下游自己决定要不要展示。宁可模糊,不可缺失,这是血缘工具和编译器的本质区别。
四、边设计:8 种边类型,每种都有语义
边是有向的,EdgeType 一共 8 种:
| EdgeType | 语义 | 典型场景 |
|---|---|---|
DIRECT |
直接传递,无加工 | SELECT id FROM t |
TRANSFORM |
标量表达式加工 | a.amount * b.rate、CASE WHEN |
AGGREGATION |
聚合加工 | SUM(x)、COUNT(*)、窗口函数 |
CONDITIONAL |
条件影响 | WHERE/HAVING 中参与过滤的列 |
UNION |
多路合并 | UNION ALL 的多个分支 |
JOIN |
连接键关联 | ON a.id = b.id |
BRANCH |
一源多目标 | 同一列被多个输出引用 |
FILTER |
过滤 | 与 CONDITIONAL 类似场景的细分 |
为什么不全用一种"数据流"边?因为下游消费场景对边的语义敏感:
- 做影响分析 时,
DIRECT边意味着改类型要同步改下游;AGGREGATION边意味着下游只是统计值,风险等级完全不同。 - 做数据质量溯源 时,
CONDITIONAL边告诉你要复现这条数据,还得看 WHERE 条件。
每条边上还挂了 sql_start/sql_end(在源 SQL 中的字节偏移)。这让"图上点击一条边 → 源 SQL 中高亮对应片段"成为可能,也是我们的前端做 SQL 编辑器和血缘图双向联动的基石。
五、关键决策 1:transform_expr 挂元数据,不设中间节点
这是我们改过一次 的设计。早期版本里,a.amount * b.rate AS total 会生成一个中间 TRANSFORM 节点:
css
amount ──→ [TRANSFORM: amount * rate] ──→ total
看起来很"规整",但实际用起来全是痛点:
- 图复杂度暴涨,每个表达式多一个节点;
- 前端要为 TRANSFORM 节点做特殊渲染;
- BFS 高亮、路径查询都要处理这个"半列半表达式"的四不像。
改版后的方案:源列直连目标列,表达式存进 metadata["transform_expr"] 和 edge.transform_expr:
bash
amount ──TRANSFORM (expr: "amount * rate")──→ total
前端在列名旁渲染一个 ƒ 徽标,hover 显示表达式,点击还能反向高亮 SQL 中的对应片段。图节点数直接砍掉一截,语义反而更清晰了。
还有一个细节:聚合表达式要拼上 GROUP BY 上下文 。SUM(amount) 的 transform_expr 实际存储为 "SUM(amount) GROUP BY dept_id"------因为脱离分组维度谈聚合是没有意义的,下游做口径比对时必须知道这个数是按什么粒度算出来的。
六、关键决策 2:虚拟节点------用归一化对抗 SQL 的表达多样性
SQL 的表达方式太多了,同一个"建表+灌数据"有 N 种写法。如果每种写法都生成不同形状的图,下游根本没法消费。我们的解法是引入虚拟节点做归一化:
1. CTAS / CREATE VIEW / INSERT...SELECT 的中间节点
sql
CREATE VIEW v_order AS SELECT id, amount FROM orders;
统一归一化为经过一个虚拟中间表:
python
orders.id ──→ __ctas_src__.id ──→ v_order.id
orders.amount ──→ __ctas_src__.amount ──→ v_order.amount
__ctas_src__(INSERT 用 __insert_src__)是约定俗成的保留前缀。好处是所有"写入目标表"类语句的图形状完全一致,遍历逻辑只有一套。
2. COUNT(*) 的星号节点
* 不是列,但它有真实的血缘(源表的所有列都影响了这个计数)。我们为每个源表创建一个 name="*" 的虚拟列节点(FQN 去重后复用):
orders.* ──AGGREGATION──→ cnt
这样 SELECT COUNT(*) 不再是血缘断头路。
3. CTE 不穿透
sql
WITH cte AS (SELECT id FROM t)
SELECT id FROM cte;
血缘是 t.id → cte.id → 输出,而不是 t.id → 输出 直连。CTE 是逻辑层,用户在 SQL 里明确写了这个中间形态,血缘图就如实保留它------否则用户对不上号:"我 SQL 里的 cte 去哪了?"
七、关键决策 3:FQN + 大小写折叠
跨数据库系统做血缘,标识符的坑第一个就是大小写:Oracle 默认把 id 折叠成 ID,PostgreSQL 不加引号时折叠成小写,MySQL 看配置......
模型层的解法是两层:
- 所有节点的
id/fqn都经过_fold()折叠 后再参与比较和连接,折叠策略由CaseFolding枚举决定(LOWER / UPPER / INSENSITIVE / SENSITIVE),每个方言有默认值。 name保留原始书写,展示层用。
这样 Oracle 的 Orders.ID 和 MySQL 的 orders.id 在图里能正确对上是同一个东西,而用户看到的还是自己熟悉的写法。
八、双趟解析:元数据如何进来
光靠 AST 能推出的血缘是有限的,最大的缺口是 SELECT *。我们的 resolver 是两趟设计:
sql
Pass 1: DdlExtractor 扫描同批 SQL 中的 CREATE/ALTER TABLE
→ 构建 MetadataRegistry(表结构 + 列信息)
Pass 2: LineageResolver 携带 MetadataProvider 再次解析
→ 展开 SELECT *、消歧列、穿透视图
MetadataProvider 是个 ABC,有几个现成实现:内存注册表、JSON 文件、活库直查(带 TTL 缓存)、主备合并。元数据越全,血缘越准------这也是为什么血缘平台通常要搭配元数据采集(catalog)一起卖。
九、一个完整例子
sql
SELECT
a.id AS order_id,
a.amount * b.rate AS amount_cny,
COUNT(*) AS cnt
FROM orders a
JOIN exchange_rate b ON a.currency = b.currency
GROUP BY a.id, a.amount, b.rate;
产出的血缘图(简化后的 JSON):
json
{
"nodes": [
{ "id": "orders.id", "type": "COLUMN", "group_id": "orders", "name": "id" },
{ "id": "orders.amount", "type": "COLUMN", "group_id": "orders", "name": "amount" },
{ "id": "orders.*", "type": "COLUMN", "group_id": "orders", "name": "*" },
{ "id": "exchange_rate.*", "type": "COLUMN", "group_id": "exchange_rate", "name": "*" },
{ "id": "order_id", "type": "COLUMN", "group_id": "", "name": "order_id",
"metadata": {} },
{ "id": "amount_cny", "type": "COLUMN", "group_id": "", "name": "amount_cny",
"metadata": { "transform_expr": "a.amount * b.rate" } },
{ "id": "cnt", "type": "COLUMN", "group_id": "", "name": "cnt",
"metadata": { "transform_expr": "COUNT(*) GROUP BY a.id, a.amount, b.rate" } }
],
"edges": [
{ "source": "orders.id", "target": "order_id", "type": "DIRECT" },
{ "source": "orders.amount", "target": "amount_cny", "type": "TRANSFORM",
"transform_expr": "a.amount * b.rate" },
{ "source": "exchange_rate.rate", "target": "amount_cny", "type": "TRANSFORM",
"transform_expr": "a.amount * b.rate" },
{ "source": "orders.*", "target": "cnt", "type": "AGGREGATION",
"transform_expr": "COUNT(*) GROUP BY a.id, a.amount, b.rate" },
{ "source": "a.currency", "target": "b.currency", "type": "JOIN" }
],
"warnings": []
}
注意几个点:amount_cny 有两条入边 (两个源列都参与了表达式);COUNT(*) 走虚拟星号节点 + AGGREGATION;简单直传列的 group_id 为空串(它是查询输出,不归属任何物理表);JOIN 条件列之间有一条 JOIN 边记录关联关系。
十、模型如何被消费
同一份 LineageGraph,三个出口:
- REST API :
POST /api/analyze返回NodeOut[]+EdgeOut[],其中metadata: dict原样透传transform_expr; - 图存储:存入 Kuzu(嵌入式图数据库),支持 Cypher 变长路径查询做 N 层上下游追溯;
- 前端 :Vue Flow 渲染,按
group_id聚合成表卡片,metadata.transform_expr非空则显示ƒ徽标,sql_start/sql_end驱动 SQL 高亮。
写在最后
回顾整个模型,核心的设计原则其实就三条:
- 节点最小化------只留列节点,表/视图退化为分组属性,虚拟节点按需构造;
- 语义放边上 ------边类型 +
transform_expr承载全部加工语义,节点保持"干净"; - 不确定要显式表达 ------
confidence、warnings都是模型的一等公民,模糊血缘必须可识别、可降级,而不是悄悄丢数据。
血缘模型本身不复杂,复杂的是让它对抗真实世界 SQL 的多样性。如果你也在做类似的系统,欢迎交流。
本文基于我们的实践整理,代码结构:解析层(sqlglot 方言扩展,28 种方言)→ 解析器(LineageResolver,五层架构)→ 模型层(本文主角)→ 存储/API/前端。后续会再写一篇讲 SQL 方言扩展的坑,感兴趣的关注一下~