《Java 服务端通信工程》2.3 字段演进与兼容矩阵

把兼容性拆成线上、源码、二进制、行为四个维度,给出新增、删除、改名、改类型、改枚举、动 oneof 的完整兼容矩阵,解释 int32 与 sint32 为什么不能互换,并用 Descriptors 的保留位检查写一条可在 CI 执行的契约回归测试。

承接的问题

2.1 讲了字段号为什么不能复用,2.2 讲了 JSON 没有字段号带来的兼容问题。这一节把「改字段」这件事拆细:同样是新增一个字段,有时安全,有时会让旧客户端解析出错误数据;同样是改类型,int32 到 int64 可能没事,int32 到 sint32 一定出事。

判断依据不能靠记忆,要靠编码规则和一张矩阵。这一节给出四个兼容维度、一张逐项结论表、几个必须理解的编码差异,以及一条用 Descriptors 写的回归测试。

兼容性的四个维度

「兼容」不是一个布尔值。同一个改动可能线上兼容、源码不兼容:

维度问题检查方式
线上兼容旧客户端能不能读新服务端的字节,反之亦然用旧 schema 解析新消息,字段值和未知字段是否正确
源码兼容重新生成代码后,业务代码还能不能编译mvn clean install,看调用点是否报错
二进制兼容已编译的旧 jar 能不能和新 jar 一起跑类签名、方法签名是否变化
行为兼容字段还在,但语义变了,调用方是否仍按预期工作契约测试、灰度观察、对比回归

线上兼容是底线,行为兼容最容易被忽略。把 page_size 的默认值从 20 改成 50,字段没变、字节没变,但所有不传分页大小的客户端都会多拿一倍数据。这类改动在编译期和线上都看不出来,只能靠行为测试和灰度。

字段演进兼容矩阵

下表以「旧 schema 已发布,新 schema 准备上线」为前提。结论里的「是」指旧客户端和新服务端可以互通:

改动线上源码结论与条件
新增字段,使用新编号是是安全;旧端忽略未知字段
删除字段并 reserved 编号和名字是否安全;调用点编译失败,需同步清理
删除字段但不 reserved是否危险;编号可能被复用,线上错位
重命名字段是否线上安全;源码需同步改,日志和 JSON 字段名会变
修改字段编号否是禁止;等于删除旧字段再加新字段
int32 → int64是否非负值安全;负值编码都是 10 字节,但源码类型变了
int32 → uint32否否禁止;负值会被解释成巨大的正数
int32 → sint32否否禁止;zigzag 编码不同,值完全错位
int64 → uint64否否禁止;负值语义不同
string → bytes是*否有条件;旧端可能校验 UTF-8,二进制会解析失败
fixed32 → sfixed32是否位模式相同,解释不同;只影响符号语义
fixed32 → int32否否禁止;wire type 从 I32 变成 VARINT
新增枚举值是是安全;旧端落到 UNRECOGNIZED 或保留数字
删除枚举值是*否危险;旧数据仍可能出现该值,业务分支要保留
标量加 optional是否线上兼容;存在性语义变了,hasXxx() 可用
repeated ↔ 单个字段否否禁止;编码从 LEN 变成标量或反之
字段移入 oneof是*否有条件;presence 语义和生成 API 都变
新增方法是是安全;旧客户端不调用即可
修改方法名否否禁止;:path 变了,路由返回 UNIMPLEMENTED
修改调用模式否否禁止;流模型不匹配
修改服务名或包名否否禁止;完整方法名变了

表里的「有条件」不是措辞模糊,而是「线上字节能读,但业务可能出错」。string → bytes 就是这样:两者都是 LEN 编码,旧端拿到的仍是一段字节;但如果旧端按 UTF-8 校验,遇到二进制内容会拒绝,错误发生在解析层,而不是协议层。

类型变更的编码分析

矩阵背后的判断依据是 wire type 和值编码。把常用的类型分组:

组类型wire type值编码
varint 有符号int32、int64VARINT补码,负数 10 字节
varint 无符号uint32、uint64VARINT无符号
varint zigzagsint32、sint64VARINT(n << 1) ^ (n >> 31)
定长fixed32、sfixed32、floatI324 字节小端
定长fixed64、sfixed64、doubleI648 字节小端
长度前缀string、bytes、消息、packed repeatedLEN长度 + 字节

同一组内,wire type 相同,但值编码可能不同:

  • int32 和 int64 都是补码 varint。非负值完全兼容;负数都编码成 10 字节,位模式也兼容。所以线上兼容,源码类型变了。
  • int32 和 uint32 都是 varint,但 -1 在 int32 里是 ff ff ff ff ff ff ff ff ff 01,在 uint32 里被解释成 4294967295。同一个字节序列,两个值。禁止互换。
  • sint32 用 zigzag,-1 编码成 01;int32 的 -1 编码成 10 字节。同一个字段号、同一个 wire type,值完全不同。禁止互换。
  • fixed32 和 sfixed32 都是 4 字节小端,位模式相同,只差符号解释。线上能读,但负数的业务含义会变。

