TDR Blob 内嵌 Protobuf 部分字段读写

TDR Blob 内嵌 Protobuf 部分字段读写

当 TDR 表的 Blob 中保存了 Protobuf(下文简称 PB)数据时,可以通过字段路径,只读取或更新其中需要的内容。例如,只修改路由规则中的实例地址,无需在客户端读取、解析并回写整个 Blob。表本身仍然是 TDR 表。

PB 的二进制编码是半自解释的:每个字段带有字段编号和 wire type,解析方可以据此确定字段数据的边界;其中字符串、bytes、message 等使用带长度的编码。Tcaplus 利用这些信息,按路径提取或替换所选内容,保留未选中的内容。编码格式可参阅 Protobuf 官方编码说明。

PB 数据与定义的兼容性需要业务自行保证。 服务端知道 TDR 表结构,但不持有 Blob 内业务数据的 .proto 定义。wire type 并不等于完整的业务类型,也不能单独说明 message 层级、map/repeated 或 oneof 关系。存量数据、本次写入的数据、构造路径和解析结果所用的 PB 定义必须兼容;路径转换成功不代表历史数据一定符合当前定义。已上线的字段编号不能随意改变或复用,其他类型变更也应遵守 Protobuf 的消息演进规则。

下面先用一组 C++ 示例混合选择常见类型的路径,并给出简短的 Go 对照,再展开说明路径规则。完整接入流程见 C++ SDK 示例和 Go SDK 示例。

1. 综合读写示例

1.1 示例数据与准备

本功能适用于 TDR Generic 表,支持单条部分读取、单条部分更新和批量部分读取,不适用于 List/SortList 表。服务端和 SDK 均须支持该功能:

  • C++ 使用 Service API 3.55.0 SP06(3.55.0.209070)及以上版本,该版本提供 tcaplus_tdr_pb_fields.h;Go SDK 的 v0.6.36 已包含本文使用的接口。
  • 服务端须为 Tcapsvr 3.73.0 2026-10-10 以后的版本。旧版后端对这三个命令返回 SVR_ERR_FAIL_TO_FIND_META,需要升级后端。

沿用 service_info.xml 中的 routeinfo Blob 和原生 TDR 字段 filterdata:

<entry name="routeinfo_len" type="uint" defaultvalue="0" desc="路由规则信息长度" />
<entry name="routeinfo" type="char" count="1024" refer="routeinfo_len" desc="路由规则信息" />

routeinfo_len 是 Blob 的有效字节数,1024 是数组容量。Blob 中存放以下普通 PB message,无需创建 PB 表或添加 Tcaplus PB 表选项:

syntax = "proto3";
package routeinfo;

message RouteInfo {
  uint32 version = 1;
  string strategy = 2;
  Weight weight = 3;
  repeated string tags = 4;
  repeated Instance instances = 5;
  map<int64, Instance> instance_map = 6;
  map<string, string> settings = 7;
}
message Weight { uint32 cpu = 1; uint32 memory = 2; }
message Instance { string addr = 1; uint32 port = 2; uint32 weight = 3; }

假定客户端已初始化并注册该表,主键为 ("dev", "oa", "com") 的记录已存在,Blob 中是合法的 RouteInfo 编码,且至少有一个 instances 元素、instance_map[1001] 和 settings['region']。这些前提与 Go SDK 初始数据示例一致。

C++ 代码使用生成的 SERVICE_INFO、routeinfo::RouteInfo,并包含 tcaplus_tdr_pb_fields.h。下文省略客户端初始化、主键填写、请求创建和响应接收循环,完整流程见 C++ SDK 示例。

1.2 混合选择路径并读取

先创建 TCAPLUS_API_PB_FIELD_GET_REQ 请求、添加记录,并通过 SetData 设置带主键的 TDR 数据。然后将需要的 PB 字段名和原生 TDR 字段放在同一组请求中:

