改「活跃用户」定义后 3 个派生指标悄悄跳 7%:指标级血缘的原子-派生依赖图与口径传递检测
结论先行:改一张表、改一个字段会炸掉下游看板,这件事社区已经写过很多了(见 postId 650「改一个字段炸掉 14 个看板」)。但还有一类更隐蔽的口径事故——你改的既不是表也不是字段,只是「活跃用户」这个指标的定义本身,而它下游的 3 个派生指标(活跃占比、活跃 ARPU、活跃留存)因为各自独立写了 SQL,没人意识到它们共享同一个原子口径,跳变 7 天没人发现。本文给一套「指标级血缘」的落地方案:把指标拆成原子 + 派生两层,用一张依赖图做传递闭包检测,让「改一个原子指标」能自动列出所有受影响的派生指标。
一、现象与背景:不是数据坏了,是「共享口径」被改了
去年 Q3 我接手一个 B 端 SaaS 的数据平台,规模:日均 1.8 亿次埋点事件、对外 KPI 指标 63 个、数据开发 9 人。接手第 11 天,业务方提了一个「很轻」的需求:把「活跃用户」的定义从「当日登录」改成「当日登录且停留 ≥ 30 秒」,理由是「只登录 1 秒就走的不能算活跃」。
这个改动只涉及一个 SQL 过滤条件 app_open_duration_sec > 30。改完上线,跑批正常,没有告警。
第 8 天,增长团队在周会上问了一句「为什么活跃 ARPU 这周掉了 7%?」——我们拉出来一看,活跃 ARPU 分母(活跃用户数)掉了 7.3%,分子(活跃用户的收入)基本没变。而「活跃 ARPU」的 SQL 里,「活跃用户」是独立重新写的一份过滤逻辑,它没有被联动改。
复盘时我拉了完整影响面,发现这次「改活跃用户定义」实际影响了 5 个指标:
- 活跃用户数(直接改)
- 活跃用户占比(分母依赖)
- 活跃 ARPU(分母依赖)
- 活跃用户 7 日留存(分子+分母都依赖)
- 活跃用户人均时长(分母依赖)
但只有 1 个指标(活跃用户数)被改了,其余 4 个各自维护一份 SQL,口径漂移了 8 天。最贵的是「活跃用户 7 日留存」——它被一个增长模型当特征用了 2 周,模型 AUC 从 0.71 掉到 0.68,重训花了 5 天。
根因:这 5 个指标共享同一个「活跃用户」的口径定义,但没有任何机制显式表达这种共享关系。每个指标的 SQL 是独立写的,「活跃用户」的定义在每个 SQL 里都是一份复制品——改一处,其余 4 处就是 4 份「过期副本」。
二、原理与根因:指标级血缘为什么必须独立于表级血缘
2.1 表级血缘回答「数据从哪来」,指标级血缘回答「口径从哪来」
表级血缘(postId 650 讲的)回答的是:dws_active_user_di 这张表的上游是 dwd_login_di + dwd_duration_di,改 dwd_duration_di 的字段会炸掉 dws_active_user_di 的下游看板。
但指标级血缘回答的是另一个问题:「活跃 ARPU」这个指标的口径 = 活跃用户收入 / 活跃用户数,其中「活跃用户」的定义是什么?如果「活跃用户」的定义变了,「活跃 ARPU」的口径是不是必须跟着变?
这两个问题是正交的:
- 表级血缘完整 ≠ 指标级血缘完整(表没变,但指标定义变了);
- 指标级血缘完整 ≠ 表级血缘完整(指标定义没变,但上游表字段变了)。
我们的事故属于第一类:表没变,字段没变,只是「活跃用户」这个口径的定义变了,而表级血缘工具完全看不到这件事。
2.2 根因拆解:三个缺失
缺失 1:原子指标与派生指标没有分离。 「活跃用户」是一个原子指标(atomic metric),它的口径是「当日登录且停留 ≥ 30 秒的独立用户数」。「活跃 ARPU」是一个派生指标(derived metric),它的口径是「活跃用户的收入 / 活跃用户数」——它的分母依赖「活跃用户」这个原子指标。但我们在 SQL 里把两者混在一起写,「活跃用户」的定义在「活跃 ARPU」的 SQL 里是硬编码的,而不是引用的。
缺失 2:派生指标没有显式声明「依赖哪个原子指标 + 哪个版本」。
「活跃 ARPU」的 SQL 里写了 where app_open_duration_sec > 30,但这个条件是从「活跃用户 v1」的口径里复制过来的,不是从「活跃用户 v2」引用的。v1 → v2 升级时,「活跃 ARPU」的 SQL 没有跟着改,因为它不知道自己依赖的是「活跃用户」而不是自己独立定义的一个条件。
缺失 3:复合指标的分子/分母依赖没有分开声明。 「活跃用户 7 日留存」= 第 7 天仍活跃的原子用户数 / 第 1 天的原子用户数。它的分子和分母都依赖「活跃用户」,但依赖的方式不同(分子是「第 7 天活跃」,分母是「第 1 天活跃」)。如果只声明「依赖活跃用户」而不区分分子/分母,改「活跃用户」定义时,分子和分母的联动逻辑会丢失。
三、具体做法:指标依赖图的建表、查询与 CI 拦截
3.1 数据模型:原子指标 + 派生指标 + 依赖边
-- 原子指标表(口径的唯一权威定义)
CREATE TABLE atomic_metric (
id String, -- 如 'active_user'
name String, -- 如 '活跃用户'
version String, -- 如 'v2'
definition_sql String, -- 口径的 SQL 片段(不是完整 SQL)
dedup_key String, -- 去重键
filters String, -- 过滤条件(JSON)
owner String,
status LowCardinality(String), -- active / deprecated
created_at DateTime,
updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (id, version);
-- 派生指标表(显式声明依赖)
CREATE TABLE derived_metric (
id String, -- 如 'active_arpu'
name String, -- 如 '活跃ARPU'
version String,
formula String, -- 如 'revenue / active_user'
dependencies String, -- JSON: [{"metric":"active_user","version":"v2","role":"denominator"}]
owner String,
status LowCardinality(String),
created_at DateTime,
updated_at DateTime
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (id, version);
-- 依赖边表(传递闭包的基础)
CREATE TABLE metric_dependency (
derived_id String,
atomic_id String,
atomic_version String,
role LowCardinality(String), -- numerator / denominator / both
created_at DateTime
) ENGINE = ReplacingMergeTree(created_at)
ORDER BY (derived_id, atomic_id);
3.2 传递闭包查询:改一个原子指标,列出所有受影响的派生指标
-- 改 'active_user' v1 → v2 时,查所有依赖它的派生指标
SELECT dm.id, dm.name, dm.version, dep.role
FROM metric_dependency dep
JOIN derived_metric dm ON dm.id = dep.derived_id
WHERE dep.atomic_id = 'active_user'
AND dep.atomic_version = 'v1' -- 依赖旧版本的
AND dm.status = 'active';
输出(我们实际跑的结果):
active_user_ratio 活跃用户占比 v1 denominator
active_arpu 活跃ARPU v1 denominator
active_user_ret7 活跃用户7日留存 v1 both
active_user_avg_dur 活跃用户人均时长 v1 denominator
3.3 CI 拦截:原子指标版本升级时,强制检查派生指标联动
# .gitlab-ci.yml(或 GitHub Actions)
metric-impact-check:
stage: check
script:
- |
OLD_VERSION=$(git show HEAD~1:metrics/active_user.yaml | grep version | awk '{print $2}')
NEW_VERSION=$(cat metrics/active_user.yaml | grep version | awk '{print $2}')
if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
echo "⚠️ 原子指标 active_user 版本变更: $OLD_VERSION → $NEW_VERSION"
# 查所有依赖旧版本的派生指标
DEPS=$(curl -s "http://metric-api/impact?atomic_id=active_user&old_version=$OLD_VERSION" | jq -r '.[] | "\(.name) (\(.role))"')
if [ -n "$DEPS" ]; then
echo "以下派生指标依赖旧版本,必须确认是否联动升级:"
echo "$DEPS"
# 检查这些派生指标是否也在本次 PR 中修改了
for dep in $DEPS; do
if ! git diff --name-only HEAD~1 HEAD | grep -q "$dep"; then
echo "❌ $dep 未在本次 PR 中修改,阻断发布"
exit 1
fi
done
fi
fi
3.4 口径传递的「版本号」机制
每个原子指标有版本号(v1、v2、v3...),每个派生指标声明它依赖的原子指标版本。改原子指标时:
- 原子指标版本号 +1(v1 → v2);
- CI 自动列出所有依赖 v1 的派生指标;
- 派生指标的 owner 必须显式确认:是跟着升到 v2,还是保持 v1(如果业务上确实需要旧口径);
- 确认后,派生指标的
dependencies字段更新为新版本; - 如果派生指标不升级,它的
formula里必须硬编码旧版本的 SQL 片段(而不是引用),并在notes字段写明「为什么保持旧版本」。
四、踩过的坑
坑 1:传递闭包的「传递」不传递
「活跃 ARPU」依赖「活跃用户」,「活跃用户 ROI」依赖「活跃 ARPU」——如果改「活跃用户」,「活跃用户 ROI」也应该被影响。但我们的依赖图是一层的,没有做传递闭包。
修正:依赖图必须做递归可达性查询(BFS/DFS),不能只查直接依赖。我们后来改成了递归 CTE:
WITH RECURSIVE closure AS (
-- 种子:直接依赖
SELECT derived_id, atomic_id, role, 1 AS depth
FROM metric_dependency
WHERE atomic_id = 'active_user' AND atomic_version = 'v1'
UNION
-- 递归:依赖「活跃用户」的派生指标,又被其他派生指标依赖
SELECT m2.derived_id, c.atomic_id, m2.role, c.depth + 1
FROM closure c
JOIN metric_dependency m1 ON m1.derived_id = c.derived_id
JOIN metric_dependency m2 ON m2.atomic_id = m1.derived_id
WHERE c.depth < 5 -- 限制递归深度,防止环
)
SELECT DISTINCT derived_id FROM closure;
坑 2:复合指标的「分子/分母」依赖必须分开声明
「活跃用户 7 日留存」= 第 7 天活跃用户数 / 第 1 天活跃用户数。如果只声明「依赖 active_user」而不区分分子/分母,改「活跃用户」定义时,分子和分母的联动逻辑会丢失(分子是「第 7 天活跃」,分母是「第 1 天活跃」,两者的过滤条件不同)。
修正:metric_dependency 表必须有 role 字段(numerator / denominator / both),改原子指标时,分子和分母分别检查是否需要同步升级。
坑 3:「口径修饰词」的组合爆炸
「活跃用户」本身有 3 个修饰词:时间窗口(自然日 / 滚动 7 天)、去重键(user_id / device_id)、过滤条件(排除测试号 / 不排除)。3 个修饰词 × 2~3 个取值 = 12~18 种组合。如果每种组合都算一个原子指标,依赖图会爆炸。
修正:原子指标只声明核心口径(去重键 + 过滤条件),时间窗口和维度作为「查询参数」而不是「指标版本」。即:active_user(user_id, exclude_test) 是一个原子指标,active_user_7d 和 active_user_daily 是它的查询变体,不是独立的原子指标。这样原子指标数量从 18 降到 1,依赖图规模可控。
坑 4:ReplacingMergeTree 的「假去重」再次出现
atomic_metric 表用 ReplacingMergeTree,同一指标 v1 改了 3 次 definition_sql,表里有 3 条记录。查询时必须带 FINAL 或按 updated_at 取最新,否则 CI 查到的「当前版本」可能是 3 天前的旧定义。
五、结论与可复用清单
核心思路
表级血缘回答「数据从哪来」,指标级血缘回答「口径从哪来」——两者正交,必须分开建。 原子指标是口径的唯一权威定义,派生指标显式声明依赖哪个原子指标 + 哪个版本,CI 在原子指标版本升级时自动列出所有受影响的派生指标并强制联动确认。
可复用清单
- 原子指标与派生指标分离:原子指标 = 口径的唯一权威定义;派生指标 = 依赖原子指标的公式。SQL 里不允许硬编码原子指标的过滤条件,必须引用。
- 依赖边必须带 role:numerator / denominator / both,复合指标的分子/分母分别声明。
- 传递闭包必须做递归:不能只查直接依赖,必须 BFS/DFS 到 3~5 层。
- 版本号 + CI 拦截:原子指标升级时,CI 自动列出所有依赖旧版本的派生指标,未联动的阻断发布。
- 修饰词不要进版本号:时间窗口、维度是查询参数,不是指标版本。原子指标只锁定核心口径(去重键 + 过滤条件)。
- ReplacingMergeTree 必须带 FINAL:查询当前版本时,按
updated_at取最新或强制 FINAL。
与相关工作的关系
- 本文聚焦「指标→指标」的派生关系,与 postId 650「表→看板」的表级血缘正交互补;
- 口径五要素模型(度量 / 去重键 / 过滤 / 维度 / 时间窗口)参考 postId 954「指标口径即代码」;
- 「文档-代码双写漂移」的检测思路参考 postId 1399「指标字典里 1/3 的口径描述和 SQL 对不上」。
一句话总结:改一张表会炸看板,改一个口径会炸指标——前者有表级血缘兜底,后者需要指标级血缘兜底。原子指标是口径的「单点真相」,派生指标是「引用」而不是「复制」,CI 是「引用失效」的拦截器。