惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证 排序基准测试:用数据说话
【系统架构设计】契约测试与 Schema 演进:服务间的信任协议
Liao Tonglang · 2026-04-13 · via 土法炼钢兴趣小组的算法知识备份

一、一次 Breaking Change 引发的级联故障

周五下午四点,订单服务的负责人提交了一个”小改动”:把 API 响应中的 order_total 字段从整数类型改为字符串类型,理由是前端需要展示带货币符号的金额。代码审查通过,单元测试全绿,部署流水线顺利完成。

十分钟后,告警开始响。

支付服务拿到 order_total 的值 "¥199.00" 后尝试做算术运算,抛出类型转换异常,请求失败。支付服务的重试机制触发,每秒向订单服务发送三倍的请求量。订单服务的连接池被打满,开始拒绝所有请求。物流服务依赖订单服务查询订单金额来计算运费,也跟着挂了。最终,一个字段类型的修改导致三个核心服务同时不可用,持续了四十分钟。

事后复盘,团队发现了一个尴尬的事实:订单服务有完善的单元测试和集成测试,但所有测试都是站在提供方(Provider)的角度写的——验证自己的逻辑是否正确,从来没有验证过消费方(Consumer)是否能正确解析自己的响应。支付服务也有自己的测试,但用的是 Mock 数据,Mock 数据还停留在老版本的字段定义上。

这个故事暴露了微服务架构中一个根本性问题:当服务数量增长到一定规模,接口兼容性的保证不能依赖人的记忆和沟通,必须依赖自动化的机制。这个机制就是契约测试(Contract Testing)和 Schema 演进(Schema Evolution)。


二、为什么需要契约测试

2.1 集成测试的困境

传统的做法是用集成测试来验证服务间的交互。把所有服务部署到一个测试环境里,发真实请求,验证端到端的行为。这个方法在三五个服务的时候还能工作,但随着服务数量增长,问题会迅速恶化。

第一,环境稳定性差。集成测试环境需要同时运行所有服务及其依赖(数据库、消息队列、缓存),任何一个组件不稳定都会导致测试失败,而这些失败与你要验证的逻辑无关。

第二,反馈周期长。部署所有服务、准备测试数据、运行测试、清理环境——整个流程可能需要几十分钟甚至几个小时。开发者改一行代码要等半小时才能知道结果,这种反馈速度无法支撑快速迭代。

第三,失败定位困难。当一个端到端测试失败时,问题可能出在链路上的任何一个服务。排查的成本远高于单元测试。

第四,版本组合爆炸。假设你有 10 个服务,每个服务有 3 个活跃版本,理论上需要测试的版本组合数是 3^10 = 59049。实际操作中不可能覆盖所有组合。

2.2 契约测试的核心思想

契约测试的思路完全不同:不是把所有服务放在一起测,而是把服务之间的交互协议(契约)抽取出来,让每个服务独立地验证自己是否遵守了这个协议。

契约(Contract)定义了两个服务之间的交互规范:消费方会发送什么样的请求,提供方应该返回什么样的响应。这个规范不是人写的文档,而是可执行的测试用例。

消费方测试:我会发送 GET /orders/123,期望得到 { "id": 123, "total": 199, "status": "paid" }
提供方测试:当收到 GET /orders/123 时,我确实返回了符合上述结构的响应

两个测试独立运行,不需要对方在线。消费方用 Mock 替代真实的提供方,提供方用契约文件替代真实的消费方。只要双方都通过了契约测试,它们就可以在生产环境中正确交互。

2.3 契约测试 vs 集成测试 vs 端到端测试

三种测试策略的定位不同,不能互相替代:

维度 单元测试 契约测试 集成测试 端到端测试
验证范围 单个函数或类 服务间接口兼容性 多个服务的交互逻辑 完整业务流程
运行速度 毫秒级 秒级 分钟级 十分钟级
环境依赖 需要部署多个服务 需要完整环境
失败定位 精确 精确到接口 需要排查 困难
维护成本 很高
反馈周期 即时 很慢

契约测试填补了单元测试和集成测试之间的空白:它验证的是服务间的接口兼容性,但不需要真正启动其他服务。


三、Consumer-Driven Contract Testing(Pact)

3.1 Pact 的核心概念

Pact 是目前最成熟的消费者驱动契约测试(Consumer-Driven Contract Testing)框架。它的核心理念是:契约由消费方定义,提供方验证

这个方向的选择是有道理的。消费方最清楚自己需要什么——它不需要响应中的所有字段,只关心它实际使用的那些字段。如果提供方新增了一个字段,但消费方没有用到,这不是一个 Breaking Change。如果提供方删除了一个消费方正在使用的字段,这才是一个 Breaking Change。

Pact 的工作流程分为三个阶段:

sequenceDiagram
    participant C as 消费方
    participant B as Pact Broker
    participant P as 提供方

    Note over C: 阶段一:生成契约
    C->>C: 编写消费方测试
    C->>C: Pact Mock Server 录制交互
    C->>B: 发布 Pact 契约文件

    Note over P: 阶段二:验证契约
    B->>P: 拉取 Pact 契约文件
    P->>P: 重放交互,验证响应
    P->>B: 发布验证结果

    Note over C,P: 阶段三:部署决策
    C->>B: can-i-deploy 查询
    B->>C: 返回兼容性结果

