1. 先把 SGDC 说明白:它到底在解决什么问题
SGDC 这个词第一次出现在我们周会白板上时,会议室里有一半人以为是某个新出的开源组件。它其实是我们团队内部对Schema-Guided Data Contract(模式引导的数据契约)的简写,说白了就是一份用机器能读的格式写下来、能自动校验、能进流水线的接口与数据约定。它要解决的问题很朴素:两个系统之间传数据,到底谁说了算、字段长什么样、少了字段算不算错、改了字段会不会把别人搞挂。只要是两个人以上协作、数据要跨进程或跨团队流动的场景,SGDC 就有存在的价值。这篇文章适合三类人看:正在被接口对接反复扯皮的开发、要维护数据仓库和报表口径的数据同学、以及刚接手一堆"历史遗留接口"不知道从哪儿下手的维护者。我会从设计思路、字段规则、实操写法、流水线接入一直到排错实录,把一份能真正跑起来的 SGDC 拆开讲一遍,里面很多细节是文档里不会写、但踩过一次就忘不掉的东西。
1.1 从一个字段改名引发的连环故障
去年我们遇到过一次挺典型的故障。上游服务做了一次"无感知重构",把用户标识字段从user_id改成了uid,他们自己的单测全绿,发布也很顺利。问题出在第二天早上:下游的报表任务把解析失败的行直接丢了,报表数字从几十万掉到几千,业务方在群里问了一上午,我们才从日志里翻出那几千条KeyError。事后复盘,所有人都同意"这是一个沟通问题",但我不太接受这个结论。沟通问题的本质是没有把约定变成可执行的检查——约定只存在于某次口头同步和一份三个月没更新的文档里,那它就不算约定,只能算记忆。
这类事故的修复方式往往很低效:加个群、发个通知、要求"以后改字段提前说一声"。人的记忆和自觉性是靠不住的,尤其是跨团队、跨时区、迭代节奏不一样的时候。真正有效的做法是把这份约定写成一个文件,放进代码仓库,让改字段这个动作必须先改契约、契约不通过校验就发不出去。这就是 SGDC 最核心的价值:它把"我们商量好的"变成了"机器拦住你的"。
1.2 契约、文档、注释:三者到底怎么分工
很多人第一次接触契约概念时会问:这不就是接口文档吗?我用文档工具生成一份不也一样?差别在于服务对象不同。文档是给人看的,契约是给机器看的。给人看的东西必然允许模糊,比如"这个字段是用户编号",人可以理解;但机器看到这句话什么也做不了,它需要知道这是整数还是字符串、长度上限多少、能不能为空、取值有没有枚举范围。
字段上的注释和契约也不是一回事。注释是局部说明,通常跟着某段代码走,只对维护这段代码的人有意义;契约是全局约定,是对外承诺,跟代码在哪、怎么实现没关系。我一般的分工是这样的:契约负责结构、类型、必填性和取值范围,是硬约束,违反就报错;注释负责解释"为什么这么设计",比如某个字段为什么用整数存金额而不存浮点,这是给人读的;文档则由契约自动生成,永远不手写,因为手写文档一定会滞后。三者各司其职,最忌讳的是拿其中一种去顶替另一种,比如在注释里写约束条件,或者在文档里写"大概三十位左右",这类模糊表述在工程上等于没有。
1.3 哪些团队现在就该上手 SGDC
不是所有项目都需要写契约。我判断的标准有三条,满足任意两条就值得投入:接口的消费方超过三个、数据要进数据仓库或对外输出、这个接口的生命周期超过半年。反过来,一个人写的原型、一次性的数据迁移脚本、内部用完就扔的临时工具,写契约纯属给自己加负担——契约是有维护成本的,字段改了要同步改契约、要过兼容性检查、要走评审,小项目扛不住这个开销。
另外还有一类场景经常被忽略:同一个人写的上下游。很多人觉得我自己写的生产者和我自己写的消费者,还用得着写契约?我的经验是恰恰需要,因为"我自己"这个前提在半年后就不成立了,那时候你已经忘了当时为什么把某个字段设计成可空。写下契约,本质上是写给未来的自己看的一份承诺书。所以我的建议不是"所有项目都上契约",而是"凡是需要考虑兼容性的地方,都应该有契约",哪怕这份契约最初只有二十行。
2. 动笔之前:SGDC 的结构设计怎么定
写契约最容易犯的错,是打开编辑器就开始敲字段名。我见过不少第一版契约,字段定义写得挺全,但结构一团乱:元信息散落在注释里、示例和规则混在一起、命名一会儿下划线一会儿驼峰。这种契约三个月后没人愿意维护,因为改一个字段要先花十分钟找到它。所以在敲代码之前,我会先花二十分钟把骨架定下来。骨架定好了,后面填内容就是机械劳动,谁来填都一样。
2.1 命名先定骨架:三层结构最省心
我推荐的骨架是三层:元信息层、字段定义层、约束与示例层。元信息层只放跟数据内容无关的东西——契约名、版本号、负责人、最后修改时间、这个契约对应的服务名。字段定义层是主体,一个字段一条记录,包含名称、类型、是否必填、描述。约束与示例层放正则、枚举、数值范围、以及至少一条完整可用的样例数据。三层分开的好处是,校验工具可以只读前两层做快速检查,人在评审时重点看第三层,职责清晰。
命名风格这件事没有绝对的对错,但必须在整个组织里统一。我待过的团队有过一次惨痛教训:Java 服务导出的是驼峰userId,Python 服务导出的是下划线user_id,前端拿到的 JSON 里两种都有,最后是靠一个转换层硬扛了一年。我的建议是对外传输统一用一种风格,内部实现随便。如果团队没有强偏好,选下划线,因为在数据库、SQL、数据分析工具里它是通用写法,少一次映射就少一次出错的机会。命名本身也要有意义,status不如order_status明确,type这种词更是重灾区,一个订单对象里出现三个type是常事,最后没人说得清哪个是哪个。
2.2 类型与精度:金额、时间、枚举最容易翻车
字段类型看着简单,实际上大部分线上数据问题都出在这三样上。我整理过一份清单,每次写契约都会对一遍。
| 数据种类 | 推荐写法 | 不推荐写法 | 原因 |
|---|---|---|---|
| 金额 | 整数(最小货币单位)或十进制字符串 | 浮点数 | 浮点累加会产生误差,对账时对不上 |
| 时间 | ISO 8601 带时区偏移的字符串 | 无时区的本地时间字符串 | 跨时区场景下含义不明确 |
| 时间戳 | 毫秒级整数并注明单位 | 秒/毫秒混用 | 单位缺失会导致相差一千倍 |
| 枚举 | 显式列出全部取值,并预留 unknown | 自由字符串 | 上游新增取值时下游会直接崩 |
| 布尔 | 真布尔类型 | "Y"/"N"、0/1 | 各语言解析行为不一致 |
| ID | 字符串 | 整数 | 超过安全整数范围会精度丢失 |
这张表里的每一条我都能讲出一个真实案例。金额用浮点这一条,出问题的概率接近百分之百,因为单笔计算看不出偏差,一旦做汇总聚合,误差就会慢慢累积,最后财务报表和业务后台差几块钱,查起来要命。ID 用整数也有类似的问题,某些语言里整数超过某个范围会丢精度,两个不同的 ID 解析出来变成同一个,这种 bug 极难排查。
枚举这一条我要多讲两句。我习惯在每个枚举里加一个unknown或者other,即使当前业务上不可能出现。原因是枚举的演进方向永远是"上游加值、下游不认识",加一个兜底取值,下游至少能把数据存下来而不是直接报错丢弃。这不是偷懒,而是一种演进策略:先保证数据不丢,再保证语义准确。
2.3 必填与可空:required 不等于非空
这是新手最容易混淆的一组概念,我见过不少人把它们当成一回事。它们其实是三个独立的维度:字段是否存在(required)、存在时值是否可以为空(nullable)、不存在时用什么默认值(default)。一个字段完全可以做到"必须出现,但值可以是空",比如deleted_at,删除时间必须传,没删除时传空;也完全可以做到"可以不出现,出现就必须有值",比如某些可选配置项。
我的一般原则是:新增字段一律先设为可选。原因很实在——契约改了要立刻生效,但所有生产者的发版节奏你控制不了,只要有一个生产者没升级,它发出来的数据就没有这个字段,契约写死必填,那就是自己给自己制造故障。等观察到所有生产者的流量里都稳定带上这个字段了,再把它改成必填,这才是安全的推进顺序。
反过来,真正的核心字段必须一开始就设成必填,比如订单号、用户标识、业务流水号。这些字段缺失的数据本身就是脏数据,让它进系统只会污染下游。判断标准可以简单点:这个字段缺了,这条数据还有没有意义?没意义就必填,有意义就选填。
2.4 版本演进:v1 就要预埋的东西
契约的版本号放在哪里,其实有讲究。我倾向于把版本号放在契约文件本身的元信息里,而不是放在字段名或者 URL 里。见过太多user_id_v2这种命名,两年后字段名长得像绕口令。URL 里放版本号也有问题,它逼着消费者改地址,而大部分改动其实是兼容的,没必要惊动所有人。
决定版本号怎么递进之前,先要有一套兼容性判定规则,这套规则最好在写 v1 的时候就定下来,写进 README 里。我自己用的规则是:加可选字段算兼容、加枚举取值算不兼容、删字段算不兼容、改类型算不兼容、改字段名等同于删旧加新。这套规则看起来简单,但它是后面所有自动化检查的基础。没有它,评审时每个人都有自己的判断,讨论就变成了吵架。
另外,从 v1 开始就要给"废弃"留位置。我会在字段定义里预留一个deprecated标记位和deprecated_since字段,第一次写的时候全空着,等真要下线某个字段时,先把它标成废弃,跑一段时间观察,再删。这个过程后面第 4 章会详细讲,但前提是契约结构里得先有这个字段,不然到时候只能靠文档口口相传。
3. 实操:一份能直接跑起来的 SGDC 怎么写
纸上谈兵到此为止,下面我把一份完整的契约从零写一遍。我选 JSON Schema 作为载体,原因是它的生态最成熟:校验器多、代码生成工具多、几乎所有语言都有现成的库。如果你用的是 Protobuf 或 Avro,思路完全一样,只是语法不同。这一章里的代码都可以直接复制到项目里跑,我把每一步的意图和容易踩的点都标出来。
3.1 用 JSON Schema 写第一版契约
先看骨架部分。我把元信息统一放在$comment或者自定义扩展字段里,因为标准的 JSON Schema 关键字是固定的,塞自定义信息容易被校验器警告。
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.internal/contracts/order-created/1.0.0", "title": "order_created", "description": "订单创建事件。由订单服务生产,消费者包括履约、结算、数仓。", "x-owner": "order-platform@example.internal", "x-version": "1.0.0", "type": "object", "additionalProperties": false, "required": ["order_id", "user_id", "amount", "currency", "created_at"], "properties": { "order_id": { "type": "string", "minLength": 1, "maxLength": 64, "description": "订单唯一标识,全局唯一,不可复用" }, "user_id": { "type": "string", "minLength": 1, "description": "下单用户标识" }, "amount": { "type": "integer", "minimum": 1, "description": "订单金额,单位为分。整数存储避免浮点误差" }, "currency": { "type": "string", "enum": ["CNY", "USD", "HKD", "unknown"], "description": "币种,三位大写代码,未知币种统一填 unknown" }, "created_at": { "type": "string", "format": "date-time", "description": "创建时间,ISO 8601 带时区偏移,例如 2024-05-01T10:00:00+08:00" } } }几个关键点值得单独说明。第一,additionalProperties: false这一行很关键,它表示"契约里没定义的字段一律不允许出现"。有人担心这会太严格,导致生产者多加一个字段就报错。我的看法是这恰恰是我们要的:多加字段本质上是在偷偷改契约,必须被发现。如果确实要加,走正式流程加进契约,这不难。反过来说,如果不设这一条,生产者偷偷加字段、消费者偷偷依赖这个字段,形成一份"影子契约",那才是真正的隐患。
第二,amount用integer加minimum: 1,配合描述里写清单位是分。这个设计看着土,但它救过我们不止一次。有一次下游某同学直接按"元"来理解,做出来的报表金额差了一百倍,好在契约里写死了单位,我们对着一看就定位了。
第三,currency的枚举里我加了unknown。这不是业务上的真实币种,纯粹是为了让上游新增币种时下游不崩。这种做法在很多团队会引发争论,觉得不够"干净"。我的态度是,工程上的干净是相对的,能容错的契约比理论上完美的契约活得久。
3.2 校验规则、枚举与示例的写法
骨架搭好之后,把条件约束和示例补上。示例这一块经常被省略,我觉得非常可惜,因为示例是契约里唯一能被人快速理解的部分。新人接手时不会逐个字段读描述,他会先看examples里的那条数据,一眼就明白这条消息长什么样。
{ "properties": { "order_id": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,64}$", "description": "只允许字母数字下划线中划线,不接受空格与其他符号" }, "status": { "type": "string", "enum": ["created", "paid", "shipped", "closed", "unknown"], "default": "created" }, "items": { "type": "array", "minItems": 1, "maxItems": 500, "items": { "type": "object", "required": ["sku_id", "quantity"], "properties": { "sku_id": { "type": "string", "minLength": 1 }, "quantity": { "type": "integer", "minimum": 1, "maximum": 9999 } } } } }, "examples": [ { "order_id": "ORD-20240501-0001", "user_id": "U10086", "amount": 19900, "currency": "CNY", "status": "paid", "created_at": "2024-05-01T10:00:00+08:00", "items": [ { "sku_id": "SKU-1001", "quantity": 2 } ] } ] }这里的maxItems和maxLength是我吃过亏之后加的。一开始觉得没必要的上限,往往就是某次异常流量把下游打挂的入口。有次上游一个循环写错了,一口气推了三十万条明细,下游内存直接爆掉。加了上限之后,这类数据在入口就被拦住了,报错信息也清楚,不用人去猜。上限怎么定?我的方法是拿过去一段时间的真实数据统计一个 P99.9 值,再往上留三到五倍余量,既能拦住异常,又不会误伤正常业务。比如明细条数 P99.9 是 80 条,那设 500 就很宽松了。
pattern这个约束也要谨慎使用。它很强大,但正则写错会误伤正常数据,而且错误信息往往不友好。我的经验是只在有明确外部规范的地方用,比如单号格式、手机号格式,其余场景用minLength和maxLength就够了。写正则之后一定要拿真实数据回放一遍,我见过\d和[0-9]在某些实现里行为差异导致漏校验的情况。
3.3 从契约生成代码与文档
契约写完之后不要手写对应的实体类,一定要生成。手写必然跟契约脱节,而且改一次要改好几处。Python 生态里我常用datamodel-code-generator从 JSON Schema 生成 Pydantic 模型,Node 生态可以用json-schema-to-typescript,Java 用jsonschema2pojo。
# 生成 Python 模型 datamodel-codegen \ --input contracts/order-created/1.0.0.json \ --input-file-type jsonschema \ --output src/generated/order_created.py \ --output-model-type pydantic_v2.BaseModel # 生成 TypeScript 类型 npx json-schema-to-typescript \ contracts/order-created/1.0.0.json \ > src/types/order-created.d.ts生成的代码要提交到仓库,但要在文件头加注释标明"自动生成,请勿手工修改",并在流水线里加一步校验:重新生成一遍,如果有差异就报错。这一步能挡住很多人偷偷改生成文件的行为。
文档同理,不要手写。我用json-schema-for-humans或者简单点的widdershins,从契约直接产出 HTML 或 Markdown,挂到内部站点上。这样做最大的好处是文档永远不会过期,因为它就是契约本身渲染出来的。以前我们维护文档要专门排期,现在这一步直接省掉了。
3.4 把契约挂进流水线
契约放在仓库里不校验,等于没写。我一般会在流水线的第一个阶段加两道关:格式与示例校验、兼容性检查。
# 1. 校验所有契约文件本身合法,且示例数据符合各自的 schema npx ajv-cli validate \ -s contracts/**/*.json \ -d "contracts/**/examples/*.json" \ --strict=false # 2. 与主干分支上的旧版本做兼容性对比 npx openapi-diff \ https://git.internal/contracts/order-created/1.0.0.json \ contracts/order-created/1.1.0.json \ --fail-on-incompatible第一道关很好理解,就是把契约本身和示例都跑一遍校验。这里有个细节:示例数据一定要单独存成文件而不是内联在 schema 里,这样校验命令能直接读取,也方便做回归。第二道关是关键,用工具把当前分支的契约和主干上的旧版本对比,一旦检测到破坏性改动直接让流水线红掉。工具会输出具体哪个字段的哪个属性变了,开发一看就懂,不需要开会讨论。
注意:兼容性工具不是万能的,它对"字段语义变了但类型没变"这类改动无能为力。比如
amount从"分"改成"元",类型还是整数,工具检查不出来。这类改动只能靠人评审,所以契约的变更评审环节不能省。
3.5 契约测试:让上下游真的按契约跑
流水线校验解决的是"契约本身写得对不对",但生产者实际发出来的数据符不符合契约,还得靠运行时的校验。我通常会在两个地方插卡点。
生产侧,在序列化之后、发送之前校验一次。这一步的意义是尽早发现自己的 bug,别把脏数据推出去。校验失败直接抛异常,让这次发送失败,配合重试或者进死信队列,问题不会扩散。消费侧,在反序列化之后立刻校验一次。这一步的意义是保护自己,如果上游破坏了契约,我能立刻知道并且把这条消息单独存起来,而不是让脏数据流进业务逻辑。
from jsonschema import Draft202012Validator validator = Draft202012Validator(schema) def consume(raw: dict) -> None: errors = sorted(validator.iter_errors(raw), key=lambda e: e.path) if errors: # 不丢弃,写入隔离区,带原始数据和错误原因 quarantine.write(raw, [e.message for e in errors]) metrics.increment("contract_violation", tags={"contract": "order_created"}) return handle(raw)这里我特意不抛异常丢弃,而是写进隔离区。原因很简单:丢弃会让数据静默消失,隔离会让问题显性化。隔离区里堆了几条,看一眼监控面板就知道,然后人工决定是回补还是丢弃。这个设计让我们从"第二天业务方发现数字不对"变成了"五分钟内监控告警"。
4. 踩坑与排错:SGDC 落地后最常遇到的六类问题
契约上线不等于万事大吉,真正的麻烦往往出现在它运行半年之后。这一章我把这些年遇到的高频问题整理出来,包括兼容性判定、字段废弃流程和一张排错速查表。这些问题在任何一个有一定规模的系统里都会遇到,提前知道怎么处理能省下大量时间。
4.1 兼容性判定:哪些改动算破坏性
| 改动类型 | 是否兼容 | 处理方式 |
|---|---|---|
| 新增可选字段 | 兼容 | 直接发布,小版本号加一 |
| 新增必填字段 | 不兼容 | 禁止;若必须,先设为可选再逐步收紧 |
| 删除字段 | 不兼容 | 走废弃流程,观察期结束后再删 |
| 字段重命名 | 不兼容 | 等同于删旧加新,双写过渡 |
| 放宽约束(如上限变大) | 兼容 | 直接发布 |
| 收紧约束(如上限变小) | 不兼容 | 先统计真实数据分布,确认不误伤再改 |
| 枚举新增取值 | 不兼容 | 优先在上游做映射,或下游预留 unknown |
| 字段类型变更 | 不兼容 | 新增新字段,旧字段走废弃 |
| 仅修改描述文字 | 兼容 | 直接发布 |
这张表最容易被忽视的一行是"枚举新增取值"。很多人的直觉是加一个取值怎么会不兼容?但如果下游用的是严格校验,遇到不认识的取值就会直接报错。这就是为什么我在契约里坚持留unknown:有了兜底取值,上游加值这件事的破坏性就被大幅削弱了。
另一行是"收紧约束"。放宽好办,收紧麻烦,因为现网里可能已经存在超出新上限的数据。我的做法是先跑一段时间的统计,把字段的真实取值范围摸清楚,再决定新的上限。凭感觉设上限是很容易翻车的,尤其是一些看起来规规矩矩的字符串字段,比如商品名称,实际上可能有非常长的例外数据。
4.2 字段废弃与双写过渡期
废弃字段是契约演进里最容易拖成烂尾的环节。我的流程分四步,缺一步都会出问题。
第一步,在契约里把字段标记为deprecated,并写明替代字段和计划下线时间。这一步只是加标记,不影响任何运行逻辑,关键是让所有人看得见。第二步,生产侧开始双写,新旧字段同时发送,保证还没升级的消费者不受影响。第三步,打开监控,观察旧字段的实际消费情况。怎么观察?可以在旧字段上加埋点,谁读了这个字段就记一笔,跑两周基本能确定还有哪些消费者在用。第四步,确认没有消费者之后,从契约里删除字段,同时删除生产侧的写入逻辑。
这四步里最容易跳过的是第三步。不少人觉得"我已经通知了,大家应该都改完了吧",直接进第四步,结果某个冷门任务挂了。我吃过这个亏,一个季度才跑一次的结算任务用了旧字段,谁都没想起来。所以我现在一律要求观察期不少于一个完整的业务周期,月度任务就观察一个月,季度任务观察一个季度。听起来慢,但比出事故快。
4.3 排错速查表
| 现象 | 可能原因 | 定位方法 | 处理方式 |
|---|---|---|---|
| 消费侧大量解析失败 | 上游发了契约外字段或改了类型 | 打日志输出原始消息,本地跑一次校验器 | 隔离脏数据,联系上游回滚 |
| 某字段整体为默认值 | 生产者没升级,没发这个字段 | 看生产者版本号和发布记录 | 回滚契约的必填设置或等生产者升级 |
| 契约校验通过但数据不对 | 语义变了,类型没变 | 抽样对比新旧数据分布 | 人工评审,走版本升级 |
| 流水线一直报兼容性问题 | 对比基准版本选错 | 检查对比的旧版本号 | 换成主干上正确的发布版本 |
| 生成代码与运行时行为不一致 | 生成文件没重新生成 | diff 一下生成结果 | 重新生成并提交 |
| 校验耗时明显上升 | schema 里有复杂正则或深层嵌套 | 打点统计单条校验耗时 | 简化正则,拆分嵌套结构 |
排在最后一行的问题是我最近才注意到的:契约写得太复杂会拖慢运行时的校验速度。有一份契约里嵌了五层数组和对象,单条消息校验要十几毫秒,高吞吐场景下直接成了瓶颈。后来我把嵌套结构打平了一层,正则也简化了,耗时降到两毫秒以内。这件事提醒我,契约也不是越详尽越好,写得越复杂,维护和运行的代价越高。
5. 协作与维护:让 SGDC 不变成一堆死文件
技术方案本身不难,难的是让它活下来。我见过太多团队轰轰烈烈搞了一轮契约治理,三个月后契约文件和代码脱节,没人再更新,最后变成了一份谁也不敢删的历史文件。这一章讲的是协作机制,包括谁写谁评审、仓库怎么组织、版本号怎么管。这部分内容看起来"不技术",但它决定了你的 SGDC 能不能撑过第一年。
5.1 谁写、谁评审、谁拍板
一个常见误区是把契约的编写责任推给消费者。理由听起来合理:消费者最清楚自己需要什么。但实践下来问题很多,因为消费者不了解生产者的实现约束,容易提出做不到的要求,扯皮成本极高。我的做法是由生产者主笔,消费者参与评审,双方的技术负责人共同拍板。
生产者主笔的理由是,契约描述的终究是生产者能提供什么,生产者对自己的数据最清楚。消费者评审的职责不是改字段,而是提出使用场景下的疑虑,比如"这个字段可能为空吗?""新增取值多久通知一次?"拍板机制也很重要,很多争议其实不是技术问题而是责任问题,比如要不要保留某个没人用的字段,这时候需要有个明确的决策人,否则讨论会无限期拖下去。
还有一个细节:评审一定要看示例数据,不能只看字段列表。字段列表看十遍也不如一条真实的示例数据来得直观。我要求每份契约评审时必须附上至少三条示例,分别覆盖正常数据、边界数据(比如最大值)、异常数据(比如字段为空)。评审人对着这三条数据过一遍,问题基本能暴露出来。
5.2 仓库结构与版本号怎么管
契约放在哪里,我倾向于独立的契约仓库,而不是散落在各个服务仓库里。理由是契约本质上是多方共享的资产,放在某一个服务的仓库里,其他方改起来要跨仓库提 PR,心理成本和流程成本都高。独立仓库还有个好处,可以给整个仓库设置统一的 CI 规则,任何人都绕不过去。
目录结构我一般按"契约名/版本号"来组织。
contracts/ ├── order-created/ │ ├── 1.0.0.json │ ├── 1.1.0.json │ ├── examples/ │ │ ├── normal.json │ │ └── boundary.json │ └── CHANGELOG.md ├── order-paid/ │ └── ... └── README.md版本号我用语义化版本:不兼容改动升主版本,兼容的新增升次版本,文字修改升修订号。这里有个经验:主版本号不要轻易升,因为每升一次意味着所有消费者都要动。能用次版本解决的问题就不要升主版本,比如新增字段一律走次版本。我们仓库里跑了一年多,主版本只升过两次,都是因为业务模型发生了根本性变化。
CHANGELOG 也是必需品,但不要手写流水账。我的做法是在 CI 里对比两个版本的 JSON,自动生成"新增了哪些字段、删除了哪些字段、哪些字段的约束变了"的差异列表,追加到 CHANGELOG 里。自动生成的记录不会有情绪化的描述,也不会有遗漏,谁看都一目了然。
5.3 我从几次事故里总结的几条经验
最后分享几条实操中攒下来的经验,都是踩过坑之后才想明白的,跟标准文档里的建议不太一样。
第一条,契约要先于代码上线。生产者改代码和改契约的顺序,应该是先改契约、走评审、合并,再改代码发版。顺序反过来的话,会有一段时间代码行为和契约不一致,如果这段时间正好有新人照着契约写消费者,就会踩坑。我们有次就是这个顺序,代码先发了,契约过了一周才合并,那一周里下游按旧契约写的解析逻辑全部报错。
第二条,示例数据要定期用真实数据替换。契约里的示例写久了会失真,因为业务在变,字段的实际取值范围也在变。我现在每季度从生产环境脱敏抽一条真实数据,替换掉示例。这一步花不了几分钟,但能让示例一直保持"像真的"。
第三条,不要为了统一而统一。曾经有个阶段我们试图把组织内所有契约的命名、结构、层级全部对齐,结果花了两个月,收获甚微,还耽误了业务迭代。后来我改了策略,只统一三件事:命名风格、必填与可空的判定规则、版本号规则。其余的结构细节各团队自己定。契约治理的目的是减少沟通成本,如果治理本身变成了最大的沟通成本,那就本末倒置了。
第四条,给契约配上监控指标。契约不是写完就完了,它需要被观测。至少要有这么几个指标:校验失败次数、隔离区积压量、每个字段的实际填充率。填充率这个指标特别有用,它能告诉你哪些字段实际上没人用,为后续的废弃提供依据。我们靠这个指标清掉了十几个"僵尸字段",全程没有出过任何问题,因为数据说话,谁也没法反驳。
我自己维护契约这套东西快三年了,最大的体会是它不解决所有问题,但能解决最让人头疼的那一类问题——跨团队的、无声的、事后才发现的数据不一致。刚开始推的时候阻力不小,有人觉得是额外负担,直到出过两次由字段变更引发的事故之后,大家的接受度明显高了。现在新接口立项,写下第一版契约已经成了默认动作,不是流程要求,而是习惯。如果你的团队正准备开始,我的建议是从一个最关键、消费者最多的接口切入,把它做扎实,跑通整套流程,再往外推,比一上来铺开十个接口要靠谱得多。