关于 evolving-apis-and-schemas
面向需要跨服务、客户端和持久化数据修改 API 或数据模式的开发者。通过分阶段、可回退的发布流程,确保部署期间新旧读取方都能兼容。
主要功能
- 01识别所有模式使用方
- 02分类变更与兼容性风险
- 03规划扩展、迁移、收缩阶段
- 04提供 Protobuf、SQL、HTTP 和 JSON 指引
- 05规划回退与验证
适用场景
- 01跨服务重命名数据库列
- 02新增必填字段且避免破坏调用方
- 03规划大规模数据回填
- 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 阶段运行足够长的时间,以确认一切正常。