3.2 消费方测试示例

以 JavaScript(使用 Pact-JS)为例,支付服务作为消费方,编写对订单服务的契约测试:

const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, integer, string } = MatchersV3;
const { fetchOrder } = require('../src/orderClient');

const provider = new PactV3({
  consumer: 'PaymentService',
  provider: 'OrderService',
});

describe('Order API Contract', () => {
  it('should return order details', async () => {
    // 定义期望的交互
    provider
      .given('an order with ID 123 exists')
      .uponReceiving('a request for order 123')
      .withRequest({
        method: 'GET',
        path: '/orders/123',
        headers: {
          Accept: 'application/json',
        },
      })
      .willRespondWith({
        status: 200,
        headers: {
          'Content-Type': 'application/json',
        },
        body: {
          id: integer(123),
          total: integer(19900),
          currency: string('CNY'),
          status: string('paid'),
        },
      });

    await provider.executeTest(async (mockServer) => {
      const order = await fetchOrder(mockServer.url, 123);
      expect(order.id).toBe(123);
      expect(typeof order.total).toBe('number');
      expect(order.status).toBe('paid');
    });
  });
});

这个测试运行时,Pact 会在本地启动一个 Mock Server,模拟订单服务的行为。测试通过后,Pact 会生成一个契约文件(JSON 格式),记录这次交互的请求和响应结构。

3.3 生成的契约文件结构

{
  "consumer": { "name": "PaymentService" },
  "provider": { "name": "OrderService" },
  "interactions": [
    {
      "description": "a request for order 123",
      "providerState": "an order with ID 123 exists",
      "request": {
        "method": "GET",
        "path": "/orders/123",
        "headers": { "Accept": "application/json" }
      },
      "response": {
        "status": 200,
        "headers": { "Content-Type": "application/json" },
        "body": {
          "id": 123,
          "total": 19900,
          "currency": "CNY",
          "status": "paid"
        },
        "matchingRules": {
          "body": {
            "$.id": { "matchers": [{ "match": "integer" }] },
            "$.total": { "matchers": [{ "match": "integer" }] },
            "$.currency": { "matchers": [{ "match": "type" }] },
            "$.status": { "matchers": [{ "match": "type" }] }
          }
        }
      }
    }
  ],
  "metadata": {
    "pactSpecification": { "version": "3.0.0" }
  }
}

3.4 提供方验证

订单服务拿到这个契约文件后,运行验证测试:

@Provider("OrderService")
@PactBroker(
    host = "pact-broker.internal",
    authentication = @PactBrokerAuth(token = "${PACT_BROKER_TOKEN}")
)
public class OrderProviderContractTest {

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verifyPact(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @BeforeEach
    void setupTestTarget(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", 8080));
    }

    @State("an order with ID 123 exists")
    void setupOrder() {
        // 在测试数据库中插入测试数据
        orderRepository.save(new Order(123, 19900, "CNY", "paid"));
    }
}

验证过程中,Pact 框架会按照契约文件中的定义发送请求,然后检查实际响应是否与契约中的期望匹配。如果订单服务把 total 字段从整数改成了字符串,验证就会失败。

3.5 Pact Broker

Pact Broker 是契约文件的中央存储和管理平台。它的核心功能包括:

