一、一次"对不上数"引发的三天排查
先讲个真实场景。某天业务方反馈:"昨天的 GMV 日报比前天掉了 40%,是不是数据出问题了?"
数据团队开始排查,过程是这样的:
- 看日报 SQL,逻辑没变,最近一周没人提交改动;
- 往上找依赖的 DWS 层表,行数正常,也没报错;
- 再往上找 DWD 层,发现有一张订单明细表昨天重跑过;
- 继续往上,发现这张表的上游,有个 ODS 表的
pay_amount字段,被上游业务库改了含义(从"实付金额"改成了"应付金额"); - 而业务库的这次变更,根本没有人通知数据团队。
三天时间,一半花在"顺着 SQL 一条条往上翻"这件事上。如果有数据血缘,这个排查应该是 30 秒 :找到报表 → 点"上游血缘" → 一眼看到 pay_amount 字段在两天前被改过,且没有下游通知记录。
这就是数据血缘的价值。但真正做过的人都知道,这东西说起来简单,做起来坑极多,尤其是字段级血缘。
二、血缘的三个层次:别一上来就做字段级
很多团队一上来就想做字段级血缘,然后死在半路。正确的做法是分层递进:
| 层次 | 描述 | 实现难度 | 价值 | 准确率可达 |
|---|---|---|---|---|
| 表级血缘 | 表 A 的数据来自表 B、C | ★★☆☆☆ | 影响分析、调度依赖 | 95%+ |
| 字段级血缘 | 表 A 的 col_x 来自表 B 的 col_y |
★★★★☆ | 口径追溯、变更影响精确到字段 | 85%~92% |
| 算子级血缘 | 字段经过了哪些转换(filter/join/agg/udf) | ★★★★★ | 数据质量根因、口径自动解读 | 70%~80% |
建议路径:先做表级(1~2 周能上线,立刻产生价值),再补字段级(需要啃 SQL 解析),算子级除非有强需求否则缓做。
还有一个现实约束值得提前知道:算子级血缘要求你解析的不只是 SQL,还要理解 UDF 内部的转换逻辑,而这些 UDF 往往是 Java/Scala/Python 写的黑盒函数。这就是为什么算子级准确率很难超过 80%,也是我们把它的优先级排在最后的原因:不是解析不出来,而是投入产出比不划算。
本文的重心在第二层,字段级血缘,因为它是"投入产出比拐点":做出来之后,变更影响分析才真正可用。
三、整体架构设计
┌─────────────────────────────────────────────────────────────┐
│ 数据源层 │
│ Hive Metastore │ Spark History │ Airflow/DolphinScheduler │
│ (表结构元数据) │ (执行过的SQL) │ (调度DAG & 任务定义) │
└────────┬────────────────┬─────────────────┬─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ SQL 采集层 │
│ 定时拉取 History Server API / 解析调度系统任务定义 / │
│ 监听 metastore DDL 事件 │
│ 产出: {sql_id, sql_text, user, engine, submit_time} │
└────────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 血缘解析引擎(核心) │
│ ① SQL 标准化(去注释/统一方言) │
│ ② AST 解析(sqlglot / ANTLR) │
│ ③ 表/字段提取 + 别名解析 │
│ ④ SELECT 列 → 来源列的映射推导 │
│ ⑤ 产出边: (target_table.col) ← (source_table.col) │
└────────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 存储与查询层 │
│ 图数据库 Neo4j / JanusGraph(血缘天然是图) │
│ 表结构、解析异常、人工修正记录 → MySQL │
└────────────────────────────┬────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ 应用层 │
│ 血缘可视化 │ 变更影响分析 │ 口径追溯 │ 调度依赖校验 │
└─────────────────────────────────────────────────────────────┘
技术选型上有几个关键决策:
- 解析器选 sqlglot 而不是 ANTLR 原生语法:sqlglot 用 Python 实现,支持 20+ 种方言(Hive/Spark/Presto/MySQL/ClickHouse...),AST 结构统一,且自带 lineage 模块。用 ANTLR 你得自己维护 Hive/Spark/Presto 三套 g4 语法文件,成本高出数倍。
- 存储选图数据库:血缘是典型的多跳图查询("这个字段改了会影响下游几张表"本质是可达性查询)。关系库用递归 CTE 也能做,但 5 跳以上性能急剧劣化。Neo4j 单跳查询在毫秒级,10 跳也在百毫秒内。
- 采集要覆盖"执行过的 SQL"而不只是"任务定义 SQL":很多团队只解析调度系统里定义的 SQL,结果漏掉了大量临时查询(ad-hoc)。Spark Hive ThriftServer 的 history 才是最全的源。
四、核心技术:用 sqlglot 抽字段级血缘
4.1 先看一个最简例子
sql
sql
INSERT OVERWRITE TABLE dws_order_daily
SELECT
o.order_id,
o.pay_amount,
u.user_name
FROM dwd_order_detail o
JOIN dim_user u ON o.user_id = u.user_id
WHERE o.dt = '2026-09-23'
我们期望抽出的字段级血缘(边):
sql
dws_order_daily.order_id ← dwd_order_detail.order_id
dws_order_daily.pay_amount ← dwd_order_detail.pay_amount
dws_order_daily.user_name ← dim_user.user_name
以及表级血缘:dws_order_daily ← {dwd_order_detail, dim_user}。
4.2 sqlglot 基础:解析与遍历
python
python
import sqlglot
from sqlglot import exp
sql = """
INSERT OVERWRITE TABLE dws_order_daily
SELECT o.order_id, o.pay_amount, u.user_name
FROM dwd_order_detail o JOIN dim_user u ON o.user_id = u.user_id
WHERE o.dt = '2026-09-23'
"""
# 解析成 AST(指定方言,Hive)
tree = sqlglot.parse_one(sql, dialect="hive")
print(repr(tree))
# Insert(this=Table(this=Identifier(this=dws_order_daily)), expression=Select(...))
# 提取所有表引用
tables = [t.sql() for t in tree.find_all(exp.Table)]
print(tables) # ['dwd_order_detail', 'dim_user']
注意 INSERT ... SELECT 的 AST 里,目标表在 Insert.this,查询体在 Insert.expression,别找错位置。
4.3 关键难点一:别名(alias)解析
SQL 里几乎不可能不用别名:FROM dwd_order_detail o 中的 o 就是表的别名,SELECT o.pay_amount AS amt 中的 amt 是列的别名。血缘的核心工作就是维护"别名 → 实体"的映射表。
python
python
def build_alias_map(tree):
"""遍历 FROM/JOIN 子句,建立 别名 -> 真实表名 的映射"""
alias_map = {}
for table in tree.find_all(exp.Table):
# table.name 是表名
# table.alias 是别名(如 FROM xxx o 中的 o)
real = table.name
alias = table.alias_or_name # 没别名就用表名本身
alias_map[alias] = real
return alias_map
# 上例得到: {'o': 'dwd_order_detail', 'u': 'dim_user'}
有了它,遇到 o.pay_amount 就能还原成 dwd_order_detail.pay_amount。
4.4 关键难点二:SELECT 列的来源推导
对每个 SELECT 输出列,要找出它引用了哪些源列。思路是遍历该列表达式里的所有 Column 节点:
python
python
def extract_column_lineage(tree, target_table, alias_map):
"""返回 [(target_col, source_table, source_col), ...]"""
lineage = []
select = tree.expression if isinstance(tree, exp.Insert) else tree
for select_expr in select.expressions: # SELECT 后的每一列
# 目标列名:有 AS 别名用别名,否则用表达式本身
if isinstance(select_expr, exp.Alias):
target_col = select_expr.alias
expr_node = select_expr.this # AS 左边的表达式
else:
target_col = select_expr.name if hasattr(select_expr, 'name') \
else select_expr.sql()
expr_node = select_expr
# 找出该表达式引用的所有列
cols = list(expr_node.find_all(exp.Column))
if not cols:
# 常量或纯函数,无来源列(如 SELECT 'x' AS flag)
continue
for col in cols:
tbl_alias = col.table or _infer_table_from_star(...) # o / u
src_table = alias_map.get(tbl_alias, tbl_alias)
lineage.append((target_col, src_table, col.name))
return [(target_table, t, s, c) for t, s, c in lineage]
难点在于 _infer_table_from_star :当 SQL 里只有一个表且列没写前缀(如 SELECT pay_amount FROM dwd_order_detail),col.table 是空的,需要用"当前作用域唯一表"来推断。多表时必须报错或标记"待人工确认",这是很多开源血缘工具准确率上不去的主因。
4.5 关键难点三:CTE 与子查询
sql
sql
WITH tmp AS (
SELECT user_id, SUM(pay_amount) AS total FROM dwd_order_detail GROUP BY user_id
)
INSERT OVERWRITE TABLE dws_user_summary
SELECT t.user_id, t.total FROM tmp t
这里 tmp 是个 CTE,它不是真实表。正确的做法是"展开" :把 tmp 视作一个虚拟表,先解析它的定义得到 tmp.total ← dwd_order_detail.pay_amount,再把外层 dws_user_summary.total ← tmp.total 串联起来,最终 dws_user_summary.total ← dwd_order_detail.pay_amount。
sqlglot 的处理方式是先收集所有 CTE:
python
python
# 收集 CTE 定义
ctes = {}
for cte in tree.find_all(exp.CTE):
ctes[cte.alias] = cte.this # cte.this 是 CTE 的查询体
# 递归解析:遇到 CTE 引用就下钻到定义里
def resolve_source(col_table, alias_map, ctes, depth=0):
if col_table in ctes and depth < 5: # 防递归爆炸
return extract_column_lineage(ctes[col_table], col_table, {}, depth+1)
...
务必加递归深度限制。生产上见过嵌套 10 层的 CTE,还有互相引用的(虽然非法但确实存在),不加限制直接栈溢出。
4.6 关键难点四:视图(View)展开
视图是"存储的 SQL",引用视图的表,其真实血缘要展开视图定义才能得到。策略:
- 解析时遇到视图名 → 查 metastore 拿视图的 DDL(
CREATE VIEW v AS SELECT ...); - 递归解析视图定义,得到视图的字段来源;
- 拼接成完整链路。
- 给视图链路打标记 (
via_view),因为用户既想看"直接上游",也想看"穿透视图的真实来源"。
4.7 完整可运行示例
把上面的逻辑串起来,一个最小可用的字段级血缘抽取器:
python
python
import sqlglot
from sqlglot import exp
class LineageExtractor:
def __init__(self, dialect="hive"):
self.dialect = dialect
def extract(self, sql: str) -> list:
"""返回边列表: [(dst_table, dst_col, src_table, src_col)]"""
trees = sqlglot.parse(sql, dialect=self.dialect)
edges = []
for tree in trees:
if isinstance(tree, exp.Insert):
dst = tree.this.name if tree.this else None
body = tree.expression
else:
dst, body = None, tree
if body and dst:
edges += self._from_select(dst, body)
return edges
def _from_select(self, dst_table, select_node) -> list:
ctes = {c.alias: c.this for c in select_node.find_all(exp.CTE)}
alias_map = {}
for t in select_node.find_all(exp.Table):
alias_map[t.alias_or_name] = t.name
edges = []
for col_expr in select_node.expressions:
if isinstance(col_expr, exp.Alias):
dst_col, node = col_expr.alias, col_expr.this
else:
dst_col, node = (col_expr.name or col_expr.sql()), col_expr
for c in node.find_all(exp.Column):
src_alias = c.table or self._sole_table(alias_map, ctes)
# CTE 引用 → 下钻展开
if src_alias in ctes:
for (_, vc, st, sc) in self._from_select(
f"__cte_{src_alias}", ctes[src_alias]):
if vc == c.name:
edges.append((dst_table, dst_col, st, sc))
else:
src_table = alias_map.get(src_alias, src_alias)
edges.append((dst_table, dst_col, src_table, c.name))
return edges
def _sole_table(self, alias_map, ctes):
"""列没写表前缀时,若作用域内只有一张表则推断为它"""
cands = [v for k, v in alias_map.items() if k not in ctes]
return cands[0] if len(cands) == 1 else "UNKNOWN"
# ---- 使用 ----
sql = """
INSERT OVERWRITE TABLE dws_order_daily
SELECT o.order_id, o.pay_amount AS amt
FROM dwd_order_detail o WHERE o.dt = '2026-09-23'
"""
for e in LineageExtractor().extract(sql):
print(f"{e[0]}.{e[1]} <- {e[2]}.{e[3]}")
# dws_order_daily.order_id <- dwd_order_detail.order_id
# dws_order_daily.amt <- dwd_order_detail.pay_amount
这个 60 行的版本能覆盖约 70% 的常规 SQL。生产版本我们在此基础上补了视图展开、UDF 参数追踪、UNION 分支合并、SELECT * 展开(需要查 metastore 拿表结构)四块,才把准确率推到 90%+。
4.8 SELECT * 展开:血缘黑洞怎么补
INSERT INTO t2 SELECT * FROM t1 是血缘系统最常见的"断点",AST 里只有一个 Star 节点,你根本不知道有哪些列。这一条处理不好,准确率能掉 10 个点以上。
解法是结合 metastore 的表结构元数据把 * 展开成具体列:
python
python
def expand_star(select_node, alias_map, metastore):
"""把 SELECT * 展开为具体字段列表,依赖 metastore 提供表结构"""
expanded = []
for col_expr in select_node.expressions:
if isinstance(col_expr, exp.Star) or (
isinstance(col_expr, exp.Column) and col_expr.name == "*"):
tbl_alias = col_expr.table or None
# 有前缀: SELECT t1.* FROM t1 JOIN t2 → 只展开 t1
# 无前缀: SELECT * FROM t1 JOIN t2 → 展开全部表
targets = [alias_map[tbl_alias]] if tbl_alias else \
list(alias_map.values())
for t in targets:
for c in metastore.get_columns(t): # 查表结构
expanded.append((t, c, c)) # (源表, 源列, 目标列)
else:
expanded.append(col_expr) # 普通列,走原逻辑
return expanded
这里有个很容易忽略的时序问题:表结构会变(加列、改列名),而血缘是历史解析出来的。所以:
- 表结构变更时,需要回溯重算所有引用了
*的 SQL; - 我们把
(task_id, 引用了 star 的表)记在一张star_dependency表,metastore 变更事件触发时按这张表做增量重算。
上线这个机制后,我们的字段级准确率从 82% 提到了 91.3%,* 展开是单一收益最大的一项改进。如果你的血缘系统准确率卡在 80% 上不去,先查这个。
五、存储与查询:用 Neo4j 存血缘
5.1 图模型设计
节点:(:Table {name})、:Field {name, full_name}、:Task {id} 关系:
(:Field)-[:FLOWS_TO]->(:Field)(字段级血缘)(:Table)-[:DEPENDS_ON]->(:Table)(表级血缘,可由字段级聚合)(:Task)-[:WRITES]->(:Table)、(:Task)-[:READS]->(:Table)
cypher
-- 写入字段级血缘边
MERGE (a:Field {full_name: 'dwd_order_detail.pay_amount'})
MERGE (b:Field {full_name: 'dws_order_daily.amt'})
MERGE (a)-[:FLOWS_TO {task_id: 'spark_12345', update_time: timestamp()}]->(b);
5.2 核心查询:下游影响分析
"如果 dwd_order_detail.pay_amount 改口径,会影响哪些表和报表?"
cypher
MATCH path = (src:Field {full_name:'dwd_order_detail.pay_amount'})
-[:FLOWS_TO*1..8]->(dst:Field)
RETURN DISTINCT
split(dst.full_name, '.')[0] AS affected_table,
count(*) AS affected_fields,
min(length(path)) AS min_hops
ORDER BY min_hops ASC, affected_fields DESC;
这就是那个"三天排查"场景的 30 秒版本。上线后我们把这个查询包装成 API,直接挂在表结构变更的审批流上,任何人改上游字段,系统自动列出下游影响清单,附在审批单里。
5.3 上游溯源:口径从哪来
"报表里的 gmv 字段,原始数据到底是什么?"
cypher
MATCH path = (leaf:Field)<-[:FLOWS_TO*1..10]-(root:Field)
WHERE leaf.full_name = 'ads_gmv_daily.gmv'
AND NOT EXISTS { (x:Field)-[:FLOWS_TO]->(root) } -- 根节点:无上游
RETURN [n IN nodes(path) | n.full_name] AS chain, length(path) AS hops
ORDER BY hops DESC LIMIT 5;
六、落地:与调度系统集成
血缘最大的问题是时效性,SQL 变了,血缘没更新,比没有血缘更危险。我们的做法是把血缘更新挂在调度系统上:
python
python
# DolphinScheduler 的 task 后处理钩子
def on_task_success(task_id, sql_text, engine):
edges = LineageExtractor(dialect=engine).extract(sql_text)
if not edges:
log_warning(task_id, "解析失败或无线索,进入人工队列")
save_to_manual_queue(task_id, sql_text)
return
# 先删旧边(同一个 task 的血缘全量替换,保证幂等)
neo4j.run("MATCH ()-[r:FLOWS_TO {task_id:$t}]->() DELETE r", t=task_id)
for (dt, dc, st, sc) in edges:
upsert_edge(dt, dc, st, sc, task_id=task_id)
关键设计:按 task_id 做幂等替换,而不是增量追加。否则任务 SQL 改了,旧边还留着,血缘会越来越脏。
同时在 metastore 层监听 DDL 事件(ALTER TABLE ... CHANGE COLUMN),一旦检测到字段类型/注释变更,主动触发影响分析并推送告警,这才是真正"防患于未然"的闭环。
七、生产实测数据
我们在一个中等规模平台(约 1.2 万张表、8000+ 条在跑的 SQL)上线后的实测:
| 指标 | 数值 | 说明 |
|---|---|---|
| 表级血缘准确率 | 96.7% | 抽样 500 条人工校验 |
| 字段级血缘准确率 | 91.3% | 同上 |
| 单条 SQL 平均解析耗时 | 38 ms | 含 AST + 血缘推导 |
| 全量重跑(8000 条) | 约 6 分钟 | 8 进程并行 |
| 图规模 | 38 万字段节点 / 92 万边 | Neo4j 单实例 |
| 8 跳下游影响查询 | p99 < 420 ms | 加索引后 |
准确率损失主要来自四类 SQL:
- 动态 SQL(shell 里拼字符串,采集到的不是最终 SQL), 占失败率 41%;
- UDF 内部逻辑(黑盒,无法追踪字段转换), 23%;
- 多表无前缀且无法唯一推断的列,19%;
- 复杂嵌套/窗口函数/PIVOT,17%。
针对第 1 类,我们的解法是在引擎侧采集 而非在调度侧:Spark 通过 QueryExecutionListener 拿到解析后的逻辑计划,直接读 LogicalPlan 的 output/input,比解析 SQL 文本准确得多,准确率能再提 5~8 个点。这是下一步的演进方向。
7.1 血缘准确率怎么评估:没有 ground truth 怎么办
准确率是血缘系统的生命线,但评估它有个先决难题:你凭什么说某条血缘是对的? 没有标注数据,所谓"91.3%"就是自说自话。
我们的做法是建立三层校验机制,逐层收敛可信度:
- 规则校验(自动,覆盖 100%) :抽出的源表/源列必须在 metastore 里真实存在;目标列必须在目标表结构中;类型要能兼容(不能
string ← decimal却不报错)。这一层能拦掉大约一半的明显错误。 - 交叉校验(自动,覆盖约 30%) :同一张表常被多个任务写入,若两个任务解析出的血缘互相矛盾(比如 A 说
t.c1 ← src.a,B 说t.c1 ← src.b),标记冲突进人工队列。 - 人工抽样标注(每月 200 条):随机抽取 200 条 SQL,由熟悉业务的同学标注正确血缘,作为 ground truth 计算准确率。这是唯一可信的指标来源,务必坚持做。
三层叠加后,我们每月产出一份"血缘健康度报告":覆盖率(有多少表进了血缘图)、准确率(人工抽样)、失败队列长度(待人工处理)。这个报告挂在团队周会上,准确率低于 88% 就停止新功能开发、专门修准确率。
八、必踩的 6 个坑
坑 1:只解析"调度里的 SQL",漏掉 ad-hoc 查询。 临时查询经常是"数据出问题"的源头。必须接入 Spark/Presto 的 history 做补充采集,否则血缘覆盖不全,用户不信。
坑 2:SELECT * 是血缘黑洞。 INSERT INTO t2 SELECT * FROM t1,不查 metastore 你根本不知道有哪些列。必须结合表结构元数据展开 *,且表结构变更后要重新展开(这就是为什么血缘系统和 metastore 要联动)。
坑 3:临时表(tmp_xxx)污染血缘图。 Spark 里大量 CREATE TEMPORARY VIEW、Hive 里的临时表,会在图里产生大量一次性的垃圾节点。策略:建白/黑名单,临时库(如 tmp、tmp_* 前缀)的表穿透,不建节点,直接把它的上下游连起来。
坑 4:血缘图里的环。 理论上不该有环,但生产上一定有(比如 INSERT INTO t SELECT ... FROM t 的自依赖、或者 A→B→C→A 的循环依赖)。所有图查询必须设最大跳数(我们用 8~10 跳),否则一个环就能让查询跑死。
坑 5:把血缘当成"调度依赖"来用。 血缘 ≠ 调度依赖。血缘说"数据从哪来",调度依赖说"任务谁先跑",两者经常不一致(比如任务依赖一张静态维表,但维表一年才更新一次,不该每天等它)。混用会导致调度死锁。
坑 6:做完没有运营,三个月就烂掉。 血缘系统最大的敌人不是技术,是"没人维护"。必须做到:解析失败的进人工队列每周清一次、准确率每月抽样评估、新表新字段自动纳管。我们专门做了个"血缘健康度看板"(覆盖率/准确率/失败队列长度),挂在团队周会上看。
九、结论
做了一年多数据血缘,我的体会是:技术难点在 SQL 解析,但真正决定这套系统能不能活下来的,是后面的持续运营。
几个判断值得单独拎出来说。
分层做,别贪全。表级血缘两周就能上线,马上能用于影响分析和调度依赖校验,这个甜头要先吃到。字段级是深水区,但值得投入,它让"改字段要通知谁"从拍脑袋变成一份自动生成的清单。
解析器选 sqlglot 而不是 ANTLR 原生方案,省掉的是维护多套方言语法的成本。方言覆盖和 AST 易用性都够用,没必要自己造轮子。
血缘天然是图,用 Neo4j 存而不是关系库。多跳影响分析从分钟级变成毫秒级,这不是参数调优,是体验上的质变。
SELECT * 一定要展开。我们的准确率从 82% 提到 91%,这一项贡献最大,代价只是接一下 metastore 的表结构查询,第一个迭代就该做。
最容易忽略的是最后一点:解析失败要看得见。建个人工修正队列加健康度看板,否则准确率会慢慢衰减,等用户发现"血缘不准",这套系统就废了。
上线半年,最直接的变化是上游变更引发的数据事故从平均每月 2.1 起降到 0.3 起,排查从小时级缩到分钟级。治理类的投入就是这样,它不产生任何新功能,但它让整个平台变得可信。