接口改了个字段,三个调用方同时挂。复盘时提供方说「我加了个字段而已」,调用方说「你没说」。两边都没说谎:契约的边界不取决于提供方声明了什么,取决于别人已经依赖了什么。
设计期的每个命名、每个默认值、每个错误码,都是给未来改动预先上的锁。锁上得早,演进是加字段;上得晚,演进就是停机协调。
1. 契约的四方,与各自的失效方式
契约不只存在于提供方代码里。它同时被四方持有,任一方失真都会在联调时暴露。
| 参与方 | 持有的契约 | 失效方式 |
|---|---|---|
| 提供方 | 实现与 DTO | 实现改了没同步 schema |
| 调用方 | 客户端代码与假设 | 依赖未写入文档的字段 |
| 网关 | 路由、鉴权、超时、限流 | 超时短于后端处理时间 |
| 文档 | 人读的描述 | 与实际行为漂移,无人校验 |
网关这一方最常被漏掉:提供方承诺「3 秒内返回」,网关默认 2 秒超时,契约在链路上被单方面改写。超时、重试次数、请求体大小上限都该写进契约,而不是留在运维配置里。
2. 资源建模与动词
REST 不是唯一答案。选型看主要交互形态:以实体读写为主、调用方多样,REST 合适;以动作为主、参数强类型,RPC 更顺手;调用方需要按自己的形状取数,GraphQL 才有意义。
| 风格 | 建模单位 | 适合 | 代价 |
|---|---|---|---|
| REST | 资源 + 少量动作端点 | 开放接口、多客户端 | 复杂查询会催生伪资源 |
| RPC | 方法 + 请求/响应对象 | 内部服务、强类型 | 缓存与幂等要自己设计 |
| GraphQL | 图 + 字段选择 | 多端差异化取数 | 限流与 N+1 治理复杂 |
POST /orders/{id}/cancel 动作挂在资源下,可接受
POST /orders/cancel 动作当资源,语义漂移
POST /rpc OrderService.Cancel 明确是 RPC,不要伪装成 REST
判断标准不是「哪个更 RESTful」,而是调用方能否从 URL 与状态码推断出行为。推断不出来,说明建模与真实语义脱节。
3. 命名的四条约束
| 约束 | 反例 | 代价 |
|---|---|---|
| 名词用复数、表示集合 | /order/{id} |
单复数混用,客户端拼错 |
| 布尔字段用肯定式 | disabled: false 双重否定 |
取反时读错 |
| 单位与口径进字段名 | amount、time |
元/分、秒/毫秒靠猜 |
| 同一概念全局一个词 | user 与 member 并存 |
调用方以为是两个实体 |
// 差的命名:口径全靠文档
{ "amount": 1299, "time": 1710000000, "flag": 1 }
// 好的命名:字段自带语义
{ "amountCents": 129900, "createdAt": "2024-03-09T12:00:00Z", "enabled": true }
命名是最便宜的兼容手段:改名是破坏性变更,一开始就叫对则零成本。时间字段一律带时区,用本地时间字符串是给自己埋一个跨时区才爆的雷。
4. 幂等性:方法与落点
GET、PUT、DELETE 天然幂等;POST 不幂等,而支付、下单、发消息恰恰都用 POST。补偿手段是调用方带一个请求标识,服务端按键去重。
| 落点 | 实现 | 代价 |
|---|---|---|
请求头 Idempotency-Key |
网关或过滤器统一拦截 | 需要共享存储,跨实例一致 |
| 业务单号 | 唯一索引兜底 | 只覆盖有天然单号的场景 |
| 请求体指纹 | 对关键字段做哈希 | 字段顺序与可选值要规范化 |
POST /payments HTTP/1.1
Idempotency-Key: 7f3c2a91-...
Content-Type: application/json
{ "orderNo": "A1024", "amountCents": 129900 }
落点三要素:键的存储要有时效(24 小时量级即可,按业务核对)、要存响应而不只是标记(重复请求返回首次结果,而不是空 200)、要先占位再处理(并发同键时靠唯一索引决出胜者,而不是先查后写)。
5. 分页的三种风格与深翻页
| 风格 | 参数 | 优点 | 深翻页表现 |
|---|---|---|---|
| 偏移 | offset/limit |
简单、可跳页 | 越翻越慢,OFFSET 大时扫描量大 |
| 键集 | afterId/limit |
稳定、走索引 | 快,但不能跳页 |
| 游标 | 不透明 cursor |
可封装复合排序 | 快,但游标要能解码校验 |
{ "items": [], "nextCursor": "eyJpZCI6MTAyNCwiY3JlYXRlZEF0IjoiLi4uIn0=" }
偏移分页的问题不在 SQL 语法,而在它要求数据库数过前面所有行。数据量到百万级、页面翻到几百页时,响应时间随页码线性上升。键集分页把「数过去」换成「从某一行之后取」,代价是不能跳页——产品要跳页,就得接受深翻页慢,这是取舍不是 bug。
6. 错误模型:状态码还是业务码
| 方案 | 调用方要做什么 | 代价 |
|---|---|---|
| 纯 HTTP 状态码 | 按码分支 | 业务失败种类多时码不够用 |
| 纯业务码 + 恒 200 | 先判 HTTP 再判 body | 监控、网关、重试全部失效 |
| HTTP 表达类别 + body 给细节 | 先看类别再看码 | 需要约定映射,写错就两头不靠 |
// 200 里塞错误:网关看到的是成功,重试策略无从下手
{ "code": 50012, "msg": "库存不足" }
// 分开表达:类别归 HTTP,细节归 body
HTTP/1.1 409 Conflict
{ "code": "STOCK_SHORTAGE", "message": "库存不足", "retryable": false }
「200 里塞错误」的代价是把所有基础设施变成瞎子:负载均衡的 5xx 计数、APM 的错误率、客户端的自动重试,全部拿不到信号。业务码可以留,但必须与 HTTP 类别保持一致的映射,且映射表要进契约文档。
7. 限流与配额也是契约
调用方按契约写重试逻辑,服务端却用未公开的限流把请求打回,等于契约单方面失效。
| 要素 | 做法 | 不做的后果 |
|---|---|---|
| 限额公开 | 文档写明 QPS/日配额 | 调用方按自己节奏打,互相拖垮 |
| 拒绝信号 | 429 + Retry-After |
调用方立即重试,放大拥塞 |
| 剩余额度 | X-RateLimit-Remaining |
调用方无法预判,只能撞墙 |
| 维度说明 | 按 key、按租户还是按 IP | 同一租户多实例共享额度时误判 |
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
限流值本身可以变,信号不能变。给出 Retry-After 的服务端,实际上是在替调用方决定退避节奏,比自己拍一个指数退避更准。
8. 版本策略与兼容窗口
| 策略 | 表达方式 | 优点 | 代价 |
|---|---|---|---|
| URL 版本 | /v2/orders |
直观、易路由 | 复制整棵路由树 |
| Header 版本 | Accept: application/vnd.x.v2+json |
URL 稳定 | 难调试、缓存易错 |
| 不版本化 | 只做兼容变更 | 运维最轻 | 需要严格的兼容纪律 |
// 兼容窗口用「字段寿命」表达,而不是用版本号表达
@Deprecated(since = "2024-03", forRemoval = true)
private String userName; // 保留期内双写,调用方迁移完再删
| 变更 | 类别 | 代价 |
|---|---|---|
| 新增可缺省字段 | 兼容 | 调用方须忽略未知字段 |
| 放宽约束(范围/长度) | 兼容 | 反向收紧要提前公告 |
| 新端点 | 兼容 | 补文档与鉴权即可 |
| 改类型 / 改语义 / 删字段 / 收紧校验 | 破坏 | 需要版本或迁移窗口 |
判据只有一条:已上线调用方在不通知、不配合的情况下会不会失败。不会,就不该开新版本。开版本的代价不是多写一个 controller,而是此后每个修复要在 N 条分支上重复。
9. 弃用与文档一致性
弃用不是发公告,是给调用方一个可执行的迁移路径和一个可观测的截止日期。
| 动作 | 落点 | 作用 |
|---|---|---|
| 响应头提示 | Deprecation、Sunset |
调用方在日志里能看到 |
| 调用量统计 | 按版本/字段分组 | 判断能否真的关停 |
| 灰度关停 | 先对内部调用方关,再对外 | 把事故限制在可控范围 |
| 文档同步 | 生成而非手写 | 防止文档与实现漂移 |
# 契约以代码为准:CI 里比对 schema,破坏性变更直接失败
$ openapi-diff old.yaml new.yaml --fail-on incompatible
$ oasdiff breaking old.yaml new.yaml
文档手写必然漂移,因为没人会因为改文档而被打回。让 schema 从代码生成、让 CI 校验破坏性变更,是把一致性从人的自觉变成流水线的一部分。
10. 契约测试能测到什么
| 能测 | 测不到 |
|---|---|
| 字段名、类型、必填性 | 字段语义是否被正确理解 |
| 提供方是否满足既有约定 | 调用方是否忽略未知字段 |
| 枚举新增是否破坏消费方 | 超时、重试、幂等是否真的生效 |
契约测试的价值在于把「我以为」变成「CI 说」,它替代不了联调,也替代不了对语义的评审。只有一方写契约、另一方验证的流程,才叫契约测试;两边各自 mock 自己,测的是想象。
11. 排错:五种「文档没错但就是不通」
| 现象 | 原因 | 处理 |
|---|---|---|
| 调用方解析失败 | 新增字段未标注可选 | schema 里显式 required |
| 重试导致重复扣款 | 幂等键未落库或未占位 | 先写键再处理,唯一索引兜底 |
| 列表越翻越慢 | 偏移分页遇到大表 | 改键集,或限制最大页深 |
| 429 后雪崩 | 无 Retry-After,客户端立即重试 |
补退避信号并公开配额 |
| 网关 504 但后端成功 | 网关超时短于业务耗时 | 超时进契约,与 SLA 对齐 |
12. 边界:什么情况不必上这套
| 场景 | 结论 | 理由 |
|---|---|---|
| 内部单调用方 | 不版本化,靠同步发布 | 协调成本低于版本维护成本 |
| 短期活动接口 | 只保证字段可加 | 生命周期短于弃用流程 |
| 原型阶段 | 允许破坏性变更 | 过早固化会锁死错误建模 |
| 纯内部 RPC | 契约由 IDL 保证 | 再加一层文档是重复 |
| 单机脚本调用 | 无需契约 | 没有第二方 |
契约的强度应与调用方数量 × 变更频率 × 故障代价匹配。三个调用方、每季度改一次、挂了能手动补,就不需要版本树;三十个调用方、每周发版、挂了要赔钱,连命名都得开会定。
13. 自测
- 契约的四方里,哪一方最容易被漏掉,漏掉后以什么形式暴露?
- 什么信号说明「该上 RPC 而不是 REST」?
Idempotency-Key为什么必须「先占位再处理」?- 偏移分页在深翻页时慢在哪一层,键集分页用什么换掉了这个成本?
- 「200 里塞业务错误」损害的是哪三类基础设施能力?
- 判断一次改动是否需要新版本,只用哪一问?