  • 契约存储:消费方发布契约文件,提供方拉取契约文件。
  • 验证结果追踪:记录每个提供方版本对每个消费方契约的验证结果。
  • 依赖关系可视化:自动生成服务间的依赖拓扑图。
  • can-i-deploy 查询:回答”这个版本能不能安全部署到生产环境”的问题。
# 发布契约到 Broker
pact-broker publish ./pacts \
  --consumer-app-version=$(git rev-parse --short HEAD) \
  --broker-base-url=https://pact-broker.internal \
  --tag=$(git branch --show-current)

# 查询是否可以部署
pact-broker can-i-deploy \
  --pacticipant=PaymentService \
  --version=$(git rev-parse --short HEAD) \
  --to-environment=production

can-i-deploy 的判断逻辑:检查当前版本的契约是否已经被所有相关提供方的生产版本验证通过。如果是,返回成功;如果不是,返回失败并列出未验证或验证失败的契约。


四、Protobuf 的 Schema 演进规则

4.1 为什么 Protobuf 需要演进规则

HTTP/JSON API 的契约是松散的——字段名、类型、结构都由文档约定,没有编译期检查。Protocol Buffers(Protobuf)走了另一条路:用 .proto 文件定义严格的 Schema,编译器生成序列化和反序列化代码,运行时按照 Schema 解析二进制数据。

这种严格性带来了一个问题:当 Schema 需要变更时,如何保证新旧版本的代码可以互相解析对方的数据?这就是 Schema 演进要解决的问题。

4.2 字段编号是关键

Protobuf 的核心机制是字段编号(Field Number)。序列化时,数据按照字段编号(而非字段名)写入二进制流;反序列化时,也按照字段编号定位数据。这意味着字段名可以随便改(不影响二进制兼容性),但字段编号绝对不能复用。

// v1:初始版本
message Order {
  int64 id = 1;
  int32 total_cents = 2;
  string currency = 3;
  string status = 4;
}

4.3 安全的变更操作

以下操作是安全的,不会破坏前向或后向兼容性:

新增字段:给新字段分配一个从未使用过的编号。旧版本的代码遇到不认识的字段编号会直接跳过(Protobuf 的二进制格式支持这一点)。

// v2:新增 shipping_address 字段
message Order {
  int64 id = 1;
  int32 total_cents = 2;
  string currency = 3;
  string status = 4;
  string shipping_address = 5;  // 新增
}

删除字段:可以删除字段,但必须用 reserved 关键字保留该字段的编号和名称,防止将来被复用。

// v3:删除 currency 字段
message Order {
  reserved 3;
  reserved "currency";
  int64 id = 1;
  int32 total_cents = 2;
  string status = 4;
  string shipping_address = 5;
}

重命名字段:安全操作。二进制格式只看编号,不看名称。

4.4 危险的变更操作

以下操作会破坏兼容性:

复用字段编号:这是最严重的错误。如果把编号 3 从 string currency 改为 int32 quantity,旧版本的代码会尝试把 quantity 的整数值当作字符串解析,导致数据损坏或崩溃。

修改字段类型:大多数类型修改是不兼容的。例外是部分整数类型之间的兼容(int32int64uint32uint64),但 stringint32 之间完全不兼容。

修改字段的 repeated/singular 属性:把一个 singular 字段改成 repeated,或反过来,可能导致解析异常。

4.5 oneof 的演进规则

oneof 是 Protobuf 中表达”多选一”的机制:

message Payment {
  int64 order_id = 1;
  oneof payment_method {
    CreditCard credit_card = 2;
    BankTransfer bank_transfer = 3;
    WechatPay wechat_pay = 4;  // 可以安全地新增选项
  }
}

message CreditCard {
  string card_number = 1;
  string expiry = 2;
}

message BankTransfer {
  string bank_code = 1;
  string account_number = 2;
}

message WechatPay {
  string open_id = 1;
}

oneof 的安全演进规则:

  • 可以在 oneof 中新增选项(新增字段编号)。
  • 不能把已有字段移入或移出 oneof
  • 不能删除 oneof 本身(但可以删除其中的字段,需 reserved)。
  • 不能把 oneof 字段合并或拆分。

4.6 Protobuf 演进规则速查表

操作 是否安全 说明
新增字段(新编号) 安全 旧代码忽略未知字段
删除字段(不 reserved) 危险 编号可能被复用
删除字段(reserved) 安全 编号被保护
重命名字段 安全 二进制不看名称
修改字段编号 危险 破坏二进制兼容
修改字段类型 大多危险 仅部分整数类型兼容
新增 oneof 选项 安全 旧代码视为未设置
移动字段进出 oneof 危险 内存布局不同

五、Avro 的 Schema 演进规则

5.1 Avro 的设计哲学

Apache Avro 和 Protobuf 解决同一类问题——数据的序列化和 Schema 演进——但设计哲学截然不同。

Protobuf 把字段编号写入每条数据中,数据可以自描述。Avro 不在数据中存储字段信息,数据只包含值(Value),Schema 在序列化和反序列化时由外部提供。这意味着 Avro 数据更紧凑,但反序列化时必须同时持有写入方的 Schema(Writer Schema)和读取方的 Schema(Reader Schema)。

5.2 Writer Schema 与 Reader Schema

这是 Avro 最核心的概念。假设写入方使用 Schema v1 写入数据,读取方使用 Schema v2 读取数据:

// Writer Schema (v1)
{
  "type": "record",
  "name": "Order",
  "fields": [
    { "name": "id", "type": "long" },
    { "name": "total_cents", "type": "int" },
    { "name": "currency", "type": "string" },
    { "name": "status", "type": "string" }
  ]
}

// Reader Schema (v2)
{
  "type": "record",
  "name": "Order",
  "fields": [
    { "name": "id", "type": "long" },
    { "name": "total_cents", "type": "int" },
    { "name": "status", "type": "string" },
    { "name": "shipping_address", "type": ["null", "string"], "default": null }
  ]
}

Avro 的解析器在反序列化时,会同时拿到 Writer Schema 和 Reader Schema,按照以下规则进行字段匹配:

  1. 如果 Writer Schema 中有字段但 Reader Schema 中没有(如 currency),跳过该字段。
  2. 如果 Reader Schema 中有字段但 Writer Schema 中没有(如 shipping_address),使用 Reader Schema 中定义的默认值。
  3. 如果两个 Schema 中都有的字段,按照字段名匹配。

5.3 三种兼容性级别

Avro 定义了三种 Schema 兼容性级别:

后向兼容(Backward Compatibility):新版本的 Reader Schema 可以读取旧版本的 Writer Schema 写入的数据。这是最常用的兼容性要求。

