PB 表数据读写与版本兼容性

PB 表数据读写与版本兼容性

PB 表使用 Protocol Buffers 编码,增加或删除字段通常具有良好的二进制兼容性。但是,“Protobuf 编码能够解析”不等于“本次改表允许”,也不等于“新老业务版本可以安全地同时写入”。最终结果还取决于业务使用的 proto、记录中实际保存的数据以及本次读写的范围。

本文集中说明 PB 表定义变更、完整 message 写入、部分字段更新、UnknownField 和灰度发布之间的关系。具体接口签名和字段路径语法见《部分字段查询和更新》与《条件过滤和更新功能》;控制台操作见《修改表》。

先明确四个对象

判断兼容性时,需要同时区分以下对象:

  • TcaplusDB 当前表定义:服务端用它校验请求,并解析 FieldSet、Operation、Condition 中的字段。
  • 业务进程使用的 proto:灰度期间,不同进程可能分别使用 v1、v2 等版本。
  • 记录中实际存储的二进制数据:表定义删除字段时,已有记录不会因此被自动扫描和重写,被删除字段的编码仍可能存在。
  • 本次读写范围:完整 message、父 message、叶子字段的覆盖范围不同,不能仅凭客户端 proto 版本判断结果。

分析一个兼容性问题时,建议依次确认:

  1. TcaplusDB 当前使用哪个版本的表定义?
  2. 读请求和写请求分别使用哪个版本的业务 proto?
  3. 记录中是否已经写入新字段或已删除字段?
  4. 业务进行了全量读取还是部分字段读取?
  5. 写入是完整 message、父 message,还是叶子字段更新?
  6. 写入对象是复用读取结果,还是重新构造、逐字段复制或经过 JSON 等格式中转?
  7. 请求是否携带按字段名解析的 FieldSet 路径、Operation、Condition 或 SQL 文本?

缺少其中任一信息,都可能得到错误结论。

PB 表定义变更规则

TcaplusDB 对 PB 表的约束可能严于 Protobuf 自身的 wire compatibility 规则。即使 Protobuf 文档认为某种变更可以在二进制层面解析,也必须先满足 TcaplusDB 的改表规则。

常用改表约束

变更常规改表是否允许说明
增加 optional、repeated 或 proto3 普通字段允许旧记录中没有该字段,新版本读取时得到协议默认值
增加 required 字段不允许旧记录无法满足 required 约束
删除非主键 optional 或 repeated 字段有条件允许缓存表不允许删除字段;缓写、同步、索引等能力依赖的字段还有额外限制
删除主键或 required 字段不允许主键集合、required 字段必须保持稳定
修改字段编号不允许等价于删除旧字段并增加新字段,且可能破坏存量数据解释
复用历史已删除字段的编号不允许包括嵌套 message 内的字段编号
修改同一编号字段的名称或 label不允许optional、repeated、required 之间也不能互改
修改同一编号字段的类型通常不允许常规改表仅有 string 变为 bytes 的单向例外
删除字段后,在新编号上复用旧字段名不建议分阶段改表可能通过校验,但会造成旧 Operation、Condition 或 SQL 文本按新字段解释
修改 proto2/proto3 syntax 或表 message 身份不允许客户端和服务端必须识别为同一张表、同一种协议语法
修改主键、分片键或本地索引定义不允许这些定义应在建表时确定
修改 Generic/List/SortList 类型或排序规则不允许List 最大元素个数只能增加,不能减少
修改缓存、字段加密等关键属性受限不应把属性修改当作普通字段兼容变更

对于被删除的字段,业务 proto 也应保留历史信息:

message FooData {
    reserved 3;
    reserved "old_field";
}

TcaplusDB 会阻止历史字段编号被复用;业务侧同时 reserved 原编号和原名称,可以更早地在 proto 编译或代码评审阶段发现误用。即使新字段采用了新编号,也不建议复用历史字段名。

允许改表不等于适合灰度

改表校验回答的是“服务端是否接受新表定义”,灰度兼容回答的是“新老客户端是否可以同时正确读写”,两者是不同问题。

例如,string 变为 bytes 可以通过常规表定义校验,因为两者在 Protobuf 中都使用 length-delimited 编码。但是新老业务 proto 对同一编号的类型定义不同:

  • 客户端或接入层的协议元数据校验可能拒绝请求;
  • 新 bytes 数据不保证是旧 string 可以接受的 UTF-8 文本;
  • 依赖字段类型的 Condition、Operation 和业务逻辑也可能改变行为。

