Skip to content

API与事件契约治理:REST、OpenAPI、Protobuf、Schema Registry与兼容发布

微服务契约不是一份过期的接口文档,而是生产者和消费者之间可执行的承诺:字段、类型、缺省值、错误、幂等语义、顺序、版本和安全边界。只要服务独立发布,新生产者、旧生产者、新消费者、旧消费者就会在滚动窗口中同时存在;一个看似简单的字段删除、枚举新增或默认值变化,都可能让部分实例持续失败。

本页统一讲REST/OpenAPI、gRPC/Protobuf和MQ事件Schema,说明兼容方向、契约测试、Schema Registry、Expand–Migrate–Contract和回滚边界。

一、学习目标

  1. 区分语法契约、行为契约和业务不变量。
  2. 设计资源化REST、HTTP幂等和稳定错误模型。
  3. 理解OpenAPI的Design-first、Code-first与代码生成边界。
  4. 区分Backward、Forward和Full Compatibility。
  5. 解释Protobuf字段编号、未知字段、枚举和oneof演进。
  6. 设计MQ Event Envelope、eventId、版本和时间语义。
  7. 理解Avro、JSON Schema、Protobuf与Schema Registry。
  8. 使用Consumer-Driven Contract验证真实消费假设。
  9. 设计数据库、API和事件的Expand–Migrate–Contract发布。
  10. 在CI、灰度和回滚中验证多版本组合。

二、契约有三个层次

层次示例只验证这一层的风险
语法契约字段名、类型、必填、状态码JSON能解析但业务语义错误
行为契约重试、幂等、分页、超时、错误分类单次Happy Path通过但故障行为不兼容
业务不变量已支付不能退回待支付、金额不能变负技术接口正确但业务数据损坏

OpenAPI能描述大量HTTP结构,Protobuf能约束二进制Schema,Schema Registry能阻止不兼容Schema注册,但它们都无法自动证明“重复请求不会重复扣款”。行为和业务不变量仍需测试与状态机。

三、为什么必须兼容滚动发布窗口

mermaid
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表达资源,方法表达动作语义

http
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 statusincrement不同

HTTP方法语义不能替代业务幂等。创建订单使用POST时,客户端生成稳定Idempotency-Key,服务端用唯一约束、请求摘要和结果记录保证重复调用复用第一次结果。

五、请求与响应模型

不要让数据库Entity直接成为公网契约。独立DTO可以:

  • 防止客户端写入内部status、createdBy等字段。
  • 隔离数据库改名和关联关系。
  • 为创建、修改、查询表达不同必填规则。
  • 防止新增敏感字段被自动序列化。

新增可选响应字段通常较安全,前提是旧消费者忽略未知字段;把可选请求字段改为必填、缩小字符串长度、改变金额单位或把null改成错误,都可能是破坏性变更。

5.1 Null、缺失和空值

以下语义必须定义:

json
{}
{"remark": null}
{"remark": ""}

在PATCH中它们可能分别表示“不修改”“清空”和“设置为空字符串”。若框架反序列化后无法区分,需使用显式Patch模型或操作列表。

六、稳定错误模型

json
{
  "code": "INVENTORY_NOT_ENOUGH",
  "message": "库存不足",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "details": {
    "sku": "sku-1001"
  }
}

错误契约需要:

  • HTTP状态表达协议大类。
  • 稳定机器可读业务code。
  • 面向用户的message可本地化,消费者不能依赖其文本判断。
  • details有版本和敏感信息边界。
  • 明确哪些错误可重试、是否结果未知。

不要把所有异常都返回200,也不要把库存不足返回503诱导客户端重试。500响应不应泄露SQL、堆栈、Token和内部地址。

七、分页和排序也是契约

Offset分页简单,但数据变化时可能重复或跳过,深页扫描也慢。Cursor/Keyset分页应定义稳定排序,例如:

text
ORDER BY created_time DESC, id DESC

Cursor应包含排序位置和版本并防篡改,不能只用不唯一时间。响应要定义是否有下一页、Cursor有效期和筛选条件变化后的行为。

八、API版本与演进策略

常见版本位置:URI /v2/orders、Header、Media Type或内部服务发现元数据。没有一种方式自动解决兼容,核心仍是变更分类和生命周期。

优先做兼容扩展:新增可选字段、增加新端点、增加可识别的新能力。以下变更通常要新版本或迁移期:

  • 字段类型变化。
  • 金额单位变化。
  • 枚举含义变化。
  • 删除消费者仍使用的字段。
  • 错误码语义变化。
  • 排序或分页稳定性变化。

版本必须有Owner、弃用时间、调用方清单、流量指标和删除门禁。只写@Deprecated不等于消费者已迁移。

8.1 破坏性变更怎样分类

很多事故来自“我只是改了一个字段”。契约评审时不要只问“编译能不能过”,要问旧消费者、新消费者、历史数据和回滚代码会怎样。

变更是否通常兼容风险说明推荐做法
响应新增可选字段通常兼容旧消费者若严格拒绝未知字段会失败先验证旧Reader容忍未知字段
请求新增可选字段通常兼容服务端默认值必须明确定义缺省语义和灰度开关
请求字段从可选改必填破坏性旧调用方不传会400新增V2端点或先兼容缺省值
响应字段删除破坏性旧消费者仍读取该字段先统计调用方,迁移后再删
字段类型变化破坏性stringnumber会反序列化失败新增字段,旧字段保留迁移期
金额单位变化高危破坏性元和分混用会造成资金错误新增amountFen,禁止复用旧字段
枚举新增取决于Reader旧代码可能异常或走危险default必须有UNKNOWN和安全分支
错误码语义变化破坏性调用方重试、降级和提示会错新增错误码,旧码保持原语义
排序规则变化可能破坏分页重复、遗漏、对账不一致新增排序版本或Cursor协议
字段含义变化但类型不变最危险Schema检查通常发现不了新字段表达新语义,旧字段不复用