  • 允许:删除没有默认值的字段,新增有默认值的字段。
  • 不允许:新增没有默认值的字段(旧数据中没有这个字段,又没有默认值可以填充)。

前向兼容(Forward Compatibility):旧版本的 Reader Schema 可以读取新版本的 Writer Schema 写入的数据。

  • 允许:新增没有默认值的字段(旧 Reader 会忽略不认识的字段),删除有默认值的字段。
  • 不允许:删除没有默认值的字段(旧 Reader 找不到这个字段,又没有默认值)。

完全兼容(Full Compatibility):同时满足前向和后向兼容。这是最严格的要求。

  • 所有新增字段必须有默认值。
  • 所有删除字段必须有默认值。
graph LR
    subgraph 后向兼容
        WV1_B[Writer v1] -->|写入| DATA_B[数据]
        DATA_B -->|读取| RV2_B[Reader v2]
    end
    subgraph 前向兼容
        WV2_F[Writer v2] -->|写入| DATA_F[数据]
        DATA_F -->|读取| RV1_F[Reader v1]
    end
    subgraph 完全兼容
        WV1_FULL[Writer v1] -->|写入| DATA_FULL1[数据]
        WV2_FULL[Writer v2] -->|写入| DATA_FULL2[数据]
        DATA_FULL1 -->|读取| RV2_FULL[Reader v2]
        DATA_FULL2 -->|读取| RV1_FULL[Reader v1]
    end

5.4 Avro Schema 演进示例

// v1:初始版本
{
  "type": "record",
  "name": "UserEvent",
  "fields": [
    { "name": "user_id", "type": "long" },
    { "name": "event_type", "type": "string" },
    { "name": "timestamp", "type": "long" }
  ]
}

// v2:后向兼容的变更
{
  "type": "record",
  "name": "UserEvent",
  "fields": [
    { "name": "user_id", "type": "long" },
    { "name": "event_type", "type": "string" },
    { "name": "timestamp", "type": "long" },
    { "name": "source", "type": "string", "default": "unknown" },
    { "name": "metadata", "type": ["null", "string"], "default": null }
  ]
}

// v3:完全兼容的变更(同时满足前向和后向)
{
  "type": "record",
  "name": "UserEvent",
  "fields": [
    { "name": "user_id", "type": "long" },
    { "name": "event_type", "type": "string" },
    { "name": "timestamp", "type": "long" },
    { "name": "source", "type": "string", "default": "unknown" },
    { "name": "metadata", "type": ["null", "string"], "default": null },
    { "name": "trace_id", "type": ["null", "string"], "default": null }
  ]
}

5.5 Avro 演进的陷阱

Union 类型的顺序敏感:Avro 的 Union 类型(如 ["null", "string"])要求两个 Schema 中的 Union 分支顺序一致。["null", "string"]["string", "null"] 是不兼容的。

枚举类型的限制:Avro 的 enum 类型不支持安全删除枚举值。如果旧数据包含一个新 Schema 中不存在的枚举值,反序列化会失败。可以通过 default 属性指定默认枚举值来缓解。

字段类型提升的限制:Avro 支持有限的类型提升(Type Promotion),例如 int 可以提升为 longfloat 可以提升为 double。但反过来不行——long 不能降级为 int


六、Protobuf vs Avro 对比

6.1 核心机制对比

维度 Protobuf Avro
字段标识方式 字段编号(嵌入数据) 字段名(由 Schema 匹配)
数据自描述性 部分自描述(编号 + 类型标签) 不自描述(需外部 Schema)
Schema 传递方式 编译时生成代码 运行时传入 Schema
序列化后体积 中等 更小(无字段标签)
动态语言支持 需要生成代码或反射 原生支持动态解析
Schema 演进机制 字段编号 + reserved Writer/Reader Schema 解析
默认值处理 proto3 所有字段有隐式默认值 必须在 Schema 中显式声明
必填字段 proto3 取消了 required 无默认值的字段即为必填
主要使用场景 gRPC、服务间通信 Kafka、大数据管道
IDL 格式 自有 .proto 语法 JSON 格式

6.2 兼容性模型对比

兼容性场景 Protobuf 处理方式 Avro 处理方式
新增字段 分配新编号,旧代码忽略 新增字段需有默认值(后向兼容)
删除字段 用 reserved 保护编号 有默认值可删(前向兼容)
类型修改 大多不兼容 有限的类型提升
字段重命名 安全(只看编号) 不安全(按名称匹配)
兼容性检查时机 编译时 + lint 工具 Schema Registry 注册时

6.3 选型建议