因此,string 变为 bytes 应视为不支持新老版本同时运行的协调变更,不能按普通新增字段的方式灰度。特殊白名单放开的其它不兼容变更同样没有通用兼容保证,必须逐项评估数据迁移、发布和回滚方案。

服务端校验的边界

TcaplusDB 可以校验提交到服务端的表定义是否遵循正常演进规则,并记录已经删除的字段编号,但不能保证每个业务进程本地使用的 proto 都来自同一条演进链。业务可能手工维护了错误的字段编号、名称、类型、主键或索引选项,也可能跳过中间版本后继续发送旧的字段路径和文本表达式。

因此,业务侧不能只以“服务端改表成功”作为兼容性证明。所有读写方都应使用经过统一管理的 proto,并分别验证它与服务端当前表定义、存量数据和其它灰度版本的兼容性。即使请求没有立即报协议错误,也不代表字段一定按业务预期被解释。

PB API 的读写范围

以下使用类 C++ 伪代码表达统一语义,不代表某个语言 SDK 的完整接口签名。

完整 message 写入

Add、Update 和 Set 的记录存在性语义不同:

  • Add:记录不存在时插入。
  • Update:更新已存在的记录。
  • Set:记录存在时更新,不存在时插入。

它们提交数据时都会序列化业务提供的完整 PB message。对于兼容性分析,可以把它们归为完整 message 写入:本次提交的 message 中没有的已知字段或未知字段,不会凭空由客户端补回。

record = Get(key);       // 完整读取
record.mutable_profile()->set_level(10);
Update(record);          // 完整 message 写入

如果 record 是刚刚完整读取并解析得到的同一个对象,业务 proto 不认识的新字段可能仍保存在 UnknownField 中,重新序列化时可以一并写回。如果业务新建了另一个对象、只复制已知字段,或主动清理了 UnknownField,这些字段就不会出现在写入数据中。

FieldSet 部分字段更新

FieldSet,以及其它 SDK 中对应的部分字段更新接口,以服务端已存记录为基础,只修改字段路径选中的范围。路径越靠近叶子,影响范围越小。

FieldSet({"foo_data.x"}, delta);  // 只修改 x
FieldSet({"foo_data"}, delta);    // 替换整个 foo_data 子 message
  • 更新 foo_data.x 时,foo_data 内未选中的兄弟字段由服务端保留。
  • 更新 foo_data 时,提交数据会替换整个 foo_data。提交的子 message 是否携带 UnknownField,将直接影响未知的兄弟字段能否保留。
  • 更新整个 repeated、map 或 message 字段时,同样要按“替换该字段完整内容”分析。
  • 一次请求不要同时选择父路径和子路径,例如 {"foo_data", "foo_data.x"},否则更新顺序和结果不可预期。

FieldSet 的详细路径能力、map 和 repeated 限制见《部分字段查询和更新》。

Operation 和 Condition

Operation 是服务端基于记录当前值执行的额外原子操作,例如:

counter += 1
PUSH items #[-1] [$ = 100]

Operation 不等同于完整 message 覆写,也不等同于 FieldSet 的字段集合;一个部分更新请求可以同时携带 FieldSet 数据、Operation 和 Condition。服务端先用 Condition 判断当前记录,再应用指定字段更新和 Operation。

Operation、Condition 以及相关 SQL 文本中的字段名和字段类型,按 TcaplusDB 当前表定义解析。它们不会根据请求来自 v1 还是 v3 自动切换含义。因此,复用历史字段名或者让旧业务版本长期发送文本表达式,可能比普通二进制读写更危险。

部分读取也会影响后续写入

FieldGet 只返回指定字段和必要的主键信息。由部分读取构造出的 message 不是完整记录,未返回字段也不会作为 UnknownField 自动出现。

partial = FieldGet({"foo_data.x"}, key);
partial.mutable_foo_data()->set_x(2);
Update(partial);  // 错误示范:把部分读取结果用于完整 message 写入

如果后续需要完整写入,应先完整读取;如果只修改少量字段,应继续使用 FieldSet。需要用完整返回值做判断时,可以使用相应接口的完整结果返回选项,但要注意记录较大时的网络开销。

UnknownField 与删除字段的数据生命周期

