本节摘要:proto 的标量类型系统远比"整数和字符串"复杂:整数有定长与变长两族、有符号有无符号之别、sint 系列专为负数而生;string 与 bytes 在 proto3 里语义分野明确;默认值行为是所有困惑的源头。本节给出类型选择的决策表与三组高频困惑的机理级解释,读完你应当能为任何业务字段选出正确的标量类型并预判其字节形态。
承接 2.1 的产权规则:编号定了字段身份之后,类型决定这个字段在字节层长什么样、占多少空间。本节是类型系统的完整发掘,为第 3 章 varint 与 ZigZag 的逐字节拆解做语义准备。
先把标量类型全族摊开,按字节层行为分族:
| 类型族 | 类型 | 字节形态 | 适用场景 |
|---|---|---|---|
| 变长整数 | int32、int64、uint32、uint64、sint32、sint64 | varint(1–10 字节,值越小越短) | 多数数值字段默认选这里 |
| 定长整数 | fixed32、sfixed32、fixed64、sfixed64 | 恒 4 / 8 字节 | 数值常年很大的字段 |
| 布尔 | bool | varint(0 或 1,恒 1 字节数值) | 开关量 |
| 文本 | string | 长度前缀 + UTF-8 字节 | 人类可读文本 |
| 二进制 | bytes | 长度前缀 + 原始字节 | 图片、哈希、密文、任意二进制 |
| 浮点 | float、double | 定 4 / 8 字节 IEEE754 | 科学计算、坐标 |
分族的判断逻辑:变长整数编码对"值小"的数字极度友好(值 1 只占 1 字节数值),对负数极度不友好(int 系的负数恒占满 10 字节数值);定长整数放弃了压缩,换取可预测的体积。所以选型第一问:你的值域是什么形状?
答案出人意料地简单:因为变长编码的存在,int32 与 int64 在字节层几乎一样大(只要值不超 32 位范围,两者编码相同;超大值时 int64 多几个字节)。所以选择的依据不是空间,而是值域与兼容性——值域可能增长的字段直接上 int64,避免第 5 章要讲的"int32 改 int64 兼容但 int64 改 int32 截断"的不对称麻烦。
uint 系的语义差别要小心:uint32 用 32 个比特表示非负数,如果你的业务里出现负数的可能性不是零(哪怕"理论上不会"),就别选 uint——一旦负数进来,uint 解读会把它读成一个巨大的正数,比如 -1 被解读成 4294967295,且不报错。
这是类型系统里最值得讲透的一处设计。int 系的负数编码有个要命的问题:-1 编码成 varint 要占满 10 个字节的数值部分。原因在编码层(第 3 章拆解机理,这里先给结论):负数按补码表示是全 1 的 64 位模式,varint 按无符号方式切字节,64 个 1 要 10 个字节才能装下。于是有"值域为负数或可能为负"的字段——温度、纬度、差值、账户变动——用 int32 存 -1 比存 100 万还贵 8 倍。
sint32/sint64 就是为这个场景发明的:它们用 ZigZag 变换把有符号数映射到无符号数(0 映射 0、-1 映射 1、1 映射 2、-2 映射 3……),映射后的值全部非负且幅值翻倍,varint 编码立即变短——-1 只占 1 字节数值。代价是:sint 与 int 在字节层互不兼容,字段从 int32 改成 sint32 是第 5 章明确禁止的 wire type 级变更(两者 wire type 不同:int32 是 varint 型,sint32 是 ZigZag varint 型)。
💡 类型选择直觉:正数为主的普通数值选 int32/int64;明显带负数且量大的(差值、坐标偏移、增量)选 sint;值域天然恒大的(哈希截断值、大 ID 的高位段)考虑 fixed64。
proto3 里两者编码形态相同(长度前缀+内容),差别纯在语义契约:string 声明"内容必须是合法 UTF-8",bytes 声明"任意字节序列"。选错的后果不在体积而在校验——多数实现会对 string 做编码检查,塞进非法 UTF-8 会序列化失败或触发告警。经验法则:给人看的(名字、描述、标签)用 string;给机器算的(SHA 哈希、随机 ID、加密后密文、图片缩略数据)用 bytes。一个高频失误是拿 string 装 base64 编码后的二进制——多出 33% 体积还引入一层编解码成本,能改 bytes 就改 bytes。
proto3 的标量字段全部隐式可缺省,这带来三个必须钉死的事实:
int32 count = 1; 从未赋值时,序列化结果里没有字段 1 的任何字节——不是输出 0,是彻底缺席;需要区分"显式 0"与"缺席"时,工具有三:editions 的显式 presence(首选,1.2 节讲过的回归)、wrapper 类型(google.protobuf.Int32Value 包装后 0 与缺席可区分)、oneof 包装(把标量放进 oneof 组,组内字段有天然 presence)。
message Gauge { int32 raw_value = 1; // 0 与缺席不可区分 google.protobuf.Int32Value value = 2; // wrapper:null 与 0 可区分 oneof explicit_level { // oneof:设置即有 presence int32 level = 3; } }
背景:某位置服务的事件消息里有 6 个坐标相关字段,原定义清一色 int32。日均 300 亿条事件,其中两个字段是相邻两点的位移差值(正负密集),两个字段是米级绝对坐标(恒为大值),两个字段是置信度百分比(0 到 100 的小整数)。操作:位移差值改成 sint32(负数高频,ZigZag 收益最大);绝对坐标评估后改 fixed32(值域稳定在 20 亿上下,varint 需要 5 字节,fixed 恒 4 字节,反而更小);置信度保留 int32 但编号调进热区(值小于 128,varint 数值只占 1 字节,tag 必须省)。结果:单条消息平均从 31 字节降到 19 字节,管道带宽成本下降约四成。解读:类型选型的收益来自对值域形状的精确刻画——位移字段负数占一半,是 ZigZag 的教科书场景;绝对坐标值恒大,varint 的压缩优势反转成劣势。变式:如果位移差值可能超 32 位(极端漂移),用 sint64 而不是 int64,负数问题与位数无关。
单个字段的地基打完,下一节把字段粘合成结构:枚举的第一编号陷阱、oneof 的互斥语义、map 的真身其实是重复 message。