编号比名字活得久
一句话总结
字段编号的寿命比名称更长。新字段要用新编号添加,删掉的编号要用 reserved 封存,绝对不能做修改编号的事。此外,proto3 的标量不会发送默认值(0、空字符串),所以要区分“发送了 0”和“什么都没发送”,就需要 optional。
为什么需要它
最佳实践文档的第一句话,就是这个模块的前提——客户端和服务器永远不会在完全相同的时刻更新。即使想一起部署,也可能有一方被回滚,而日志的某个角落里还留着用旧 schema 序列化的字节。所以“现在双方是同一份 schema,没问题”这种假设,从来就没有成立过。
正如上一个模块所见,wire 里只有编号。因此判断 schema 变更是否安全,标准不是能否通过编译,而是旧的二进制程序读取新字节、新的二进制程序读取旧字节时,含义能否得到保留。proto3 语言指南按照这个标准,把变更分成三类——对 wire 安全的、不安全的、有条件兼容的。
工作原理
安全的变更。 添加新字段是安全的。旧代码产生的字节,新代码可以原样读取(新字段取默认值);新代码产生的字节,旧代码也能读取,只是把不认识的编号当作未知字段(unknown field)放过去。proto3 会把未知字段保留下来,在重新序列化时包含进去——也就是说,夹在中间的旧服务不会把新字段抹掉。不过,如果转换成 JSON,或者把字段逐个搬到另一个对象里,这种保留就会失效。
删除字段也是安全的。条件只有一个——不要再使用那个编号。文档要求把删掉的编号放进 reserved 列表。编号和名称可以一起预留,但不能把两者混在同一条语句里。
message Order {
reserved 3; // 지운 note 의 번호. 9 to 11 처럼 범위도 된다
reserved "note"; // 이름 예약은 별도 문장으로
int32 id = 1;
int32 qty = 2;
int64 unit_price = 4;
string currency = 5;
}
reserved 是由编译器来守护的约定。之后如果有人写 string memo = 3;,protoc 会拒绝。名称预留对二进制没有影响,是为 TextProto、JSON 这类会把名称序列化进去的格式准备的。
不安全的变更。 修改已有字段的编号。文档把它定义为“相当于删除该字段,再创建一个相同类型的新字段”。问题在于,解析器根本无从得知这一点。文档列出的后果是:花在调试上的时间、解析和合并错误(这还是最好的情况)、隐私泄露、数据损坏。文档还写出了编号被复用的两个常见原因:为了好看而重新编号,以及没有预留已删除的编号。
有条件兼容。 int32、uint32、int64、uint64、bool 可以互相读取,但值可能被截断(把 64 位的值按 int32 读取,会被截成 32 位)。sint32 和 sint64 只能彼此兼容,与其他整数类型不兼容——因为它们要经过 ZigZag。string 和 bytes 只有在字节是有效的 UTF-8 时才可以。fixed32 与 sfixed32、fixed64 与 sfixed64 则是成对兼容。文档明确指出,这一类只有在能控制部署顺序时才可使用,而最佳实践文档干脆说“几乎不要改类型”。把 int32 改成 string,wire type 会从 VARINT 变成 LEN,所以连这一类都算不上。
默认值与 presence。 在 proto3 中,没有标签的标量遵循隐式 presence——值为默认值时不序列化。数字是 0,字符串和 bytes 是空值,bool 是 false。所以如果发送 qty = 0,wire 上根本没有字段 2 的记录,接收方无法区分“发送了 0”和“没有发送”。文档写明,在这种状态下连 has_ 方法也没有。
加上 optional 标签,就变成显式 presence。明确设置过的值,即使是默认值也会被序列化(10 00 两个字节),并且可以查询它是否被设置过。文档建议给 proto3 的基本类型始终加上 optional——这样通往 Editions 的路更平滑,而且在部分更新(patch)中可以表达“改成 0”。在隐式 presence 下,默认值不会被合并,就需要 FieldMask 这样的外部机制。改标签本身是二进制兼容的,但如果一方信赖 has_,那么经过另一方来回一趟之后,这个信息可能就丢了,文档给出了这样的例子。
编号的范围。 从 1 到 536,870,911,其中 19,000–19,999 是实现保留的,编译器会拒绝。因为 tag 中有 3 位被 wire type 占用,所以是 29 位而不是 32 位。
在现场相遇的样子
标题里的那起事件是这样发生的。订单服务团队整理 schema 时,把 qty 改成了 1 号,把 id 改成了 2 号。新服务器用新 schema 序列化,部署顺利完成。但结算批处理是三个月前的构建。那个批处理仍然把编号 1 当作 id 来读,结果订单 7788 的数量被统计成了 7788 个,而不是 3 个。两者的 wire type 都是 VARINT,所以连出错的余地都没有。实验中的 renumbered.bin 正是这些字节。
第二种类型是“0 消失了”的事故。库存服务发出了 qty = 0 的更新,但由于是隐式 presence,这个字段根本没被带上,而接收方的合并逻辑是“只覆盖来了的部分”。结果,数量保持了之前的值没有改变。这正是文档警告的“在部分更新中无法表达默认值”的情形。如果加了 optional,10 00 两个字节就会被带上,0 也就传达到了。
第三种是删除之后的复用。某个团队在删除 note 时没有预留编号 3,半年后另一个人把 string memo 放进了 3 号。在重新处理日志的过程中,旧订单的 note 被读成了 memo。名称虽然不同,但类型相同,所以这次同样没有出错。这就是最佳实践文档写下“只要这个变更上过线,日志里的某个地方就会有序列化后的版本”的原因。
下一项实验要做什么
保持把 v1 schema 写死在代码里的旧客户端不动,把 schema 改成 v2。用新编号添加新字段,看旧客户端跳过它们,并在删除 note 的同时留下 reserved。接着让旧客户端去读编号被调换过的 renumbered.bin,记录 id 和 qty 被读反了的结果——这就是标题中的那起事件。分别用隐式和显式 presence 生成 qty = 0,比较字节,最后留下带有 optional 和 reserved 的最终 schema 以及兼容性表。