上游把 status 从数字 1/2/3 改成字符串 "PAID"/"REFUNDED",改了映射、跑了单测、发了版。三天后报表口径先崩,第五天风控策略开始误判——下游没人收到通知,而下游代码把这些值当数字做了区间判断。
这和接口不兼容改动有本质区别:接口改动会抛异常,数据改动不会。没有调用栈、没有编译期检查、没有一行报错,只有一份看起来正常但语义已经变了的表。
1. 数据契约与接口契约的四个硬差别
| 维度 | 接口契约 | 数据契约 | 直接后果 |
|---|---|---|---|
| 检查时机 | 编译期或调用时 | 读取时 | 错误延迟到消费方 |
| 失败形态 | 抛异常 / 4xx | 静默错误值、空值、口径漂移 | 无告警可依赖 |
| 变更可见性 | 服务方发版即可查 | 已落历史数据同样受影响 | 回滚数据极贵 |
| 消费方数量 | 通常可枚举 | 常不可枚举 | 通知覆盖率天然低 |
第三行最容易被低估。接口改错了回滚代码就行;数据改了,历史分区里写进去的旧值不会自己变回来,要么双写一段时间、要么写回填脚本——回填是数据契约治理里最贵的一项。
2. 契约要写死的六件事
| 要素 | 不写会怎样 | 典型事故 |
|---|---|---|
| 字段名 | 同义不同名(user_id/uid) |
join 静默丢行 |
| 类型 | 数值与字符串混用 | 区间判断永为假 |
| 枚举取值 | 新增值下游当未知处理 | 新状态计入"其他" |
| 空值语义 | 分不清"没有值"与"值为零" | 均值被拉低 |
| 唯一性 | 消费方默认主键唯一 | 重复计数 |
| 更新时效 | 假设实时,实际 T+1 | 报表与线上对不上 |
//─── 契约样例:枚举与空值语义必须显式,不靠约定
contract: order.status
version: 3
owner: trade-platform
fields:
status:
type: string
enum: ["CREATED", "PAID", "REFUNDED"]
unknown_policy: reject // reject / pass-through,必须选一个
paid_at:
type: timestamp
nullable: true
null_means: 未支付 // 空值不是"未知",要写明具体含义
freshness: T+1 04:00 // 更新时效进契约,不进口头约定
unknown_policy 决定新枚举值上线时下游是报错还是放行。两者都可以,但必须选一个并写下——不写,实际行为由每个消费方各自实现决定,同一个新值在不同系统里含义就不同。
3. 血缘:图是画的,不是测的
多数团队都有一张血缘图。它通常是人工填报的:上游在某个平台上勾选下游消费者。这类图有三个典型失真:
| 失真 | 成因 | 后果 |
|---|---|---|
| 漏报 | 有人直接连库,没走数据平台 | 图上看不到这个消费方 |
| 过时 | 任务下线了,关系没删 | 通知发给已无人维护的任务 |
| 只到任务级 | 只记"任务 A 依赖表 T" | 不知道依赖的是哪一列 |
列级血缘才回答"字段改了谁受影响",但它必须测出来而不是填出来:解析 SQL 取列引用、解析作业配置取输入输出、运行期抓实际读取的列。
-- 从查询日志里抽取列级引用,是列级血缘最可靠的来源
SELECT user_id, status, paid_at FROM ods_order WHERE dt = '${bizdate}';
//─── 从 jobs 定义与 SQL 文本批量抽取列引用,作为血缘初稿
$ rg -o -N 'SELECT[^;]+FROM\s+\w+' jobs/ | sort -u > lineage.txt
$ rg -l 'ods_order' jobs/ | wc -l // 真实引用点数,与血缘图比对
判据是覆盖率:血缘图引用点数 ÷ 实际引用点数。低于八成时,任何"我们通知下游"的承诺都不可信。
4. 发现下游影响的三条路
| 方式 | 覆盖率 | 成本 | 能发现 |
|---|---|---|---|
| 列级血缘 | 中高 | 中,需解析与日志采集 | 结构化引用 |
| 样本比对 | 高 | 低,当天可用 | 实际消费方(含直连的) |
| 字段使用统计 | 中 | 低,靠审计日志 | 哪些列真被读了 |
样本比对最被低估,因为它能抓到血缘抓不到的人:
//─── 样本比对:改动前后各取一份样本,看下游哪些表的内容随之变了
$ rg -n 'ods_order|order_status' audit/query-2026*.log | wc -l
$ diff <(cut -f1-4 before.tsv) <(cut -f1-4 after.tsv) | head
实际用法是反过来的:先建立"表内容指纹"的定期快照,字段一改,比对哪些下游表指纹变了。这条路不需要任何人填报血缘,代价是覆盖率依赖快照范围。
5. 字段废弃的顺序与"加字段"的陷阱
-- 正确顺序:三段动作,不是一次上线
-- 第 1 段 停写:上游不再产生新值,旧列仍可读;新列先并存
ALTER TABLE ods_order ADD COLUMN status_v2 VARCHAR(16) NULL COMMENT '并存期';
-- 第 2 段 停读:下游全部切到新列,观察一个完整业务周期
-- 第 3 段 删列:确认无读取后再 DDL,并按 MySQL 版本核实在线 DDL 行为
| 阶段 | 动作 | 最短观察期 | 跳过后的表现 |
|---|---|---|---|
| 停写 | 新旧并存双写 | 一个业务周期 | 切换当天发现缺字段 |
| 停读 | 下游分批切读 | 一个对账周期 | 对账不平才发现漏切 |
| 删列 | DDL 删除 | 确认读取全停 | 边缘任务突然失败 |
"先停写"最违反直觉:多数团队先让下游切读、再停上游写。先停写的意义在于——新列的数据完整性可被独立验证,而旧列还在,随时能对账。
"加字段"看起来安全,但它会破坏两类下游:
| 破坏形式 | 原因 | 表现 |
|---|---|---|
SELECT * 展开 |
消费方按位置取值 | 新列插在中间导致下标错位 |
| 位置依赖 | 导出文件按列序对接 | 列偏移一位,全表错位 |
| 结构假设 | 下游按"列数固定"校验 | 校验失败或静默跳过 |
| 序列化假设 | 下游用定长格式解析 | 截断或解析异常 |
结论:加字段算兼容改动的前提是"所有消费方都按列名取值"。这个前提被验证之前,加字段与删字段风险同级。
6. 隐式契约:比显式字段更容易出事
| 隐式契约 | 谁在依赖它 | 破坏方式 |
|---|---|---|
| 枚举会遇到的所有值 | 下游的 if/switch 与策略表 | 新增值落入"其他"分支 |
| 时间精度(秒/日) | 下游日期分组 | 改精度后分组数翻倍 |
| 分页语义(是否含边界) | 下游的增量拉取 | 边界行重复或丢失 |
| 空表与零行的区别 | 下游"无数据"判断 | 有任务无数据被当故障 |
| 主键排序相对稳定 | 下游增量水位 | 水位推进后漏行 |
| 时区 | 下游日切点 | 跨日数据归属错 |
这份清单上每一项都不是"字段",不会有任何契约文档写到它们。它们靠行为约定存在,只能用变更通知保护:任何影响这六项的改动,即使动了零个字段,也要走通知流程。
//─── 变更通知的最小字段集:没有下游清单就不能发
change_id: CHG-2026-0417
contract: order.status
kind: enum_add // enum_add / type_change / precision_change
new_values: ["CANCELLED"]
affected: // 必须非空;为空意味着血缘没覆盖,先补血缘
- risk-strategy
- report-daily
notice_deadline: 2026-11-20
7. 谁有权改契约
这条必须写死,而且必须写在一句话里:契约的变更权属于数据属主,不属于产出方,也不属于消费方。
| 角色 | 能做什么 | 不能做什么 |
|---|---|---|
| 数据属主 | 批变更、定义语义、定排期 | 改语义而不通知 |
| 产出方 | 提变更、做双写与回填 | 自己批准自己的变更 |
| 消费方 | 提需求、要求观察期 | 直连库绕过契约 |
| 平台方 | 采血缘、跑通知、校验契约 | 决定字段语义 |
"消费方不得直连库"是这套治理的地基:一旦有人直连,血缘与通知的覆盖率就不再是 100%,整条通知链失去意义。要么开只读代理视图并纳入血缘采集,要么明确这些消费者在覆盖范围之外。
8. 度量与边界
| 指标 | 计算方式 | 读数含义 |
|---|---|---|
| 契约覆盖字段比 | 已登记 ÷ 关键表字段数 | 治理覆盖面 |
| 通知覆盖率 | 实际触达 ÷ 实际存在 | 血缘准确率 |
| 下游事故数 | 因数据变更导致的下游故障数 | 唯一的最终指标 |
| 血缘准确率 | 抽检 N 条正确比例 | 通知能不能信 |
| 回填成本 | 回填行数或工时 | 变更的隐性账单 |
血缘准确率必须靠抽检:抽样十个已知存在的引用,看血缘图里有没有。这动作一小时内能完成,却决定了后面所有通知机制是否值得信任。
| 场景 | 建议 | 理由 |
|---|---|---|
| 单系统内部表 | 用代码内类型定义 | 没有跨边界,契约退化为注释 |
| 临时中间表、寿命小于一周 | 不登记 | 通知成本高于风险 |
| 只在一次任务内使用的数据 | 不登记 | 消费方唯一且可见 |
| 消费方与产出方同团队 | 用接口契约替代 | 链路短,契约成本收不回 |
| 关键表字段少于二十个 | 一页 README 代替平台 | 平台化收益低于维护成本 |
正式契约治理的成本不在采集工具,而在每次变更都要有人确认受影响清单并等观察期。这个等待期会变成交付节奏的一部分,所以它只适合"改错了要回填几个月数据"的表。改错了只需重跑一次任务的数据,正式契约是过度投入。
9. 排错:五种"改了才知道"
| 现象 | 根因 | 处理 |
|---|---|---|
| 报表数字变小但无报错 | 枚举新值落入"其他"或空值参与计算 | 查枚举与空值语义,补 unknown_policy |
| 判断逻辑突然不生效 | 类型从数值变字符串 | 比对样本值实际类型 |
| join 后行数变少 | 字段名或唯一性假设变了 | 查主键唯一性与重复行 |
| 增量任务丢行 | 分页边界或水位排序变了 | 用同一水位重跑,比对行数 |
| 有任务但无新数据 | 空表与零行语义被改动 | 明确"无数据"的表达方式 |
这些现象的共同点是:报错信息里没有一个字提到契约。排错入口不是日志,而是"这次变更动了什么"——变更通知的字段列表是第一手证据。
10. 自测
- 数据改动和接口改动,哪一种更难回滚,为什么?
- 契约里
unknown_policy不写,实际行为由谁决定? - 血缘图准确率该怎么抽检,为什么不能看填报完整度?
- 字段废弃的顺序为什么是"先停写、再停读"?
- "加字段"在什么前提下才算兼容改动?
- 哪六项隐式契约即使动了零个字段也需要走通知流程?