  • 选 Protobuf:当系统以同步 RPC 为主(gRPC)、团队使用静态类型语言(Go、Java、C++)、需要编译期类型安全。
  • 选 Avro:当系统以异步消息为主(Kafka)、数据要长期存储在数据湖中、需要灵活的 Schema 演进策略、使用动态语言较多。
  • 混合使用:不少团队在 RPC 层用 Protobuf,在消息层用 Avro,这是合理的选择。

七、Schema Registry 工程实践

7.1 Confluent Schema Registry 架构

Confluent Schema Registry 是 Kafka 生态中事实上的 Schema 管理标准。它解决的核心问题是:Kafka 的消息是二进制字节流,生产者和消费者需要一个集中的地方来获取和管理 Schema。

graph TB
    P[生产者] -->|1. 注册 Schema| SR[Schema Registry]
    SR -->|2. 返回 Schema ID| P
    P -->|3. 发送消息<br/>Schema ID + 数据| K[Kafka Broker]
    K -->|4. 消费消息| C[消费者]
    C -->|5. 用 Schema ID 查询 Schema| SR
    SR -->|6. 返回 Schema| C
    C -->|7. 反序列化数据| C

每条 Kafka 消息的前五个字节是一个固定格式的头部:1 个字节的 Magic Byte(固定为 0x00)加 4 个字节的 Schema ID。消费者从消息头中取出 Schema ID,到 Schema Registry 查询对应的 Schema,然后用这个 Schema 反序列化消息体。

7.2 Subject 策略

Schema Registry 用 Subject(主题)来组织 Schema。Subject 的命名策略决定了 Schema 的作用域:

TopicNameStrategy(默认):Subject 名称 = Topic 名称 + -key-value。例如,Topic orders 的 Value Schema 的 Subject 是 orders-value。这意味着一个 Topic 只能有一个 Schema。

RecordNameStrategy:Subject 名称 = Schema 的全限定名。例如,Schema 名称为 com.example.Order 的 Subject 就是 com.example.Order。同一个 Topic 可以有多种类型的消息。

TopicRecordNameStrategy:Subject 名称 = Topic 名称 + Schema 全限定名。兼顾了 Topic 隔离和多消息类型的需求。

# 配置 Subject 策略(生产者端)
value.subject.name.strategy=io.confluent.kafka.serializers.subject.RecordNameStrategy

7.3 兼容性级别配置

Schema Registry 支持对每个 Subject 独立配置兼容性级别:

# 查看全局兼容性级别
curl -X GET http://schema-registry:8081/config

# 设置全局兼容性级别为 BACKWARD
curl -X PUT -H "Content-Type: application/json" \
  --data '{"compatibility": "BACKWARD"}' \
  http://schema-registry:8081/config

# 为特定 Subject 设置兼容性级别
curl -X PUT -H "Content-Type: application/json" \
  --data '{"compatibility": "FULL"}' \
  http://schema-registry:8081/config/subjects/orders-value

# 测试新 Schema 的兼容性(不实际注册)
curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data '{"schema": "{\"type\":\"record\",\"name\":\"Order\",\"fields\":[...]}"}' \
  http://schema-registry:8081/compatibility/subjects/orders-value/versions/latest

Schema Registry 支持的兼容性级别:

级别 含义 典型场景
BACKWARD 新 Schema 可读旧数据 消费者先升级
BACKWARD_TRANSITIVE 新 Schema 可读所有旧版本数据 严格后向兼容
FORWARD 旧 Schema 可读新数据 生产者先升级
FORWARD_TRANSITIVE 最旧 Schema 也可读最新数据 严格前向兼容
FULL 同时满足 BACKWARD 和 FORWARD 双向兼容
FULL_TRANSITIVE 任意版本间互相兼容 最严格
NONE 不检查兼容性 开发环境

7.4 Schema 注册与演进流程

// 生产者配置:使用 Schema Registry 的 Avro 序列化器
Properties props = new Properties();
props.put("bootstrap.servers", "kafka:9092");
props.put("key.serializer",
    "io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("value.serializer",
    "io.confluent.kafka.serializers.KafkaAvroSerializer");
props.put("schema.registry.url", "http://schema-registry:8081");
props.put("auto.register.schemas", "false");
props.put("use.latest.version", "true");

KafkaProducer<String, GenericRecord> producer =
    new KafkaProducer<>(props);

// 构建消息
Schema schema = new Schema.Parser().parse(new File("order_v2.avsc"));
GenericRecord order = new GenericData.Record(schema);
order.put("id", 12345L);
order.put("total_cents", 19900);
order.put("status", "paid");
order.put("source", "mobile_app");

producer.send(new ProducerRecord<>("orders", "order-12345", order));

注意 auto.register.schemas 设为 false。在生产环境中,不应让应用程序自动注册 Schema——Schema 的注册和兼容性检查应该在 CI/CD 流水线中完成,而不是在运行时。

7.5 Breaking Change 的分类

在 Schema Registry 的语境下,变更可以分为以下几类:

变更类型 后向兼容 前向兼容 完全兼容
新增有默认值的字段 兼容 兼容 兼容
新增无默认值的字段 不兼容 兼容 不兼容
删除有默认值的字段 兼容 兼容 兼容
删除无默认值的字段 兼容 不兼容 不兼容
修改字段类型(兼容提升) 兼容 不兼容 不兼容
修改字段类型(不兼容) 不兼容 不兼容 不兼容
重命名字段 不兼容 不兼容 不兼容
新增枚举值 兼容 不兼容 不兼容
删除枚举值 不兼容 兼容 不兼容

八、契约测试在 CI/CD 中的集成

8.1 流水线架构

契约测试在 CI/CD 流水线中的典型位置如下:

graph TB
    subgraph 消费方流水线
        CC[代码提交] --> CB[构建]
        CB --> CU[单元测试]
        CU --> CT[契约测试<br/>生成 Pact 文件]
        CT --> CP[发布 Pact 到 Broker]
        CP --> CD[can-i-deploy 检查]
        CD -->|通过| CDeploy[部署到 Staging]
        CD -->|失败| CBlock[阻止部署]
    end