跨组一定不兼容:wire type 变了,解码器会按错误的长度读值,后面的字段全部错位。fixed32 → int32 从 I32 变成 VARINT,string → int32 从 LEN 变成 VARINT,都是这种情况。

string 和 bytes 是同一组里的特例:wire type 都是 LEN,字节内容原样传输。区别在语义校验:string 要求 UTF-8,bytes 不要求。把文本改成二进制,旧端可能因为非法 UTF-8 拒绝;把二进制改成文本,新端可能因为同样原因拒绝。

reserved:删除字段的唯一正确姿势

删除字段时,编号和名字都要保留:

message Book {
  reserved 5, 6, 9 to 11;
  reserved "publisher", "publish_year", "legacy_code";
  string isbn = 1;
  string title = 2;
  string author = 3;
  int32 stock = 4;
  string language = 7;
  int32 page_count = 8;
}

规则:

  • reserved 的编号不能被任何新字段使用,protoc 会在编译期报错。
  • reserved 的名字不能被任何新字段使用,防止「名字回来了但编号变了」。
  • 范围写法 9 to 11 包含两端;9 to max 可以保留到最大编号。
  • 19000–19999 是 Protobuf 内部保留区,不能使用,也不需要自己 reserved。

只保留编号不保留名字,下一个人可能把编号 5 用作 language,旧数据被解析成语言字符串;只保留名字不保留编号,编号 5 可能被复用成 stock,旧数据被解析成库存。两个都保留,才是完整的墓碑。

在 linkd-v3 里落地:兼容检查与保留位回归

先写一个不依赖生成代码的检查器,把矩阵里的规则固化成代码:

package com.plume.linkd.v3.contract;

public final class SchemaCompat {

    public enum Verdict { SAFE, CONDITIONAL, BREAKING }

    public record Field(int number, String name, String type, boolean repeated, boolean optional) {
    }

    public static Verdict check(Field oldField, Field newField) {
        if (oldField.number() != newField.number()) {
            return Verdict.BREAKING;
        }
        if (oldField.repeated() != newField.repeated()) {
            return Verdict.BREAKING;
        }
        if (!oldField.type().equals(newField.type())) {
            return switch (oldField.type() + "->" + newField.type()) {
                case "int32->int64", "int64->int32" -> Verdict.CONDITIONAL;
                case "string->bytes", "bytes->string" -> Verdict.CONDITIONAL;
                case "fixed32->sfixed32", "sfixed32->fixed32" -> Verdict.CONDITIONAL;
                default -> Verdict.BREAKING;
            };
        }
        if (oldField.optional() != newField.optional()) {
            return Verdict.CONDITIONAL;
        }
        if (!oldField.name().equals(newField.name())) {
            return Verdict.CONDITIONAL;
        }
        return Verdict.SAFE;
    }

    public static void main(String[] args) {
        System.out.println("int32->int64=" + check(
                new Field(4, "stock", "int32", false, false),
                new Field(4, "stock", "int64", false, false)));
        System.out.println("int32->sint32=" + check(
                new Field(4, "stock", "int32", false, false),
                new Field(4, "stock", "sint32", false, false)));
        System.out.println("string->bytes=" + check(
                new Field(2, "title", "string", false, false),
                new Field(2, "title", "bytes", false, false)));
        System.out.println("rename=" + check(
                new Field(4, "stock", "int32", false, false),
                new Field(4, "quantity", "int32", false, false)));
        System.out.println("repeated=" + check(
                new Field(4, "stock", "int32", false, false),
                new Field(4, "stock", "int32", true, false)));
    }
}

预期输出:

int32->int64=CONDITIONAL
int32->sint32=BREAKING
string->bytes=CONDITIONAL
rename=CONDITIONAL
repeated=BREAKING

这个检查器只覆盖单字段,真实项目还要检查「删除字段是否 reserved」「新增字段编号是否冲突」「枚举值是否减少」。

保留位可以用生成代码的描述符直接断言,放进 comm-api 的测试:

package com.plume.linkd.v3.api;

import com.google.protobuf.Descriptors;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class BookReservedTest {

    @Test
    void deletedFieldNumbersStayReserved() {
        Descriptors.Descriptor book = Book.getDescriptor();
        assertTrue(book.isReservedNumber(5));
        assertTrue(book.isReservedNumber(6));
        assertTrue(book.isReservedName("publisher"));
        assertFalse(book.isReservedNumber(7));
    }

    @Test
    void languageUsesTheNextFreeNumber() {
        Descriptors.FieldDescriptor language = Book.getDescriptor().findFieldByName("language");
        assertEquals(7, language.getNumber());
        assertEquals(Descriptors.FieldDescriptor.Type.STRING, language.getType());
        assertEquals(1, Book.getDescriptor().findFieldByName("isbn").getNumber());
    }
}

