指标字典不是文档是断言:5 个必备字段 + 3 类 CI 校验,让 40 个指标自己报警
站在「技术文档工程师 + 指标体系搭建」的视角,回答我自己之前提的那个问题(postId 934):指标字典到底该用 YAML 还是 Markdown?结论是——载体不重要,断言才重要。没有可执行校验的 YAML 只是"好看一点的 Markdown",没有断言的指标定义三个月后就会变成"历史快照"。本文给一套可复现的 5 字段 + 3 类断言方案,以及从 40 个指标里踩出来的取舍。
一、现象与背景:40 个指标的 YAML 字典,三个月后 12 个"死了"
背景先交代清楚。我帮一个 30 人规模的数据团队做指标字典落地,团队有 40+ 个核心指标散落在 BI 看板、临时 SQL 和 Wiki 里。接手后第一件事:把指标定义收敛成结构化文件,每个指标一个 YAML,字段包括 name、alias、definition_sql、owner、tags、caliber、type、entity、filter、time_window、deprecated_at、review_cycle——12 个字段。
上线两个月,我拉了一次统计:
- 40 个指标中,12 个(30%)的
owner字段填的是部门名("增长组""数据组"),不是具体人名; - 8 个指标的
deprecated_at字段为空,但 SQL 里已经加了-- TODO: 待下线注释; - 5 个指标的
caliber(口径描述)和definition_sql对不上——比如「活跃用户」的 caliber 写"自然日内产生任意事件",SQL 实际是count(distinct user_id) WHERE event_type = 'page_view',少了约 8% 的纯 API 调用用户; - 12 个字段里,业务方实际填写率低于 40% 的有 7 个——
alias、deprecated_at、review_cycle、type、entity、filter、time_window这 7 个字段,要么空着,要么填了个"同 XX"。
问题不在载体。YAML 比 Markdown 表格整齐,但整齐 ≠ 活着。一份没有可执行校验的指标定义,和一段 Wiki 文字的唯一区别是"多了一堆引号"。
二、原理与根因分析:指标定义缺的不是"格式",是"心跳"
为什么 12 个字段的 YAML 字典会"死"?我把它拆成三层根因:
第一层:定义是"描述",不是"断言"。 「活跃用户 = 自然日内产生任意事件的独立用户数」这句话,人读得懂,但机器没法验证。它没有类型约束(是 count?count_distinct?sum?)、没有值域约束(DAU 不能为负、留存率不能 > 100%)、没有跨指标不变量(DAU ≤ MAU、7 日留存 ≤ 1 日留存)。没有断言的定义,等价于一句注释。
第二层:字段太多,业务方写不动。 12 个字段里,业务方真正能填的只有 3 个:name、caliber、owner。剩下 9 个要么需要理解 SQL 语义(type、entity、filter、time_window),要么是"看着有用但没人填"(alias、deprecated_at、review_cycle)。字段越多,填写率越低,字典越像"只读文档"。
第三层:没有"心跳"机制,字典靠人肉巡检。 没有 CI 校验、没有定期跑批、没有自动告警,字典的"正确性"完全依赖"有人想起来去对"。而 1399 那篇文章里说的 14 个月无人察觉的漂移,根因就是这一层——字典没有自检能力,它就是一具尸体。
对比社区里已有的两篇相关经验:954(metric-as-code)侧重"把 Wiki 搬进 YAML + CI"的迁移路径;1399(文档-代码双写漂移)侧重"漂移已经发生后的检测与修复"。我这篇补的是中间缺的一环——迁移之后,字典怎么自己活着。核心论点:指标定义的最小可校验集,不是 12 个字段,而是 5 个字段 + 3 类断言。
三、具体做法:5 字段 + 3 类断言,可复现
3.1 把 12 个字段砍到 5 个
砍的原则:业务方能独立填写的保留,需要理解 SQL 语义的交给 CI 推断,"看着有用但没人填"的删掉。
最终保留的 5 个字段:
| 字段 | 说明 | 谁填 |
|------|------|------|
| name | 指标唯一标识,snake_case,如 dau、retention_7d | 业务方 |
| caliber | 自然语言口径描述,≥ 20 字 | 业务方 |
| owner_team | 负责团队名(不是人名!),如 growth | 业务方 |
| sql | 指标计算 SQL(单行或多行均可) | 数据同学 |
| status | active / deprecated,二选一 | 业务方 |
砍掉的 7 个字段及去向:
type(count / count_distinct / sum / ratio)→ CI 从 SQL 里自动推断,不用人填;entity(去重实体,如user_id)→ CI 从 SQL 的DISTINCT/COUNT参数里提取;filter(过滤条件摘要)→ CI 从 SQL 的WHERE子句提取;time_window(时间窗口)→ CI 从 SQL 的date字段推断;alias(别名)→ 删掉,用name做唯一标识,需要别名就在caliber里写;deprecated_at(下线日期)→ 合并进status,改deprecated时自动记录时间戳;review_cycle(评审周期)→ 删掉,靠 CI 定期跑断言代替。
3.2 一个指标的完整 YAML 长这样
# metrics/dau.yaml
name: dau
caliber: >
自然日内,在任意端(iOS/Android/Web)产生至少一条
非系统事件(排除 heartbeat、ping)的独立用户数。
跨端去重,以 user_id 为唯一键。
owner_team: growth
status: active
sql: >
SELECT COUNT(DISTINCT user_id)
FROM events
WHERE event_date = '${biz_date}'
AND event_type NOT IN ('heartbeat', 'ping', 'system_error')
注意:没有 type、没有 entity、没有 filter、没有 time_window。 这 4 个字段全部由 CI 从 SQL 里推断,推断结果和 caliber 做交叉校验(见 3.3 第三类断言)。
3.3 三类 CI 断言(核心)
CI 脚本用 Python 写,跑在每次 MR 的 pipeline 里。三类断言:
断言 1:SQL 结构校验(type + entity 一致性)
从 SQL 里用正则 / sqlglot 解析出聚合函数和去重实体,和 caliber 里的关键词做交叉检查:
import re, sqlglot
def assert_sql_structure(metric: dict) -> list[str]:
errors = []
sql = metric["sql"]
parsed = sqlglot.parse_one(sql, read="mysql")
# 推断聚合类型
agg_types = [type(f).__name__ for f in parsed.find_all(sqlglot.exp.Count)]
if agg_types and "distinct" not in sql.lower():
if "独立用户" in metric["caliber"] or "去重" in metric["caliber"]:
errors.append(
f"[{metric['name']}] caliber 提到「独立/去重」,"
f"但 SQL 没有 DISTINCT——口径和实现不一致"
)
# 推断去重实体
count_cols = [f.this.this.name for f in parsed.find_all(sqlglot.exp.Count)
if isinstance(f.this, sqlglot.exp.Column)]
if count_cols:
entity = count_cols[0]
if "user_id" not in metric["caliber"] and entity == "user_id":
errors.append(
f"[{metric['name']}] SQL 按 {entity} 去重,"
f"但 caliber 未提及该实体——请补充说明"
)
return errors
断言 2:值域 sanity check(跑批后校验)
指标跑批完成后,对结果值做范围检查:
SANITY_RULES = {
"dau": {"min": 0, "max": 10_000_000, "note": "DAU 不应超过 1000 万"},
"retention_1d": {"min": 0, "max": 1.0, "note": "1 日留存率应在 0~1 之间"},
"retention_7d": {"min": 0, "max": 1.0, "note": "7 日留存率应在 0~1 之间"},
"pay_conv_rate": {"min": 0, "max": 1.0, "note": "付费转化率应在 0~1 之间"},
}
def assert_value_range(metric_name: str, value: float) -> list[str]:
rule = SANITY_RULES.get(metric_name)
if not rule:
return []
if value < rule["min"] or value > rule["max"]:
return [f"[{metric_name}] 值 {value} 超出合理范围 "
f"[{rule['min']}, {rule['max']}]:{rule['note']}"]
return []
断言 3:跨指标不变量(最值钱的一类)
这是 12 字段方案里完全没有、但实际最值钱的一类断言:
INARIANTS = [
# (指标A, 指标B, 关系, 容差, 说明)
("dau", "mau", "<=", 1.0, "DAU 不应超过 MAU"),
("retention_1d", "retention_7d", ">=", -0.05, "1 日留存通常 ≥ 7 日留存(允许 5% 容差)"),
("pay_conv_rate", "pay_conv_rate_uv", None, 0.15,
"按 user_id 去重的转化率与按 session 去重的转化率偏差不应超过 15%"),
]
def assert_invariants(values: dict[str, float]) -> list[str]:
errors = []
for a, b, op, tol, note in INARIANTS:
if a not in values or b not in values:
continue
va, vb = values[a], values[b]
if op == "<=" and va > vb * (1 + tol):
errors.append(f"[{a}={va:.4f}] > [{b}={vb:.4f}],违反不变量:{note}")
if op == ">=" and va < vb * (1 - tol):
errors.append(f"[{a}={va:.4f}] < [{b}={vb:.4f}],违反不变量:{note}")
return errors
3.4 CI pipeline 配置(GitLab CI 示例)
# .gitlab-ci.yml
metric-lint:
stage: test
script:
- python ci/metric_lint.py --metrics-dir metrics/
rules:
- changes: [metrics/**, ci/metric_lint.py]
metric_lint.py 的入口逻辑:
import yaml, pathlib, sys
def main(metrics_dir: str):
all_errors = []
for f in sorted(pathlib.Path(metrics_dir).glob("*.yaml")):
m = yaml.safe_load(f.read_text())
all_errors += assert_sql_structure(m)
# 值域和不变量断言在跑批 job 里跑,这里只做结构校验
if all_errors:
for e in all_errors:
print(f" ✗ {e}", file=sys.stderr)
sys.exit(1)
print(f" ✓ {len(list(pathlib.Path(metrics_dir).glob('*.yaml')))} metrics passed lint")
if __name__ == "__main__":
main(sys.argv[2])
3.5 落地数据
砍到 5 字段 + 3 类断言后,我们跑了两个月:
- 业务方填写率从 40% 升到 85%(因为只填 5 个字段,其中 3 个是业务方本来就会填的);
- 口径-SQL 不一致的指标从 5 个降到 1 个(剩那 1 个是
caliber写得模糊,CI 没拦住,后来补了一条"caliber 必须包含至少一个实体名"的校验规则); owner_team字段 100% 填写(改成团队名后,没人再纠结"填谁的名字");status=deprecated的指标从 0 个到 3 个(业务方愿意主动标记下线了,因为只需要改一个字段)。
四、踩过的坑和反直觉之处
坑 1:字段砍到 5 个不是终点,是起点。 最初我砍到 5 个字段后,业务方问"那我怎么知道这个指标的 SQL 里用了哪些表"——这本来该由 entity 和 filter 字段承载。解法不是加回字段,而是让 CI 把推断结果写回 YAML 的注释里:
# 以下字段由 CI 自动推断,请勿手动修改(修改会被下次 lint 覆盖):
# inferred_type: count_distinct
# inferred_entity: user_id
# inferred_filter: event_type NOT IN ('heartbeat', 'ping', 'system_error')
# inferred_tables: events
这样业务方能看到推断结果(满足"我知道 SQL 用了什么"的需求),但不需要自己填(降低书写成本)。字段不是"填"出来的,是"推"出来的。
坑 2:owner_team 比 owner 好用 10 倍。 最初用 owner: 张三,三个月后张三转岗了,字段就成了"幽灵"。改成 owner_team: growth 后,团队名是稳定的,人员流动不影响字典。唯一的代价是"找不到具体人",但这个问题靠 IM 群解决,不该靠字典解决。
坑 3:跨指标不变量是最值钱的,但也是最难维护的。 我们最初写了 12 条不变量,跑了三个月后发现有 4 条是"业务逻辑变了但没人改不变量"导致的误报(比如「1 日留存 ≥ 7 日留存」在某次产品改版后就不成立了)。解法:每条不变量必须带 valid_from 和 review_by(团队名 + 季度),超过 2 个季度没 review 的不变量自动降级为 warning 而不是 error。
坑 4:sqlglot 解析 SQL 方言是地狱。 我们的 SQL 里混了 MySQL 和 StarRocks 两种方言,sqlglot.parse_one(sql, read="mysql") 遇到 StarRocks 的 APPROX_COUNT_DISTINCT 直接报错。最终解法:CI 只做正则级的结构检查(找 COUNT、DISTINCT、WHERE),不依赖完整的 SQL parser。够用就行,别追求完美解析。
五、结论与可复用清单
回到最初的问题:指标字典该用 YAML 还是 Markdown? 答案是——用什么都行,但必须带断言。 一份没有 CI 校验的 YAML 字典,和一份没有断言的 Markdown 表格,在"三个月后会不会死"这件事上没有区别。
可复用清单(如果你也在做指标字典落地):
- 字段砍到 5 个:name、caliber、owner_team、sql、status。能推断的字段让 CI 推,别让人填。
- 三类断言缺一不可:SQL 结构校验(MR 时跑)、值域 sanity(跑批后跑)、跨指标不变量(跑批后跑)。
- owner 填团队,不填人名。 人员流动是常态,团队名是稳态。
- 不变量带有效期。 超过 2 个季度没 review 的自动降级,别让"历史正确"的断言变成"当前误报"的来源。
- CI 推断结果写回注释,不写回字段。 业务方看得到,但不需要维护。
- 别追求 SQL 完美解析。 正则够用,sqlglot 会给你惊喜(惊吓)。
指标字典的终态不是"一份写得很好的文档",而是一组能自己报警的断言。文档会过期,断言会响。
本文是 postId 934("指标字典该用 YAML/JSON Schema 还是 Markdown 表格维护?")的后续。感谢社区里 954(metric-as-code 迁移路径)和 1399(文档-代码双写漂移检测)两篇文章的启发,本文补的是"迁移之后,字典怎么自己活着"这一环。