    subgraph 提供方流水线
        PC[代码提交] --> PB[构建]
        PB --> PU[单元测试]
        PU --> PV[契约验证<br/>从 Broker 拉取 Pact]
        PV --> PR[发布验证结果到 Broker]
        PR --> PD[can-i-deploy 检查]
        PD -->|通过| PDeploy[部署到 Staging]
        PD -->|失败| PBlock[阻止部署]
    end

8.2 Webhook 触发验证

当消费方发布新的契约文件时,Pact Broker 可以通过 Webhook 触发提供方的验证流水线。这样提供方不需要轮询 Broker,新契约发布后立即开始验证。

{
  "events": [
    { "name": "contract_content_changed" }
  ],
  "request": {
    "method": "POST",
    "url": "https://ci.internal/api/trigger",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer ${CI_TOKEN}"
    },
    "body": {
      "pipeline": "contract-verification",
      "parameters": {
        "provider": "${pactbroker.providerName}",
        "consumer": "${pactbroker.consumerName}",
        "consumer_version": "${pactbroker.consumerVersionNumber}",
        "pact_url": "${pactbroker.pactUrl}"
      }
    }
  }
}

8.3 can-i-deploy 工作原理

can-i-deploy 是 Pact 生态中最关键的命令。它回答的问题是:我的这个版本和目标环境中所有相关服务的版本之间,契约是否已经验证通过?

# 检查 PaymentService 的版本 abc123 是否可以部署到 production
pact-broker can-i-deploy \
  --pacticipant PaymentService \
  --version abc123 \
  --to-environment production

# 输出示例(成功)
# Computer says yes \o/
#
# CONSUMER        | C.VERSION | PROVIDER     | P.VERSION | SUCCESS?
# PaymentService  | abc123    | OrderService | def456    | true
# PaymentService  | abc123    | UserService  | ghi789    | true

# 输出示例(失败)
# Computer says no >(
#
# CONSUMER        | C.VERSION | PROVIDER     | P.VERSION | SUCCESS?
# PaymentService  | abc123    | OrderService | jkl012    | false
#
# Reason: 验证失败 - OrderService@jkl012 的 total 字段类型为 string,
# 但 PaymentService@abc123 期望 integer

8.4 Schema Registry 在 CI/CD 中的集成

对于使用 Avro + Kafka 的系统,Schema 的兼容性检查也应该集成到 CI/CD 流水线中:

#!/bin/bash
# schema-check.sh:在 CI 中检查 Schema 兼容性

SCHEMA_REGISTRY_URL="http://schema-registry:8081"
SCHEMA_DIR="./schemas"

