API与事件契约治理:REST、OpenAPI、Protobuf、Schema Registry与兼容发布
微服务契约不是一份过期的接口文档,而是生产者和消费者之间可执行的承诺:字段、类型、缺省值、错误、幂等语义、顺序、版本和安全边界。只要服务独立发布,新生产者、旧生产者、新消费者、旧消费者就会在滚动窗口中同时存在;一个看似简单的字段删除、枚举新增或默认值变化,都可能让部分实例持续失败。
本页统一讲REST/OpenAPI、gRPC/Protobuf和MQ事件Schema,说明兼容方向、契约测试、Schema Registry、Expand–Migrate–Contract和回滚边界。
一、学习目标
- 区分语法契约、行为契约和业务不变量。
- 设计资源化REST、HTTP幂等和稳定错误模型。
- 理解OpenAPI的Design-first、Code-first与代码生成边界。
- 区分Backward、Forward和Full Compatibility。
- 解释Protobuf字段编号、未知字段、枚举和
oneof演进。 - 设计MQ Event Envelope、eventId、版本和时间语义。
- 理解Avro、JSON Schema、Protobuf与Schema Registry。
- 使用Consumer-Driven Contract验证真实消费假设。
- 设计数据库、API和事件的Expand–Migrate–Contract发布。
- 在CI、灰度和回滚中验证多版本组合。
二、契约有三个层次
| 层次 | 示例 | 只验证这一层的风险 |
|---|---|---|
| 语法契约 | 字段名、类型、必填、状态码 | JSON能解析但业务语义错误 |
| 行为契约 | 重试、幂等、分页、超时、错误分类 | 单次Happy Path通过但故障行为不兼容 |
| 业务不变量 | 已支付不能退回待支付、金额不能变负 | 技术接口正确但业务数据损坏 |
OpenAPI能描述大量HTTP结构,Protobuf能约束二进制Schema,Schema Registry能阻止不兼容Schema注册,但它们都无法自动证明“重复请求不会重复扣款”。行为和业务不变量仍需测试与状态机。
三、为什么必须兼容滚动发布窗口
flowchart TD
A["旧Producer与旧Consumer正在运行"] --> B["先发布新Consumer"]
B --> C["新旧Consumer同时读取旧消息"]
C --> D["再发布新Producer开始写新字段"]
D --> E["新旧Producer和Consumer短期共存"]
E --> F["确认旧版本消失且历史消息过期"]
F --> G["最后删除旧字段或兼容逻辑"]兼容窗口不只等于Deployment滚动的几分钟,还包括:
- MQ中保留数天的旧消息。
- 离线客户端数月后才升级。
- 重试队列和死信重放。
- 灾备站运行旧版本。
- 回滚时旧代码重新上线。
因此“所有服务一起发布”不能代替兼容设计。
四、REST契约设计
4.1 URI表达资源,方法表达动作语义
POST /api/orders
GET /api/orders/{orderId}
PATCH /api/orders/{orderId}
POST /api/orders/{orderId}/cancellations命令型业务可以使用子资源或明确动作端点,不必为追求纯REST把复杂状态转换伪装成任意字段PATCH。关键是语义稳定、权限和幂等明确。
4.2 HTTP方法与幂等
| 方法 | HTTP语义上的幂等性 | 业务注意 |
|---|---|---|
| GET | 应幂等且安全 | 不应偷偷扣库存 |
| PUT | 同一目标完整替换通常幂等 | 并发覆盖要ETag/版本 |
| DELETE | 重复删除后目标状态相同 | 重复响应可为204或已删除语义 |
| POST | 默认不保证幂等 | 支付、订单要Idempotency-Key |
| PATCH | 取决于操作 | set status与increment不同 |
HTTP方法语义不能替代业务幂等。创建订单使用POST时,客户端生成稳定Idempotency-Key,服务端用唯一约束、请求摘要和结果记录保证重复调用复用第一次结果。
五、请求与响应模型
不要让数据库Entity直接成为公网契约。独立DTO可以:
- 防止客户端写入内部status、createdBy等字段。
- 隔离数据库改名和关联关系。
- 为创建、修改、查询表达不同必填规则。
- 防止新增敏感字段被自动序列化。
新增可选响应字段通常较安全,前提是旧消费者忽略未知字段;把可选请求字段改为必填、缩小字符串长度、改变金额单位或把null改成错误,都可能是破坏性变更。
5.1 Null、缺失和空值
以下语义必须定义:
{}
{"remark": null}
{"remark": ""}在PATCH中它们可能分别表示“不修改”“清空”和“设置为空字符串”。若框架反序列化后无法区分,需使用显式Patch模型或操作列表。
六、稳定错误模型
{
"code": "INVENTORY_NOT_ENOUGH",
"message": "库存不足",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"details": {
"sku": "sku-1001"
}
}错误契约需要:
- HTTP状态表达协议大类。
- 稳定机器可读业务code。
- 面向用户的message可本地化,消费者不能依赖其文本判断。
- details有版本和敏感信息边界。
- 明确哪些错误可重试、是否结果未知。
不要把所有异常都返回200,也不要把库存不足返回503诱导客户端重试。500响应不应泄露SQL、堆栈、Token和内部地址。
七、分页和排序也是契约
Offset分页简单,但数据变化时可能重复或跳过,深页扫描也慢。Cursor/Keyset分页应定义稳定排序,例如:
ORDER BY created_time DESC, id DESCCursor应包含排序位置和版本并防篡改,不能只用不唯一时间。响应要定义是否有下一页、Cursor有效期和筛选条件变化后的行为。
八、API版本与演进策略
常见版本位置:URI /v2/orders、Header、Media Type或内部服务发现元数据。没有一种方式自动解决兼容,核心仍是变更分类和生命周期。
优先做兼容扩展:新增可选字段、增加新端点、增加可识别的新能力。以下变更通常要新版本或迁移期:
- 字段类型变化。
- 金额单位变化。
- 枚举含义变化。
- 删除消费者仍使用的字段。
- 错误码语义变化。
- 排序或分页稳定性变化。
版本必须有Owner、弃用时间、调用方清单、流量指标和删除门禁。只写@Deprecated不等于消费者已迁移。
8.1 破坏性变更怎样分类
很多事故来自“我只是改了一个字段”。契约评审时不要只问“编译能不能过”,要问旧消费者、新消费者、历史数据和回滚代码会怎样。
| 变更 | 是否通常兼容 | 风险说明 | 推荐做法 |
|---|---|---|---|
| 响应新增可选字段 | 通常兼容 | 旧消费者若严格拒绝未知字段会失败 | 先验证旧Reader容忍未知字段 |
| 请求新增可选字段 | 通常兼容 | 服务端默认值必须明确 | 定义缺省语义和灰度开关 |
| 请求字段从可选改必填 | 破坏性 | 旧调用方不传会400 | 新增V2端点或先兼容缺省值 |
| 响应字段删除 | 破坏性 | 旧消费者仍读取该字段 | 先统计调用方,迁移后再删 |
| 字段类型变化 | 破坏性 | string改number会反序列化失败 | 新增字段,旧字段保留迁移期 |
| 金额单位变化 | 高危破坏性 | 元和分混用会造成资金错误 | 新增amountFen,禁止复用旧字段 |
| 枚举新增 | 取决于Reader | 旧代码可能异常或走危险default | 必须有UNKNOWN和安全分支 |
| 错误码语义变化 | 破坏性 | 调用方重试、降级和提示会错 | 新增错误码,旧码保持原语义 |
| 排序规则变化 | 可能破坏 | 分页重复、遗漏、对账不一致 | 新增排序版本或Cursor协议 |
| 字段含义变化但类型不变 | 最危险 | Schema检查通常发现不了 | 新字段表达新语义,旧字段不复用 |
最危险的是“结构兼容、语义不兼容”。例如amount仍是数字,但单位从元改成分;status=SUCCESS仍是字符串,但含义从“扣款成功”变成“受理成功”。Schema Registry和OpenAPI Diff可能放行,但业务会错。
8.2 多版本兼容矩阵
发布前至少验证四类组合:
flowchart TD
A["旧Provider"] --> B["旧Consumer"]
A --> C["新Consumer"]
D["新Provider"] --> B
D --> C| 组合 | 为什么要测 | 典型问题 |
|---|---|---|
| 旧Provider + 旧Consumer | 基线应稳定 | 测试环境基线不可信 |
| 旧Provider + 新Consumer | 新消费者能否读旧响应和旧消息 | 新代码要求新字段导致空指针 |
| 新Provider + 旧Consumer | 旧消费者能否容忍新响应和新消息 | 未知字段、未知枚举、错误码变化 |
| 新Provider + 新Consumer | 新能力是否正常 | 只测Happy Path,忽略回滚窗口 |
这张矩阵解释了为什么推荐“先发宽容Reader,再发新Writer”。Reader先能读新旧格式,Writer再开始写新格式,最后等待旧Reader、旧消息和回滚窗口退出后才删除旧格式。
发布顺序可以记成:
兼容Reader先上线 -> Writer开始双写或写新字段 -> 迁移所有Consumer -> 等历史消息过期 -> 删除旧字段如果反过来先让Writer写新字段、新枚举或新单位,旧Reader马上暴露在未知格式下。线上表现通常是部分消费者报错、MQ重试暴涨、某些实例400或解码失败。
8.3 URI、Header和Media Type版本怎么选
| 方式 | 示例 | 优点 | 风险 |
|---|---|---|---|
| URI版本 | /api/v2/orders | 直观、网关和文档好治理 | 容易复制整套接口,版本膨胀 |
| Header版本 | X-Api-Version: 2 | URI稳定,适合内部调用 | 网关、缓存、文档和调试要额外治理 |
| Media Type版本 | application/vnd.order.v2+json | 表达内容协商语义 | 学习和工具成本较高 |
| 服务发现metadata | version=v2 | 适合微服务灰度路由 | 不能替代接口字段兼容 |
内部微服务更常用“兼容扩展 + 灰度metadata”,公网OpenAPI更常用URI或Header显式版本。无论哪种方式,都不能跳过兼容窗口。版本号只是路由和识别手段,不是破坏性变更的免死金牌。
8.4 删除字段前必须知道谁在用
删除字段要有证据,而不是靠群里问一句“还有人用吗”。可以组合这些手段:
| 手段 | 能证明什么 | 局限 |
|---|---|---|
| API网关访问日志 | 哪些应用调用了哪个端点 | 不知道是否读取某字段 |
| Consumer Contract | 消费者声明依赖字段和行为 | 未接入的消费者不可见 |
| SDK版本上报 | 哪些客户端仍是旧版本 | 手写HTTP调用可能漏掉 |
| 字段级埋点 | 旧字段是否仍被返回或读取 | 成本高,要脱敏 |
| OpenAPI/Proto依赖库扫描 | 代码层是否仍引用旧字段 | 动态语言和反射读取难发现 |
| MQ消费组清单 | 哪些消费者读取事件 | 不知道是否依赖具体字段 |
删除门禁至少包括:旧版本实例数为0、旧SDK流量为0、历史消息保留期已过、死信和重试队列已处理、灾备版本已升级、回滚镜像能读取当前数据格式。
九、OpenAPI治理
9.1 Design-first
先评审OpenAPI,再生成Server Stub或Client。适合跨团队公共API,能够在实现前发现命名和兼容问题;风险是生成代码与领域模型混杂。
9.2 Code-first
从Controller和注解生成文档,上手快;风险是实现细节成为偶然契约、缺少描述或生成结果未进入Review。
无论哪种方式,都应把规范作为版本化制品,CI执行:
- 语法校验。
- 与已发布基线做breaking-change diff。
- Mock或契约测试。
- 生成客户端编译测试。
- 发布到API Catalog。
生成客户端不能替代超时、认证、幂等和错误处理;也不要把生成DTO直接当数据库Entity。
十、Backward、Forward与Full兼容
定义要说明“谁读谁写”:
- Backward Compatibility:新Reader能读取旧Writer产生的数据。
- Forward Compatibility:旧Reader能读取新Writer产生的数据。
- Full Compatibility:同时满足两个方向。
团队和Schema Registry产品界面可能从“新Schema相对旧Schema”的角度命名,评审时必须写清Writer/Reader版本,避免只说“向后兼容”却双方理解相反。
flowchart TD
A["准备Schema V2"] --> B["用V2 Reader读取V1数据"]
A --> C["用V1 Reader读取V2数据"]
B --> D{"Backward是否满足"}
C --> E{"Forward是否满足"}
D --> F["决定发布新Reader或新Writer顺序"]
E --> F十一、Protobuf兼容规则
- 已发布字段编号不能复用。
- 删除字段后使用
reserved保留编号和名称。 - 新字段应让旧Reader可忽略,并为旧数据定义缺省语义。
- 不能只因Java类型可转换就随意改变wire type。
- 枚举必须保留0值UNKNOWN/UNSPECIFIED。
- 新枚举值到旧消费者时可能变成未识别值,业务不能默认成危险动作。
oneof新增分支要设计旧Reader行为。- 金额使用整数最小单位或明确Decimal消息,不用double。
未知字段是否在解析、修改、再序列化后保留取决于语言和Runtime,应测试真实版本,不能靠印象。
十二、MQ事件契约
{
"eventId": "evt-20260718-001",
"eventType": "OrderPaid",
"eventVersion": 2,
"occurredAt": "2026-07-18T10:00:00.123Z",
"producer": "payment-service",
"aggregateId": "order-1001",
"aggregateVersion": 8,
"traceparent": "00-...-...-01",
"data": {
"paidAmountFen": 10000,
"currency": "CNY"
}
}字段语义:
- eventId用于幂等,不等于Broker messageId。
- eventType表达稳定业务事实,使用过去式。
- eventVersion表达Payload契约版本。
- aggregateVersion用于拒绝状态倒退。
- occurredAt是业务发生时间,不是消费时间。
- producer和Trace用于取证,不作为业务幂等唯一依据。
事件是已经发生的事实,不能把OrderPaid后来改成“可能支付”。需要撤销时发布新的PaymentRefunded或修正事件,保留审计链。
十三、Schema Registry怎样工作
flowchart TD
A["Producer准备注册新Schema"] --> B["Registry按Subject找到历史版本"]
B --> C["执行配置的兼容性检查"]
C --> D{"是否兼容"}
D -- "否" --> E["拒绝注册并阻断发布"]
D -- "是" --> F["生成Schema ID或版本"]
F --> G["Producer按ID序列化消息"]
G --> H["Consumer按ID获取Reader/Writer Schema并反序列化"]Registry存储和检查Schema,但不能自动验证:
- 字段单位从元变成分。
- 默认值业务含义变化。
- 消费者是否错误地拒绝未知字段。
- 同一个eventType是否仍表示同一事实。
- 重复和乱序是否安全。
Avro常依赖Writer Schema与Reader Schema解析;Protobuf依赖字段编号;JSON Schema可验证JSON结构。选型要考虑语言、消息大小、可读性、兼容规则和治理生态。
十四、Consumer-Driven Contract
CDC契约由消费者表达它真正依赖的Provider行为,Provider在CI中验证。例如资产服务只依赖支付响应中的orderId、status和特定错误码,不必把Provider全部实现细节固化。
flowchart TD
A["Consumer编写交互期望"] --> B["生成并发布Contract"]
B --> C["Provider CI拉取相关Consumer契约"]
C --> D["用真实Provider实现验证"]
D --> E{"全部通过"}
E -- "是" --> F["允许进入灰度"]
E -- "否" --> G["阻断破坏性发布"]CDC不能代替集成测试:它通常验证已声明交互,无法覆盖基础设施、认证链、网络、数据库迁移和未声明消费者。必须治理契约Owner、版本、环境和已下线消费者,防止契约墓地永久阻塞变更。
十五、Expand–Migrate–Contract
字段从amount_yuan迁移到amount_fen:
- Expand:数据库/API/事件先新增
amount_fen,不删旧字段。 - 新Reader优先读新字段,缺失时回退旧字段。
- Writer在迁移期双写,并监控两个值一致。
- 回填历史数据,记录进度、失败和校验和。
- 发布所有消费者并确认旧版本、旧消息和灾备环境退出。
- 停止写旧字段,观察回滚窗口。
- Contract:最后删除旧字段和兼容逻辑。
flowchart TD
A["新增新字段且保持旧字段"] --> B["新代码双读双写"]
B --> C["回填历史并核对"]
C --> D["迁移全部消费者"]
D --> E["停止旧字段写入"]
E --> F["经过回滚与消息保留窗口"]
F --> G["删除旧字段"]双写不是原子一致的万能方案;同库字段可放同一事务,跨服务双写仍需Outbox/CDC。回填要限速、按主键游标、幂等并避免覆盖迁移期间的新值。
十六、枚举与状态机演进
新增PARTIALLY_REFUNDED时,旧消费者可能:
- 反序列化失败。
- 进入default分支。
- 错误映射成REFUNDED。
- 将未知值写回并覆盖。
消费者必须有UNKNOWN分支并选择安全行为。高风险状态未知时应停止自动处理并告警,不能把未知支付状态默认为成功或失败。
状态机迁移还要保证新旧版本都不允许状态倒退,并使用aggregateVersion或条件更新拒绝旧事件。
十七、JDK 8 Demo:宽容Reader与安全未知枚举
import java.util.HashMap;
import java.util.Map;
public class CompatibleReaderDemo {
enum Status { PAID, REFUNDED, UNKNOWN }
static Status readStatus(Map<String, Object> event) {
Object value = event.get("status");
if (value == null) return Status.UNKNOWN;
try {
return Status.valueOf(String.valueOf(value));
} catch (IllegalArgumentException ex) {
return Status.UNKNOWN;
}
}
public static void main(String[] args) {
Map<String, Object> v2 = new HashMap<String, Object>();
v2.put("orderId", "order-1001");
v2.put("status", "PARTIALLY_REFUNDED"); // 旧Reader未知
v2.put("refundAmountFen", 5000L); // 旧Reader忽略新增字段
Status status = readStatus(v2);
System.out.println("status=" + status);
System.out.println("safeToShip=" + (status == Status.PAID));
}
}旧消费者不会因新字段失败,也不会把未知退款状态误判成可发货。真实JSON/Avro/Protobuf行为必须用项目所选序列化库版本做契约测试。
十八、CI与发布门禁
推荐流水线:
- Lint OpenAPI/Proto/Schema。
- 与生产基线执行Breaking Change Diff。
- Provider单元与集成测试。
- Consumer Contract验证。
- 生成多语言客户端并编译。
- 用旧Reader读新数据、新Reader读旧数据。
- 数据库Expand迁移检查。
- 灰度并按版本观察错误、未知字段和反序列化失败。
- 保留可执行回切,但确认旧代码兼容新数据。
“镜像能回滚”不代表契约能回滚。新代码已经写入新枚举、新Schema消息或数据库新格式后,旧代码可能无法读取,必要时只能前滚修复。
十九、生产Runbook
19.1 发布后部分实例400/反序列化失败
- 按Consumer版本、Producer版本和消息Schema ID拆分。
- 保存失败原始Payload并脱敏,确认字段、类型、枚举和Content-Type。
- 检查是否只有旧Consumer拒绝未知字段。
- 停止新Writer继续扩大影响,保留消息用于修复重放。
- 发布兼容Reader或转换层,不直接丢弃消息。
19.2 新字段一直为空
- 检查Producer是否真正写新字段。
- 检查网关、DTO映射和序列化忽略规则。
- 检查Consumer使用旧生成客户端或旧Schema缓存。
- 对照Schema ID、契约版本和部署版本。
- 区分历史旧消息与新消息。
19.3 回滚后旧版本崩溃
- 检查新代码是否已产生旧代码不认识的枚举或数据。
- 检查数据库列、约束和默认值是否已Contract删除。
- 检查MQ保留中的新版本消息。
- 必要时停止回滚,采用兼容前滚版本读取新旧格式。
二十、常见误区
| 误区 | 后果 |
|---|---|
| JSON是弱类型所以天然兼容 | 消费者仍可能严格拒绝未知字段或类型 |
| 新增字段永远安全 | 旧Reader、签名、缓存Key可能受影响 |
| 枚举新增不影响旧代码 | 反序列化失败或危险default |
| OpenAPI生成成功就正确 | 行为、幂等和业务不变量未验证 |
| Schema Registry保证业务兼容 | 单位和语义变化仍可通过结构检查 |
| 所有服务一起发就无需兼容 | MQ、离线客户端、灾备和回滚仍有旧版本 |
| 数据库回滚脚本能解决一切 | 新数据和外部副作用无法简单逆转 |
二十一、面试回答主线
先说明契约包含结构、行为和业务不变量;再按滚动发布解释多版本共存;REST讲HTTP语义、错误和OpenAPI,RPC讲Protobuf编号与未知枚举,MQ讲Event Envelope和Schema Registry;最后用CDC、Expand–Migrate–Contract、CI Diff、灰度和回滚窗口形成治理闭环。
标准回答见API与事件契约治理面试题。
二十二、关联知识点
本章小结
契约治理的本质是让独立发布的生产者和消费者在时间上解耦。兼容不是“JSON能解析”,而是旧、新Reader与Writer、历史消息、灾备版本和回滚代码都能保持安全业务语义。只有Schema检查、消费者契约、Expand迁移、灰度和运行指标共同存在,接口演进才可控。