预期结果(JUnit 5):两条测试都通过。如果某次改动把 language 的编号改成 5,第一条仍然通过、第二条失败;如果有人删掉 reserved 5,第一条失败。这类断言比代码评审更可靠,因为它每次构建都会跑。

行为兼容:字节没变也会出事

四维兼容里,行为兼容最难自动化。常见的几类:

改动字节变化行为影响
改默认分页大小 20 → 50无客户端拿到双倍数据,尾延迟上升
改枚举值的业务含义无旧客户端按旧含义处理,状态错乱
改字段单位(毫秒 → 秒)无数值差 1000 倍,通常要等线上告警才发现
改排序规则无分页结果重复或遗漏
改错误码的原因码有(trailers)客户端分支失效,重试策略错位
改限流阈值无高峰期错误率上升

处理方式不是「禁止改」,而是「改之前先确认调用方」。单位变更应该新增字段(timeout_ms → timeout_seconds 或反过来),旧字段保留一个版本;枚举含义变更应该新增枚举值,不要改旧值的注释;排序规则变更要提供显式参数,不要改默认。

灰度与回滚

字段演进最终要落到发布流程:

  1. 服务端先兼容。 新服务端同时能读旧字段和新字段,旧客户端不受影响。
  2. 客户端再升级。 新客户端开始发新字段,服务端两种都认。
  3. 观察一个版本。 看错误率、延迟、未知字段日志,确认没有调用方还在用旧语义。
  4. 清理旧字段。 确认无调用方后,reserved 编号和名字,再删代码。
  5. 回滚预案。 如果新字段导致问题,服务端要能忽略它并回退到旧逻辑,不能要求客户端先回滚。

第 12 章会把这条流程和 x-api-version、灰度标记结合起来。这里只需要记住:兼容不是「一次发布」,而是「两个版本共存一段时间」。

常见错误与边界

错误一:删除字段不 reserved。 最常见也最危险。线上不会立刻报错,等编号被复用时才出现「老数据变成新字段」。

错误二:认为重命名字段是破坏性变更。 线上按编号,重命名是兼容的;但源码、日志、JSON 字段名都会变。结论要写「线上安全、源码不兼容」,不能笼统说「不兼容」。

错误三:把 int32 改成 sint32 当成优化。 值编码完全变了,旧数据会被解析成错误的数。要改只能新增字段,双写一段时间。

错误四:删除枚举值。 旧数据里仍可能有该值,新代码的 switch 没有分支,可能落到默认值或抛异常。枚举值只增不删,废弃值保留并标注。

错误五:只跑编译,不跑契约测试。 编译只能发现源码不兼容,发现不了编号复用和保留位缺失。描述符断言必须进 CI。

边界:optional 的存在性不是免费的。 加 optional 线上兼容,但每次设置值都会多写一个 tag,且生成 API 从 getX() 变成 hasX()/getX()。已有代码要跟着改。

边界:oneof 的兼容性取决于怎么加。 把已有字段移进 oneof 线上可读,但生成 API 变了,而且「同时设置多个字段」的旧行为不再允许。新增一个 oneof 成员通常安全,前提是旧端不会同时设置。

边界:reserved 不能跨消息继承。 每个 message 的保留位是独立的,父消息的 reserved 不会保护嵌套消息的编号。

小结

  • 兼容性有四个维度:线上、源码、二进制、行为;线上兼容是底线,行为兼容最容易被忽略。
  • 新增字段、reserved 删除、新增枚举值、新增方法通常安全;改编号、改 wire type、改调用模式一定不安全。
  • int32 与 int64 线上兼容但源码不兼容;int32 与 uint32、sint32 的值编码不同,禁止互换。
  • string 与 bytes 同为 LEN 编码,但 UTF-8 校验不同,属于有条件兼容。
  • 删除字段必须同时 reserved 编号和名字;19000–19999 是 Protobuf 保留区。
  • 用 Descriptor.isReservedNumber / isReservedName 写回归测试,让保留位缺失在 CI 里失败。
  • 字节没变也可能行为不兼容:默认值、单位、排序、枚举含义的改动都要单独评审。

字段和兼容规则都清楚了,接下来进入调用侧。第 3 章从四种调用模式开始,解释 unary、server streaming、client streaming 和 bidi 各自适合什么场景。

阅读导航:上一节 2.2 JSON 与 Protobuf 的取舍 | 下一节 3.1 四种调用模式

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收