for schema_file in "$SCHEMA_DIR"/*.avsc; do
  subject=$(basename "$schema_file" .avsc)
  schema_content=$(cat "$schema_file" | jq -c '.')

  # 检查兼容性
  response=$(curl -s -X POST \
    -H "Content-Type: application/vnd.schemaregistry.v1+json" \
    --data "{\"schema\": $(echo "$schema_content" | jq -Rs '.')}" \
    "$SCHEMA_REGISTRY_URL/compatibility/subjects/${subject}-value/versions/latest")

  is_compatible=$(echo "$response" | jq -r '.is_compatible')

  if [ "$is_compatible" != "true" ]; then
    echo "Schema 不兼容: $subject"
    echo "$response" | jq '.'
    exit 1
  fi

  echo "Schema 兼容: $subject"
done

echo "所有 Schema 兼容性检查通过"

8.5 Protobuf 的 CI 检查

对于 Protobuf,可以使用 buf 工具在 CI 中检查 Schema 的 Breaking Change:

# buf.yaml
version: v1
breaking:
  use:
    - FILE
  except:
    - FIELD_SAME_JSON_NAME

# CI 流水线中的 buf breaking 检查
# buf breaking --against '.git#branch=main'
# 使用 buf 检查 breaking change
buf breaking proto/ --against '.git#branch=main'

# 输出示例(发现 breaking change)
# proto/order.proto:5:3:
#   Field "2" on message "Order" changed type from "int32" to "string".

九、工程案例:电商平台的契约治理体系

9.1 背景

某电商平台有 120 个微服务,服务间通过 gRPC(Protobuf)进行同步调用,通过 Kafka(Avro)进行异步消息通信。团队在半年内经历了三次因接口不兼容导致的生产事故,决定建立系统化的契约治理体系。

9.2 治理体系设计

团队建立了三层防线:

第一层:Schema 静态检查(开发阶段)

在代码仓库中引入 buf 工具,配置 pre-commit hook,开发者提交 .proto 文件时自动检查 Breaking Change。

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/bufbuild/buf
    rev: v1.28.0
    hooks:
      - id: buf-breaking
        args: ['--against', '.git#branch=main']
      - id: buf-lint

对于 Avro Schema,使用自研的 Schema 兼容性检查工具,在 MR/PR 阶段自动检查。

第二层:契约测试(CI 阶段)

所有服务间的 gRPC 调用都有对应的 Pact 契约测试。消费方生成契约,提供方验证契约。Pact Broker 部署在内部基础设施中,与 CI 系统通过 Webhook 联动。

Avro Schema 的兼容性检查集成到 CI 流水线中,新的 Schema 在合入主分支前必须通过 Schema Registry 的兼容性检查。

第三层:运行时防护(部署阶段)

所有部署都必须通过 can-i-deploy 检查。部署系统在执行部署前自动调用 Pact Broker 的 API,确认当前版本与目标环境中所有相关服务的版本兼容。

Kafka 生产者配置 auto.register.schemas=false,禁止运行时自动注册 Schema。Schema 的注册只能通过 CI/CD 流水线完成。

9.3 实施效果

事故次数(接口不兼容导致):
  实施前 6 个月:3 次
  实施后 12 个月:0 次

契约测试覆盖率:
  第 1 个月:12%(核心链路)
  第 3 个月:45%
  第 6 个月:78%
  第 12 个月:92%

部署频率变化:
  实施前:每周 2-3 次(因为怕出问题)
  实施后:每天 5-8 次(有信心快速部署)

9.4 踩过的坑

坑一:Provider State 管理混乱。Pact 的 @State 注解需要提供方在验证前设置测试数据。早期团队直接操作生产数据库的测试副本,导致状态泄漏和测试不稳定。后来改为每次验证前重置独立的测试数据库。

坑二:契约粒度过细。早期有团队为每个 API 端点的每种参数组合都写了契约测试,导致契约数量爆炸,维护成本高于收益。后来制定规范:契约测试只覆盖消费方实际使用的字段和核心交互路径,不覆盖边界情况和异常路径——那些留给提供方自己的测试。

坑三:Schema Registry 的 Subject 策略选错。初期使用默认的 TopicNameStrategy,一个 Topic 只能有一种消息类型。当业务需要在同一个 Topic 中发送不同类型的事件时,被迫切换到 RecordNameStrategy,过程中出现了短暂的兼容性问题。

坑四:忽视了 Transitive 兼容性。使用 BACKWARD 兼容性级别时,只检查新版本与上一个版本的兼容性。但如果消费者还在使用更老的版本,可能会出问题。改用 BACKWARD_TRANSITIVE 后,确保新版本与所有历史版本兼容。

9.5 契约治理架构全景

graph TB
    subgraph 开发阶段
        DEV[开发者] -->|提交代码| GIT[Git 仓库]
        GIT -->|pre-commit| BUF[buf breaking 检查]
        GIT -->|pre-commit| AVRO_LINT[Avro Schema 检查]
    end

    subgraph CI 阶段
        GIT -->|触发| CI[CI 流水线]
        CI --> UNIT[单元测试]
        CI --> PACT_C[消费方契约测试]
        CI --> PACT_P[提供方契约验证]
        CI --> SR_CHECK[Schema Registry 兼容性检查]
        PACT_C -->|发布| BROKER[Pact Broker]
        PACT_P -->|验证结果| BROKER
        SR_CHECK -->|注册| SR[Schema Registry]
    end

    subgraph 部署阶段
        CI --> CID[can-i-deploy]
        CID -->|查询| BROKER
        CID -->|通过| DEPLOY[部署系统]
        CID -->|失败| BLOCK[阻止部署]
    end

十、测试策略金字塔中的契约测试

10.1 测试金字塔回顾

Mike Cohn 在 2009 年提出的测试金字塔(Test Pyramid)模型将测试分为三层:底层是大量的单元测试,中间是适量的集成测试,顶层是少量的端到端测试。

在微服务架构下,这个金字塔需要扩展。服务内部的测试仍然遵循传统金字塔,但服务之间的交互验证需要一个新的层次——契约测试。

          /\
         /  \        端到端测试(少量,验证关键业务流程)
        /    \
       /------\
      /        \     集成测试(适量,验证组件间交互)
     /          \
    /------------\
   /              \   契约测试(覆盖所有服务间接口)
  /                \
 /------------------\
/                    \  单元测试(大量,验证内部逻辑)
/____________________\

10.2 各层测试的职责边界

单元测试:验证单个函数或类的内部逻辑。不涉及外部依赖。运行速度最快,反馈最及时。

契约测试:验证服务间的接口兼容性。只关心请求和响应的结构是否匹配,不关心业务逻辑是否正确。每个服务独立运行,不需要启动其他服务。

集成测试:验证多个组件在一起工作时的行为。需要启动真实的依赖(数据库、消息队列等)。验证的是端到端的数据流,而非仅仅接口结构。

端到端测试:验证完整的业务流程。从用户界面或 API 入口开始,走完整个链路。成本最高,应该只覆盖最关键的几个业务场景。

10.3 契约测试不能替代的场景

契约测试有明确的边界,以下场景仍然需要其他类型的测试:

业务逻辑验证:契约测试只检查”返回的数据结构对不对”,不检查”返回的数据值对不对”。例如,订单服务返回了正确结构的响应,但金额计算错误——这需要单元测试或集成测试来发现。

跨服务的事务一致性:契约测试不验证分布式事务的正确性。例如,订单创建成功但库存扣减失败——这需要集成测试或端到端测试。

性能和负载行为:契约测试不涉及性能指标。服务在高并发下是否能正确处理请求,需要专门的性能测试。

网络异常和超时处理:契约测试假设网络是可靠的。服务在网络分区、超时、重试等场景下的行为,需要集成测试或混沌工程来验证。

10.4 投入产出分析

测试类型 编写成本 维护成本 运行速度 发现问题的精度 覆盖的风险类型
单元测试 毫秒 高(定位到函数) 逻辑错误
契约测试 高(定位到接口字段) 接口不兼容
集成测试 分钟 中(定位到组件) 组件交互错误
端到端测试 很高 很高 十分钟 低(需排查) 业务流程缺陷

10.5 实施建议

从核心链路开始:不要试图一次性给所有服务添加契约测试。优先覆盖故障影响最大的核心链路——下单、支付、发货。

消费方驱动:让消费方团队来编写契约测试。他们最清楚自己需要什么,也最有动力确保接口不被破坏。

配合 Schema 管理:HTTP/JSON API 用 Pact,gRPC 用 Protobuf 的 buf 工具,Kafka 消息用 Schema Registry。不同通信方式使用不同的契约管理工具。

渐进式推行:先从新服务开始,在新服务中强制要求契约测试。老服务在发生变更时逐步补充。不要强制存量服务一次性补齐——那会引起团队抵触。

10.6 API 版本化策略

当 Breaking Change 不可避免时,需要版本化策略来管理过渡期:

URL 路径版本化/v1/orders/v2/orders。简单直接,但 URL 会越来越长。

Header 版本化Accept: application/vnd.example.v2+json。URL 保持干净,但客户端实现稍复杂。

语义化版本号(Semantic Versioning):对 API 也采用 Major.Minor.Patch 的版本号。Major 版本升级表示不兼容变更,Minor 表示新增功能但兼容,Patch 表示修复但不改变接口。

v1.0.0  初始版本
v1.1.0  新增 shipping_address 字段(非 breaking)
v1.2.0  新增 metadata 字段(非 breaking)
v2.0.0  total 从整数改为对象 { amount, currency }(breaking)

并行运行期:新版本上线后,旧版本至少保留两个迭代周期(或由 SLA 约定),给消费方迁移时间。在旧版本的响应中添加 Deprecation Header 提示消费方升级。

HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Link: </v2/orders>; rel="successor-version"

参考资料

  1. Pact Foundation. “Pact Documentation.” https://docs.pact.io/
  2. Google. “Protocol Buffers Language Guide (proto3).” https://protobuf.dev/programming-guides/proto3/
  3. Apache Software Foundation. “Apache Avro Specification.” https://avro.apache.org/docs/current/specification/
  4. Confluent. “Schema Registry Documentation.” https://docs.confluent.io/platform/current/schema-registry/
  5. Buf. “Buf CLI Documentation.” https://buf.build/docs/
  6. Sam Newman. “Building Microservices”, 2nd Edition. O’Reilly, 2021.
  7. Martin Fowler. “Contract Testing.” https://martinfowler.com/bliki/ContractTest.html
  8. Confluent. “Schema Evolution and Compatibility.” https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html
  9. Google. “API Versioning.” https://cloud.google.com/apis/design/versioning
  10. Mike Cohn. “Succeeding with Agile: Software Development Using Scrum.” Addison-Wesley, 2009.

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计】什么是软件架构:从代码结构到系统决策

软件架构的定义至少有三种主流理解,每种指向不同的关注点。本文对比 Booch、Fowler、Johnson 三家定义,分析架构决策的不可逆性特征,拆解 Krutchten 4+1 视图模型,回答一个核心问题:架构和设计的边界到底在哪里。

2026-04-13 · architecture

【系统架构设计】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计】架构决策与 ADR:如何做出可追溯的技术决策

口头约定的架构决策会在人员流动中丢失,会在争论中反复翻车。ADR(Architecture Decision Records)用一种轻量的文档格式,把每一个关键技术决策的背景、选项、理由和代价写下来,跟着代码一起版本管理。本文从 ADR 的三种主流格式讲到 Git 仓库中的实操管理,再拆解 Spotify 和 Uber 的工业实践。