2、数据血缘基础理论:血缘的粒度、采集方式与存储模型

数据血缘,说白了就是数据从哪来、经过谁、变成啥、去了哪。在特征平台里,血缘分析是排查特征异常、评估变更影响的核心工具。我刚开始做特征平台时,总觉得血缘就是个“数据地图”,后来踩过几次坑才明白——粒度不对、采集不全、存储拉胯,血缘就是一张废纸。

这一节,咱们把血缘的三个基础问题聊透:粒度、采集、存储。

2.1 血缘的粒度:你究竟要追踪到多细?

血缘的粒度,决定了你能回答什么级别的问题。我个人习惯把粒度分成三层:字段级、表级、任务级。

粒度 追踪对象 典型问题 我遇到的坑
字段级 列 → 列 “用户年龄”这个字段是从哪张表的哪个列来的? 字段级血缘缺失,排查特征异常时只能靠猜
表级 表 → 表 这张特征宽表依赖哪些源表? 表级血缘够用,但定位具体列变更时不够精确
任务级 ETL/Spark/Flink 任务 → 产出表 这个特征是由哪个任务产出的?任务挂了影响哪些下游? 任务级血缘常被忽略,但它是运维止损的关键

我的建议:特征平台至少要做到“字段级 + 任务级”的双层血缘。表级血缘可以作为兜底,但别只依赖它。我在一个项目中只做了表级血缘,结果某个特征字段被上游改了类型,排查了整整两天——如果有字段级血缘,10 分钟就能定位。

2.2 血缘的采集方式:三种主流方案

采集血缘,没有银弹。不同场景用不同方式。我总结下来,主流方案就三种:解析、打点、逆向工程。

2.2.1 解析(Parse)

解析 SQL 或代码,提取输入输出关系。这是最常用的方式。

  • 适用场景:Spark SQL、Hive、Flink SQL 等结构化查询
  • 优点:准确、无侵入、可离线批量跑
  • 缺点:对自定义 UDF、动态 SQL 支持差

举个例子,解析一条 SQL:

INSERT OVERWRITE TABLE dwd.user_feature PARTITION(dt='2024-01-01')
SELECT 
  u.user_id,
  u.age,
  o.total_amount
FROM ods.user u
JOIN ods.order o ON u.user_id = o.user_id
WHERE o.dt = '2024-01-01'

解析后,我们能得到:

  • 表级:ods.user + ods.order → dwd.user_feature
  • 字段级:u.user_id → user_id, u.age → age, o.total_amount → total_amount

避坑指南:我曾经用 ANTLR 写过一个 SQL 解析器,结果被各种方言(Hive、Spark、Presto)的语法差异搞到崩溃。后来改用 Calcite 的 SQL 解析器,兼容性好很多。如果你也要做解析,建议直接用 Calcite 或 Spark 自带的 Catalyst 分析器。

2.2.2 打点(Instrumentation)

在代码中埋点,运行时采集血缘信息。适合复杂逻辑、自定义算子。

  • 适用场景:PySpark、Flink DataStream、自定义 ETL
  • 优点:能覆盖解析搞不定的场景
  • 缺点:有侵入性、影响性能、需要改造现有代码

举个例子,在 PySpark 中打点:

def read_with_lineage(spark, path, table_name):
    df = spark.read.parquet(path)
    # 记录血缘:输入表
    lineage.record_input(table_name, path, df.schema)
    return df

def write_with_lineage(df, path, table_name):
    # 记录血缘:输出表 + 字段映射
    lineage.record_output(table_name, path, df.schema)
    df.write.parquet(path)

注意:打点方式容易漏采。我记得有一次,某个同事在代码里用了 df.withColumn 创建新字段,但打点代码没覆盖这个算子,导致字段级血缘断链。所以打点一定要覆盖所有数据变换算子,别偷懒。

2.2.3 逆向工程(Reverse Engineering)

从数据本身或元数据推断血缘。比如对比两张表的字段名、数据类型、数据分布。

  • 适用场景:没有源码、没有打点、历史遗留系统
  • 优点:无侵入、能补全缺失的血缘
  • 缺点:不准确、只能推断、不能保证 100% 正确