UnknownField 是当前业务 proto 不认识、但 Protobuf 二进制解析器可以保留的字段。它既可能是“新客户端写入、旧客户端不认识”的新增字段,也可能是“表定义已经删除、存量记录仍携带”的历史字段。

删除字段通常只修改 TcaplusDB 表定义,不会立即改写所有记录。历史数据何时消失,取决于后续写入:

后续动作历史或新增未知字段的结果
仅修改表定义原记录的二进制字段仍可能存在
完整二进制读取到不认识该字段的 proto字段可进入 UnknownField
复用解析所得 message,再做二进制序列化UnknownField 通常可以保留
新建 message 或逐字段复制已知字段UnknownField 丢失
主动清理 UnknownFieldUnknownField 丢失
经 JSON、TextFormat、REST 等非二进制表示中转不应依赖 UnknownField 被保留
FieldSet 其它叶子字段未选中的历史字段通常保留
FieldSet 覆盖其父 message是否保留取决于提交的父 message 是否携带该字段
完整写入一个不携带该字段的 message字段从该记录中消失
数据迁移清理或删除记录字段消失

因此,“删除字段后仍能从 UnknownField 读取”只能作为迁移期现象,不能作为长期数据访问接口。业务不应继续依赖已经从表定义删除的字段。

常见表变更的兼容性结论

场景读取兼容性写入风险灰度建议
增加普通字段旧数据由新 proto 读取时得到默认值;旧 proto 可把已写入的新字段作为 UnknownField旧版本完整写入、父 message 替换或非二进制中转可能清除新字段可以灰度,但要审计旧版本写入范围
删除普通字段删除前写入的数据仍可能以 UnknownField 存在旧版本仍可能把该字段写回;新版本完整覆写可能将其清理先停止业务依赖,再改表;编号永久保留
同编号修改类型可能无法解析或被元数据校验拒绝存量数据可能被错误解释除 string 变 bytes 的改表例外外不允许;例外也不做混合版本灰度
删除后在新编号复用旧名称二进制编号不同,不会自动当作同一字段旧 Operation、Condition 和 SQL 文本会按当前同名字段解释使用新的语义名称,不要复用历史名称
修改 repeated、map 或父 message子字段本身可能 wire-compatible父级整体替换会删除未携带的元素或未知字段优先叶子级 FieldSet 或专用 Operation

还需要关注以下业务层风险:

  • 默认值和 presence:旧记录没有新增字段时,新版本读取的是默认值。若业务必须区分“从未设置”和“显式设置为默认值”,应使用当前 proto 工具链支持的显式 presence 设计,或另设状态字段。
  • enum 新值:新版本写入的新枚举值可能无法被旧业务逻辑识别。旧代码不要假设枚举集合永远不变,也不要在未知值上执行错误的默认分支。
  • oneof 调整:把已有字段移入 oneof、改变互斥关系或复用 oneof 成员,都可能改变 presence 和清理语义,应作为专项迁移处理。
  • 并发读改写:即使 proto 完全一致,两个实例全量读改写同一记录也可能互相覆盖。可使用版本检查避免丢失更新,或用 FieldSet、Operation 缩小更新范围。

新老业务版本灰度发布

发布前检查

改表前应盘点所有可能访问该表的生产者和消费者,包括在线进程、离线任务、管理工具、补偿程序、消息队列消费者和仍可能重试的旧请求。至少检查:

  • 各访问方使用的 proto 版本;
  • 各版本 proto 是否来自同一条受控演进链,表 message、主键、索引、字段编号和类型是否一致;
  • 是否存在完整 message 写入或父 message 更新;
  • 是否把 FieldGet 结果用于完整写入;
  • 是否通过 JSON、REST、TextFormat 或自定义对象做中转;
  • FieldSet 路径、Operation、Condition 和 SQL 文本是否包含本次变更字段名;
  • 是否依赖字段默认值、presence、enum 未知值或 oneof 行为;
  • 回滚到旧业务版本时,旧版本会怎样写入新数据。

推荐发布顺序

  1. 准备新 proto,并完成新读旧、旧读新、交叉写入和回滚测试。
  2. 先在 TcaplusDB 服务端变更表定义,使服务端当前定义成为后续请求的统一基准。
  3. 再灰度发布新业务版本,同时保留旧版本观察窗口。
  4. 灰度期间优先使用叶子级 FieldSet 或 Operation,避免旧版本完整覆写包含新字段的父 message 或记录。
  5. 监控协议元数据不匹配、Condition/Operation 解析失败、字段意外回默认值、版本冲突和反序列化异常。
  6. 等旧实例、离线任务、重试请求和消息积压全部退出兼容窗口后,再结束灰度。