const char* pb_fields[] = {
    "version",                  // 普通字段
    "weight.cpu",               // 嵌套 message 子字段
    "tags",                     // 整个 repeated 字段
    "instances[0].addr",        // repeated 元素的子字段
    "instance_map[1001].port",   // 整数 key 的 map 子字段
    "settings['region']"        // 字符串 key 的 map value
};
const TcaplusService::TdrPbFieldGroup field_groups[] = {
    TcaplusService::TdrPbFieldGroup(
        "routeinfo", routeinfo::RouteInfo::descriptor(), pb_fields),
    TcaplusService::TdrPbFieldGroup("filterdata") // 原生 TDR 一级 value 字段
};
int32_t ret = TcaplusService::SetAllTdrPbFieldNames(request, field_groups);
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;
return server.SendRequest(request);

TdrPbFieldGroup 的三个参数依次是 TDR Blob 路径、PB 类型的 descriptor、相对 PB 根 message 的字段名列表。descriptor 只提供类型信息,不携带读写数据;多个 Blob 可以分别设置分组。这种字段名写法称为 name path,名称取 .proto 中的原名,不是 JSON name 或 C++/Go 生成的成员名。

收到响应后,依次检查 GetResult、FetchRecord 和 GetData 的结果。下面假定返回的 TDR 记录已取到 row 中,按有效长度解析部分 PB:

if (row.dwRouteinfo_len > sizeof(row.szRouteinfo)) {
    return TcapErrCode::API_ERR_OVER_MAX_FIELD_VALUE_LEN;
}
routeinfo::RouteInfo partial;
if (!partial.ParsePartialFromArray(row.szRouteinfo, static_cast<int>(row.dwRouteinfo_len))) {
    return TcapErrCode::API_ERR_UNPACK_MESSAGE;
}
// 从 partial 读取所选 PB 字段,从 row.szFilterdata 读取原生 TDR 字段。

结果保留 PB 的 message 层级,只包含选中的内容。未返回的 strategy、weight.memory 等字段不代表原记录中为空;返回的 refer 也是部分 PB 的长度。不能将部分结果直接用于完整 Blob 覆盖。

1.3 用同一组路径更新

创建新的 TCAPLUS_API_PB_FIELD_SET_REQ 请求并添加记录,填写相同主键,复用上述 field_groups。下面为每条选中路径准备新值,并设置原生 TDR 字段:

routeinfo::RouteInfo delta;
delta.set_version(2);
delta.mutable_weight()->set_cpu(80);
delta.add_tags("blue");
delta.add_tags("green");
delta.add_instances()->set_addr("10.0.0.99");
(*delta.mutable_instance_map())[1001].set_port(9090);
(*delta.mutable_settings())["region"] = "gz";
snprintf(row.szFilterdata, sizeof(row.szFilterdata), "%s", "enabled");

const size_t encoded_size = delta.ByteSizeLong();
if (encoded_size > sizeof(row.szRouteinfo)) {
    return TcapErrCode::API_ERR_OVER_MAX_FIELD_VALUE_LEN;
}
if (!delta.SerializePartialToArray(row.szRouteinfo, static_cast<int>(encoded_size))) {
    return TcapErrCode::API_ERR_PACK_MESSAGE;
}
row.dwRouteinfo_len = static_cast<uint32_t>(encoded_size);
int32_t ret = record->SetData(&row, sizeof(row));
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;
ret = TcaplusService::SetAllTdrPbFieldNames(request, field_groups);
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;
return server.SendRequest(request);

请求由“主键 + 字段路径 + 增量 PB”组成:路径决定修改范围,增量提供新值,这里的“增量”不是数值自增。此次更新将 tags 整体替换为两项,只修改第一个实例的地址及 map 中指定的内容;strategy、weight.memory、实例的其他字段和其他 map key 保留。选中的字段若未提供新值,可能表示清空,详见第 3 节。

该接口只更新已有记录,记录不存在或已过期时不会自动插入。发送成功后仍须检查服务端响应。

1.4 Go 对照

Go 使用 BuildPaths 构造同一组路径,再交给同步接口。以下 client 已初始化,data 已填好主键,TableName 指向示例表:

paths, err := tdrpb.BuildPaths(
    tdrpb.Blob("routeinfo", (*routeinfo.RouteInfo)(nil),
        "version", "weight.cpu", "tags", "instances[0].addr",
        "instance_map[1001].port", "settings['region']"),
    tdrpb.Raw("filterdata"),
)
if err != nil {
    return
}
if err = client.DoPBFieldGet(TableName, data, &option.TDROpt{FieldNames: paths}); err != nil {
    return
}
partial := &routeinfo.RouteInfo{}
if err = tdrpb.UnmarshalPartial(data.Routeinfo, data.Routeinfo_Len, partial); err != nil {
    return
}

(*routeinfo.RouteInfo)(nil) 只提供类型信息。更新时构造与 C++ 示例相同的增量,用 tdrpb.MarshalPartial 或 MarshalPartialTo 写入 data.Routeinfo、填写 Routeinfo_Len 和 Filterdata,再调用 client.DoPBFieldUpdate(TableName, data, &option.TDROpt{FieldNames: paths})。完整代码见 Go SDK 示例。

2. 字段名接口与 SetFieldNames

SetFieldNames 原来用于选择 TDR 一级字段,如 filterdata、routeinfo。本功能将选择范围扩展到 Blob 内嵌的 PB 字段,因此 PB 路径可以与原生 TDR 一级 value 字段混用。嵌套 PB 路径须配合本功能的部分读写接口使用,普通 TDR Get/Update 的字段选择规则不变。

C++ 的 SetAllTdrPbFieldNames 封装了字段名转换和 SetFieldNames 调用。Go 的 BuildPaths 返回转换后的列表,将其赋给 TDROpt.FieldNames 后,由 DoPB* 接口构造请求并调用 SetFieldNames。字段名转换本身不发送请求。

SetFieldNames 中的路径保留 TDR 字段名,进入 PB 后使用 .proto 等号右侧的字段编号,称为 tagid path。综合示例的对应关系如下:

选择的字段完整 tagid path
versionrouteinfo.1
weight.cpurouteinfo.3.1
tagsrouteinfo.4
instances[0].addrrouteinfo.5#[0].1
instance_map[1001].portrouteinfo.6[1001].2
settings['region']routeinfo.7['region']
原生 TDR 字段 filterdatafilterdata

需要直接指定字段编号时,C++ 可调用 int32_t SetFieldNames(const char* field_name[], unsigned field_count);Go 可把对应列表赋给 TDROpt.FieldNames,或直接调用 Request 的 SetFieldNames([]string) error。tdrpb.Raw 也可原样传入完整 tagid path,不做字段名转换。SetFieldNames 不接收 routeinfo.version 这样的 PB 字段名路径。

直接构造请求时,应先初始化请求、添加记录并 SetData,再设置字段路径,检查各步返回值。SDK 自动补齐承载 PB 的一级 TDR 字段及所需 refer,无需手动加入 routeinfo、routeinfo_len;手动同时选择 routeinfo 和 routeinfo.1 会产生父子路径冲突。

3. 路径规则与读写语义

3.1 路径终点决定读写范围

路径结束在子字段、message 或集合上,操作范围也随之变化。以下普通路径均为 PB name path:

选择的路径增量内容更新结果
version空 PB清除 version 编码,本例 proto3 解析为 0
weight.cpuweight: {cpu: 80}只修改 cpu,memory 保留
weightweight: {cpu: 80}替换整个 weight,memory 不保留
tagstags: ["blue", "green"]替换整个数组,不是追加
instance_map[1001]map 中 key 1001 的新 value替换或新增该元素,其他 key 保留
instance_mapmap 中仅含 key 1001替换整个 map,其他 key 不保留

普通标量、message 或整个集合被列入路径,但增量中没有该字段,表示清除。proto3 零值、空字符串等可能不写入编码,因此路径列表也表达了清空意图。未选中的字段保留,包括未选中的未知字段;整体替换 message、集合或 Blob 时,须提供其中所有希望保留的内容。map 指定 key 的更新和删除另有规则,见下文。

读取普通字段时,若记录中没有该字段的编码,可以成功返回不含该字段的部分 PB;解析后的默认值由客户端定义决定。指定的 map key 不存在或 repeated 下标越界则返回错误,不等同于读到默认值。

3.2 repeated 下标与返回位置