举个例子,如果两张表都有 user_id 字段,且数据类型都是 STRING,我们可以推断它们之间有血缘关系。但如果有字段重命名,逆向工程就抓瞎了。

我的经验:逆向工程只能作为“补丁”用。我建议优先用解析 + 打点,覆盖 90% 的场景,剩下的 10% 用逆向工程兜底。别指望逆向工程能解决所有问题——它连字段重命名都搞不定。

2.3 血缘的存储模型:怎么存才能查得快?

血缘数据存不好,查询时就是灾难。我见过有人把血缘存成 JSON 大字段,结果查一个字段的上游要扫全表。下面是我推荐的三种存储模型。

2.3.1 图存储(Neo4j / JanusGraph)

血缘天然是图结构:节点是表、字段、任务,边是依赖关系。图数据库查询血缘路径非常快。

  • 优点:支持多跳查询、路径分析、影响范围分析
  • 缺点:运维成本高、学习曲线陡

举个例子,查询“字段 A 的所有上游字段”:

MATCH (f:Field {name: 'user_id'})<-[:PRODUCES*]-(upstream:Field)
RETURN upstream

2.3.2 关系型存储(MySQL / PostgreSQL)

用关系表存储血缘关系。简单、稳定、团队都熟。

  • 优点:运维简单、容易集成
  • 缺点:多跳查询性能差、递归查询写起来麻烦

表结构示例:

CREATE TABLE field_lineage (
    id BIGINT PRIMARY KEY,
    upstream_table VARCHAR(255),
    upstream_field VARCHAR(255),
    downstream_table VARCHAR(255),
    downstream_field VARCHAR(255),
    task_id VARCHAR(255),
    created_at TIMESTAMP
);

CREATE INDEX idx_downstream ON field_lineage(downstream_table, downstream_field);
CREATE INDEX idx_upstream ON field_lineage(upstream_table, upstream_field);

2.3.3 文档存储(Elasticsearch)

把血缘关系存成文档,利用 ES 的倒排索引做快速搜索。

  • 优点:搜索快、支持全文检索、适合做血缘搜索
  • 缺点:不支持复杂图遍历、更新成本高

文档示例:

{
  "downstream_table": "dwd.user_feature",
  "downstream_field": "age",
  "upstreams": [
    {"table": "ods.user", "field": "age", "task": "etl_user_feature"},
    {"table": "ods.profile", "field": "age", "task": "etl_user_feature"}
  ],
  "task_id": "etl_user_feature_20240101"
}

我的选择:特征平台的血缘存储,我推荐“图存储 + 关系型”双写。图存储做实时查询和路径分析,关系型做离线批量和审计。ES 可以作为搜索层,但不是必须的。你想想看,如果只存关系型,查一个字段的完整血缘链路,可能要递归几十次——图数据库一次查询就搞定。

2.4 本章小结:一张图看懂血缘基础

下面这张图,把血缘的粒度、采集方式、存储模型串起来了。我每次给团队做血缘设计时,都会先画这张图——它能让所有人对齐认知。

数据血缘基础架构 血缘粒度 字段级(列→列) 表级(表→表) 任务级(任务→产出) 特征平台建议:字段级+任务级 采集方式 解析(Parse) 打点(Instrumentation) 逆向工程(Reverse) 推荐:解析+打点为主,逆向兜底 存储模型 图存储(Neo4j) 关系型(MySQL) 文档存储(ES) 推荐:图存储+关系型双写 核心原则 1. 粒度决定你能回答什么级别的问题 2. 采集方式没有银弹,组合使用才是正解 3. 存储模型要兼顾查询性能和运维成本 采集血缘 存储血缘 查询血缘 采集 → 存储 → 查询,三个环节环环相扣 任何一个环节设计不好,血缘分析就是空中楼阁

嗯,这一节的内容就到这里。血缘的粒度、采集、存储,是后续所有血缘分析功能的基础。你在实际项目中,可以先从字段级血缘 + 解析采集 + 图存储开始,跑通后再逐步完善。别一开始就想搞大而全——我见过太多团队,血缘系统设计得无比复杂,最后没人用。