从 0 设计一个列级数据血缘模型:我们踩过的坑与最终方案

从 0 设计一个列级数据血缘模型:我们踩过的坑与最终方案

最近在做一个企业级 SQL 数据血缘分析平台(支持 28 种 SQL 方言),把血缘模型的设计过程整理出来分享给大家。血缘分析这个领域,说难不难------本质就是"从 A 表的 a 列,算出了 B 表的 b 列";但要做好列级(column-level)血缘,模型设计上有一堆绕不开的取舍。本文不讲如何解析 SQL(那是另一篇的量级),专注聊血缘结果的数据模型该怎么设计

一、先明确:表级血缘 vs 列级血缘

网上大部分血缘工具(包括很多开源方案)止步于表级血缘orders → dws_order_summary,一张箭头图完事。

但对数据治理来说,表级血缘几乎没用。真正的诉求是:

  • 这个报表字段 amount_cny 的数据源头是哪张表的哪个列?(影响分析)
  • 我要改 orders.amount 的类型,下游会炸掉哪些东西?(变更评估)
  • 这个字段经过了什么加工逻辑?(amount * rateSUM 聚合?还是 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 只产出 COLUMNLITERAL 两类节点。

表的信息去哪了?放在每个列节点的 group_id 里------group_id 是该列归属的表/CTE/子查询的 FQN。前端渲染时按 group_id 把列聚合成"表卡片",列是卡片里的行。

这样设计的好处:

  1. 图更纯粹 。所有遍历算法(BFS 找上下游、路径查询)只面对一种节点,不用到处写 if node.type == TABLE 的分支。
  2. 分组是视图,不是数据。同一份血缘数据,可以按表分组展示,也可以按图层平铺展示,模型不用改。
  3. 虚拟列有地方放 。下面会讲,COUNT(*) 需要凭空造出 * 列节点,如果表也是节点,这些虚拟节点的归属关系会更绕。

另一个字段是 confidence(0.0~1.0)。SQL 血缘不是非黑即白的------遇到动态 SQL、SELECT * 无元数据可展开、影子表等情况,解析器只能给出"猜测"。与其丢掉这些不确定的结果,不如打上置信度让下游自己决定要不要展示。宁可模糊,不可缺失,这是血缘工具和编译器的本质区别。

四、边设计:8 种边类型,每种都有语义

边是有向的,EdgeType 一共 8 种:

EdgeType 语义 典型场景
DIRECT 直接传递,无加工 SELECT id FROM t
TRANSFORM 标量表达式加工 a.amount * b.rateCASE 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 看配置......

模型层的解法是两层:

  1. 所有节点的 id/fqn 都经过 _fold() 折叠 后再参与比较和连接,折叠策略由 CaseFolding 枚举决定(LOWER / UPPER / INSENSITIVE / SENSITIVE),每个方言有默认值
  2. 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 APIPOST /api/analyze 返回 NodeOut[] + EdgeOut[],其中 metadata: dict 原样透传 transform_expr
  • 图存储:存入 Kuzu(嵌入式图数据库),支持 Cypher 变长路径查询做 N 层上下游追溯;
  • 前端 :Vue Flow 渲染,按 group_id 聚合成表卡片,metadata.transform_expr 非空则显示 ƒ 徽标,sql_start/sql_end 驱动 SQL 高亮。

写在最后

回顾整个模型,核心的设计原则其实就三条:

  1. 节点最小化------只留列节点,表/视图退化为分组属性,虚拟节点按需构造;
  2. 语义放边上 ------边类型 + transform_expr 承载全部加工语义,节点保持"干净";
  3. 不确定要显式表达 ------confidencewarnings 都是模型的一等公民,模糊血缘必须可识别、可降级,而不是悄悄丢数据。

血缘模型本身不复杂,复杂的是让它对抗真实世界 SQL 的多样性。如果你也在做类似的系统,欢迎交流。

本文基于我们的实践整理,代码结构:解析层(sqlglot 方言扩展,28 种方言)→ 解析器(LineageResolver,五层架构)→ 模型层(本文主角)→ 存储/API/前端。后续会再写一篇讲 SQL 方言扩展的坑,感兴趣的关注一下~

相关推荐
Mr数据杨3 小时前
电子游戏日本市场销量预测实战 从 Kaggle 回归赛题到区域销售判断
人工智能·数据分析·kaggle竞赛
BioRunYiXue3 小时前
科研干货 | IC50全面解读:概念解析、实验设计与数据分析要点
java·开发语言·javascript·人工智能·算法·数据挖掘·数据分析
Mr数据杨3 小时前
小样本图像分类实战 Cleaned vs Dirty V2 盘子清洁识别案例解析
人工智能·数据分析·kaggle竞赛
Mr数据杨4 小时前
Arxiv论文标题生成实战 从摘要到标题的文本生成建模案例
人工智能·数据分析·kaggle竞赛
BYSJMG5 小时前
计算机毕业设计选题推荐|【基于大数据的植被光谱特征与环境因子关联分析及可视化】Spark+K-Means
大数据·python·信息可视化·数据分析·spark·kmeans·课程设计
千里码aicood5 小时前
B站美食视频数据分析与可视化
数据分析·音视频·美食
Mr数据杨6 小时前
乌克兰新闻来源分类实战 从文本分类到媒体识别建模
人工智能·数据分析·kaggle竞赛
Mr数据杨17 小时前
从文本特征到回归建模 Syllable Counting Model 实战解析
人工智能·数据分析·kaggle竞赛