name path 用 instances[0] 选择完整元素,用 instances[0].addr 继续选择其子字段。tagid path 对应 routeinfo.5#[0]、routeinfo.5#[0].1。下标从 0 开始,-1 表示最后一个元素,例如 instances[-1].addr。

tagid path 用 #[] 表示 repeated 下标,用 [] 表示 map key。 PB 的 map 在编码上相当于 repeated entry message;服务端没有业务 .proto,无法仅凭编码区分两者,因此需要 #。字段名接口根据 PB 类型自动转换;手写 routeinfo.5[0] 会被解释为按 map key 访问。

按下标访问仅适用于非 packed repeated,如 string、bytes 和 message 数组。数值类型的 repeated 在 proto3 中默认 packed,在 proto2 中默认不 packed;proto3 中需要按下标访问时,可在定义中声明 [packed = false]。

packed 数组不支持按下标访问,只能整体读取或覆盖。 packed 把多个数值编码在同一个带长度的编码段中,后端没有 .proto,无法把它与字符串、message 区分开,也无法按元素拆分。字段名接口根据 PB 类型在本地拒绝这类路径,返回 API_ERR_FIELD_TYPE_NOT_MATCH;直接用 tagid path 对 packed 数组写 #[] 下标时,SDK 和服务端都无法识别,不会拦截报错,结果也不符合按元素访问的语义,不要这样使用。

更新时目标下标必须存在,增量数组只放一个待写元素,无需补齐前面的占位项。即使将综合示例的路径改为 instances[3].addr,增量仍只需一次 add_instances();更新的是原数组第 4 个元素。

读取结果中的数组紧凑排列:读取 instances[3].addr,结果位于返回 PB 的 instances[0].addr。同一 repeated 不支持在一次请求中更新多个不同下标,也不支持按下标追加或删除;需要整体更新时选择 instances 并提供完整新数组。

3.3 map 更新、key 与删除

更新 instance_map[1001].port 只修改该 value 的 port;选择 instance_map[1001] 则替换完整 value,key 不存在时新增。增量 map 必须包含同一个 key,缺少它会返回 COMMON_ERR_ELEMENT_NOT_EXIST,不会隐式删除元素。

按 key 访问支持 string、int32、int64、uint32、uint64。整数 key 使用不带引号的十进制数,字符串 key 使用单引号或双引号;整数 1001 与字符串 '1001' 不等价。

整数 key 的取值不能大于 int64 最大值 9223372036854775807,uint64 key 同样受此限制。后端没有 .proto,无法区分 key 的实际类型,整数 key 总是按 int64 解析。同样的原因,sint32/sint64(ZigZag 编码)、fixed32/fixed64/sfixed32/sfixed64(定长编码)、bool 及浮点类型的 key 都不支持按 key 访问,需要时整体读写 map。

字段名接口根据 PB 类型在本地检查 key 的写法:字符串 key 必须加引号或写成十六进制;整数 key 只接受十进制,且须在 key 类型的取值范围内;不支持的 key 类型直接拒绝。以上不符合时返回 API_ERR_FIELD_TYPE_NOT_MATCH。

字符串 key 还可用十六进制字节表示,name path 和 tagid path 均支持。十六进制只表示字符串的字节,不能用来写整数 key:

key 写法含义
['region']、[0x726567696f6e]同一个字符串 region
[0x610062]三个字节 61 00 62,即 a、零字节、b
[0x31]字符串 1,不同于整数 [1] 和字符串 ['0x31']
['']空字符串

十六进制支持 0x/0X 和大小写数字,奇数位在开头补零,如 0xabc 等价于 0x0abc;裸 0x 或非十六进制字符不合法。0x61 与 0x6100 是不同的 key。该写法不改变 PB string 的编码约束。

删除 map 中指定 key 的元素使用 POP,POP 专用于这一场景。为新的 TCAPLUS_API_PB_FIELD_SET_REQ 请求设置主键和空增量 Blob(refer 为 0)后,在字段名前加 POP 和空格:

