60.4 元数据管理与数据目录
一句话
元数据是数据平台的「控制面数据库」:它不存业务数据,却决定谁能发现、能否信任、改动会炸到谁;没有元数据的平台,只是一个更贵的网盘。
三类元数据,三种生产者
| 类型 | 内容 | 谁生产 | 谁消费 | 更新方式 |
|---|---|---|---|---|
| 技术元数据 | 库 / 表 / 列 / 类型 / 分区 / 文件 / 行数 / min-max / 存储位置 | 采集器从 Metastore、JDBC、文件系统抓 | 查询引擎、调度、质量 | 定时爬取 + 事件 |
| 业务元数据 | 指标定义、术语、owner、分级、标签、血缘、SLA | 人(数据开发 / 分析 / 安全) | 分析师、BI、审计 | 声明式(代码 / 表单) |
| 操作元数据 | 任务、运行、耗时、失败、质量结果、访问日志、成本 | 调度器、引擎、质量平台、审计 | on-call、FinOps、安全 | 事件流 |
🔑 三类元数据的新鲜度要求完全不同:技术元数据可以分钟级,业务元数据按变更(可能几周),操作元数据必须秒级。用一套采集周期覆盖三者,要么太慢,要么把系统压垮。
采集:五种方式,五种坑
| 方式 | 延迟 | 覆盖率 | 成本 | 典型坑 |
|---|---|---|---|---|
| JDBC / API 爬取 | 分钟–小时 | 高(有库就能抓) | 中(全量扫描) | 大库 information_schema 慢;拿不到视图/存储过程逻辑 |
| Metastore 事件 | 秒级 | 中(只覆盖 Hive/Glue 等) | 低 | 事件丢一条,元数据永久缺失;要定期对账 |
| 查询日志解析 | 分钟 | 高(能拿到真实使用) | 高(日志量大) | 只能看到「被查询的」,看不到「建了没用的」 |
| SDK / OpenLineage | 实时 | 取决于接入率 | 低(应用埋点) | 各引擎接入不一致;字段规范不统一 |
| 手工声明 | 天–周 | 低但最准 | 人力 | 三个月后过期,与真实链路脱节 |
生产上的组合是:技术元数据自动爬 + 血缘事件推送 + 业务元数据代码化 + 每周对账。表级血缘的图算法与爆炸半径示例见 46.5 数据治理与血缘。
# 业务元数据代码化:和模型放在同一个仓库,随 PR 评审
dataset: dwd_trade_order_di
owner: dw-trade
layer: DWD
classification: L3
sla:
freshness_minutes: 15
available_by: "06:00"
retention_days: 1095
columns:
- name: amount_cents
description: 订单金额(分),含运费,不含退款
classification: L3
⚠️ 采集失败不会让业务挂,但会让「影响面」失真——改表时漏掉一个下游,比没有血缘更危险。所以采集器本身要有 freshness 指标:上次成功时间、本次新增/删除的表数、解析失败数。
数据目录:搜索只是入口
| 能力 | 解决什么 | 没有它会怎样 |
|---|---|---|
| 搜索 / 发现 | 「有没有这张表」 | 重复建表,同一数据 N 份 |
| 详情 / schema | 「字段什么意思」 | 猜字段、用错列 |
| owner / 值班 | 「出问题找谁」 | 告警发到群里没人接 |
| 分级 / 标签 | 「能不能用、要不要脱敏」 | 敏感数据被当普通数据开放 |
| 血缘 / 影响面 | 「改它炸到谁」 | 上线后下游报表全空 |
| 质量分 / 最近产出 | 「这数能不能信」 | 拿三天前的数据做决策 |
| 用量 / 成本 | 「谁在用、值不值」 | 冷表长期占存储,没人敢删 |
| 权限申请 | 「怎么合法拿到访问权」 | 私下导数据,审计不可见 |
🔑 目录的核心价值不是「搜索框」,而是变更前的检查清单与口径争议时的证据链。搜索谁都会做;难的是「点开一张表,能看到它的上游、下游、owner、质量、成本和审批入口」。
指标字典:把口径写进代码
| 字段 | 必须写清 | 反例 |
|---|---|---|
| 名称 | 业务唯一名,不用缩写 | gmv2、amt |
| 定义 | 一句话说清业务含义 | 「成交额」 |
| 公式 | 可执行的表达式 | 「大概就是销售额」 |
| 维度 | 可按什么拆 | 只写「按天」 |
| 过滤 | 含哪些状态、哪些渠道 | 不写「是否含退款」 |
| 来源 | 哪张表、哪一列 | 「来自订单表」 |
| owner | 谁签字、谁维护 | 空 |
| 版本 | 口径变更记录 | 直接改,不留痕 |
指标平台把这份字典变成可执行的 SQL / 语义层(如 dbt metrics、Cube、MetricFlow);指标最终落在哪一层、由谁消费,见 60.2 数仓分层 (ODS / DWD / DWS / ADS)。好处不是「自动生成 SQL」,而是口径变更走 PR:谁改的、影响哪些报表、什么时候生效,全部可查。
血缘:表级回答影响面,列级回答口径
| 问题 | 表级血缘 | 列级血缘 |
|---|---|---|
| 改这张表会影响哪些任务 | ✅ | ✅(更细) |
| 这个指标来自哪张表的哪一列 | ❌ | ✅ |
| 两个报表同名指标为什么不同 | ❌ | ✅(不同来源列/过滤) |
| 删除这一列安全吗 | ❌ | ✅ |
列级血缘的实现难度在 SQL 解析:
SELECT *、CTE、子查询、窗口函数、UDF、动态 SQL、临时表,都会让「列从哪来」变得不确定;- 生产级解析器用 AST(sqlglot、Apache Calcite),不是正则;
- 解析失败时必须 fail-closed:不发布这条血缘、告警、要求补声明,而不是猜一个边。
代码:爬 schema、建血缘、算影响面、发事件
#!/usr/bin/env python3
"""元数据爬取 + 血缘 + 影响面(SQLite,无第三方依赖)。"""
import json
import re
import sqlite3
import tempfile
import uuid
from pathlib import Path
DB = Path(tempfile.mkdtemp(prefix="meta-")) / "dw.db"
DDL = """
CREATE TABLE ods_orders (id TEXT, user_id TEXT, amount INTEGER, status TEXT, dt TEXT);
CREATE TABLE ods_users (id TEXT, phone TEXT, city TEXT);
CREATE TABLE dwd_orders (order_id TEXT, user_id TEXT, amount INTEGER, status TEXT, dt TEXT);
CREATE TABLE dwd_users (user_id TEXT, phone TEXT, city TEXT);
CREATE TABLE dws_sales (dt TEXT, gmv INTEGER, orders INTEGER);
CREATE TABLE ads_gmv (dt TEXT, gmv INTEGER);
CREATE TABLE tmp_export (x TEXT);
INSERT INTO ods_orders VALUES ('o1','u1',100,'paid','2026-10-01'),
('o2','u2',200,'paid','2026-10-01');
INSERT INTO ods_users VALUES ('u1','13800000001','北京'),('u2','13800000002','上海');
"""
TASKS = [
{"name": "dwd_orders_daily",
"sql": "INSERT INTO dwd_orders SELECT id,user_id,amount,status,dt FROM ods_orders",
"columns": {"order_id": "ods_orders.id", "user_id": "ods_orders.user_id",
"amount": "ods_orders.amount", "status": "ods_orders.status", "dt": "ods_orders.dt"}},
{"name": "dwd_users_daily",
"sql": "INSERT INTO dwd_users SELECT id,phone,city FROM ods_users",
"columns": {"user_id": "ods_users.id", "phone": "ods_users.phone", "city": "ods_users.city"}},
{"name": "dws_sales_daily",
"sql": """INSERT INTO dws_sales
SELECT o.dt, SUM(o.amount), COUNT(*)
FROM dwd_orders o JOIN dwd_users u ON o.user_id = u.user_id GROUP BY o.dt""",
"columns": {"dt": "dwd_orders.dt", "gmv": "dwd_orders.amount", "orders": "dwd_orders.order_id"}},
{"name": "ads_gmv_daily",
"sql": "INSERT INTO ads_gmv SELECT dt,gmv FROM dws_sales",
"columns": {"dt": "dws_sales.dt", "gmv": "dws_sales.gmv"}},
# 解析器不支持的写法:CTE。生产上应交给 sqlglot/Calcite,解析失败要拒绝发布血缘
{"name": "dwd_orders_merge",
"sql": "WITH x AS (SELECT * FROM ods_orders) INSERT INTO dwd_orders SELECT * FROM x",
"columns": {}},
]
META = {
"ods_orders": {"owner": "data-eng", "level": "L3", "desc": "订单源表"},
"ods_users": {"owner": "data-eng", "level": "L3", "desc": "用户源表"},
"dwd_orders": {"owner": "dw-trade", "level": "L3", "desc": "订单明细"},
"dwd_users": {"owner": "dw-user", "level": "L3", "desc": "用户明细"},
"dws_sales": {"owner": "dw-trade", "level": "L2", "desc": "销售日汇总"},
"ads_gmv": {"owner": "bi-team", "level": "L2", "desc": "GMV 看板"},
"tmp_export": {"owner": "", "level": "", "desc": ""},
}
COL_META = {"phone": "L4", "id": "L2", "amount": "L3", "gmv": "L2"}
def crawl(con):
out = {}
for (name,) in con.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"):
cols = [r[1] for r in con.execute(f"PRAGMA table_info({name})")]
rows = con.execute(f"SELECT COUNT(*) FROM {name}").fetchone()[0]
out[name] = {"columns": cols, "rows": rows}
return out
def parse(sql):
if re.search(r"\bWITH\b", sql, re.I) or re.search(r"\bFROM\s*\(", sql, re.I):
raise ValueError("含 WITH/子查询,当前解析器不支持")
clean = re.sub(r"--[^\n]*", "", sql)
target = re.search(r"INSERT\s+INTO\s+([A-Za-z_]\w*)", clean, re.I)
sources = re.findall(r"\b(?:FROM|JOIN)\s+([A-Za-z_]\w*)", clean, re.I)
if not target:
raise ValueError("找不到 INSERT INTO 目标表")
return target.group(1), list(dict.fromkeys(sources))
def main():
con = sqlite3.connect(DB)
con.executescript(DDL)
tables = crawl(con)
print("=== 爬取到的数据集(技术元数据)===")
print(f"{'name':<12}{'rows':>5} columns")
for n, t in tables.items():
print(f"{n:<12}{t['rows']:>5} {','.join(t['columns'])}")
edges, col_edges, errors = set(), set(), []
for task in TASKS:
try:
out_table, sources = parse(task["sql"])
for s in sources:
edges.add((s, out_table))
for tgt_col, src in task["columns"].items():
col_edges.add((src, f"{out_table}.{tgt_col}"))
except ValueError as e:
errors.append((task["name"], str(e)))
print("\n=== 表级血缘 ===")
for s, t in sorted(edges):
print(f" {s} -> {t}")
def downstream(start, graph):
seen, stack = [], [start]
while stack:
cur = stack.pop()
for s, t in graph:
if s == cur and t not in seen:
seen.append(t)
stack.append(t)
return seen
impact = downstream("ods_orders", edges)
col_impact = downstream("ods_orders.amount", col_edges)
print(f"\n=== 改 ods_orders 的影响面 ===\n {len(impact)} 个下游:{impact}")
print(f"\n=== 改 ods_orders.amount 的列级影响 ===\n {len(col_impact)} 个下游:{col_impact}")
total_cols = sum(len(t["columns"]) for t in tables.values())
owned = sum(1 for n in tables if META.get(n, {}).get("owner"))
desc = sum(1 for n in tables if META.get(n, {}).get("desc"))
level = sum(1 for n in tables if META.get(n, {}).get("level"))
classified = sum(1 for t in tables.values() for c in t["columns"] if c in COL_META)
covered = {s for s, _ in edges} | {t for _, t in edges}
print(f"\n=== 覆盖率 ===")
print(f" owner {owned}/{len(tables)};描述 {desc}/{len(tables)};分级 {level}/{len(tables)}")
print(f" 列分级 {classified}/{total_cols};血缘覆盖 {len(covered)}/{len(tables)}"
f"(孤立表:{set(tables)-covered})")
print(f"\n=== 解析失败(fail-closed,不发布该任务血缘)===")
for name, why in errors:
print(f" {name}: {why}")
event = {
"eventType": "COMPLETE",
"eventTime": "2026-10-30T21:30:00+08:00",
"producer": "https://example.com/metadata-crawler",
"run": {"runId": str(uuid.uuid5(uuid.NAMESPACE_URL, "dws_sales_daily/2026-10-30"))},
"job": {"namespace": "dw", "name": "dws_sales_daily"},
"inputs": [{"namespace": "sqlite://dw", "name": "dwd_orders"},
{"namespace": "sqlite://dw", "name": "dwd_users"}],
"outputs": [{"namespace": "sqlite://dw", "name": "dws_sales"}],
}
print("\n=== OpenLineage 风格事件(字段有裁剪)===")
print(json.dumps(event, ensure_ascii=False, indent=1))
main()
本机实测(macOS,Apple M1,Python 3.14.8):
=== 爬取到的数据集(技术元数据)===
name rows columns
ads_gmv 0 dt,gmv
dwd_orders 0 order_id,user_id,amount,status,dt
dwd_users 0 user_id,phone,city
dws_sales 0 dt,gmv,orders
ods_orders 2 id,user_id,amount,status,dt
ods_users 2 id,phone,city
tmp_export 0 x
=== 表级血缘 ===
dwd_orders -> dws_sales
dwd_users -> dws_sales
dws_sales -> ads_gmv
ods_orders -> dwd_orders
ods_users -> dwd_users
=== 改 ods_orders 的影响面 ===
3 个下游:['dwd_orders', 'dws_sales', 'ads_gmv']
=== 改 ods_orders.amount 的列级影响 ===
3 个下游:['dwd_orders.amount', 'dws_sales.gmv', 'ads_gmv.gmv']
=== 覆盖率 ===
owner 6/7;描述 6/7;分级 6/7
列分级 8/22;血缘覆盖 6/7(孤立表:{'tmp_export'})
=== 解析失败(fail-closed,不发布该任务血缘)===
dwd_orders_merge: 含 WITH/子查询,当前解析器不支持
=== OpenLineage 风格事件(字段有裁剪)===
{
"eventType": "COMPLETE",
"eventTime": "2026-10-30T21:30:00+08:00",
"producer": "https://example.com/metadata-crawler",
"run": {
"runId": "19ae45b7-e2a5-50f7-afad-f0a8057f7e34"
},
"job": {
"namespace": "dw",
"name": "dws_sales_daily"
},
"inputs": [
{
"namespace": "sqlite://dw",
"name": "dwd_orders"
},
{
"namespace": "sqlite://dw",
"name": "dwd_users"
}
],
"outputs": [
{
"namespace": "sqlite://dw",
"name": "dws_sales"
}
]
}
读数:
| 结果 | 含义 | 生产动作 |
|---|---|---|
| 表级影响 3 个下游 | 改 ods_orders 要通知 DWD / DWS / ADS | 上线前自动 @ owner,排期 |
| 列级影响 3 跳 | amount → dwd_orders.amount → dws_sales.gmv → ads_gmv.gmv | 改列前能指出「动的是 GMV 口径」 |
| 血缘覆盖 6/7 | tmp_export 是孤立表,无上游也无下游 | 下线或补登记,别让它变成「谁也不敢删」 |
| 列分级 8/22 | 大量列没有分级 | 无法做列级权限与脱敏 |
| 解析失败 1 条 | CTE 不在解析器能力内,血缘缺失 | 换 AST 解析器,或要求任务补声明 |
🔑 列级血缘的价值在这条链上:
ods_orders.amount一改,能直接指出「GMV 看板的 gmv 会变」。表级血缘只能说「ads_gmv 可能受影响」,给不出口径证据。
元数据本身也要治理
| 问题 | 指标 | 阈值 / 动作 |
|---|---|---|
| 缺 owner | owner 覆盖率 | < 95% 不允许发布到生产目录 |
| 缺描述 | 描述覆盖率 | 核心表 100%,长尾 ≥ 80% |
| 缺分级 | 分级覆盖率 | L3/L4 必须 100% |
| 血缘缺失 | 血缘覆盖率 | 核心链路 100%,解析失败必须告警 |
| 孤立表 | 无上游且无下游 | 季度清理,先软删再物理删 |
| 元数据过期 | 上次采集时间 | 超过 24h 告警 |
| 元数据泄露 | 列名/注释含敏感信息 | 目录本身要做权限与审计 |
分级字段的判定口径见 38.5 数据分级与脱敏。元数据的权限常被忽略:列名、注释、样本数据、owner 邮箱本身就是情报。一个能匿名搜索「身份证」列名的人,已经获得了攻击地图。目录要按分级授权,查询行为要审计(见 3.5 审计与取证);目录本身的访问策略与数据权限共用一套模型,见 60.6 数据安全与权限。
生产落地:指名 → 可抄 → 可验 → 可退
指名:目录用 DataHub(替代 OpenMetadata / Apache Atlas);血缘事件用 OpenLineage + Marquez(替代自研埋点);转换层血缘用 dbt docs / dbt lineage;云上可直接用 Unity Catalog / AWS Glue Catalog。
可抄(DataHub 采集 PostgreSQL,未验证:本机未安装 DataHub,GMS 默认 8080、前端 9002):
# recipe.yml
source:
type: postgres
config:
host: localhost
port: 5432
username: meta_reader
password: ${PG_PASSWORD}
database: shop
include_tables: true
include_views: true
sink:
type: datahub-rest
config:
server: "http://localhost:8080"
datahub ingest -c recipe.yml
# 预期:Produced 42 entities / 0 failures(数字随库而定)
# OpenLineage 事件推给 Marquez(默认 API 5000)
curl -X POST http://localhost:5000/api/v1/lineage \
-H 'Content-Type: application/json' \
-d @event.json
# 预期:201 Created
可验:本节脚本直接可跑,输出影响面与覆盖率;产品侧在目录里搜索 dwd_trade_order_di,应能看到 owner、schema、上下游与最近一次采集时间。可退:采集器是只读旁路,停掉不影响业务;血缘发布失败只影响「新链路不可见」,不应阻断任务,但要告警并在发布门禁里检查「核心表血缘是否齐全」。
反模式
| 反模式 | 后果 |
|---|---|
| 血缘靠人工维护文档 | 三个月后与真实链路脱节,改表仍靠问人 |
| 只采集技术元数据,不登记业务口径 | 目录能搜到表,但没人知道字段能不能用 |
| 解析失败就「跳过」 | 血缘缺一条边,影响面少一半,事故时才发现 |
| 用正则解析所有 SQL | CTE / 子查询 / UDF 全漏,列级血缘不可信 |
| 采集器没有 freshness 监控 | 元数据停在三个月前,目录变成「历史博物馆」 |
| 目录不做权限 | 列名与注释泄露敏感信息,等于给出攻击地图 |
| 指标改名不留别名 | 老报表引用断裂,搜索不到历史指标 |
| 只建目录不接权限申请 | 用户找到数据却拿不到权限,最后还是私下导 |
自检
Q1 技术元数据、业务元数据、操作元数据,各自由谁生产?为什么不能都用「定时爬取」?
▸ 答案:技术元数据由采集器从 Metastore / JDBC / 文件系统生产;业务元数据由数据开发、分析、安全人员声明(代码或表单);操作元数据由调度器、查询引擎、质量平台、审计系统以事件方式产生。定时爬取只能拿到「结构」,拿不到「口径」与「运行状态」;操作元数据要求秒级,爬取周期太慢。
Q2 为什么「Metastore 事件 + 定期对账」要一起用,只留一个会怎样?
▸ 答案:事件延迟低,但会丢(网络、重启、消费失败),丢了就永久缺失;定期对账能补齐,但延迟高。只留事件 → 元数据缺口无人发现;只留对账 → 新建的表几小时内搜不到,影响开发效率。两者组合:事件做实时, 对账做兜底,并监控「事件消费延迟」与「对账新增/删除数」。
Q3 表级血缘已经能算影响面,为什么还要列级血缘?举一个只有列级能回答的问题。
▸ 答案:表级只能说「这张表的下游是谁」,回答不了「这个指标用了哪一列、经过什么过滤」。例如两个报表都叫「销售额」,一个用 amount,另一个用 amount - refund;表级血缘显示它们都来自 dwd_orders,看起来一致,实际口径不同。只有列级血缘能追到「一个来自 amount,一个来自 amount 与 refund 的组合」,给出差异证据。
Q4 SQL 解析器遇到 CTE 解析失败,为什么必须 fail-closed,而不是「先不给血缘、静默跳过」?
▸ 答案:静默跳过会让血缘图缺边,而缺边的影响面看起来「更小」——改表时系统告诉你「没有下游」,实际下游存在。这比没有血缘更危险,因为人会更相信系统。正确做法是标记该任务血缘为 UNRESOLVED、告警、在发布门禁里阻断核心链路,直到补声明或换解析器。
Q5 数据目录的核心价值为什么不是「搜索」?
▸ 答案:搜索只解决「找到」,但数据平台真正的问题是「找到后敢不敢用、改它会不会炸、出事找谁」。这些要靠 owner、分级、血缘、质量分、成本、审批入口共同回答。一个只有搜索的目录,最终会变成「能搜到但没人信」的页面,用户还是回去问同事。
Q6 元数据本身为什么要做权限与审计?
▸ 答案:元数据包含列名、注释、样本、owner 联系方式、血缘与用量,这些本身就是攻击地图与商业情报。能搜索「身份证 / 银行卡」列的人,已经知道去哪偷;能看血缘的人,知道哪个上游最薄弱。目录要按数据分级授权,查询与导出要审计,敏感列的注释也要脱敏。
Q7 一个可用的指标字典,至少写清哪几项?
▸ 答案:名称、业务定义、可执行公式、维度、过滤条件(含不含退款/取消)、来源表与列、owner、版本与变更记录。缺公式就无法复现;缺过滤就会同名不同值;缺 owner 就没人对口径负责;缺版本就无法回答「这个数什么时候变的」。