mcpField
E
evolving-apis-and-schemas

evolving-apis-and-schemas

github.com/osmontero开发1693 次安装今天更新
代码仓库

关于 evolving-apis-and-schemas

面向需要跨服务、客户端和持久化数据修改 API 或数据模式的开发者。通过分阶段、可回退的发布流程,确保部署期间新旧读取方都能兼容。

主要功能

  1. 01识别所有模式使用方
  2. 02分类变更与兼容性风险
  3. 03规划扩展、迁移、收缩阶段
  4. 04提供 Protobuf、SQL、HTTP 和 JSON 指引
  5. 05规划回退与验证

适用场景

  1. 01跨服务重命名数据库列
  2. 02新增必填字段且避免破坏调用方
  3. 03规划大规模数据回填
  4. 04检查 Protobuf 变更的兼容性

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/osmontero/opencode-skills --skill evolving-apis-and-schemas

安装后在 Claude Code 中输入/evolving-apis-and-schemas确认已加载。

附带文件references/migration-recipes.md

详细介绍

演进 API 和 Schema

概述

Schema 变更是披着一行代码差异外衣的分布式系统问题。读取数据的代码不只有你即将部署的代码,还包括发布期间仍在运行的版本、你无法控制的客户端,以及去年二进制版本写入的行。

核心原则: 在发布的每个时刻,旧读取方和新读取方都必须能正确工作。如果存在某个时刻不满足这一点,就需要增加步骤,而不是增加信心。

配套技能: 使用 writing-plans 安排跨多个版本的变更;运行任何破坏性操作前使用 verifying-before-completion。

铁律

ADDITIVE FIRST, DESTRUCTIVE LAST — NEVER IN THE SAME DEPLOY

添加是可逆的;删除、重命名和收窄则不可逆。在它们之间,至少要有一个已全面部署且两种结构都能正常工作的版本,并留出足够时间,确保你不会回滚到它之前的版本。

没有例外:

  • 不能因为“旧列肯定没人用了”就跳过——用查询证明,而不是用 grep
  • 不能因为“我们会一起部署所有内容”就跳过——发布不是原子的,回滚也不是
  • 不能因为“这只是个小表”就跳过
  • 不能对重命名例外处理。重命名就是删除再添加,也是最常见的服务中断原因

第 1 步:确定使用方

在对变更分类之前,先列出哪些使用方会读取这种结构。答案决定了扩展/收缩是可选还是必需。

使用方能与生产方一起原子性地重新部署吗?后果
同一个二进制、同一个结构体可以普通重构——编译器会捕获问题
你负责的另一个服务不可以——滚动部署意味着两个版本会同时运行必须采用扩展/收缩
你无法控制的客户端(SDK、移动应用、第三方)永远不可以对其进行版本管理;旧结构会永久存在
持久化数据不可以——你无法重新部署一行数据旧格式的行会比每次部署都存在得更久
队列或事件日志不可以——在途消息由旧代码写入两种格式都必须能解析
副本或集群中的对等节点不可以——升级期间会混用不同版本两个方向都必须兼容

最常被遗忘的一行是持久化数据。 磁盘上已有的每个值,都是由你不再运行的代码写入的。如果新代码无法读取它,你就发布了一个数据损坏 Bug,只会在旧记录上显现。

第 2 步:对变更分类

类别示例要求
安全添加可选字段;添加新端点、表或 RPC;添加索引;扩大类型范围发布即可
扩展/收缩添加必填字段;重命名;更改类型;拆分或合并;删除任何内容执行下文的三阶段流程
破坏性——必须版本化改变现有名称的含义;从已发布的 API 中移除内容;收窄可接受的输入发布新版本,并设置弃用窗口

第二行与第三行的区别在于:当你最终可以更新每个读取方时,扩展/收缩才有效。如果你无法做到——例如已发布的移动应用或公共 API——就必须在支持窗口要求的期限内继续支持旧结构。

第 3 步:扩展/收缩流程

按顺序部署三次。每次都可以独立发布,也可以独立回滚。

1. EXPAND    Add the new shape. Nothing reads it yet.
             Writers write BOTH old and new.        ← revertible
             ─── deploy, verify, let it soak ───
2. MIGRATE   Backfill existing data into the new shape.
             Switch readers to the new shape.
             Writers still write both.              ← revertible
             ─── deploy, verify, let it soak ───
3. CONTRACT  Stop writing the old shape. Drop it.   ← the point of no return

阶段之间要有门槛,而不只是等待时间: 在收缩之前,通过查询确认没有任何内容再写入旧结构,也没有任何内容再读取它——检查应用日志、pg_stat_statements,或在第 1 阶段添加的显式计数器。“已经过了两周”不是证据。

回填不是迁移。 Schema 变更(DDL)放入迁移中;移动数据(DML)则放入单独的、可恢复的分批任务中。对一千万行执行单条 UPDATE 会持有锁、耗尽 WAL,并且超时后无法恢复执行。

常见情况的实操方案——添加 NOT NULL 列、重命名、更改类型、拆分表,以及 SQLite 的重建流程:references/migration-recipes.md。

Protobuf 和 gRPC

字段的编号才是线上的标识,名称不是。

