Proto 仓库多服务共用时,分支策略不跟上接口演进,编译失败几乎不可避免
Proto 仓库被多个服务共用时,如果分支策略还是「一个 main 走天下」,接口一旦演进,编译失败只是时间问题。这不是 Protobuf 本身的问题,而是仓库模型和团队协作方式没跟上接口生命周期的节奏。
我见过最典型的情况:A 团队在 proto 仓库里改了一个 message 的字段类型,从 int32 改成 int64,或者给某个 RPC 方法加了一个必填参数。main 分支合并完,A 团队的消费端服务更新过去了,一切正常。但 B 团队的服务还在用旧版本的生成代码,下一次 CI 拉取 main 编译,直接炸掉。然后 B 团队要么被迫停下来适配,要么把自己的 proto 版本锁死,再也不跟 main 同步。
问题的根源在于:多人共用的 proto 仓库被当成普通业务代码仓库管理,而接口演进天然需要兼容窗口,这个窗口在单一 main 分支模型里无法表达。
下面拆开说。
为什么单一 main 分支在 proto 仓库里必然出问题
普通业务代码仓库里,main 分支的代码就是「当前线上要跑的版本」,改坏了最多影响自己服务。但 proto 仓库的 main 分支是「所有消费方共享的接口契约源」,任何一次合并都会同时推送给所有下游。
这里有一个本质区别:业务代码的变更范围是内聚的,proto 的变更范围是发散且不可控的。你无法在合并一个 breaking change 到 main 时,同时原子性地更新所有消费方服务。不同团队有自己的发布节奏、测试周期和冻结窗口,硬要求他们同步升级,协作成本会高到不可持续。
更隐蔽的问题是:即使所有团队都「尽量兼容」,只要没有强制约束,breaking change 迟早会混进来。 因为写 proto 的人往往不知道有哪些下游在用某个 message 或 RPC。文档会过时,README 没人看,最后只能靠编译失败来「发现」破坏性变更。
可行的分支策略:版本分支 + 兼容期
比较成熟的方案是给 proto 仓库引入版本分支,配合明确的兼容期规则。
具体做法:
main分支永远只包含向下兼容的变更。加字段、加方法、加枚举值,这些都可以直接进 main。- 任何 breaking change(删字段、改类型、重命名、删除 RPC 方法)不允许直接合并到 main。先创建一个版本分支,比如
v2或rpc-v2。 - 消费方服务在准备好之后,显式切换到对应版本分支。切换动作由服务团队自己控制节奏。
- 兼容期内,main 和版本分支并行维护。main 上的兼容性修复需要 cherry-pick 到版本分支。
- 兼容期结束后(所有下游确认迁移完成),版本分支归档或删除。
这个模型的核心是把「接口变更的决策权」和「消费方适配的时机」解耦。写 proto 的人不需要等所有人准备好才能改接口,消费方也不需要被迫在某个时间点前完成升级。
一个具体的例子:我们团队之前有一个 OrderService 的 proto,其中 CreateOrderRequest 里有个 coupon_id 字段,类型从 string 改成 int64。这个变更直接改 main 的话,支付服务、风控服务、报表服务全都会在下次编译时挂掉。后来改成:创建 order-v2 分支,在分支里完成类型变更,同时 main 上保留 coupon_id 的 string 版本,另加一个 coupon_id_v2 的 int64 字段作为过渡。下游服务按自己的节奏切到 order-v2,全部切换完成后,再从 main 上删除旧的 coupon_id 字段。
这个过渡期我们设了 4 周,实际上 3 周就全部迁移完了,期间没有任何一次编译失败是因为 proto 变更导致的。
如果不想引入版本分支,至少要做两件事
有些团队规模小、服务数量少,觉得版本分支太重。那至少要在流程上补两个机制。
第一,CI 里加 breaking change 检测。用 buf breaking 命令(buf 是 Protobuf 的现代工具链,1.x 版本已经比较稳定)对 PR 做检查,对比当前分支和 main 的差异,发现破坏性变更直接让 CI 失败。这个检查是纯机械的,不需要人判断,能把大部分「不小心引入的 breaking change」挡在合并之前。
# .github/workflows/proto-ci.yml 片段
- name: Check breaking changes
run: |
buf breaking \
--against "https://github.com/your-org/proto-repo.git#branch=main,subdir=proto" \
--config buf.yaml
buf breaking 默认的规则集包括:不能删除字段、不能改变字段类型、不能删除 RPC 方法、不能改变方法签名等。这些规则对应的是 wire 兼容性,和编译失败直接相关。
第二,强制要求 PR 描述里列出所有已知消费方。如果检测到 breaking change 且必须合并,PR 作者需要手动列出受影响的服务,并 @ 对应团队的 owner 确认。这个流程不阻止变更,但把「变更影响面」从隐式变成显式,避免下游在不知情的情况下被打破。
这两个机制加起来,能把 80% 以上的意外编译失败挡在门外。剩下的 20% 往往是跨语言的类型映射问题(比如 Java 的 int64 映射到 JavaScript 的 number 精度丢失),这类问题靠工具检测不出来,需要靠团队约定和 code review 把关。
版本分支不是万能药,但比「出了问题再修」强得多
版本分支策略有一个明显的代价:维护成本上升。兼容期内,同一个 proto 文件可能需要在多个分支上分别修复 bug,cherry-pick 冲突会随着分支数量增加而恶化。如果同时有 5 个版本分支在活跃,维护成本会接近指数级增长。
所以版本分支的数量必须严格控制。我的建议是:同时活跃的版本分支不超过 2 个。超过这个数,说明兼容期拖得太长,或者下游迁移意愿不足,这时候需要有人站出来推动迁移,而不是继续开新分支。
另一个常见的坑是:版本分支上的 proto 文件和 main 上的 proto 文件 package 名相同,导致生成代码在同一个服务里冲突。解决方案是在版本分支上改 package 名(比如 order.v2),这样新旧两套生成代码可以在同一个服务里共存,服务可以渐进式迁移,而不是一次性切换。
// main 分支
syntax = "proto3";
package order.v1;
// order-v2 分支
syntax = "proto3";
package order.v2;
package 名区分开之后,Go 的生成代码路径也会不同(order/v1 和 order/v2),Java 的包名也不同,两边可以同时 import,互不干扰。这个细节很多团队会忽略,导致版本分支形同虚设——因为切分支之后生成代码的 import 路径全变了,反而引入额外的适配工作。
常见问题
问:小团队只有三四个服务,有必要搞版本分支吗?
没必要。三四个服务的情况下,直接约定「改 proto 的人负责同步更新所有下游」,然后靠 CI 编译来兜底就够。版本分支的价值在服务数量超过 5 个、且分属不同团队时才开始显现。在这之前,流程上的约定比分支策略更经济。
问:buf breaking 检测会不会太严格,导致正常演进被卡住?
会,但这是有意的。buf breaking 的默认规则集偏保守,把「可能破坏下游」的变更都标出来。如果你确定某个变更在特定场景下是安全的(比如只有 Java 消费方、没有 JavaScript 消费方),可以在 buf.yaml 里针对特定字段或方法关闭对应规则。关键是要有一个显式的「豁免」动作,而不是默认放行。
问:版本分支上的 proto 文件改了 bug,怎么同步回 main?
用 cherry-pick。但要注意:如果版本分支改了 package 名(比如 order.v2),main 上的 package 名是 order.v1,cherry-pick 会直接冲突。这时候需要手动移植变更内容,而不是机械地 cherry-pick commit。这也是为什么兼容期不宜过长——双分支维护的手工移植成本会随着时间累积。
问:如果某个下游服务就是不肯迁移,一直锁在旧版本分支上怎么办?
设置明确的兼容期截止时间。比如版本分支创建后 6 周,不管有没有下游迁移完,版本分支都会被冻结(不再接受修复),并通知所有未迁移的服务。如果服务在冻结后因为旧版本 proto 的 bug 出问题,责任在服务团队自己。这个规则需要在团队间提前达成共识,不能临时追加。