对于 string 变 bytes 或特殊白名单变更,不应套用上述新老版本共存流程。应先停止或排空旧写入方,在受控窗口内变更服务端定义并切换全部业务版本。

回滚注意事项

新增普通字段时,应用回滚到旧 proto 是否安全,仍取决于旧版本的写入方式。旧版本如果只更新无关叶子字段,通常可以保留新字段;如果进行完整写入、父 message 替换或非二进制中转,新字段可能被清除。

不要因为应用回滚就盲目回退服务端表定义。新版本可能已经写入新字段,新旧实例和在途请求也可能尚未退出。回滚前应重新按本文开头的判断清单评估服务端定义、存量数据和所有写入方。

典型案例

嵌套 message 增加字段

v1 与 v2 定义如下:

message FooData {
    int32 x = 1;  // v1 已有
    int32 y = 2;  // v2 新增
}

message tb_pb_roleinfo {
    option (tcaplusservice.tcaplus_primary_key) = "openid";

    string openid = 1;
    FooData foo_data = 2;
}

执行过程:

  1. TcaplusDB 表定义先升级到 v2。
  2. 新业务使用 v2 完整读取记录,再通过 FieldSet({"foo_data.y"}) 写入 y = 3。
  3. 老业务使用 v1 完整读取同一记录,修改 x = 2 后写回。
  4. 新业务再次使用 v2 读取。

步骤 3 的字段路径决定最终结果:

老业务的写法v2 再读到的 y原因
FieldSet({"foo_data.x"})3服务端只修改叶子字段 x,未选中的 y 保持不变
FieldSet({"foo_data"}),复用完整读取所得的 foo_data3v1 不认识的 y 仍在该子 message 的 UnknownField 中,并随父 message 写回
FieldSet({"foo_data"}),使用新建或逐字段复制的 foo_data0新对象不携带 y,整个父 message 被替换后,v2 读取协议默认值
完整 Update/Set,复用完整读取所得的整条 message3UnknownField 随完整 message 重新序列化
完整 Update/Set,使用新建、部分读取或经 JSON 中转的 message0写入对象不携带 y,完整记录覆写后字段消失

所以,如果步骤 3 确实是 FieldSet({"foo_data.x"}),答案确定为 3,不需要再判断 v1 是否保留 UnknownField。只有更新范围扩大到 foo_data 或整条记录时,才需要区分“复用读取对象”和“重新构造对象”。

删除字段但记录中仍有数据

假设 v1 有 int32 old_score = 3,v2 删除该字段并保留编号:

message Role {
    reserved 3;
    reserved "old_score";
}

改表后,已有记录中的编号 3 不会自动消失。v2 完整读取时,它可能出现在 UnknownField 中;v2 只 FieldSet 其它叶子字段时,它通常继续保留;v2 用不携带该字段的新 message 完整写入后,它才从该记录中消失。业务不能把 UnknownField 的暂时存在当成删除字段后的正式读取方式。

删除字段后复用字段名

假设协议经历以下版本:

// v1
int32 a = 3;

// v2
// 删除 a,并保留编号 3

// v3
string a = 4;

v3 没有复用历史编号,但复用了字段名。TcaplusDB 已使用 v3 表定义时,如果仍有 v1 业务发送:

a += 1
a == 123

服务端会把文本中的 a 解析为当前的 string a = 4,而不是 v1 的 int32 a = 3。自增会因类型不适用而失败,Condition 也可能解析失败或得到与旧业务预期不同的结果。

因此,删除字段后不但不能复用编号,也应避免复用名称。发布前必须搜索所有 FieldSet 路径、Operation、Condition 和 SQL 文本,而不能只比较 proto 的字段编号。

部分读取后完整写入

假设记录包含 profile、inventory 和 settings,业务仅通过 FieldGet({"profile.level"}) 读取等级,然后把返回对象交给 Update。由于该对象没有完整的 inventory、settings 以及其它未读取内容,完整写入可能清除这些数据。

正确做法是继续使用 FieldSet({"profile.level"});如果业务确实需要完整写入,应先完整读取记录,并配合版本检查处理并发修改。

相关文档