PB 表数据读写与版本兼容性
PB 表使用 Protocol Buffers 编码,增加或删除字段通常具有良好的二进制兼容性。但是,“Protobuf 编码能够解析”不等于“本次改表允许”,也不等于“新老业务版本可以安全地同时写入”。最终结果还取决于业务使用的 proto、记录中实际保存的数据以及本次读写的范围。
本文集中说明 PB 表定义变更、完整 message 写入、部分字段更新、UnknownField 和灰度发布之间的关系。具体接口签名和字段路径语法见《部分字段查询和更新》与《条件过滤和更新功能》;控制台操作见《修改表》。
先明确四个对象
判断兼容性时,需要同时区分以下对象:
- TcaplusDB 当前表定义:服务端用它校验请求,并解析 FieldSet、Operation、Condition 中的字段。
- 业务进程使用的 proto:灰度期间,不同进程可能分别使用 v1、v2 等版本。
- 记录中实际存储的二进制数据:表定义删除字段时,已有记录不会因此被自动扫描和重写,被删除字段的编码仍可能存在。
- 本次读写范围:完整 message、父 message、叶子字段的覆盖范围不同,不能仅凭客户端 proto 版本判断结果。
分析一个兼容性问题时,建议依次确认:
- TcaplusDB 当前使用哪个版本的表定义?
- 读请求和写请求分别使用哪个版本的业务 proto?
- 记录中是否已经写入新字段或已删除字段?
- 业务进行了全量读取还是部分字段读取?
- 写入是完整 message、父 message,还是叶子字段更新?
- 写入对象是复用读取结果,还是重新构造、逐字段复制或经过 JSON 等格式中转?
- 请求是否携带按字段名解析的 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 丢失 |
| 主动清理 UnknownField | UnknownField 丢失 |
| 经 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 行为;
- 回滚到旧业务版本时,旧版本会怎样写入新数据。
推荐发布顺序
- 准备新 proto,并完成新读旧、旧读新、交叉写入和回滚测试。
- 先在 TcaplusDB 服务端变更表定义,使服务端当前定义成为后续请求的统一基准。
- 再灰度发布新业务版本,同时保留旧版本观察窗口。
- 灰度期间优先使用叶子级 FieldSet 或 Operation,避免旧版本完整覆写包含新字段的父 message 或记录。
- 监控协议元数据不匹配、Condition/Operation 解析失败、字段意外回默认值、版本冲突和反序列化异常。
- 等旧实例、离线任务、重试请求和消息积压全部退出兼容窗口后,再结束灰度。
对于 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;
}
执行过程:
- TcaplusDB 表定义先升级到 v2。
- 新业务使用 v2 完整读取记录,再通过
FieldSet({"foo_data.y"})写入y = 3。 - 老业务使用 v1 完整读取同一记录,修改
x = 2后写回。 - 新业务再次使用 v2 读取。
步骤 3 的字段路径决定最终结果:
| 老业务的写法 | v2 再读到的 y |
原因 |
|---|---|---|
FieldSet({"foo_data.x"}) |
3 |
服务端只修改叶子字段 x,未选中的 y 保持不变 |
FieldSet({"foo_data"}),复用完整读取所得的 foo_data |
3 |
v1 不认识的 y 仍在该子 message 的 UnknownField 中,并随父 message 写回 |
FieldSet({"foo_data"}),使用新建或逐字段复制的 foo_data |
0 |
新对象不携带 y,整个父 message 被替换后,v2 读取协议默认值 |
完整 Update/Set,复用完整读取所得的整条 message |
3 |
UnknownField 随完整 message 重新序列化 |
完整 Update/Set,使用新建、部分读取或经 JSON 中转的 message |
0 |
写入对象不携带 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"});如果业务确实需要完整写入,应先完整读取记录,并配合版本检查处理并发修改。
相关文档
- 《PB 表》:PB 表定义、数据类型与建表示例。
- 《修改表》:OMS 控制台改表操作。
- 《部分字段查询和更新》:FieldGet、FieldSet、FieldUpdate 的接口和字段路径。
- 《条件过滤和更新功能》:Condition、Operation 的能力与限制。
- Protocol Buffers Proto3 Language Guide
- Protocol Buffers Best Practices
- Protocol Buffers Field Presence