"这个月活跃用户怎么少了四千?"报表会上,增长说按他的口径没少,数据说按数仓口径就是少,产品说两个数都不是他要的。讨论三天,最后发现三份报表的分母不同——没有一个人的数字是错的,但没有一个能用来决策。
麻烦不在修,在认账:链路三四段,每段都有合理理由,每段的负责人都不认为后果落在自己身上。这篇拆责任链——脏数据从哪来、判据是什么、谁该动手。
1. 四种来源与归因难点
| 来源 | 典型表现 | 归因难点 |
|---|---|---|
| 采集端 | 客户端时钟错、必填字段缺失、重复上报 | 日志只记成功,异常被 SDK 吞掉 |
| 转换端 | 关联键丢失、类型截断、时区错位 | 中间层无断言,错到下游才显形 |
| 消费端误用 | 拿曝光数当去重用户数 | 使用方不懂字段语义,也没有拦截 |
| 定义分歧 | 三个部门三个"活跃用户" | 无权威定义,谁报表响谁赢 |
前两类是工程问题,后两类是治理问题,但症状一样:数字不对。归因第一步不是查数据,而是判断"这属于哪一类"——把定义分歧当采集 bug 修,会白改一周埋点。
2. "谁产生谁负责"为何经常失效
这条原则听着无懈可击,落地时失效在四处:
| 环节 | 产生方看到 | 动力在哪 |
|---|---|---|
| 埋点 | 事件发出去了,接口 200 | 上报成功即完成,不看下游 |
| 转换 | 任务跑完了,无报错 | 无断言,脏数据静默通过 |
| 消费 | 报表能出数 | 出数不等于对,无人对账 |
| 决策 | 数字与预期不符 | 先质疑数据源,再质疑自己 |
核心矛盾是产生方离后果太远:埋点工程师看不到"缺了 device_id,归因报告算丢了 12% 转化"。责任要可执行,必须把后果搬到产生方眼前——契约、对账、下游回传的失败样例,而不是一句"你负责"。
3. 数据契约:把语义写成可校验的东西
| 要素 | 必须写清 | 不写的后果 |
|---|---|---|
| 字段语义 | 这个字段到底度量什么 | 同名不同义,跨表 join 出错 |
| 取值范围 | 枚举、上下界、单位 | 分出"元/分"级错误 |
| 空值语义 | NULL 是缺失、未知,还是零 | 均值被 NULL 拉偏或吞掉 |
| 更新频率 | 实时/小时/日,延迟上界 | 下游按 T+1 用实时表,读到半截 |
# 契约即代码:可被 CI 校验,也可生成文档
table: fact_user_action
owner: growth-data
freshness: {mode: incremental, max_delay: 2h}
columns:
user_id: {type: string, nullable: false, semantics: 设备首次登录后绑定的用户标识}
event_time: {type: timestamp, tz: UTC, nullable: false, skew_max: 5m}
channel: {type: enum, values: [organic, paid, referral, unknown], default: unknown}
amount: {type: decimal, unit: cent, min: 0}
契约的价值不在文档,在它能在流水线里被执行:nullable、enum、unit、max_delay 都能变成一条断言,断言失败就阻塞发布。
4. 校验放在哪里
| 位置 | 严格度 | 代价 |
|---|---|---|
| 写入端(采集/入库) | 高,脏数据进不来 | 灵活度低,新字段要改 schema |
| 转换端(ETL/建模) | 中,可分级拦截 | 需要维护断言,误报要有人处理 |
| 消费端(报表/模型) | 高,但重复 | 每个消费方各写一遍,口径漂移 |
判据只有一条:错误代价是否随链路放大。放大得厉害(金额、用户标识、时间戳)就在写入端拦;不放大(可选标签)就在消费端过滤。
全放写入端,业务会被 schema 变更拖死;全放消费端,等于同一件事做 N 遍且每次不同。常见组合是关键字段写入端强校验 + 其余转换端分级校验 + 消费端兜底过滤。
5. 不是所有脏数据都要修
| 级别 | 判据 | 处理 |
|---|---|---|
| 致命 | 会导致错误决策或错误扣款 | 阻塞发布,必须修 |
| 可容忍 | 影响局部展示,不改变结论 | 记录、监控、定期清理 |
判据是**"会不会导致错误决策"**,不是"是否干净"。3% 空值率若只影响一张下钻明细表,修它收益接近零;同样 3% 落在 GMV 分母上,就是 P0。
治理最常见的失控方式是追求全域零脏数据:成本线性上升,收益迅速归零,最后团队学会绕过校验。
6. 发现机制与它们的成本
| 机制 | 发现什么 | 成本 |
|---|---|---|
| 对账 | 上下游总量、金额、条数是否一致 | 需要独立数据源,维护对账规则 |
| 分布监控 | 空值率、枚举占比、分位数漂移 | 需要基线,误报要调阈值 |
| 抽样核对 | 人工看具体记录 | 人力,覆盖率低但能发现语义错误 |
三者互补:对账抓总量缺口,分布监控抓形态突变,抽样核对抓"数字对但意思错"。只有对账会漏掉语义错误,只有抽样会漏掉规模问题。
-- 分布监控的最小可用形式:按天看关键字段的空值与形态
SELECT dt,
COUNT(*) AS rows,
SUM(CASE WHEN user_id IS NULL THEN 1 ELSE 0 END) / COUNT(*) AS null_rate,
COUNT(DISTINCT channel) AS channel_cardinality,
APPROX_PERCENTILE(amount, 0.5) AS p50_amount
FROM fact_user_action
WHERE dt >= CURRENT_DATE - 7
GROUP BY dt
ORDER BY dt;
channel_cardinality 突然从 4 变成 40,通常不是业务变化,是上游枚举被写坏了——这类信号比空值率更早出现。
7. 修复的责任划分
| 动作 | 谁做 | 不该谁做 |
|---|---|---|
| 改代码/改埋点 | 产生方(业务研发) | 数据团队代改,改完没人认 |
| 补历史数据 | 数据平台 + 产生方确认口径 | 单独由平台猜口径回刷 |
| 通知下游 | 发现方,且必须落到具体人 | 发到公共群,无人认领 |
| 判定是否需要重算 | 指标 owner | 由报数的人自行决定 |
补历史数据最易出事:回刷口径若与当初不一致,历史曲线会出现无法解释的台阶,之后每次同比都要解释。所以回刷必须留"口径版本 + 区间 + 影响面"三样记录。
# 修复工单:三件事写不清,下游就无从判断数据还能不能用
issue: {table: fact_user_action, field: channel, window: "2026-10-01..10-07"}
cause: upstream enum written as lowercase # 归因结论,不是猜测
fix: {code: MR-1234, backfill: true, scope: affected_partitions_only}
impact: {metrics: [gmv_daily, funnel_report], notify: [growth, exec-report]}
verify: {compare: rows_upstream vs rows_downstream, tolerance: 0}
8. 血缘与影响面
没有血缘,修复只能靠经验猜:"改这个字段应该没事吧?"然后某个下游看板静默归零两周。
-- 字段级血缘:给定字段,向上找直接消费者(示意结构,按自家元数据表核对)
WITH RECURSIVE up AS (
SELECT table_name, column_name, 0 AS depth
FROM lineage_edges WHERE source = 'fact_user_action.channel'
UNION ALL
SELECT e.table_name, e.column_name, up.depth + 1
FROM lineage_edges e JOIN up ON e.source = up.table_name || '.' || up.column_name
WHERE up.depth < 5
)
SELECT * FROM up ORDER BY depth;
血缘的用途有三个:评估改动影响面、定位"这个数从哪来"、故障时决定先停哪张表。不必精确到每次查询,但必须覆盖源表到核心指标的主链。
9. 定义分歧:最难的一类
同一个"活跃用户",增长按"7 日内有登录",数据按"7 日内有任意事件",产品按"7 日内有核心行为"。三个都合理,合起来就是三份报表。
处理方式不是统一口径(往往统一不了),而是把口径写成带版本的独立对象,让报表显式引用:
metric: active_user
version: v3
definition:
numerator: count(distinct user_id)
denominator: null # 比率型指标才需要
window: 7d
dedup_key: user_id
filters: [event_type in (login, core_action), is_bot = false]
owner: growth-data
supersedes: v2 # v2 为"任意事件",保留至 2026-Q2
recompute: false # 口径变更不回刷历史,改用双线并行
只写名字不写这六项(分子、分母、时间窗、去重键、排除条件、owner),等于把解释权留给会议室。口径变更必须留旧版本,否则历史曲线断成两截,分析全部作废。
10. 度量
| 指标 | 定义要点 | 健康信号 |
|---|---|---|
| 数据事故数 | 按"是否影响对外数字"分级 | 下降,且致命级趋零 |
| 平均发现时长 | 从数据出错到被发现 | 缩短,靠监控而非靠人反馈 |
| 平均修复时长 | 从发现到下游恢复可信 | 缩短,含回刷 |
| 对账覆盖率 | 核心表中有对账规则的比例 | 上升,优先覆盖对外指标 |
第二个指标最能说明水平:发现时长以周计,实际是在等业务方来投诉——那叫客服,不叫质量体系。
11. 排错:数字不对时的排查顺序
| 现象 | 先查什么 | 常见结论 |
|---|---|---|
| 总量差一截 | 上游条数 vs 下游条数 | 过滤条件被新增,或分区漏读 |
| 某天骤降 | 任务是否成功、分区是否存在 | 上游延迟,读到半截数据 |
| 同比失真 | 口径版本是否变更 | 变更未回刷,或回刷区间错 |
| 只有一张报表错 | 该报表的 join 与去重键 | 多对多放大,重复计数 |
最后一条很典型:join 放大产生的错误数字看起来完全合理,它只是把一部分行数了两次。去重键写进契约,就是为了让它在建模阶段暴露。
12. 边界:什么时候不必建治理体系
| 场景 | 判断 | 替代做法 |
|---|---|---|
| 数据不参与决策 | 错也无后果 | 只做基本校验 |
| 单团队自产自用 | 责任链只有一环 | 口头约定 + 抽样自查 |
| 数据量极小可人工核对 | 工具成本高于人力 | 每月人工对一次 |
判据:**当"错一次"的代价低于"建体系"的成本,先别建体系,先把错一次的代价算清楚。**治理的门槛是后果,不是数据量。
13. 自测
- 四类来源里,为什么把定义分歧当采集 bug 修会白改一周?
- "谁产生谁负责"失效的核心原因是什么,怎么让责任可执行?
- 数据契约的四个要素里,哪一个最容易被漏掉且后果最隐蔽?
- 校验放在写入端还是消费端,判据是什么?
- 什么样的脏数据可以不修?判据是哪一句?
- 发现时长长期以周计,说明当前实际靠什么在发现数据问题?