最危险的是“结构兼容、语义不兼容”。例如amount仍是数字,但单位从元改成分;status=SUCCESS仍是字符串,但含义从“扣款成功”变成“受理成功”。Schema Registry和OpenAPI Diff可能放行,但业务会错。

8.2 多版本兼容矩阵

发布前至少验证四类组合:

mermaid
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、旧消息和回滚窗口退出后才删除旧格式。

发布顺序可以记成:

text
兼容Reader先上线 -> Writer开始双写或写新字段 -> 迁移所有Consumer -> 等历史消息过期 -> 删除旧字段

如果反过来先让Writer写新字段、新枚举或新单位,旧Reader马上暴露在未知格式下。线上表现通常是部分消费者报错、MQ重试暴涨、某些实例400或解码失败。

8.3 URI、Header和Media Type版本怎么选

方式示例优点风险
URI版本/api/v2/orders直观、网关和文档好治理容易复制整套接口,版本膨胀
Header版本X-Api-Version: 2URI稳定,适合内部调用网关、缓存、文档和调试要额外治理
Media Type版本application/vnd.order.v2+json表达内容协商语义学习和工具成本较高
服务发现metadataversion=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执行:

  1. 语法校验。
  2. 与已发布基线做breaking-change diff。
  3. Mock或契约测试。
  4. 生成客户端编译测试。
  5. 发布到API Catalog。

生成客户端不能替代超时、认证、幂等和错误处理;也不要把生成DTO直接当数据库Entity。

十、Backward、Forward与Full兼容

定义要说明“谁读谁写”:

  • Backward Compatibility:新Reader能读取旧Writer产生的数据。
  • Forward Compatibility:旧Reader能读取新Writer产生的数据。
  • Full Compatibility:同时满足两个方向。

团队和Schema Registry产品界面可能从“新Schema相对旧Schema”的角度命名,评审时必须写清Writer/Reader版本,避免只说“向后兼容”却双方理解相反。

mermaid
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兼容规则

  1. 已发布字段编号不能复用。
  2. 删除字段后使用reserved保留编号和名称。
  3. 新字段应让旧Reader可忽略,并为旧数据定义缺省语义。
  4. 不能只因Java类型可转换就随意改变wire type。
  5. 枚举必须保留0值UNKNOWN/UNSPECIFIED。
  6. 新枚举值到旧消费者时可能变成未识别值,业务不能默认成危险动作。
  7. oneof新增分支要设计旧Reader行为。
  8. 金额使用整数最小单位或明确Decimal消息,不用double。

未知字段是否在解析、修改、再序列化后保留取决于语言和Runtime,应测试真实版本,不能靠印象。

十二、MQ事件契约

json
{
  "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怎样工作

mermaid
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中验证。例如资产服务只依赖支付响应中的orderIdstatus和特定错误码,不必把Provider全部实现细节固化。

mermaid
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

  1. Expand:数据库/API/事件先新增amount_fen,不删旧字段。
  2. 新Reader优先读新字段,缺失时回退旧字段。
  3. Writer在迁移期双写,并监控两个值一致。
  4. 回填历史数据,记录进度、失败和校验和。
  5. 发布所有消费者并确认旧版本、旧消息和灾备环境退出。
  6. 停止写旧字段,观察回滚窗口。
  7. Contract:最后删除旧字段和兼容逻辑。
mermaid
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与安全未知枚举

java
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与发布门禁

推荐流水线:

  1. Lint OpenAPI/Proto/Schema。
  2. 与生产基线执行Breaking Change Diff。
  3. Provider单元与集成测试。
  4. Consumer Contract验证。
  5. 生成多语言客户端并编译。
  6. 用旧Reader读新数据、新Reader读旧数据。
  7. 数据库Expand迁移检查。
  8. 灰度并按版本观察错误、未知字段和反序列化失败。
  9. 保留可执行回切,但确认旧代码兼容新数据。

“镜像能回滚”不代表契约能回滚。新代码已经写入新枚举、新Schema消息或数据库新格式后,旧代码可能无法读取,必要时只能前滚修复。

十九、生产Runbook

19.1 发布后部分实例400/反序列化失败

  1. 按Consumer版本、Producer版本和消息Schema ID拆分。
  2. 保存失败原始Payload并脱敏,确认字段、类型、枚举和Content-Type。
  3. 检查是否只有旧Consumer拒绝未知字段。
  4. 停止新Writer继续扩大影响,保留消息用于修复重放。
  5. 发布兼容Reader或转换层,不直接丢弃消息。

19.2 新字段一直为空

  1. 检查Producer是否真正写新字段。
  2. 检查网关、DTO映射和序列化忽略规则。
  3. 检查Consumer使用旧生成客户端或旧Schema缓存。
  4. 对照Schema ID、契约版本和部署版本。
  5. 区分历史旧消息与新消息。

19.3 回滚后旧版本崩溃

  1. 检查新代码是否已产生旧代码不认识的枚举或数据。
  2. 检查数据库列、约束和默认值是否已Contract删除。
  3. 检查MQ保留中的新版本消息。
  4. 必要时停止回滚,采用兼容前滚版本读取新旧格式。

二十、常见误区

误区后果
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迁移、灰度和运行指标共同存在,接口演进才可控。