Protobuf 与 gRPC 契约:二进制 Schema 怎样安全演进
消息在新旧进程间都能反序列化,为什么升级仍可能破坏业务? 这类事故通常不是序列化库突然失效,而是双方只共享了字段形状,没有共享字段身份、业务含义、状态前提和演进规则。二进制 wire-compatible 只证明字节可解析,不证明应用语义、生成代码或 ProtoJSON 安全。字段号是长期身份,presence、默认值、enum、oneof、deadline 和错误状态共同构成 RPC 契约。
契约不是一份文档,而是跨时间的承诺
protoc 根据 proto3 或 Editions 文件生成消息与服务存根;客户端序列化字段号和 wire type,经 HTTP/2 调用服务端;deadline、metadata、cancellation 与 status 穿过拦截器和业务处理。旧解析器保留未知字段时,新字段可跨旧节点往返。
proto3、Protobuf 版本标识 2023 与 Protobuf 版本标识 2024 已经形成可使用的语言和特性基线;Protobuf 版本标识 2026 仍属于后续发布计划,不能被生产契约、生成器或示例当成已经交付的前提。选择 Editions 时还要核对 protoc、各语言 runtime 与生成代码的支持窗口,Edition 编号也不等于运行时发行版本。
一次契约调用怎样从制品走到业务状态
字段删除后同时 reserve number 和 name;兼容门禁分别评估 binary wire、ProtoJSON、生成代码和业务语义。每个 RPC 定义 deadline 预算、幂等性、可重试 status、取消传播和最大消息体。
复用删除字段号会把旧字节解释成新语义;把字段移入 oneof 可能清除数据;新增 enum 值会破坏穷举分支;只设置本地 timeout 而不传播 deadline 会让下游继续运行;UNKNOWN 会吞掉可治理失败。
身份比名字更重要
删除身份必须经历停用、观测、保留和禁止复用。Protobuf 删除字段后 reserve number/name;错误 type 废弃后仍保留文档和解析映射;事件类型停止生产后仍要支持历史重放;operationId 改名会改变生成 SDK 方法名,即使路径和响应完全相同也可能造成源码不兼容。
兼容性不是单向的布尔值
至少区分 backward、forward、full、source、binary、wire 与 semantic compatibility。新读取者能读旧数据是 backward;旧读取者能读新数据是 forward;两者都成立才是 full。生成 SDK 的方法签名改变属于 source compatibility;Java 二进制链接失败属于 binary;Protobuf 字节能解析属于 wire;解析后做出同一业务动作才是 semantic。
兼容矩阵的行是活跃消费者及版本,列是候选契约和生产者行为。矩阵单元格保存结构验证、生成编译、样本回放、错误分支与性能限制结果。只测试最新 SDK 会让旧移动端、离线任务、合作方和延迟消息成为盲区。
结构验证必须建立引用闭包
错误契约必须告诉调用方下一步
gRPC status 同样需要稳定映射。INVALID_ARGUMENT 表示与系统状态无关的参数错误,FAILED_PRECONDITION 表示当前状态不满足,ABORTED 常用于需在更高层重试的并发冲突,DEADLINE_EXCEEDED 表示截止时间结束。服务端不要把所有业务异常压成 UNKNOWN;客户端也不能对所有 UNAVAILABLE 无限重试。
可运行模型一:让破坏性变更显形
运行 ProtoFieldEvolutionDemo.java:
javac --release 17 -Xlint:all -Werror ProtoFieldEvolutionDemo.java
java ProtoFieldEvolutionDemo预期输出:
oldFields=[1, 2, 3] newFields=[1, 2, 4] reserved=[3] reused=0 unknownPreserved=true wireSafe=true第一个模型把兼容判断压缩为稳定数量关系:新增 required、字段号复用、阻塞消费者、浮点误差或状态机非法转换。它不替代真实解析器,却能让评审者看到“哪些输入导致哪条门禁失败”,并能稳定用于回归。
修改模型输入做反向实验:把 optional 变 required、从 reserved 集合取字段号、新增旧消费者不能识别的事件、让金额经过 double 或移除问题类型。若模型仍报告 publishable,说明门禁只检查了表面结构。
可运行模型二:验证运行时仍然收敛
运行 DeadlinePropagationDemo.java:
javac --release 17 -Xlint:all -Werror DeadlinePropagationDemo.java
java DeadlinePropagationDemo预期输出:
budgetMs=500 hops=[120, 180, 250] started=2 cancelledAtHop=3 remainingBefore=[500, 380, 200] deadlinePreserved=true第二个模型覆盖引用缺失、deadline 传播、事件重放、presence、消费者契约或回滚制品。结构兼容只是第一道门,运行时还必须在超时、重复、乱序、缺省值和版本并存时保持可判定结果。
验收不应只断言进程退出码。需要核对输出中的失败数、阻塞消费者、最终版本、剩余预算、错误泄漏和 rollbackArtifact。任何非零破坏都应关联 owner、例外期限与迁移计划,不能以“先上线观察”替代证据。
Deadline、幂等和重试也是契约
契约应声明最大请求/响应、流式背压、分页大小和批次上限。生成 SDK 若默认无限读取响应或自动重试非幂等调用,同样属于契约缺陷。性能限制必须进入规范扩展、客户端策略或配套机器可读策略,不能只藏在运维文档。
时间、金额、ID、枚举与 Null 要逐个定义
枚举新增必须假设旧消费者会收到未知值。客户端模型保留 UNKNOWN/UNRECOGNIZED 和原始值,不应在未知值上崩溃或默认映射为某个真实业务状态。服务端在收缩枚举前先观察未知值流量,确认所有生产者完成迁移。
事件契约还要承担历史
同步 API 可以通过部署窗口逐步迁移,事件则可能在日志、重试队列、对象存储和审计归档中保留多年。消费者升级后仍要能重放旧事件;新事件投入生产后,滞后消费者仍可能是旧代码。因此事件兼容矩阵必须覆盖历史语料和最大保留期。
发布门禁要横跨生产者和消费者
安全边界必须写进 Schema 和生成器
个人数据字段标注分类、用途、日志策略和删除传播。SDK 的 toString、调试拦截器与自动日志默认脱敏;事件契约记录数据保留与擦除关联键。契约制品公开范围也要分级,内部管理接口不应因为生成文档而自动暴露。
观测要能回答哪个版本在破坏谁
发布看板至少显示活跃消费者版本分布、最老受支持版本、候选变更破坏数、SDK 生成/编译结果、provider/consumer 通过率、unknown enum、解析失败和弃用接口流量。没有消费者版本可见性,就无法安全删除旧字段或旧路径。
回滚必须能复现旧制品与旧语义
版本号是沟通工具,不是兼容机制。路径版本、媒体类型版本、RPC 新方法或事件新类型都只建立新身份;迁移、双写、流量观测、消费者支持与终止条件仍需单独设计。能兼容扩展时避免无意义大版本,确实改变语义时也不要用小版本掩盖。
架构决策必须留下可复审证据
二进制 wire-compatible 只证明字节可解析,不证明应用语义、生成代码或 ProtoJSON 安全。字段号是长期身份,presence、默认值、enum、oneof、deadline 和错误状态共同构成 RPC 契约。 这一主题最终要守住:字段身份永不复用,调用截止时间与失败状态沿整条 RPC 链保持可判定。若只能证明最新生产者与最新客户端的 happy path,就还没有建立可演进契约。
契约源、解析 bundle、digest、生成器版本与测试报告可追溯。明确区分传输、结构、业务、运行和演进五层语义。所有机器身份删除后保留并禁止复用,名称不承担隐式判断。
兼容矩阵覆盖所有活跃消费者、历史样本与双向部署顺序。引用闭包、dialect、循环、外部资源和解析预算经过门禁。错误类型、状态、可重试性和 detail 脱敏具有稳定规则。
deadline、取消、幂等键、分页游标和容量上限进入契约。时间、金额、ID、枚举、Null 与未知值有跨语言语义。SDK 实际生成并编译,Provider 与 Consumer 测试均执行。
expand/migrate/contract、弃用终止条件和回滚制品经过演练。两个 Java 17 模型以严格编译运行,输出与正文逐项一致。
以下一手资料用于核对协议与规范语义,实际采用版本仍需匹配解析器、生成器和语言运行时能力:
Protocol Buffers proto3 guide。Protobuf Editions overview。gRPC core concepts
