联调最常出现的场景:对方说"返回 200 就是成功",你返回 200 但 body 里 success=false;对方把 status 当成订单状态,其实是支付状态;超时后对方重试三次,你扣了三笔。
这些都不是沟通问题,是契约没写清。契约不是接口签名——签名只说明"怎么调用",契约要说明"调用之后能依赖什么"。这篇讲怎么把这份承诺写成可执行的东西。
1. 签名不等于契约
| 只写签名 | 写清契约 |
|---|---|
POST /orders |
幂等吗?重复提交返回什么? |
CreateOrderResult create(CreateOrderCmd cmd) |
cmd 里哪些字段必填?取值为空是什么意思? |
Return 200 |
200 代表业务成功还是仅代表收到? |
throws OrderException |
调用方该重试、该提示用户,还是该报障? |
契约 = 调用方能依赖的全部约定,五类都要写:输入、输出、错误、时序、性能。
一份"看起来完整"的文档常缺的是后两类:出错之后怎么办,慢的时候算不算正常,而这恰是事故高发区。
2. 契约必须写明的五件事
| 项 | 必须回答的问题 | 不写的后果 |
|---|---|---|
| 输入范围 | 哪些字段必填、长度/精度上限、空值的含义 | 调用方传 "",服务端当有效值处理 |
| 输出含义 | 每个字段的业务语义,空集合与 null 的区分 | 前端把 null 当"没有",其实是"未查询" |
| 错误分类 | 哪些错可重试、哪些错要改参数、哪些错要报障 | 一律重试,把参数错误重试成雪崩 |
| 幂等性 | 重复调用安全吗?幂等键是什么、窗口多久 | 超时重试造成重复扣款 |
| 超时行为 | 服务端多久放弃、放弃后状态是什么 | 调用方超时重试,服务端仍在执行 |
第一行的"空值含义"最常缺失:没传、传了空、查不到是三种不同语义,返回必须能区分。
// 输入:显式区分三种空
{ "couponCode": null } // 不指定优惠券
{ "couponCode": "" } // 非法,报参数错误
{ "couponCode": "AB12" } // 指定
3. 错误契约:可区分、可行动、带上下文
错误设计的目标不是"表达出错了",而是让调用方能决定下一步做什么:调用方的错该改参数,服务方的错可重试,不确定的错先查状态。
| 错误类别 | 谁的问题 | 调用方该做什么 | 典型 HTTP |
|---|---|---|---|
| 参数错误 | 调用方 | 改参数,不要重试 | 400 |
| 认证/授权失败 | 调用方 | 换凭证,不要重试 | 401/403 |
| 资源不存在 | 调用方 | 检查 ID,不要重试 | 404 |
| 冲突/状态非法 | 调用方 | 重新读取状态再决定 | 409 |
| 限流 | 双方 | 退避后重试 | 429 |
| 服务端错误/超时 | 服务方或未知 | 有限次重试 + 报障;超时先查状态 | 5xx/504 |
三条硬要求:code 是稳定枚举(调用方可以 switch,不要复用同一个 code 表示两种错);message 面向人(可改文案,调用方不得解析它做逻辑);details 给程序(带定位问题需要的字段与 traceId)。
// 错误响应:code 稳定、message 给人看、details 给程序用
{
"code": "ORDER_STATUS_INVALID",
"message": "订单已支付,不能重复支付",
"details": { "orderId": "O-1024", "currentStatus": "PAID", "requiredStatus": "CREATED" },
"traceId": "b7c1e9f0"
}
最常见的坏做法是"HTTP 200 + success:false"表示全部失败:网关、监控、重试库都按状态码判断,这个约定把它们全骗了。要么统一用语义化状态码,要么在契约里声明"业务错误一律 200 看 code"。
4. 幂等:重复请求的返回值也得写清
幂等的定义:同一个请求执行一次与执行多次,对系统状态的影响相同——注意是"状态影响相同",不是"返回值相同"。实现前提有三个,缺一不可:
| 前提 | 说明 | 缺了会怎样 |
|---|---|---|
| 幂等键 | 由调用方生成,全局唯一(如 requestId、业务单号) |
服务端无法识别"这是同一次请求" |
| 去重窗口 | 保留多久的键记录(典型量级:分钟到天,按业务重试跨度定) | 窗口太短,重试穿透;太长,存储膨胀 |
| 返回值缓存 | 重复请求返回首次结果,而不是再算一遍 | 调用方拿到矛盾的结果 |
// 幂等键 + 结果缓存的最小实现
public Result createOrder(String requestId, CreateOrderCmd cmd) {
var cached = idemStore.get(requestId); // 去重窗口内的历史结果
if (cached != null) return cached; // 直接返回首次结果,标记 replay
return idemStore.computeIfAbsent(requestId, () -> doCreate(cmd));
}
契约里要写清三件事:幂等键是哪个字段、窗口多久、重复请求返回首次结果还是409 冲突——必须选定一种,否则调用方没法写重试逻辑。
5. 向后兼容的三条规则
| 变更 | 安全吗 | 说明 |
|---|---|---|
| 加字段(可选、有默认) | 安全 | 老客户端忽略未知字段,解析要宽松 |
| 加枚举值 | 有风险 | 老客户端 switch 无 default 会崩 |
| 改字段语义(同名改含义) | 不安全 | 老客户端静默算错,最难排查 |
| 改字段类型(int → string) | 不安全 | 反序列化直接失败 |
| 收紧校验(必填化、长度变小) | 不安全 | 原本能过的请求开始失败 |
1. 加:只加可选字段,解析必须宽松(忽略未知字段)。
2. 改语义:一律视为不兼容,走新字段或新版本。
3. 删:先废弃并观测,确认无流量后再删,至少跨一个发布周期。
"加枚举值"是新手最容易踩的隐雷:看起来只多一个选项,实际会打崩所有没写 default 的客户端。
6. 版本策略:两种做法与各自的代价
| 策略 | 形式 | 优点 | 代价 |
|---|---|---|---|
| URL 版本 | /v1/orders、/v2/orders |
直观、易路由、易做灰度 | URL 膨胀,资源身份被版本切碎 |
| 内容协商 | Accept: application/vnd.x.v2+json |
URL 稳定,语义干净 | 调试不直观,网关/缓存常配错 |
| 字段级演进 | 只加不改 | 维护成本最低 | 需要极强纪律,包袱留在同一接口 |
入门阶段的务实建议:能靠"只加不改"解决的不加版本号;必须破坏兼容时用 URL 版本,排障成本最低。定了策略就写进契约,别一半路径一半 header。
7. 契约的落地形式与文档腐化
| 形式 | 谁维护 | 能防住什么 | 腐化风险 |
|---|---|---|---|
| 手写文档(Wiki) | 人 | 意图与背景 | 高,改代码不改文档 |
| OpenAPI / Proto 定义 | 代码同仓 | 结构与字段漂移 | 低,但语义仍靠注释 |
| 契约测试(消费者驱动) | CI | 真实调用方依赖的字段 | 低,需要接入成本 |
| 类型定义共享 | 代码 | 编译期不一致 | 中,跨语言失效 |
文档腐化的根因是文档与代码是两份真相。能用代码生成的别手写;不能生成的(语义、时序、超时)写在与接口同仓的 Markdown 里,改接口的 PR 必须同改。
8. 内部接口与服务间接口
| 维度 | 内部接口(同进程) | 服务间接口(跨进程) |
|---|---|---|
| 谁写谁读 | 同一批人,可随时重构 | 不同团队,改动需协商 |
| 失败模式 | 抛异常,同栈可查 | 超时、半成功、重复投递 |
| 契约载体 | 类型定义 + 注释 | 显式文档 + 契约测试 |
| 变更通知 | 改完说一声 | 必须提前公告 + 兼容期 |
| 必须额外写清 | 线程安全性 | 超时、重试、幂等、鉴权、限流 |
跨进程之后要多承诺五件事。这是"能不能拆服务"的真正判据:团队承诺不了这五件事,拆出去就是把内部调用变成事故源。
9. 最小契约模板
## 接口:创建订单
- 方法与路径:POST /v1/orders
- 幂等:是。幂等键为 header `X-Request-Id`,窗口 24h,重复请求返回首次结果(含原 orderId)
- 超时:服务端 3s 放弃;放弃后状态可能已落库,调用方须以查询接口为准
- 输入:skuId 必填;qty 1–99;couponCode 可选,null=不使用,""=参数错误
- 输出:orderId 必有;status ∈ {CREATED, PAID, CANCELLED};未知值按未知处理
- 错误:
| code | HTTP | 可重试 | 含义 |
| ORDER_STATUS_INVALID | 409 | 否 | 状态不允许该操作 |
| RATE_LIMITED | 429 | 是(退避) | 触发限流 |
| INTERNAL | 500 | 是(≤2 次) | 服务端异常,带 traceId |
- 性能预期:P99 < 300ms(典型量级),峰值 QPS 200
一页写完比十页文档有用;缺一行,联调就有人来问。
10. 常见错误
| 错误 | 后果 | 改法 |
|---|---|---|
用 null 表示失败 |
调用方无法区分"没有数据"与"出错" | 用错误码或 Optional/包装类型 |
| 错误码复用 | 一个 code 两种含义,调用方逻辑分叉 | 一码一义,新增不复用 |
| 静默截断返回条数 | 调用方以为拿全了,实际少了一半 | 返回 total 与 hasMore,或明确报错 |
| 不写超时默认值 | 各方默认不同,级联超时 | 契约里写死超时与放弃后的语义 |
11. 排错:五种"契约写了还是对不上"的情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 联调时字段名对不上 | 契约只写了签名,没写字段语义 | 补字段表,标注空值含义 |
| 重试导致重复数据 | 契约没写幂等键或窗口 | 补幂等键,服务端做去重 |
| 上线后老客户端报错 | 加了必填字段或收紧校验 | 回退为可选,走新字段 |
| 文档与实际不符 | 两份真相,无人同步 | 结构由代码生成,语义同仓维护 |
12. 边界:什么情况不必写正式契约
| 场景 | 做法 | 理由 |
|---|---|---|
| 同进程内部方法 | 类型 + 注释即可 | 可重构,无跨团队成本 |
| 一次性脚本 | 不写 | 没有第二个调用方 |
| 实验性接口 | 内部约定,标注"随时可变" | 承诺成本高于收益 |
| 已废弃接口 | 只维护废弃公告 | 不再演进 |
一句话:契约的成本由"是否有别人依赖"来付;有人依赖且变更要走流程,就必须写。
13. 自测
- 为什么说"签名不等于契约"?签名之外还有哪四类信息?
- 输入里"没传""传了空""查不到"三种空,分别该怎么表达?
- 错误码设计的三条硬要求是什么?为什么
message不能被程序解析? - 幂等要求"状态影响相同"还是"返回值相同"?重复请求的返回值有哪两种合法选择?
- "加枚举值"为什么可能是不兼容变更?
- 同一个方法拆成跨进程调用后,必须多承诺哪五件事?