const char* pop_fields[] = {"POP instance_map[1001]"};
const TcaplusService::TdrPbFieldGroup pop_groups[] = {
    TcaplusService::TdrPbFieldGroup(
        "routeinfo", routeinfo::RouteInfo::descriptor(), pop_fields)
};
int32_t ret = TcaplusService::SetAllTdrPbFieldNames(request, pop_groups);
if (ret != TcapErrCode::GEN_ERR_SUC) return ret;

转换后的 tagid path 为 POP routeinfo.6[1001],也可以直接交给 SetFieldNames。发送请求即可删除 key 1001,其他 key 保留;删除不存在的 key 也成功。Go 使用 tdrpb.Pop("routeinfo", (*routeinfo.RouteInfo)(nil), "instance_map[1001]") 分组,交给 BuildPaths 后得到相同路径。

POP 是路径列表中的指令,不通过 SetOperation 或 opt.Operation 传递。普通更新直接填写路径,不加前缀。Go 的 Blob 中只填写字段名路径,删除使用 Pop,不在字段名中拼接操作符。

4. 限制与进阶用法

4.1 字段选择、容器层数与嵌套 Blob

字段列表不能为空,不能包含 TDR key 字段。同一请求不能重复选择同一路径,也不能同时选择父路径与子路径:

tagid path 组合冲突原因
routeinfo、routeinfo.1完整 Blob 与其子字段重叠
routeinfo.6、routeinfo.6[1001]完整 map 与其元素重叠
routeinfo.7['region']、routeinfo.7[0x726567696f6e]同一个 key
routeinfo.6[1001]、POP routeinfo.6[1001]同一元素同时更新和删除

一条 PB 路径最多访问一层容器元素,如 instance_map[1001].port。如果该 value 内还有 map/repeated,不能在同一路径上再次选择其中的元素;可以读取 value,修改后整体替换,并配合版本检查。

Blob 也可以位于 TDR 嵌套结构体中。假设 extra.bin 同样保存 RouteInfo,将分组的 Blob 前缀改为 extra.bin,选择 version 就会生成 extra.bin.1。原生 TDR 结构体子字段(如 extra.tag)不支持独立选择,需要整体读写一级字段 extra。

4.2 PB 类型与兼容性

项目规则
packed repeated只能按完整字段读取或覆盖;没有 proto 时无法可靠识别编码段中的元素类型和业务下标。直接写 tagid 下标不会被拦截,不要使用
map key 类型整数 key 总按 int64 解析,取值不能大于 int64 最大值;bool、sint*、fixed*、sfixed*、浮点类型不支持按 key 访问,必要时整体读写 map
oneof不自动清除同组其他成员。切换时将旧成员一并列入路径清空,或整体替换包含 oneof 的 message
proto2 required部分数据可能缺 required 字段,须使用 partial 编解码;服务端不验证最终数据是否满足 required 约束
proto2 默认值清除字段编码后 has_xxx() 为 false,读取值取客户端定义的默认值,不保证为 0
自增及更新表达式不支持 PB 字段自增请求,也不支持通过独立 SetOperation/opt.Operation 添加更新表达式;map 删除使用路径 POP

C++ 使用 SerializePartialToArray、ParsePartialFromArray 等 partial 接口并检查结果。Go 可使用 tdrpb.MarshalPartial、MarshalPartialTo、UnmarshalPartial;Blob 生成为 []int8 时使用对应的 Int8 函数。TDR 记录使用配套的 SetData/GetData,参见 TDR 表数据读写与版本兼容性。

4.3 数量与容量

项目限制
单条路径最多 1023 字节,含操作前缀和空白,另保留结尾 \0
单请求字段配额最多 256 项,包括 SDK 自动补齐、去重后的一级字段及独立 refer
批量读取单次最多 1024 条主键
条件文本最多 1023 字节,并受所用 SDK 的条件支持范围约束
Blob 大小增量和合并后的完整 PB 均须满足 TDR 数组容量及 refer 类型范围

综合示例的 7 条路径,加上自动补齐的 routeinfo、routeinfo_len,共占 9 项;单独选择 extra.bin.1 时补齐一级结构体 extra,共占 2 项。不要仅按叶子路径数量估算配额。

