60.5 数据质量
一句话
数据质量不是「多加几个告警」,而是把「可接受的数据」写成可执行契约,并在数据进入下一层前阻断;判据是错的数有没有流到决策者手里,不是告警响没响。
六个维度,六类规则
| 维度 | 回答的问题 | 可执行规则 | 典型漏检 |
|---|---|---|---|
| 完整性 | 该有的有没有 | 非空率、行数、分区是否存在 | 行数正常但某个渠道全空 |
| 唯一性 | 有没有重复 | 主键唯一、业务键去重率 | 重复行金额相同,DISTINCT 后看不出来 |
| 及时性 | 到得晚不晚 | 新鲜度(当前时间 − 最新分区时间)、产出时间 | 数据到了但内容是昨天的 |
| 有效性 | 格式/范围对不对 | 枚举、范围、正则、类型 | 金额为负、状态是拼写错误 |
| 一致性 | 跨表/跨层对不对 | 订单 vs 支付 vs 库存、DWD vs ADS 对账 | 两边都错但错得一样(同源 bug) |
| 准确性 | 是否反映真实世界 | 与权威源抽样比对、业务规则校验 | 系统性地多算 10%,规则本身错 |
🔑 六维度里最难的是准确性:前五个都能用数据自身算出来,准确性必须有一个「外部真值」。所以生产上常用「对账 + 抽样 + 业务规则」三件套,而不是指望一条 SQL 查出来。一致性的分级口径(哪些允许最终一致、哪些必须强一致)见 46.3 数据一致性分级。
规则类型:能抓什么、抓不到什么
| 规则 | 能抓 | 抓不到 |
|---|---|---|
| 非空 | 解析失败、字段缺失 | 空值被填了默认值 |
| 唯一 | 重复投递、Join 放大 | 两条不同 ID 的重复业务 |
| 枚举 | 状态拼写、脏值 | 新增合法枚举未同步 |
| 范围 | 负数金额、异常年龄 | 合理范围内的错值 |
| 正则 | 手机号/身份证格式 | 格式对但号码是假的 |
| 引用完整性 | 孤儿外键 | 维表本身错了 |
| 跨表对账 | 金额/行数不一致 | 同源同错 |
| 波动 | 骤增骤降 | 缓慢漂移(每周降 1%) |
| 新鲜度 | 上游延迟 | 数据到了但分区写错 |
| Schema 兼容 | 列删除/类型收窄 | 语义变化(同名不同义) |
⚠️ 只监控行数是数据团队最常见的安全感幻觉:行数正常、金额算错的事故,比「没数据」更难发现,也更难解释。
阈值为什么会疲劳:五种基线
| 方法 | 原理 | 优点 | 致命问题 |
|---|---|---|---|
| 固定阈值 | null_rate > 1% 就告警 | 简单、可解释 | 季节性/促销/低流量全误报;调松就漏报 |
| 同比 / 环比 | 和上周同一天、昨天比 | 抵消周内规律 | 需要至少 2 周历史;遇节假日失效 |
| 3σ | 偏离均值 3 个标准差 | 统计上「客观」 | 均值和标准差被异常值污染,异常越大越检不出 |
| 中位数 + MAD | 偏离中位数 4 个「稳健标准差」 | 50% 崩溃点,抗异常值 | 数据分布突变(如业务改版)会连续误报 |
| EWMA / 泊松 | 指数加权 / 低流量计数模型 | 适合低量、趋势 | 参数难调,解释成本高 |
3σ 的「掩蔽效应」值得单独记住:一个巨大的历史尖峰会把标准差撑大,让之后的真实下跌看起来「正常」。本节代码会实测给你看:30 天里注入 5 个异常,只配行数 3σ 的流水线命中 0/5,而中位数 + MAD 命中 5/5、误报 0。
代码:30 天、5 类异常,三种检测器对比
#!/usr/bin/env python3
"""数据质量引擎:固定阈值 vs 3σ vs 中位数+MAD(无第三方依赖)。"""
import random
import statistics as st
random.seed(42)
BASE = 1000
def make_days():
days = []
for i in range(1, 31):
rows = BASE + random.randint(-20, 20)
gmv = rows * random.randint(195, 205)
days.append({"i": i, "rows": rows,
"null_rate": round(random.uniform(0.0005, 0.0015), 4),
"dup_rate": round(random.uniform(0.0001, 0.0008), 4),
"fresh_min": random.randint(10, 20), "gmv": gmv})
days[19].update(rows=3000, gmv=600000) # D20:大促,合法尖峰
days[24].update(rows=600) # D25:上游漏数
days[25].update(null_rate=0.06) # D26:字段解析失败
days[26].update(gmv=days[26]["rows"] * 520) # D27:金额口径错
days[27].update(dup_rate=0.04) # D28:重复投递
days[28].update(fresh_min=180) # D29:上游延迟
return days
def zscore_flags(vals, k=3.0):
"""朴素 3σ:均值和标准差都会被异常值污染。"""
if len(vals) < 5:
return False
mu, sd = st.mean(vals), st.pstdev(vals)
return sd > 0 and abs(vals[-1] - mu) / sd > k
def mad_flags(vals, k=4.0):
"""中位数 + MAD:50% 崩溃点,少量异常值不会带偏基线。"""
if len(vals) < 5:
return False
med = st.median(vals)
mad = st.median([abs(x - med) for x in vals])
sigma = 1.4826 * mad
if sigma == 0:
return vals[-1] != med
return abs(vals[-1] - med) / sigma > k
def naive(days):
"""只配了「行数 3σ」的流水线。"""
res = {}
for idx in range(7, len(days)):
hist = [d["rows"] for d in days[idx - 7:idx]]
res[days[idx]["i"]] = zscore_flags(hist + [days[idx]["rows"]])
return res
def robust(days):
"""多规则:量、空值率、重复率、金额、新鲜度。"""
res = {}
for idx in range(7, len(days)):
d = days[idx]
hist = days[idx - 7:idx]
flags = []
if mad_flags([x["rows"] for x in hist] + [d["rows"]]):
flags.append(f"行数 {d['rows']}(基线 {int(st.median([x['rows'] for x in hist]))})")
if d["null_rate"] > max(0.02, 3 * st.median([x["null_rate"] for x in hist])):
flags.append(f"空值率 {d['null_rate']:.2%}")
if d["dup_rate"] > max(0.01, 3 * st.median([x["dup_rate"] for x in hist])):
flags.append(f"重复率 {d['dup_rate']:.2%}")
if mad_flags([x["gmv"] for x in hist] + [d["gmv"]]):
flags.append(f"GMV {d['gmv']}(基线中位数 {int(st.median([x['gmv'] for x in hist]))})")
if d["fresh_min"] > 60:
flags.append(f"新鲜度 {d['fresh_min']} 分钟")
res[d["i"]] = flags
return res
def main():
days = make_days()
n, r = naive(days), robust(days)
print(f"{'日':>3} {'行数':>6} {'空值率':>7} {'重复率':>7} {'GMV':>8} {'延迟':>5} "
f"{'3σ':<4} 鲁棒检测")
for d in days[20:]:
print(f"{d['i']:>3} {d['rows']:>6} {d['null_rate']:>7.2%} {d['dup_rate']:>7.2%} "
f"{d['gmv']:>8} {d['fresh_min']:>4}m {'⚠' if n[d['i']] else '·':<4} "
f"{';'.join(r[d['i']]) or '·'}")
injected = {25, 26, 27, 28, 29}
calm = {21, 22, 23, 24, 30}
print(f"\n注入异常 {len(injected)} 个;3σ 命中 "
f"{sorted(i for i in injected if n[i])}({sum(n[i] for i in injected)}/{len(injected)}),"
f"误报 {sorted(i for i in calm if n[i])}")
print(f" 鲁棒命中 "
f"{sorted(i for i in injected if r[i])}({sum(bool(r[i]) for i in injected)}/{len(injected)}),"
f"误报 {sorted(i for i in calm if r[i])}")
print("\n门禁判定:")
for i in sorted(injected):
p0 = any(k in " ".join(r[i]) for k in ("行数", "空值率", "重复率"))
print(f" D{i}: {'P0 阻断' if p0 else 'P1 告警'} -> {';'.join(r[i])}")
main()
本机实测(macOS,Apple M1,Python 3.14.8):
日 行数 空值率 重复率 GMV 延迟 3σ 鲁棒检测
21 1017 0.14% 0.04% 204417 13m · ·
22 988 0.10% 0.06% 200564 11m · ·
23 989 0.07% 0.06% 202745 19m · ·
24 984 0.09% 0.08% 197784 18m · ·
25 600 0.14% 0.01% 202188 11m · 行数 600(基线 993)
26 1014 6.00% 0.03% 201786 14m · 空值率 6.00%
27 1007 0.10% 0.08% 523640 14m · GMV 523640(基线中位数 202188)
28 1012 0.10% 4.00% 199364 20m · 重复率 4.00%
29 999 0.10% 0.02% 204795 180m · 新鲜度 180 分钟
30 990 0.15% 0.07% 200970 10m · ·
注入异常 5 个;3σ 命中 [](0/5),误报 []
鲁棒命中 [25, 26, 27, 28, 29](5/5),误报 []
门禁判定:
D25: P0 阻断 -> 行数 600(基线 993)
D26: P0 阻断 -> 空值率 6.00%
D27: P1 告警 -> GMV 523640(基线中位数 202188)
D28: P0 阻断 -> 重复率 4.00%
D29: P1 告警 -> 新鲜度 180 分钟
读数:
| 现象 | 数字 | 原因 |
|---|---|---|
| 3σ 全漏 | 0/5 | D20 的 3000 行把 7 天窗口的标准差撑大,D25 的 600 行落在 3σ 内 |
| MAD 全中 | 5/5 | 中位数与 MAD 不受单个尖峰影响,基线仍是 ~1000 |
| 3σ 误报 | 0 | 它的阈值太宽,宁可漏报也不误报 |
| MAD 误报 | 0 | 平静日 D21–24、D30 都落在 4 个稳健标准差内 |
| 门禁分级 | 3 个 P0 / 2 个 P1 | 行数、空值率、重复率直接阻断;金额与延迟先告警 |
🔑 这个对比不是「MAD 比 3σ 好」,而是基线必须匹配数据分布:有尖峰、有季节性的业务数据,均值/标准差不是可靠的基线;而 MAD 也有失效场景(业务改版导致分布整体平移,会连续误报),所以要配「白名单 + 人工确认 + 规则分级」。
数据质量也有 SLI / SLO
| SLI | 定义 | 目标示例 | 失败时用户感知 |
|---|---|---|---|
| 新鲜度 | 现在 − 最新分区产出时间 | P95 ≤ 30 min | 看到昨天的数 |
| 完整度 | 实际行数 / 上游应有行数 | ≥ 99.5% | 报表少一截 |
| Schema 兼容 | 不兼容变更数 / 总变更数 | 0 | 下游任务凌晨失败 |
| 质量通过率 | 通过的 P0 规则数 / 总 P0 规则数 | 100% | 错的数进决策 |
🔑 数据 SLI 与接口 SLO 的差别:接口 SLI 看「请求成功没有」,数据 SLI 看「数对不对、新不新」。两者要分开定目标、分开报警,否则「任务成功」会掩盖「数据是错的」。
门禁:在哪一层阻断,阻断到什么程度
| 位置 | 检查 | 失败动作 | 理由 |
|---|---|---|---|
| 接入(ODS 前) | schema、行数、新鲜度、重复率 | P0 阻断入湖 | 脏数据进湖后,清理成本高一个数量级 |
| DWD 发布前 | 主键唯一、非空、枚举、金额范围 | P0 阻断发布 | 明细错了,所有下游都错 |
| DWS / ADS 发布前 | 跨表对账、波动、口径断言 | P0 阻断 / P1 告警 | 汇总错了会直接进报表 |
| 消费端 | 新鲜度、质量分、血缘 | 页面打标 / 拒绝查询 | 让用户知道「这数不可信」 |
门禁位置与 60.2 数仓分层 (ODS / DWD / DWS / ADS) 的层级一一对应:接入挡在 ODS 前,明细挡在 DWD 发布前,汇总挡在 ADS 前。阻断的代价要提前设计:
- 隔离区:把不合格分区写到
quarantine,不覆盖主表,修好后重放; - 保留上一版:门禁失败时继续对外提供上一版数据,并在报表标注「数据延迟」;
- on-call:P0 阻断必须有人接,否则第二天整条链路停摆;
- 回滚开关:新规则上线先
warn,观察一周误报率再切block。
⚠️ 「阻断」不是目的。没有隔离区与重放工具的门禁,最后一定会被降级成告警——因为业务不能停。告警分级、症状 vs 原因、runbook 的设计见 50.5 告警 (分级 / 症状 vs 原因 / runbook / 降噪);质量 SLI 与错误预算见 50.7 SLO 与错误预算。
数据契约:把 schema 变更挡在发布前
| 变更 | 兼容性 | 处理 |
|---|---|---|
| 新增可空列 | 兼容 | 直接发布,通知下游 |
| 新增非空列(无默认) | 不兼容 | 先加可空列 + 回填,再收紧 |
| 删除列 | 不兼容 | 先下线消费方,保留一个版本别名 |
| 改列名 | 不兼容 | 新增列 + 双写 + 迁移,最后删旧列 |
| 类型放宽(int → long) | 兼容 | 直接发布 |
| 类型收窄(long → int) | 不兼容 | 禁止;会截断 |
| 改语义(同名不同义) | 最危险 | 必须换名 + 版本化,不能只改注释 |
契约的最小内容:schema + 语义 + SLA + owner + 兼容性策略。CI 里做 schema diff,不兼容变更直接拒绝合并;dbt 的 contract: enforced: true、Avro/Protobuf 的兼容性检查都是这个思路。兼容性分级与 Expand-Contract 的完整做法见 55.5 向后兼容。
对账:唯一能抓「系统性错误」的手段
| 对账 | 比什么 | 容差 | 差异分类 |
|---|---|---|---|
| 订单 vs 支付 | 订单数、金额 | 金额 0,行数 0(T+1 后) | 未支付、重复支付、金额不符 |
| 订单 vs 库存 | 扣减次数、剩余库存 | 0 | 超卖、少卖、回补失败 |
| DWD vs ADS | 汇总金额、行数 | 0.01% | 口径差异、过滤条件不同 |
| 源库 vs 数仓 | 行数、checksum | 0(同分区) | 漏采、重复、延迟 |
🔑 对账必须走独立链路:用同一张表、同一段 SQL 自我对账,只能发现「抄错」,发现不了「源头就错」。订单对支付要分别从两个系统取数,最好由不同团队实现。交易侧的复式记账与差异处理见 67.3 对账与差异处理(待补)。
生产落地:指名 → 可抄 → 可验 → 可退
指名:检查框架用 Great Expectations(替代 Soda、dbt tests 视栈而定);转换层内嵌测试用 dbt tests;调度集成用 Airflow(GreatExpectationsOperator 或独立任务)。
可抄(Great Expectations 0.18.x,未验证:本机未安装 GE):
# great_expectations/expectations/orders_suite.json(节选)
expectations:
- expectation_type: expect_column_values_to_not_be_null
kwargs: {column: order_id}
- expectation_type: expect_column_values_to_be_unique
kwargs: {column: order_id}
- expectation_type: expect_column_values_to_be_in_set
kwargs: {column: status, value_set: [paid, refunded, canceled, created]}
- expectation_type: expect_table_row_count_to_be_between
kwargs: {min_value: 800, max_value: 1200}
great_expectations checkpoint run orders_checkpoint
# 预期:Checkpoint succeeded;失败时进程退出码非 0,调度器据此阻断下游
dbt 侧的最小测试:
models:
- name: dwd_trade_order_di
columns:
- name: order_id
tests: [not_null, unique]
- name: status
tests:
- accepted_values:
values: ['paid', 'refunded', 'canceled', 'created']
可验:本节脚本输出「5/5 命中、0 误报」即检测器生效;生产上用「注入一条已知坏数据 → 门禁必须失败」做回归,而不是只看「今天没告警」。可退:新规则先 warn 一周,统计误报率;阻断开关按数据集配置,出问题时可单独降级为告警,并保留「上一版分区 + 质量标记」,让报表显式展示「数据不可信」。
反模式
| 反模式 | 后果 |
|---|---|
| 只监控行数 | 行数正常、金额算错,事故照样进报表 |
| 阈值拍脑袋(「空值率 1%」) | 要么天天误报,要么永远不响 |
| 用 3σ 监控有尖峰/季节性的指标 | 尖峰撑大方差,真实异常被掩蔽(本节 0/5) |
| 质量检查放在报表层 | 脏数据已经流到 DWD/DWS,清理成本翻倍 |
| 阻断但没有隔离区与重放 | 业务停摆,第二天门禁被关掉 |
| 用同一段 SQL 对账 | 同源同错,对账永远平 |
| 不做 schema 兼容检查 | 上游删列,下游凌晨全空 |
| 用「检查通过率」当质量分 | 只挑容易的规则,分数好看但关键规则缺失 |
自检
Q1 六个质量维度分别对应什么可执行规则?哪一维最难自动化?
▸ 答案:完整性→非空率/行数;唯一性→主键唯一/去重率;及时性→新鲜度/产出时间;有效性→枚举/范围/正则;一致性→跨表对账/跨层对账;准确性→与权威源抽样比对/业务规则。最难的是准确性,因为它需要「外部真值」,数据自身无法证明自己是对的,只能靠对账、抽样与业务验收。
Q2 为什么只配行数 3σ 的流水线在本节实测中 0/5 全漏?
▸ 答案:D20 的大促尖峰 3000 行落在 7 天窗口内,把均值拉高、标准差撑大(约 700),于是 D25 的 600 行偏离均值不到 3σ,被判为正常。异常值把「正常范围」撑宽了,这就是掩蔽效应(masking)。基线用均值/标准差时,越大的历史异常,越能让之后的真实异常「隐身」。
Q3 中位数 + MAD 为什么抗异常值?它在什么情况下会失效?
▸ 答案:中位数只依赖排序位置,MAD 是「到中位数距离的中位数」,两者都有 50% 崩溃点:少于一半的数据被污染时,基线基本不变。失效场景:① 业务整体平移(改版、口径变更)导致连续多天偏离,会连续误报;② 数据量很低且分布离散,MAD 可能为 0;③ 异常持续超过窗口一半,基线本身被带偏。
Q4 质量门禁应该放在哪一层?阻断还是告警?
▸ 答案:接入层挡 schema/行数/新鲜度/重复;DWD 发布前挡主键、非空、枚举、金额范围;DWS/ADS 发布前挡对账与波动;消费端做新鲜度与质量标记。阻断级别按「错了会不会直接改变决策」定:主键重复、金额为负、核心对账不平 → P0 阻断;金额偏移、延迟、长尾空值 → P1 告警并打标。阻断必须配隔离区、重放、on-call 与回滚开关,否则会被业务绕过。
Q5 哪些 schema 变更兼容,哪些不兼容?「改列名」为什么最危险?
▸ 答案:新增可空列、类型放宽兼容;删除列、改列名、类型收窄、新增无默认非空列不兼容。改列名危险在于:它既破坏旧查询(列不存在),又可能让新查询「看起来正常」(同名新列语义不同),而且历史文件/下游缓存里仍是旧名。正确做法是新增列 + 双写 + 迁移 + 下线旧列,而不是直接 rename。
Q6 为什么对账必须走独立链路?同源对账会漏什么?
▸ 答案:同源对账(同一张表、同一段 SQL、同一套过滤)只能验证「抄写一致性」,无法发现源头计算错误、漏采、口径错误。独立链路要求两边从不同系统/不同实现取数(订单系统 vs 支付系统),才能发现「同一笔业务在两个系统里不一致」。代价是要处理时间差、状态差与重复,需定义容差与差异分类。
Q7 质量告警太多,团队开始忽略,怎么降噪?
▸ 答案:① 分级:P0 阻断、P1 工单、P2 日报,不同级别走不同通道;② 基线化:用同比/MAD 替代固定阈值,按业务日历设白名单;③ 聚合:同一根因(上游延迟)只发一条,不按表刷屏;④ 责任到 owner,告警必须带「影响哪张报表/哪个指标」;⑤ 定期复盘误报,误报率高的规则下线或调参。降噪的目标不是「少发」,而是「发的每一条都可行动」。
关联
- 前置:46.3 数据一致性分级、46.5 数据治理与血缘
- 并列:50.5 告警 (分级 / 症状 vs 原因 / runbook / 降噪)、50.7 SLO 与错误预算
- 下游:60.2 数仓分层 (ODS / DWD / DWS / ADS)、60.4 元数据管理与数据目录
- 待补:67.3 对账与差异处理(未写,无链接)