Files
cs-note/hhs/gRPC/1. Protobuf 基础篇/03-字段编号与前向兼容.md
T
2026-05-24 11:42:38 +08:00

412 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [gRPC, Protobuf, field number, reserved, backward compatibility, forward compatibility, versioning]
create time: 2026-05-11 16:40
update time: 2026-05-12 14:30
---
# 字段编号与前向兼容
## 概述
Protobuf 的 wire format(二进制传输格式)从设计之初就围绕一个核心目标:**schema 可以演进,但线上服务不能断**。实现这个目标的关键在于一个字段的 tag number——每个字段分配的整数编号。
理解这套机制后,你就能看到为什么 Protobuf 能做到"删了一个字段,百万 QPS 的线上服务毫无感知"。
> [!question] 先思考一个问题
> 假设你在线上跑着 `GetUser` API,客户端和服务端都稳定运行了两年。现在你需要添加 `avatar_url` 字段并删除 `phone` 字段。**你能在不重启任何服务、不升级任何客户端的前提下完成这件事吗?**
>
> 答案是:可以,但需要正确管理 field numbers。这就是这篇笔记要讲的事。
> [!note] 核心概念速记
> - **向后兼容**(Old Client → New Server):旧版客户端收到新版服务端返回的数据 — Protobuf 保证**未知字段被安全忽略**(跳过字节即可)
> - **向前兼容**(New Client → Old Server):新版客户端请求旧版服务端返回的数据 — 缺失字段取**类型默认值**(string="", int32=0, bool=false, repeated=[])
> - **wire encoding version**:目前仅有一个版本(varint + length-delimited),如果未来推出 v2 wire protocol,兼容性规则可能会变化
> [!warning] 铁律
> 一旦字段编号在某个部署中使用了,就**永远不能再复用**它。这不是建议而是硬性约束——编号就像 UUID,一旦发出去就是它的了。
## Tag Number 分配规则
每个字段的 tag number(通常称为 field number)取值范围为 **1 ~ 536,870,911**(即 `2^29 - 1`)。这个范围不是随机的:
```protobuf
message User {
string id = 1; // 核心标识符,高频使用 → 留给 1~15
string name = 2;
string email = 3;
int32 age = 4;
bool active = 5;
string phone = 6;
string address = 7;
Role role = 8;
// ... 中间跳过一些编号供未来添加 ...
// (reserved 10 to 20)
google.protobuf.Timestamp created_at = 100;
google.protobuf.Timestamp updated_at = 101;
repeated string tags = 102;
}
```
### Wire Encoding 原理解析
Protobuf 使用 **varint encoding**(变长整数编码),tag number 越小占用的字节越少。每个字段的 wire 头部(wire tag)由两个部分组成:
```
Wire Tag = (field_number << 3) | wire_type
```
其中低 3 位固定存 wire type,高 29 位存 field number——这就是为什么最大 field number 是 `2^29 - 1` = 536,870,911。
```mermaid
flowchart LR
subgraph FieldNum["Field Number = 3"]
direction TB
N1["原始编号: 3\n(0b00011)"] --> SHIFT["<< 3 左移 3 位"]
SHIFT --> RESULT1["结果: 24\n(0b11000)"]
RESULT1 --> WIRE0["+ wire_type 0\n(同或操作 |)"]
WIRE0 --> FINAL1["最终 tag: 24\n十六进制: 0x18"]
FINAL1 --> BYTE1["✅ 仅占 1 byte"]
end
subgraph FieldNum100["Field Number = 100"]
direction TB
N2["原始编号: 100\n(0b1100100)"] --> SHIFT2["<< 3 左移 3 位"]
SHIFT2 --> RESULT2["结果: 800\n(0b1100100000)"]
RESULT2 --> WIRE2["+ wire_type 0\n(同或操作 |)"]
WIRE2 --> FINAL2["最终 tag: 800\n十六进制: 0x320"]
FINAL2 --> BYTE2["⚠️ 占 2 bytes\n(0x320 > 0x7F)"]
end
style BYTE1 fill:#d4edda
style BYTE2 fill:#fff3cd
```
> [!example] 手算验证公式
> `tag = (field_number << 3) | wire_type`
> - **field_number = 3**, wire_type = 0(varint)
> - `3 << 3 = 24 = 0x18`,0x18 | 0 = **0x18** → varint 编码只需 1 byte ✅
> - **field_number = 100**, wire_type = 0
> - `100 << 3 = 800 = 0x320`,0x320 | 0 = **0x320** → varint 需要 2 bytes(因为 0x320 > 0x7F)⚠️
对于高频通信的消息体,节省 1 byte *per message* × 每秒百万调用 = 可观的带宽节省。这就是建议核心字段用 1~15 的根本原因。
### 合理的 Field Numbering 策略
```protobuf
// user/v1/user.proto
message User {
// === Core fields (1-9): 核心字段,几乎每次都会序列化 ===
string id = 1;
string name = 2;
string email = 3;
// === Secondary fields (10-19): 常用但非必需 ===
string phone = 10;
string avatar_url = 11;
Role role = 12;
bool active = 13;
// === Tertiary fields (20-99): 偶尔使用 ===
string bio = 20;
string website = 21;
Location location = 22;
// === Audit & metadata (100-199): 系统字段,低频 ===
google.protobuf.Timestamp created_at = 100;
google.protobuf.Timestamp updated_at = 101;
string created_by = 102;
string updated_by = 103;
// === Feature flags / experimental (900-999): 灰度测试用 ===
bool new_ui_enabled = 900;
}
```
> [!tip] 预留块的好处
> 如果你的 User 消息已经有 13 个字段,未来需要新增 5 个字段,你只需要在 10~19 之间找空位。如果所有字段从 1 开始连续排列,每加一个都需要改后面所有的编号——而且已经部署的旧客户端会认为新编号的字段属于不同的语义。
## Reserved 保留字段
当你删除或重命名字段时,必须用 `reserved` 声明来防止后人误用相同的编号:
```protobuf
message User {
// ---- 当前活跃字段 ----
string id = 1;
string name = 2;
string email = 3;
int32 age = 4;
// ---- 已废弃字段的编号保留 ----
reserved "mobile"; // 之前叫 mobile 的字段已删除(同时锁定其原始编号)
reserved 5, 6; // 编号 5 和 6 已释放,禁止复用
reserved 7 to 10; // 编号 7~10 连续保留
}
```
### 两种 Reserved 语法
| 写法 | 效果 | 使用场景 |
|------|------|---------|
| `reserved <number>;` | 仅锁编号 | 你知道编号但忘了字段名 |
| `reserved "field_name";` | 同时锁定**原始编号 + 新编号** | 更推荐——如果以后有人改了字段名,这个记录仍然有效 |
> [!important] Name reservation 的双保险
> 如果你写 `reserved "mobile"`,Protobuf 编译器会查找 `mobile` 曾经占用过的所有编号并一并标记为 reserved。**即使后续有人把另一个字段改名为 `mobile`,编译也会报错**。所以优先使用 name reservation。
### 同一条语句中混合 reserve
```protobuf
// 一行内同时保留名称和编号(语义清晰 ✅)
reserved "legacy_id", "temp_field", 20 to 25, 30;
```
### Reserved 的 proto2 vs proto3 差异
| 特性 | proto2 | proto3 |
|------|--------|--------|
| `reserved` 语法 | ❌ 不支持(proto2 中没有此关键字) | ✅ 支持 |
| 替代方案 | 手动文档规范 / linter 检查 | 编译期强制锁定 |
> [!warning] Proto2 用户注意
> proto2 **没有** `reserved` 关键字!如果你在做 proto2 → proto3 迁移,原来依赖文档规范的 reserved 行为在 proto3 中可以真正落到代码里了——这是一大收益。
> [!question]- 思考题 — 点击查看解析
>
> **问题一**:假设你在线上跑着 `GetUser` API,客户端和服务端都稳定运行了两年。现在你需要添加 `avatar_url` 字段并删除 `phone` 字段。**你能在不重启任何服务、不升级任何客户端的前提下完成这件事吗?**
>
> **答案**:可以,但需要正确管理 field numbers。这就是本篇笔记要讲的事。
>
> ---
>
> **问题二**:如果一个字段被删除了,但**没有**做 reserved,随后同事添加了 `string new_feature = 5;`,此时旧客户端读到 `new_feature` 的值时会发生什么?它会当成哪个字段的值?
>
> **答案**:Protobuf 的二进制 wire encoding 中**不包含字段名**,只包含:
>
> ```
> field_number + wire_type + value_bytes
> ```
>
> 解析器完全按照 **field number 为键**来解码。它不知道 `5` 这个编号以前代表什么、现在又被分配给了谁——它只认数字。所以旧客户端会读到的字节流,将 `new_feature` 的值当成之前被删除的旧字段(原编号 5 对应的 `mobile`)的值。
>
> | 角色 | 服务端 proto (v2: `new_feature = 5`) | 旧客户端 proto (v1: `mobile = 5`, 无 reserved) |
> |------|------|------|
> | 发出去的内容 | `field_number=5, type=string, value="dark_mode"` | 期望读到手机号 |
> | 旧客户端解读 | ❌ `"dark_mode"` → 当手机号入库 | 数据语义错位 |
>
> 这就是为什么笔记里那条铁律:**field number 一旦使用过,就永远不能再复用**。正确的做法是删除时做 `reserved`,让编译器在后续有人复用该编号时直接报错。
### 删除字段的正确姿势
三步走,确保平滑过渡:
```mermaid
flowchart TD
S1["📝 步骤 1: reserved 占位"] --> S2["string old_field = 5;\n→\nreserved 5;"]
S2 --> S3["📢 步骤 2: 协调消费方迁移\n(发版窗口内切换代码)"]
S3 --> S4["⏳ 时间窗口: 3~6 个月\n等待旧客户端全部下线"]
S4 --> S5["✅ 步骤 3: 最终移除\n有人复用 → compile error"]
style S3 fill:#fff3cd
style S4 fill:#e0e7ff
style S5 fill:#d4edda
```
> [!question] 为什么不能只删字段不 reserved?
> 如果没有 reserved,同事新建字段时使用相同编号:`string feature_flag = 5;`。旧版本客户端读到这个字节流时,会把 feature_flag 的值当成旧版 mobile 字段的值——数据语义完全错位,bug 极难排查。
## 兼容性矩阵(重点章节)
Protobuf 的 wire format 设计确保了大部分 schema 变更不会破坏现有二进制协议。核心原则是:**以 field number 为寻址键,而非字段名或类型**。
### 一句话理解兼容性
```mermaid
flowchart LR
subgraph Read["老客户端读新版响应"]
direction TB
R1["未知 field_number\n→ 跳过其字节序列"] --> R2["✅ 向后兼容"]
end
subgraph WriteNew["新版客户端读旧版响应"]
direction TB
W1["缺失的 field_number\n→ 取类型零值"] --> W2["✅ 向前兼容"]
end
style R2 fill:#d4edda
style W2 fill:#d4edda
```
**为什么能做到?** Protobuf 的二进制编码中只有 `field_number + wire_type + value_bytes`,没有字段名字段类型。解析器按 number 找对应位置,遇到不认识的直接跳过——就像翻书时跳过不认识的页码。
### 完整兼容性表
| 操作 | 向后兼容? | 向前兼容? | Wire 层面原因 |
|------|-----------|-----------|--------------|
| **新增字段** | ✅ | ✅ | 老客户端跳过未知 number;新客户端读到零值 |
| **删除字段** | ✅ | ✅ | 老客户端忽略已无数据的旧 number;新客户端读不到也拿零值 |
| **修改字段类型** | ❌ | ❌ | 新旧端对同一 number 的解码规则不同(如 varint vs length-delimited) |
| **修改字段编号** | ❌ | ❌ | 同 number 对应了不同语义,两边都"以为"自己读对了 |
| **修改枚举值名称** | ✅ | ✅ | wire 上传输的是 enum 的**整数**,与名称无关 |
| **新增枚举值** | ✅ | ⚠️ | 旧端收到未知 enum value 回退为 0(proto3);proto2 会报错 |
| **删除枚举值** | ❌ | ⚠️ | 同上——旧端可能收到已被删除的 enum 数值 |
| **repeated → non-repeated** | ⚠️ | ❌ | 多元素列表无法映射到单值;proto3 默认 packed 导致编码格式不同 |
| **non-repeated → repeated** | ❌ | ❌ | 老代码无法处理多个同编号值的结构变化 |
| **添加 optional** | ⚠️ | ⚠️ | proto3 加了 `optional` 后改变了 wire encoding(从省略变为 presence bit) |
### 类型变更的陷阱示例
```protobuf
// v1 - 线上运行正常
message Config {
string timeout_ms = 1; // string 类型,wire: len-delimited
}
// v2 - 有人图省事把类型改了
message Config {
int32 timeout_ms = 1; // ← ❌ 同一编号改成了 varint!
}
```
**后果**:旧客户端把 `timeout_ms` 当字符串解析,但收到的实际是 varint 编码的整数——decode 时会报 "wrong wire type" 错误或者直接崩溃。
> [!tip] 如果必须改类型怎么办?
> 使用前面讲的「分步迁移方案」:保留原字段 + reserved,用新编号新类型定义新字段,等旧字段全部淘汰后再移除。
### 实战:安全地扩展消息
假设你有一个在线上运行的 v1 proto:
```protobuf
// v1 - 当前生产版本
message GetUserResponse {
string id = 1;
string name = 2;
string email = 3;
}
```
**需求:添加 `avatar_url` 和 `role` 两个字段,同时删除 `email`。**
❌ **错误示范 — 直接复用已被 reserved 的编号:**
```protobuf
// v2 - ❌ 编译失败
message GetUserResponse {
string id = 1;
string name = 2;
reserved 3; // email 的编号已保留
string avatar_url = 3; // ← 编译报错:field number 3 is reserved
}
```
✅ **正确做法 — 使用新编号 + reserved 占位:**
```protobuf
// v2 - ✅ 安全演进
message GetUserResponse {
string id = 1;
string name = 2;
reserved 3; // 原 email 编号锁定,防止后人误用
string avatar_url = 4; // 新字段分配新编号
User_Role role = 5;
}
```
### 正确的分步迁移方案(附时间线)
直接删字段有风险——客户端可能还在发包含该字段的请求。正确的做法是四步走:
```mermaid
gantt
title Email 字段平滑迁移时间线
dateFormat YYYY-MM
axisFormat %y-%m
section Phase 1 (v2) 双发版: 加字段不删字段
avatar_url,role :2026-01, 6M
email :active, 2026-01, 6M
section Phase 2 (v3) 标记 email 为 reserved
reserved 3 :2026-07, 1d
email (保留但标记废弃) :2026-07, 3M
section Phase 3 (v4) 正式移除 email
旧客户端 < 1% :2026-10, 1d
移除 email 声明 :2026-10, 1d
```
具体步骤:
| 阶段 | Proto 变更 | 代码层配合 | 等待期 |
|------|-----------|-----------|--------|
| **v2** | 新增 `avatar_url=4`, `role=5`;`email` 保持不变 | 服务端同时返回 `email` + `avatar_url` | 观察 3~6 个月 |
| **v3** | 删除 `string email = 3;`(保留 `reserved 3;` 防止复用) | 客户端已切换读 `avatar_url`,不再需要 email | 确认旧客户端占比 < 1% |
| **v4** | `reserved 3;` 永久存在 | 清理 email 相关残留逻辑 | — |
> [!tip] 灰度策略:双字段过渡法
> 如果需要在同一消息中过渡一个新字段到旧字段,分三阶段进行:
> ```protobuf
> // 阶段一: 服务端双写两个字段
> message User {
> string legacy_name = 1; // 旧字段,逐步弃用
> string display_name = 2; // 新字段,逐步启用
> }
>
> // 阶段二: 客户端优先读 display_name, 回退到 legacy_name
> //
> // 阶段三: 确认全部升级后移除 legacy_name (步骤见上方「删除字段的正确姿势」)
> ```
## 最佳实践
- **为每个 microservice 预留独立 namespace**:`package service_name.version`。
- **不要重复使用 field numbers**:即使在同一个文件中删除了字段也要 reserved。优先考虑 name reservation(`reserved "field_name"`)而非仅数字——它能在改名后仍然生效。
- **核心高频字段编号保持在 1~15**:节省 wire 编码开销,一个字节差 × 百万 QPS 就是巨大收益。
- **重大变更走 v2 而不是改现有文件**:降低线上风险,旧客户端可以继续用 v1 直到自然淘汰。
- **在 CI 中加入 proto linter**(如 `buf lint`):自动化检查编号冲突和命名规范。
- **proto3 中避免随意加 `optional`**:加上 `optional` 会改变 wire encoding 行为(从"省略零值"变为"有 presence bit"),可能破坏向前/向后兼容性。需要使用可选语义时优先选用 [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解#Wrapper Types 包装类型|wrapper types]]。
- **oneof 字段的编号分配要注意边界**:oneof 成员和其他普通字段**共享同一编号空间**,不要因为它们在一个 oneof block 里就单独编号。
```protobuf
// ❌ 错误:oneof 成员编号与普通字段冲突
message Query {
string id = 1; // 普通字段占用了 1
oneof filter {
int32 age = 1; // ← 编译报错:number 1 already used by id
string tag = 2; // ← 编译报错:number 2 already used by email (if exists)
}
}
// ✅ 正确:全局唯一编号规划
message Query {
string id = 1;
int32 age = 10; // 普通字段留 1~9, oneof 从 10 开始
string tag = 11;
}
```
## 本节小结
| 主题 | 一句话记住 |
|------|-----------|
| Field Number | 它是二进制协议的唯一标识,不能改名、不能复用 |
| Wire Encoding | 编号越小越省字节,核心字段放 1~15 |
| Reserved | 删字段必 reserved,优先用 name reservation 做双保险 |
| 向后兼容 | 未知编号 → 跳过;新增字段 → 老端无感 |
| 向前兼容 | 缺失字段 → 零值;删除字段 → 新端读零值 |
| Schema 演进 | 分步迁移 + double-write + 灰度下线,别暴力改造 |
## 关联笔记
- [[hhs/gRPC/1. Protobuf 基础篇/01-Protobuf 语法与消息定义]] — Protobuf 消息定义基础语法
- [[hhs/gRPC/1. Protobuf 基础篇/02-数据类型详解]] — 字段可用的所有数据类型及默认值规则
- [[hhs/gRPC/1. Protobuf 基础篇/04-Oneof 与包装类型]] — Oneof 的 field numbering 有特殊规则
- [[hhs/gRPC/6. 工程实践篇/18-模块拆分与 proto 规范]] — Proto 文件的工程组织与命名规范