TT Lab
博客 开始
学习 学习路径 课程

改了字段编号,旧客户端悄悄读出了错误的值

当新旧版本同时运行时

在 TT Lab 中继续学习

一句话总结

新旧版本同时运行期间发生的兼容性事故不会报错。所以诊断不是从日志开始,而是从用两个 schema 读取同一份字节并相互比对开始,按照不一致的名称 → 该名称的编号 → 字节中的那条记录 → 两个 .proto 中的那个编号的顺序往下追。修复永远在新版本一侧。

为什么需要它

前面的模块教了规则——不要改编号,删除的编号用 reserved 封存,类型只能换成在 wire 上兼容的类型。但在现场找上门来的不是违反规则,而是症状。“结算数字从昨天起就对不上,批处理却显示成功结束。”既没有异常也没有警告,日志翻得再多也找不到。正如最佳实践文档所说,客户端和服务器不会在同一时刻部署,几个月前构建的批处理今天仍在运行。这个批处理带着构建时的 .proto,并不知道服务器已经变成了什么样。

它如何工作

两个进程,一份字节。 新版本服务器按字段名从数据库中取出值,再按自己 .proto 规定的编号和类型写入。旧版本客户端按编号读取这些字节,并贴上自己 .proto 中的名称。名称只存在于双方的 .proto 中,wire 上没有。因此,用两个客户端读取同一个响应并按名称比对,不一致的名称就是事故所在。本实验的服务器把 gRPC 的消息帧——1 字节压缩标志、4 字节大端长度、消息——直接放在 TCP 上。真正的 gRPC 把这个帧放在 HTTP/2 DATA 帧中(gRPC over HTTP/2)。TCP 不知道消息边界,所以读满长度指定的字节是客户端的职责。

不报错却出错的五种情况。 在实验的演示中会遇到其中四种(field-renumbered 已在前面的模块中见过),在事件中五种都会遇到。

代码 v2 做了什么 旧版本看到的
enum-renumbered 在 enum 的 1 处插入新值,把后面的值往后挤 名称依次错开一位(PAID → SHIPPED)
field-renumbered 交换了两个同类型字段的编号 两个值互换
number-reused 删除一个字符串字段,把它的编号给了新的字符串字段 已删除的位置出现了别的值
sint-changed int32 改为 sint32 同样是 VARINT,读到的是 ZigZag 编码后的数——150 变成 300,-40 变成 79
wire-type-changed int32 改为 fixed32 wire type 不同,当作未知字段跳过,得到 0

第四和第五种的差别很有意思。proto3 语言指南写明 sint32 只与 sint64 兼容,与其他整数类型不兼容。wire type 相同,所以解析器毫不怀疑地读出了被改变的值。fixed32 的 wire type 本身是 I32,因此本实验的解码器会把那条记录当作未知字段跳过——在某些实现中这会变成解析错误,而报错的一方反而是更好的情况。

两种误报。 两个版本之间的差异并不都是事故。新版本删除的字段在旧版本看来是默认值,这是正确的;在 enum 末尾新增的值在旧版本看来是一个未知数字,这也是正确的。proto3 的 enum 是开放的(open),未知编号也会作为值保存——不同语言呈现的形式不同。如果测试把这两者当作事故,就没有人会再相信这个测试。

从字节到原因。 找到不一致的名称后,在旧版本的 .proto 中查看该名称的编号。在不借助 schema、把字节展开成记录的转储中找到这个编号,wire type 和原始值就会指向原因。然后在这个编号上比对两个 .proto 的声明,就能缩小到五种之一。新版本中也混有安全的变更,所以不要把差异全部回退,而是修复那一个。

在现场会遇到的情形

最常见的情形是“新版本之间一切正常”。新版本的客户端和新版本的服务器使用同一个 .proto,无论做了什么变更,彼此都能正确读取。所以新版本团队的集成测试全是绿色,问题只出现在其他团队的旧批处理中。人们会想修改旧版本来对齐,但通常做不到——那是别的团队的二进制,如果是移动应用,则已经装在用户的设备上。因此,修复的办法是把新版本的 schema 改回与旧编号兼容,并且为了不再发生同样的事,在部署前加一项测试:同时启动两个版本、比对同一份字节。静态契约检查器漏掉的地方(比如 enum 值编号这种工具不比较的地方),这种测试也能抓到。

还有一点——只保存了修改后的 .proto 却没有重启服务器,结果“改了却没变”的情况很常见。服务器在启动时编译 schema 并一直持有它。真正的 gRPC 服务器是用生成的代码编译出来的,更是如此。

下一个实验要做的事

先从一个通过 socket 直接收发 gRPC 消息帧的小工具开始。接着,用旧版本和新版本两个客户端读取一个混入了四种有害变更(上表中除 field-renumbered 以外的四种)的演示 v2 服务器的同一个响应,指出不一致的名称,在转储中找到编号和 wire type 并归因,然后在保留新功能的前提下修复为兼容。把这个流程固化成测试脚本之后,诊断并修复一个三个服务以不同原因表现出相同症状的事件。