gRPC 滚动发布期间,旧生产方、新消费方与中间服务可能同时运行。Protobuf 通过字段编号和线上类型识别二进制内容,帮助这些版本交换消息。但能读懂格式,并不等于应用已经兼容:消息完全可以解析成功,消费方却误解数值、忽略新指令,或者丢掉“未提供”和“显式设置为零”的区别。审阅时需要把这些承诺分别列出来。
分开编号、含义与字节
二进制标签由字段编号与线上类型组合而成,字段名不在这个标签里传输。保持编号和类型不变而改名,可以保留二进制解码能力,但仍会改变生成代码的访问器,也可能改变 JSON 或文本接口。gRPC 的方法名与服务名属于另一层契约,不能因为字段编号没有变化,就认为任意接口改名都不影响调用。
已经退役的编号绝不能拿来表达另一种意思。不同声明类型可能共享相同线上类型,例如有符号与无符号整数都使用变长整数编码,但负数可能被另一种解释读成很大的正数。解析成功不是语义验证。删除字段后,应在 schema 中保留对应编号与名称的 reserved 声明,让后续声明无法意外占用;它是禁止使用的范围,不是留待日后激活的占位字段。
未知字段可以在二进制解析后重新序列化的转发路径中保留,但只复制已知字段,或中途转换为 JSON,都可能丢掉它们。保留解码信息也不保证每次序列化的字节完全相同,字段顺序与编码选择可能随实现或版本变化。兼容测试应比较必要取值和存在状态,不能把普通重新序列化当成跨版本规范化摘要算法。即使一次小样本碰巧字节相等,也不能推广成所有消息的承诺。
存在性承载另一层含义
在 proto3 中,没有 optional 修饰的单值标量通常采用隐式存在性,正常接口无法区分默认值与未提供。显式可选标量则记录字段是否被设置,即便取值为零也能保留这个事实。消息字段与 oneof 成员本来就具有存在性;重复字段和映射不能区分空集合与没有提供。因此,不能把所有 proto3 字段都归为同一种默认规则。
单值字段在隐式与显式存在性之间变化,可能仍然可以二进制互读,却改变转发后的结果。一个知道该字段、但仍用隐式存在性解释它的旧读方,可以读入显式零,然后在重新编码时把默认值省略。仅在一端加上 optional,不会迫使每个中间节点保留这一区别。需要测试实际修改或转发消息的整条路径,而不是只比较两个最终端点。
坏掉的样子
新版消费方可能要求一个旧写方从未提供的字段。虽然 proto3 不支持 required 声明,应用层校验仍然可以制造同样的滚动升级故障。应先约定缺失时的行为,或安排好发布顺序,再开始强制要求这个值。可选语法本身不会定义合理默认值,不会授权某个操作,也不会让旧服务突然实现新功能。这些决定必须由业务契约给出。
还应覆盖已保存消息、延迟任务以及可能回滚到的版本,它们的存活时间可能远长于发布窗口。只比较紧邻的上一个版本,会漏掉仍然可能重新出现的旧记录。即便当前流量已经不使用某个字段,退役编号仍然需要保留。重新投递旧任务与读取历史数据,都不能被当成无关的边缘情况。
快速验证
在 Python 环境中准备 Google 的 protobuf 7.36.2 包,再运行下面完整程序。本次使用 Python 3.12.14。独立描述符池创建真实的新旧消息类,不依赖编译器,不创建磁盘样本,不启动 gRPC 服务,也不建立网络连接。它验证 Python 二进制运行时,不能代替跨语言互操作或生成客户端兼容性测试。
from google.protobuf import descriptor_pb2 as schema, descriptor_pool, message_factory
def message(fields, explicit=()):
file = schema.FileDescriptorProto(name='probe.proto', package='probe', syntax='proto3')
record = file.message_type.add(name='Record')
for name, number, kind in fields:
field = record.field.add(name=name, number=number, type=kind, label=1)
if name in explicit:
field.oneof_index = len(record.oneof_decl)
record.oneof_decl.add(name='_' + name)
field.proto3_optional = True
pool = descriptor_pool.DescriptorPool()
pool.Add(file)
return message_factory.GetMessageClass(pool.FindMessageTypeByName('probe.Record'))
INT32 = schema.FieldDescriptorProto.TYPE_INT32
UINT32 = schema.FieldDescriptorProto.TYPE_UINT32
STRING = schema.FieldDescriptorProto.TYPE_STRING
Old = message([('id', 1, INT32), ('name', 2, STRING)])
New = message([('event_id', 1, INT32), ('name', 2, STRING), ('count', 3, INT32)], ('count',))
Implicit = message([('event_id', 1, INT32), ('name', 2, STRING), ('count', 3, INT32)])
old = Old(id=7, name='alpha')
new_reader = New.FromString(old.SerializeToString())
assert new_reader.event_id == 7 and not new_reader.HasField('count')
# The added explicit zero is unknown to Old but retained in binary forwarding.
new_writer = New(event_id=7, name='alpha', count=0)
relay = Old.FromString(new_writer.SerializeToString())
returned = New.FromString(relay.SerializeToString())
assert returned.event_id == 7 and returned.HasField('count') and returned.count == 0
print('old/new binary values survive; unknown explicit zero survives the old relay')
# An implicit-presence reader knows field 3, so zero is omitted on serialization.
implicit_relay = Implicit.FromString(new_writer.SerializeToString())
lost_presence = New.FromString(implicit_relay.SerializeToString())
assert lost_presence.count == 0 and not lost_presence.HasField('count')
print('an implicit-presence relay preserves zero but loses its explicit presence')
Signed = message([('value', 1, INT32)])
Unsigned = message([('value', 1, UINT32)])
assert Unsigned.FromString(Signed(value=-1).SerializeToString()).value == 4294967295
# Distinct field order is legal; compare decoded meaning, not byte equality.
reordered = b'\x18\x00' + old.SerializeToString()
assert New.FromString(reordered) == new_writer
print('reusing an integer tag changes meaning; legal field orders decode equivalently')
第一行输出确认旧写方的数据仍然可读,而且旧二进制转发者会保留不认识的显式零。下一行展示另一种结果:当转发者认识该字段、却用隐式存在性处理它时,取值仍为零,但重新转发后 HasField 变成假。这是在回答两种不同的兼容问题,并不是运行时自相矛盾。
最后的断言把负一按不兼容的无符号解释读成 4294967295,同时接受另一种合法字段顺序。结果说明,线上可读不代表业务等价,也不代表字节具有唯一规范形式。这个描述符实验没有编译 reserved 声明;编译期约束、实际 gRPC 请求响应行为以及其他语言的转发路径,仍然需要在对应发布流程里单独验证,不能由这些本地断言代替。
来源
- Protobuf:编码,标签、变长整数与序列化顺序,已核验 2026-10-15。
- Protobuf:proto3 语言指南,二进制变更、退役字段与未知字段保留,已核验 2026-10-15。
- Protobuf:字段存在性,转发中的隐式与显式存在性,已核验 2026-10-15。
- Protobuf:该做与不该做,退役编号与兼容纪律,已核验 2026-10-15。