schema 变化时维持双向兼容的线上规则

Protobuf 让新旧服务仍能沟通

gRPC 的服务端和客户端各自按自己的节奏发布,所以 schema 必须在新旧二进制同时运行时继续演进。Protobuf 正是为此设计的:兼容契约落在字段编号与 wire type 上,而不是字段名上;读方遇到不认识的字段就跳过,而不是失败。 张力在于这份安全既窄又不受强制。能安全做的修改只有一小撮,其余会无声地污染数据,而编译器只拒绝你明确告诉它要拒绝的那些。分清哪次修改属于哪一类,就是全部功夫。 线上说的是编号,不是名字 线上每个字段都是一个 tag——字段编号左移三位再或上 wire type——后面跟着载荷。对 id=7、name=“alpha”、code=1234 这条消息,字节是 08 07 12 05 61 6c 70 68 61 18 d2 09,其中 0x18 标明字段 3 是 varint,d2 09 就是 1234。protoc --encode 产出完全相同的字节,所以这是格式本身,而不是对格式的描述。 因为名字根本不出现在线上,改名是免费的:同一批字节用一份把字段 1 叫 event_id、字段 3 叫 status 的 schema 解出来,值完全一样,只是标签变了。用新编号新增字段同样是安全的——旧读方会把它当作未知编号呈现出来并保留其字节,重新编码能精确复现新消息。正是这份保留,让旧代理可以转发它看不懂的消息。 ...

二月 26, 2028 · 1 分钟 · XB Field Notes
一种二进制序列化格式,其固定编号字段允许 schema 向前向后演进。

Protobuf 让 Schema 演进而不破坏编码

向后兼容来自两点:字段编号绝不重用;新增字段保持可选,未知字段被跳过而不是报致命错误。 编号就锁定了含义 protobuf 字段在被分配编号的那一刻,就把这个编号当作线上身份的契约;名字反而可以被随意改,wire 类型不能动。tag 号到名字的映射要小心看护,绝不要在同一号上换个含义——否则,还拿着旧定义的老进程,在看到同一编号时就会把它静默当成别的含义去解读。 .proto 里声明顺序只是写给人的便利,真正在线路上传输的是一串 (字段编号、wire 类型、值) 的三元组。只要两边的编号一致,就能安全地跳过自己看不懂的字段。这就是 protobuf 向前兼容的来源:在字段真正出现之前,先用 reserved 把一个位子占住,等于预先销出了一个稳定命名空间。 新旧双方共用一条线路 要做到前后向兼容,靠的是一组朴素的规则。向后兼容指新版程序能读旧数据写入的字节:所以旧字段要一直在,不需要时也要 reserved 起来。向前兼容指旧版本程序能容忍新 schema 写出来的数据:因为未知字段会被保留并原样写回。加一个新号字段这两个方向都兼容,改已有字段的 wire type 则不兼容。 真正的坑往往不在字段本身,而在编号的复用。删除一个编号然后换个含义,会让所有还带着旧定义、拿着旧库的老客户端读到全错的东西。所以习惯是:用 reserved 占位,不删除再复用;同一个号永远别改义;基本标量类型保持稳定;至于取值非法这类问题,要在数据进入系统的边界处用校验挡住。 未知字段跳过而非失败 让演进显得容易的特性,其实藏在编码里。未知字段会被保存下来、原样透传,于是某个不完全了解新 schema 的中间进程(网关、日志管道),也可以照常帮忙转发,而不会把新字段弄丢。这也意味着当 schema 变化时,你不是非要立刻把整条链路都升级。 要让这些好碰上,还得在同一位面持有纪律:全团队共用同一套 .proto 生成码、维护一个 schema 注册表、把最老与最新的客户端放一起做交叉版本测试、把字段编号列成 lint 让人看得见,以及一旦类型必须改,就用「新字段 + 废弃旧字段」来处理;这样新版旧版并行地主保持可读,而不是静默地把兼容性留给下一次部署去赌。 ...

十一月 19, 2027 · 2 分钟 · XB Field Notes