增量小不代表最终 Blob 一定能写入:更长的新值可能使合并数据超过容量。若 refer 为 uint8,即使数组更大,也最多表示 255 字节。

4.4 repeated 范围读取

非 packed repeated 可用 tagid path routeinfo.5#[0-1] 读取前两个元素的完整内容。左右边界均包含在内,且须位于已有元素范围内;不支持范围更新。

C++ 的 SetAllTdrPbFieldNames 和 Go 的 BuildPaths 都不支持在 name path 中写范围。C++ 直接将 tagid path 交给 SetFieldNames;Go 可通过 tdrpb.Raw("routeinfo.5#[0-1]") 或直接填写 TDROpt.FieldNames 传入。

4.5 批量读取、条件、版本与响应

操作C++ Service API 请求类型Go 同步接口
单条部分读取TCAPLUS_API_PB_FIELD_GET_REQDoPBFieldGet
单条部分更新TCAPLUS_API_PB_FIELD_SET_REQDoPBFieldUpdate
批量部分读取TCAPLUS_API_PB_BATCH_FIELD_GET_REQDoPBBatchFieldGet

批量读取对所有主键使用同一组路径。C++ 检查响应 GetResult 和每次 FetchRecord 的结果;Go 通过 BatchResult、BatchVersion 返回逐条结果和版本,即使接口返回错误,也应继续检查已返回的逐条结果。

条件作用于记录当前数据,条件字段不必出现在选择列表中。例如 routeinfo.1 < 100 AND STRING(routeinfo.2) = 'round_robin'。条件中的 PB 路径使用 tagid;SetAllTdrPbFieldNames 和 BuildPaths 不转换条件表达式。单条读取和更新支持条件,批量读取不支持。 C++ 在 AddRecord() 返回的 record 上调用 SetCondition(),在批量读取请求上调用会返回 GEN_ERR_INVALID_ARGUMENTS;Go 使用 opt.Condition。条件不成立返回 COMMON_ERR_CONDITION_NOT_MATCHED,不执行更新,语法及类型约束见 条件过滤和更新语法说明。

根据旧值计算新值、整体覆盖 message/集合时,应使用读到的正版本号并启用版本匹配检查。冲突返回 SVR_ERR_FAIL_INVALID_VERSION,需要重新读取后判断是否重试。部分更新不能防止同一字段被并发覆盖;Go 的读取响应会回写 opt.Version,复用选项更新时须明确是否需要版本检查。自动递增策略下,删除不存在的 map key 也会作为成功更新递增版本。

更新响应携带哪些数据由成功、失败两侧的 ResultFlag 分别控制:

取值成功时(ForSuccess)失败时(ForFail)
0不返回字段数据不返回字段数据
1回带请求中的字段,包括 POP 路径回带请求中的字段
2返回更新后的完整记录不适用,按不返回处理
3返回更新前的完整记录读到记录之后才失败的(条件不成立、版本冲突、map key 缺失、下标越界、数据校验失败等),返回失败前的完整记录;记录不存在、已过期,或请求在读取记录前就被拒绝时,返回空

两侧都未设置时,成功和失败都回带请求中的字段;只设置其中一侧时,另一侧不返回数据。回带的是请求中的增量,不等于合并后的完整 Blob。C++ 接收失败响应中数据的方法见 C++ SDK 示例,各取值的通用说明见 ResultFlag 响应控制。网络超时不能证明更新未执行,应按业务幂等性要求重试或读取确认。

5. SDK 示例与相关文档

  • C++ SDK 示例:TdrPbFieldGroup、SetAllTdrPbFieldNames 的接口定义,单条与批量请求、响应处理,以及完整示例的文件位置和编译运行入口。
  • Go SDK 示例:初始数据、增量构造、单条和批量调用、选项及错误码。动态 PB 类型可使用 BlobDesc/PopDesc。
  • 部分字段查询和更新:普通 TDR 接口、PB 表及本功能的入口。
  • TDR 表数据读写与版本兼容性:TDR 编解码和元数据兼容要求。
  • 错误码文档:参数错误、记录或元素不存在、条件及版本不匹配等错误的处理。