承接的问题
2.1 讲了字段号为什么不能复用,2.2 讲了 JSON 没有字段号带来的兼容问题。这一节把「改字段」这件事拆细:同样是新增一个字段,有时安全,有时会让旧客户端解析出错误数据;同样是改类型,int32 到 int64 可能没事,int32 到 sint32 一定出事。
判断依据不能靠记忆,要靠编码规则和一张矩阵。这一节给出四个兼容维度、一张逐项结论表、几个必须理解的编码差异,以及一条用 Descriptors 写的回归测试。
兼容性的四个维度
「兼容」不是一个布尔值。同一个改动可能线上兼容、源码不兼容:
| 维度 | 问题 | 检查方式 |
|---|---|---|
| 线上兼容 | 旧客户端能不能读新服务端的字节,反之亦然 | 用旧 schema 解析新消息,字段值和未知字段是否正确 |
| 源码兼容 | 重新生成代码后,业务代码还能不能编译 | mvn clean install,看调用点是否报错 |
| 二进制兼容 | 已编译的旧 jar 能不能和新 jar 一起跑 | 类签名、方法签名是否变化 |
| 行为兼容 | 字段还在,但语义变了,调用方是否仍按预期工作 | 契约测试、灰度观察、对比回归 |
线上兼容是底线,行为兼容最容易被忽略。把 page_size 的默认值从 20 改成 50,字段没变、字节没变,但所有不传分页大小的客户端都会多拿一倍数据。这类改动在编译期和线上都看不出来,只能靠行为测试和灰度。
字段演进兼容矩阵
下表以「旧 schema 已发布,新 schema 准备上线」为前提。结论里的「是」指旧客户端和新服务端可以互通:
| 改动 | 线上 | 源码 | 结论与条件 |
|---|---|---|---|
| 新增字段,使用新编号 | 是 | 是 | 安全;旧端忽略未知字段 |
删除字段并 reserved 编号和名字 | 是 | 否 | 安全;调用点编译失败,需同步清理 |
删除字段但不 reserved | 是 | 否 | 危险;编号可能被复用,线上错位 |
| 重命名字段 | 是 | 否 | 线上安全;源码需同步改,日志和 JSON 字段名会变 |
| 修改字段编号 | 否 | 是 | 禁止;等于删除旧字段再加新字段 |
int32 → int64 | 是 | 否 | 非负值安全;负值编码都是 10 字节,但源码类型变了 |
int32 → uint32 | 否 | 否 | 禁止;负值会被解释成巨大的正数 |
int32 → sint32 | 否 | 否 | 禁止;zigzag 编码不同,值完全错位 |
int64 → uint64 | 否 | 否 | 禁止;负值语义不同 |
string → bytes | 是* | 否 | 有条件;旧端可能校验 UTF-8,二进制会解析失败 |
fixed32 → sfixed32 | 是 | 否 | 位模式相同,解释不同;只影响符号语义 |
fixed32 → int32 | 否 | 否 | 禁止;wire type 从 I32 变成 VARINT |
| 新增枚举值 | 是 | 是 | 安全;旧端落到 UNRECOGNIZED 或保留数字 |
| 删除枚举值 | 是* | 否 | 危险;旧数据仍可能出现该值,业务分支要保留 |
标量加 optional | 是 | 否 | 线上兼容;存在性语义变了,hasXxx() 可用 |
repeated ↔ 单个字段 | 否 | 否 | 禁止;编码从 LEN 变成标量或反之 |
字段移入 oneof | 是* | 否 | 有条件;presence 语义和生成 API 都变 |
| 新增方法 | 是 | 是 | 安全;旧客户端不调用即可 |
| 修改方法名 | 否 | 否 | 禁止;:path 变了,路由返回 UNIMPLEMENTED |
| 修改调用模式 | 否 | 否 | 禁止;流模型不匹配 |
| 修改服务名或包名 | 否 | 否 | 禁止;完整方法名变了 |
表里的「有条件」不是措辞模糊,而是「线上字节能读,但业务可能出错」。string → bytes 就是这样:两者都是 LEN 编码,旧端拿到的仍是一段字节;但如果旧端按 UTF-8 校验,遇到二进制内容会拒绝,错误发生在解析层,而不是协议层。
类型变更的编码分析
矩阵背后的判断依据是 wire type 和值编码。把常用的类型分组:
| 组 | 类型 | wire type | 值编码 |
|---|---|---|---|
| varint 有符号 | int32、int64 | VARINT | 补码,负数 10 字节 |
| varint 无符号 | uint32、uint64 | VARINT | 无符号 |
| varint zigzag | sint32、sint64 | VARINT | (n << 1) ^ (n >> 31) |
| 定长 | fixed32、sfixed32、float | I32 | 4 字节小端 |
| 定长 | fixed64、sfixed64、double | I64 | 8 字节小端 |
| 长度前缀 | string、bytes、消息、packed repeated | LEN | 长度 + 字节 |
同一组内,wire type 相同,但值编码可能不同:
int32和int64都是补码 varint。非负值完全兼容;负数都编码成 10 字节,位模式也兼容。所以线上兼容,源码类型变了。int32和uint32都是 varint,但-1在int32里是ff ff ff ff ff ff ff ff ff 01,在uint32里被解释成4294967295。同一个字节序列,两个值。禁止互换。sint32用 zigzag,-1编码成01;int32的-1编码成 10 字节。同一个字段号、同一个 wire type,值完全不同。禁止互换。fixed32和sfixed32都是 4 字节小端,位模式相同,只差符号解释。线上能读,但负数的业务含义会变。
跨组一定不兼容:wire type 变了,解码器会按错误的长度读值,后面的字段全部错位。fixed32 → int32 从 I32 变成 VARINT,string → int32 从 LEN 变成 VARINT,都是这种情况。
string 和 bytes 是同一组里的特例:wire type 都是 LEN,字节内容原样传输。区别在语义校验:string 要求 UTF-8,bytes 不要求。把文本改成二进制,旧端可能因为非法 UTF-8 拒绝;把二进制改成文本,新端可能因为同样原因拒绝。
reserved:删除字段的唯一正确姿势
删除字段时,编号和名字都要保留:
message Book {
reserved 5, 6, 9 to 11;
reserved "publisher", "publish_year", "legacy_code";
string isbn = 1;
string title = 2;
string author = 3;
int32 stock = 4;
string language = 7;
int32 page_count = 8;
}
规则:
reserved的编号不能被任何新字段使用,protoc会在编译期报错。reserved的名字不能被任何新字段使用,防止「名字回来了但编号变了」。- 范围写法
9 to 11包含两端;9 to max可以保留到最大编号。 - 19000–19999 是 Protobuf 内部保留区,不能使用,也不需要自己
reserved。
只保留编号不保留名字,下一个人可能把编号 5 用作 language,旧数据被解析成语言字符串;只保留名字不保留编号,编号 5 可能被复用成 stock,旧数据被解析成库存。两个都保留,才是完整的墓碑。
在 linkd-v3 里落地:兼容检查与保留位回归
先写一个不依赖生成代码的检查器,把矩阵里的规则固化成代码:
package com.plume.linkd.v3.contract;
public final class SchemaCompat {
public enum Verdict { SAFE, CONDITIONAL, BREAKING }
public record Field(int number, String name, String type, boolean repeated, boolean optional) {
}
public static Verdict check(Field oldField, Field newField) {
if (oldField.number() != newField.number()) {
return Verdict.BREAKING;
}
if (oldField.repeated() != newField.repeated()) {
return Verdict.BREAKING;
}
if (!oldField.type().equals(newField.type())) {
return switch (oldField.type() + "->" + newField.type()) {
case "int32->int64", "int64->int32" -> Verdict.CONDITIONAL;
case "string->bytes", "bytes->string" -> Verdict.CONDITIONAL;
case "fixed32->sfixed32", "sfixed32->fixed32" -> Verdict.CONDITIONAL;
default -> Verdict.BREAKING;
};
}
if (oldField.optional() != newField.optional()) {
return Verdict.CONDITIONAL;
}
if (!oldField.name().equals(newField.name())) {
return Verdict.CONDITIONAL;
}
return Verdict.SAFE;
}
public static void main(String[] args) {
System.out.println("int32->int64=" + check(
new Field(4, "stock", "int32", false, false),
new Field(4, "stock", "int64", false, false)));
System.out.println("int32->sint32=" + check(
new Field(4, "stock", "int32", false, false),
new Field(4, "stock", "sint32", false, false)));
System.out.println("string->bytes=" + check(
new Field(2, "title", "string", false, false),
new Field(2, "title", "bytes", false, false)));
System.out.println("rename=" + check(
new Field(4, "stock", "int32", false, false),
new Field(4, "quantity", "int32", false, false)));
System.out.println("repeated=" + check(
new Field(4, "stock", "int32", false, false),
new Field(4, "stock", "int32", true, false)));
}
}
预期输出:
int32->int64=CONDITIONAL
int32->sint32=BREAKING
string->bytes=CONDITIONAL
rename=CONDITIONAL
repeated=BREAKING
这个检查器只覆盖单字段,真实项目还要检查「删除字段是否 reserved」「新增字段编号是否冲突」「枚举值是否减少」。
保留位可以用生成代码的描述符直接断言,放进 comm-api 的测试:
package com.plume.linkd.v3.api;
import com.google.protobuf.Descriptors;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class BookReservedTest {
@Test
void deletedFieldNumbersStayReserved() {
Descriptors.Descriptor book = Book.getDescriptor();
assertTrue(book.isReservedNumber(5));
assertTrue(book.isReservedNumber(6));
assertTrue(book.isReservedName("publisher"));
assertFalse(book.isReservedNumber(7));
}
@Test
void languageUsesTheNextFreeNumber() {
Descriptors.FieldDescriptor language = Book.getDescriptor().findFieldByName("language");
assertEquals(7, language.getNumber());
assertEquals(Descriptors.FieldDescriptor.Type.STRING, language.getType());
assertEquals(1, Book.getDescriptor().findFieldByName("isbn").getNumber());
}
}
预期结果(JUnit 5):两条测试都通过。如果某次改动把 language 的编号改成 5,第一条仍然通过、第二条失败;如果有人删掉 reserved 5,第一条失败。这类断言比代码评审更可靠,因为它每次构建都会跑。
行为兼容:字节没变也会出事
四维兼容里,行为兼容最难自动化。常见的几类:
| 改动 | 字节变化 | 行为影响 |
|---|---|---|
| 改默认分页大小 20 → 50 | 无 | 客户端拿到双倍数据,尾延迟上升 |
| 改枚举值的业务含义 | 无 | 旧客户端按旧含义处理,状态错乱 |
| 改字段单位(毫秒 → 秒) | 无 | 数值差 1000 倍,通常要等线上告警才发现 |
| 改排序规则 | 无 | 分页结果重复或遗漏 |
| 改错误码的原因码 | 有(trailers) | 客户端分支失效,重试策略错位 |
| 改限流阈值 | 无 | 高峰期错误率上升 |
处理方式不是「禁止改」,而是「改之前先确认调用方」。单位变更应该新增字段(timeout_ms → timeout_seconds 或反过来),旧字段保留一个版本;枚举含义变更应该新增枚举值,不要改旧值的注释;排序规则变更要提供显式参数,不要改默认。
灰度与回滚
字段演进最终要落到发布流程:
- 服务端先兼容。 新服务端同时能读旧字段和新字段,旧客户端不受影响。
- 客户端再升级。 新客户端开始发新字段,服务端两种都认。
- 观察一个版本。 看错误率、延迟、未知字段日志,确认没有调用方还在用旧语义。
- 清理旧字段。 确认无调用方后,
reserved编号和名字,再删代码。 - 回滚预案。 如果新字段导致问题,服务端要能忽略它并回退到旧逻辑,不能要求客户端先回滚。
第 12 章会把这条流程和 x-api-version、灰度标记结合起来。这里只需要记住:兼容不是「一次发布」,而是「两个版本共存一段时间」。
常见错误与边界
错误一:删除字段不 reserved。 最常见也最危险。线上不会立刻报错,等编号被复用时才出现「老数据变成新字段」。
错误二:认为重命名字段是破坏性变更。 线上按编号,重命名是兼容的;但源码、日志、JSON 字段名都会变。结论要写「线上安全、源码不兼容」,不能笼统说「不兼容」。
错误三:把 int32 改成 sint32 当成优化。 值编码完全变了,旧数据会被解析成错误的数。要改只能新增字段,双写一段时间。
错误四:删除枚举值。 旧数据里仍可能有该值,新代码的 switch 没有分支,可能落到默认值或抛异常。枚举值只增不删,废弃值保留并标注。
错误五:只跑编译,不跑契约测试。 编译只能发现源码不兼容,发现不了编号复用和保留位缺失。描述符断言必须进 CI。
边界:optional 的存在性不是免费的。 加 optional 线上兼容,但每次设置值都会多写一个 tag,且生成 API 从 getX() 变成 hasX()/getX()。已有代码要跟着改。
边界:oneof 的兼容性取决于怎么加。 把已有字段移进 oneof 线上可读,但生成 API 变了,而且「同时设置多个字段」的旧行为不再允许。新增一个 oneof 成员通常安全,前提是旧端不会同时设置。
边界:reserved 不能跨消息继承。 每个 message 的保留位是独立的,父消息的 reserved 不会保护嵌套消息的编号。
小结
- 兼容性有四个维度:线上、源码、二进制、行为;线上兼容是底线,行为兼容最容易被忽略。
- 新增字段、
reserved删除、新增枚举值、新增方法通常安全;改编号、改 wire type、改调用模式一定不安全。 int32与int64线上兼容但源码不兼容;int32与uint32、sint32的值编码不同,禁止互换。string与bytes同为 LEN 编码,但 UTF-8 校验不同,属于有条件兼容。- 删除字段必须同时
reserved编号和名字;19000–19999 是 Protobuf 保留区。 - 用
Descriptor.isReservedNumber/isReservedName写回归测试,让保留位缺失在 CI 里失败。 - 字节没变也可能行为不兼容:默认值、单位、排序、枚举含义的改动都要单独评审。
字段和兼容规则都清楚了,接下来进入调用侧。第 3 章从四种调用模式开始,解释 unary、server streaming、client streaming 和 bidi 各自适合什么场景。
阅读导航:上一节 2.2 JSON 与 Protobuf 的取舍 | 下一节 3.1 四种调用模式
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。