指标字典里 1/3 的口径描述和 SQL 对不上:一次「文档-代码双写漂移」的排查与单源化改造
站在「技术文档工程师 + 数据分析」的交叉视角,复盘一次跨 3 个团队、持续 14 个月无人察觉的指标口径文档漂移:指标字典(wiki)里 127 个指标抽查 60 个,20 个(33%)的自然语言描述和实际跑批 SQL 对不上,其中 3 个差异超过 5 个百分点。给一套可复现的「SQL 即文档」单源化方案,含 CI 校验脚本与字段级 diff 规则。
一、现象与背景:字典是「对的」,但字典不是「真的」
接手一个 B 端数据平台后,第一周接到一个「很轻」的需求:业务方要一份「对外可承诺指标清单」,要求把指标字典里所有指标的口径描述原样导出给法务审阅。我照做,导出后让数据同学抽查了 60 个高频指标(覆盖 DAU、活跃、留存、转化、GMV 五大类)——结果 20 个(33.3%)的字典描述和实际跑批 SQL 不一致。
举三个最典型的(数字都是真实对账结果):
- 「日活跃用户」:字典写「自然日内产生任意事件的独立用户数」,SQL 实际是
count(distinct user_id) where event_type = 'page_view'——少了 8.7% 的「只调 API 不看页面」用户; - 「7 日留存」:字典写「首次访问后第 7 天仍活跃」,SQL 实际是「首次访问后 7 天内任一天活跃」(滚动留存,不是同期群)——差 12.4 个百分点,且方向不稳定;
- 「付费转化率」:字典写「付费用户 / 活跃用户」,SQL 分母实际是
count(distinct session_id)——分母虚高,转化率被压低约 22%。
规模上这件事很贵:平台日均 1.4 亿次埋点事件,对外承诺的 KPI 指标 47 个,其中 15 个当年经历过口径调整。每一次调整如果没有人同时改字典 + SQL,字典就成了「历史快照」,而业务方、法务、算法训练集都在读字典——读到的是一份永远不会更新但被当成权威的文档。
最贵的一次:一个增长团队按字典里的「活跃用户」口径做了 3 个月投放模型,上线后才发现模型特征和实际指标差 8.7%,重新训练 + 回滚花了 6 周。这 6 周的成本远高于「口径文档和 SQL 保持同步」的成本。
二、原理与根因分析:口径的「三处定义」必然漂移
为什么指标字典会漂移?根因不是「文档没维护」,而是指标的口径天然有「三处定义」,而这三处各自演化:
1)自然语言层(指标字典 / wiki):面向业务、法务、新人,用「活跃用户」「付费转化率」这种自然语言描述。这层的更新节奏跟需求节奏走——业务方提需求、数据同学写描述、wiki 合入。
2)SQL 实现层(跑批作业 / 视图定义):面向计算引擎,是口径的「真实执行体」。这层的更新节奏跟数据问题修复节奏走——数据对不上、口径调优、性能优化,都会改 SQL。
3)BI 看板层(公式 / 数据集):面向消费方,是口径的「展示体」。这层的更新节奏跟产品迭代节奏走——看板改版、字段重命名、指标拆分。
三层的更新节奏、更新触发人、更新 review 链路完全不同,却要求「三者一致」。这在工程上等价于「API 文档、OpenAPI spec、SDK 实现三处都手写」——只要时间一长,漂移是必然的,不是偶然。
我复盘过这 20 个漂移指标的变更时间线,发现一个规律:18/20 的漂移发生在「SQL 改了、字典没改」这个方向,只有 2 个是「字典改了、SQL 没跟上」。原因是 SQL 的改动有 CI、有 review、有发布记录(git log 可查),而字典的改动往往是一次性「补文档」动作,没有持续 review 机制——没有持续 review 的文档,生命周期只等于它被写下的那一次。
从「技术文档工程」视角看,这和 API 文档的「spec vs 实现」漂移是同一类问题:解法不是「加强文档纪律」,而是让文档从单一来源生成——OpenAPI spec 是 API 文档的单源,那么指标口径的单源应该是 SQL 本身(或 SQL 旁边的结构化元数据),而不是独立的 wiki。
三、具体做法:SQL 即文档 + CI 校验 + 字段级 diff
核心思路:指标的口径元数据从「wiki 自然语言」下沉到「SQL 文件旁边的 YAML 块」,作为唯一权威定义;wiki 由脚本从 YAML 生成;CI 校验 YAML 与 SQL 的一致性。
3.1 指标口径单源:SQL + YAML frontmatter
每个跑批作业(或视图)旁边放一个 <metric>.yaml,字段级定义口径:
# metrics/dau.yaml
metric_code: dau
name: 日活跃用户
owner: data-core@
period: day
timezone: Asia/Shanghai # 关键:显式声明时区
window:
type: natural_day # natural_day | user_tz_day | rolling_24h
start: "00:00"
end: "23:59:59"
dedup_key: user_id
event_filter:
include: ["page_view", "api_call", "purchase", "share"] # 显式枚举,不写"任意事件"
exclude: ["health_check", "internal_ping"]
formula: count(distinct user_id)
version: 3
changelog:
- v3: "2025-09 加入 api_call/share 事件,对齐 B 端 SDK 埋点"
- v2: "2024-11 排除 internal_ping"
跑批 SQL 里不再写业务注释,只保留技术注释(性能、索引、分区),口径语义全部在 YAML。
3.2 CI 校验:YAML ↔ SQL 字段级 diff
CI 里跑一个校验脚本(Python,约 80 行),核心逻辑:
# ci/check_metric_consistency.py
import re, yaml, sys
def extract_sql_dedup(sql: str) -> str:
m = re.search(r'count\(\s*distinct\s+(\w+)\s*\)', sql, re.I)
return m.group(1) if m else None
def extract_sql_events(sql: str) -> set:
return set(re.findall(r"event_type\s*=\s*'(\w+)'", sql)) | \
set(re.findall(r"event_type\s+IN\s*\(([^)]+)\)", sql, re.I) and
re.findall(r"'(\w+)'", re.search(r"event_type\s+IN\s*\(([^)]+)\)", sql, re.I).group(1)))
def check(metric_file: str, sql_file: str) -> list[str]:
spec = yaml.safe_load(open(metric_file))
sql = open(sql_file).read()
errs = []
if extract_sql_dedup(sql) != spec['dedup_key']:
errs.append(f"dedup_key: spec={spec['dedup_key']} sql={extract_sql_dedup(sql)}")
sql_events = extract_sql_events(sql)
if spec.get('event_filter', {}).get('include') and \
not set(spec['event_filter']['include']) <= sql_events:
errs.append(f"event_filter.include: spec 有 {set(spec['event_filter']['include']) - sql_events} 未在 SQL 出现")
return errs
关键:不是「SQL 必须包含 spec 的所有事件」,而是「spec 里声明的事件必须出现在 SQL 里」——防止 SQL 偷偷加事件、字典没跟上。
3.3 wiki 自动生成
wiki 页面的指标条目由脚本从 YAML 生成(jinja2 模板),业务方看到的「活跃用户」描述永远是 YAML 的渲染结果,不再手写。变更流程:改口径 = 改 YAML + 改 SQL → CI diff 通过 → wiki 自动更新 → git log 留痕。
3.4 切换与回滚
上线前做了 2 周双轨:旧 wiki 和新生成 wiki 并行展示,业务方反馈「新 wiki 更准」(因为终于和实际跑的数对上了)。切换后旧 wiki 只读归档 3 个月,期间收到 4 次「旧 wiki 描述更合理」的反馈——这 4 次其实是旧 wiki 描述的是「业务期望」而非「实际实现」,我们据此反过来修了 SQL(这是漂移的正面价值:暴露了 SQL 与业务预期的真实 gap)。
四、踩过的坑与反直觉之处
坑 1:「活跃」是自然语言里的最高频歧义词。 同一个「活跃」,3 个团队有 3 种理解(事件活跃 / 页面活跃 / 会话活跃)。YAML 强制要求 event_filter.include 显式枚举事件名,不允许写「任意事件」「活跃行为」这种模糊词——CI 里加一条正则校验:include 里出现 any / active / * 直接 fail。
坑 2:时间窗口的「当日」歧义。 字典写「自然日」,SQL 里实际是 event_time >= now() - INTERVAL '24 hours'(滚动 24h,不是自然日)——两者在 0 点附近差最多 2 小时数据。YAML 强制 window.type 三选一(natural_day / user_tz_day / rolling_24h),不允许省略。
坑 3:团队习惯在 SQL 里写「业务注释」。 改 YAML 单源后,前两周 CI 一直 fail,因为有人觉得「SQL 里加一句注释说明口径更清楚」。我们的处理:SQL 注释只允许写技术信息(性能、索引、分区),口径语义必须进 YAML——注释不会被 CI 抓,但 YAML 会。这条规则写进了团队 coding standard,执行 3 个月后 SQL 里的「业务注释」基本清零。
坑 4:口径变更的「下游盘点」不能省。 改一个指标的 dedup_key,下游 BI 看板、周报 SQL、算法训练集(如果用了历史数据)都要重新评估。我们在 YAML 里加 downstream: [] 字段,变更 PR 必须填「影响了哪些下游」,reviewer 对着这个清单打勾——这是把「口径变更是发布事件」落到流程里的最小成本做法。
反直觉之处:漂移不是「文档没维护」,而是「文档和代码用了两套更新机制」。 解法不是「加强文档纪律」或「让数据同学多改一次 wiki」,而是消除双写——让文档从代码生成,让代码携带文档。这和 API 文档的 OpenAPI-first 是同一个工程原则。
五、结论与可复用清单
核心结论:指标口径的「文档-代码漂移」不是纪律问题,是架构问题。只要口径有「自然语言描述」和「SQL 实现」两处手写定义,漂移是时间问题、不是概率问题。解法是单源化:口径元数据下沉到 SQL 旁边的结构化文件,自然语言文档从单源生成,CI 做字段级 diff。
可复用清单(可直接抄):
- 每个指标一个
<metric>.yaml,必填字段:metric_code / name / owner / period / timezone / window / dedup_key / event_filter / formula / version / changelog / downstream——11 个字段,一个都不能少; event_filter.include强制显式枚举,禁止「任意事件」「活跃行为」等模糊词,CI 正则校验;window.type三选一(natural_day/user_tz_day/rolling_24h),禁止省略,timezone必填;- CI 脚本做「spec → SQL」方向的包含校验(spec 声明的事件必须出现在 SQL 里),不做「SQL → spec」方向(SQL 可以有技术细节,spec 不必穷举);
- wiki / 指标字典页面由 YAML 渲染生成,业务方不再手写自然语言描述;
- 口径变更 PR 必须填
downstream清单,reviewer 对着清单确认下游已盘点; - 切换时双轨并行 2 周,让业务方对比新旧描述,暴露「业务期望 vs 实际实现」的真实 gap——这是漂移的正面价值;
- SQL 注释只允许技术信息,口径语义必须进 YAML——注释不会被 CI 抓,YAML 会。
适用边界:这套方案适合「指标数量 30–300、有跑批作业、有 CI 链路」的团队。指标少于 10 个的直接 wiki 手写 + 人肉 review 即可,不必上这套工具链;指标超过 500 个的,建议再叠一层指标平台(如 Cube、MetricFlow),YAML 作为指标平台的 spec 输入。
一句话总结:指标口径的「文档-代码双写」是数据团队的「spec vs 实现」问题,解法不是加强纪律,而是让 SQL 携带文档、让文档从 SQL 生成——和 OpenAPI-first 是同一个工程原则。