更改结论
添加编号全新的字段安全
添加 RPC、消息或枚举值对能容忍未知值的读取方安全
删除字段仅当对编号和名称都使用 reserved 时才安全
复用已删除字段的编号**绝对不要。**旧数据会被静默解码为错误字段
重命名字段在线格式上安全;会破坏 JSON/文本编码以及所有生成的客户端
更改字段类型仅限兼容的类型族;int32↔int64 可以,int32↔sint32 不行
singular ↔ repeated按破坏性更改处理——即使线上格式兼容,语义也会改变
给现有字段添加 optional在线格式上安全;可提供 presence,不会改变线上格式
重新编号枚举值破坏性更改
更改消息或包名称破坏性更改——它是服务路径和 Any 类型 URL 的一部分
移除或重命名 RPC破坏性更改
// Deleting a field: reserve BOTH, forever
message Document {
  reserved 4, 7 to 9;
  reserved "legacy_owner", "old_status";
}
buf breaking --against '.git#branch=main'    # wire this into review, not just CI

枚举的零值是所有未设置值的默认值——应将零值设为 UNSPECIFIED,绝不能设为有意义的状态,否则就无法区分“未设置”和“设置为第一个选项”。

SQL 架构更改

危险通常不在于正确性,而在于锁定时长。需要短暂独占锁的语句会排在长时间运行的读取操作之后,而其他所有操作又会排在它之后。

SET lock_timeout = '3s';   -- fail fast instead of blocking the whole table

PostgreSQL:

操作开销安全做法
ADD COLUMN 添加可空列,或使用常量默认值短暂锁定,不重写(PG 11+)直接发布
ADD COLUMN 添加使用易变默认值的列完整重写先添加可空列,分批回填,再设置默认值
SET NOT NULL在独占锁下进行完整扫描ADD CONSTRAINT ... CHECK (c IS NOT NULL) NOT VALID → VALIDATE CONSTRAINT → 然后执行 SET NOT NULL,开销很低
CREATE INDEX创建期间阻止写入使用 CREATE INDEX CONCURRENTLY——在事务外执行;如果失败,检查是否存在 INVALID 索引
ADD FOREIGN KEY验证时锁定两张表使用 NOT VALID,然后执行 VALIDATE CONSTRAINT
ALTER COLUMN TYPE完整重写,独占锁定通过新列执行扩展/收缩
DROP COLUMN速度快,不可逆仅在收缩阶段执行

SQLite 仅支持 ADD COLUMN、RENAME COLUMN、RENAME TABLE 和 DROP COLUMN——而且 ADD COLUMN 不允许没有默认值的 NOT NULL、PRIMARY KEY 和 UNIQUE。其他操作都需要执行创建-复制-删除-重命名重建。请参阅配方参考,了解正确的操作顺序;顺序很重要:启用外键时,顺序错误会导致行被静默丢弃。

HTTP 和 JSON API

  • 添加可选响应字段仅当调用方会忽略未知字段时才安全。使用 additionalProperties: false 进行验证的客户端会因新增字段而出错——请先检查,不要想当然。
  • 添加必需请求字段会破坏所有现有调用方。应将其设为可选并提供默认值。
  • 绝不要重复利用名称。让 status 表示新含义,比使用 status_v2 更糟,因为不会有任何错误明确暴露问题。
  • 错误代码和分页结构都属于契约的一部分,即使它们不在架构中也是如此。
  • 应在资源级别进行版本控制,而不是在整个 API 级别——全局的 /v2 会迫使未更改的端点也进行重写。

可逆性

每次迁移都要回答:凌晨 3 点时,我该如何撤销这项更改?

  • 增量迁移:回退操作就是删除。请写出来。
  • 破坏性迁移:没有回退操作。应在迁移文件中明确说明这一点,并写清实际需要怎样恢复——从备份还原、从事件日志重放,或从另一张表重新推导。
  • 绝不要编写会静默丢失数据的回退迁移。通过重新创建空列来“撤销”删除,比直接拒绝回退还糟。
  • 请使用生产数据副本测试回退路径,而不是使用空的开发数据库。

验证

buf breaking --against '.git#branch=main'                       # proto
migrate up && migrate down 1 && migrate up                      # round-trip
psql -c "EXPLAIN (ANALYZE, BUFFERS) <the query the new index serves>"
  • 在还原的生产环境副本上运行迁移并计时。在 200 条开发环境记录上耗时 40 毫秒的迁移,在真实表上可能需要 40 分钟。
  • 在第 2 阶段进行双重读取并比较:读取两种结构,记录任何差异,并在真实流量下运行一段时间,再信任新结构。
  • 确认旧二进制程序针对新架构运行测试时仍能通过。这是回滚路径,也是无人测试的路径。

危险信号

  • 一个迁移文件中同时包含 ADD 和 DROP
  • 在可部署程序不止一个的代码库中执行 ALTER TABLE ... RENAME
  • 单个 UPDATE 没有 WHERE 子句,也没有分批处理
  • 用 grep 结果而不是查询来证明“旧字段未使用”
  • 在有人写入的表上执行 CREATE INDEX,却没有使用 CONCURRENTLY
  • 复用 proto 字段编号
  • 从未运行过的降级迁移
  • 在同一次发布中部署架构变更及其依赖代码

以上任何一种情况都意味着:应将变更拆分到更多次部署中。

常见错误

原地重命名。 编译器会告诉你自己的代码是否有问题,却不会告诉你另一个仍在运行的二进制程序是否有问题。

将部署视为原子操作。 滚动部署期间,两个版本都会处理流量——通常持续许多分钟;如果发布卡住,甚至会无限期持续。

忘记旧记录。 新代码必须能够解析曾经写入的所有格式,直到你确认已全部重写。

在架构迁移中回填数据。 这会在整个过程中持有锁,而且无法恢复执行。

信任 ORM 的自动迁移。 ORM 不知道还有其他读取方,因此会生成具有破坏性的变更。

跳过观察期。 第 3 阶段不可逆;唯一的保障,就是让第 2 阶段运行足够长的时间,以确认一切正常。

评论

支持 Markdown