vault backup: 2026-05-12 15:26:17
This commit is contained in:
@@ -1,20 +1,29 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# 字段编号与前向兼容
|
||||
|
||||
## 概述
|
||||
|
||||
每一个字段都有一个 tag number,这行简单的数字背后藏着 Protobuf 最核心的设计原则:**向后兼容**。理解这套机制,你就能放心地修改 protobuf schema 而不用担心打坏线上服务。
|
||||
Protobuf 的 wire format(二进制传输格式)从设计之初就围绕一个核心目标:**schema 可以演进,但线上服务不能断**。实现这个目标的关键在于一个字段的 tag number——每个字段分配的整数编号。
|
||||
|
||||
理解这套机制后,你就能看到为什么 Protobuf 能做到"删了一个字段,百万 QPS 的线上服务毫无感知"。
|
||||
|
||||
> [!question] 先思考一个问题
|
||||
> 假设你在线上跑着 `GetUser` API,客户端和服务端都稳定运行了两年。现在你需要添加 `avatar_url` 字段并删除 `phone` 字段。**你能在不重启任何服务、不升级任何客户端的前提下完成这件事吗?**
|
||||
>
|
||||
> 答案是:可以,但需要正确管理 field numbers。这就是这篇笔记要讲的事。
|
||||
|
||||
> [!note] 核心概念速记
|
||||
> - **向后兼容**(Old → New):旧版客户端跑新版服务端返回的数据 — Protobuf 保证**未知字段被安全忽略**
|
||||
> - **向前兼容**(New → Old):新版客户端跑旧版服务端返回的数据 — 缺失字段取**类型默认值**
|
||||
> - **向后兼容**(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] 注意
|
||||
> 一旦字段编号被分配并部署,就**永远不能再复用**它。这是 Protobuf 的硬伤——编号就像 UUID,一旦发出去就是它的了。
|
||||
> [!warning] 铁律
|
||||
> 一旦字段编号在某个部署中使用了,就**永远不能再复用**它。这不是建议而是硬性约束——编号就像 UUID,一旦发出去就是它的了。
|
||||
|
||||
## Tag Number 分配规则
|
||||
|
||||
@@ -43,42 +52,46 @@ message User {
|
||||
|
||||
### Wire Encoding 原理解析
|
||||
|
||||
Protobuf 使用 **varint encoding**(变长整数编码),tag number 越小占用的字节越少:
|
||||
Protobuf 使用 **varint encoding**(变长整数编码),tag number 越小占用的字节越少。每个字段的 wire 头部(wire tag)由两个部分组成:
|
||||
|
||||
| 编号范围 | Wire Encoding 大小 | 说明 |
|
||||
|---------|-------------------|------|
|
||||
| 1 ~ 15 | 1 byte | 黄金区间,预留给你的核心高频字段 |
|
||||
| 16 ~ 2047 | 2 bytes | 次优先区间 |
|
||||
| 2048+ | 3~5 bytes | 低频字段可放这里 |
|
||||
```
|
||||
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 F1["Field Number = 3"]
|
||||
A1["field_number = 3"] --> B1["<< 3"]
|
||||
B1 --> C1["24"]
|
||||
C1 --> D1["| wire_type 0"]
|
||||
D1 --> E1["tag = 24 = 0x18"]
|
||||
E1 --> F1["1 byte ✅"]
|
||||
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 F2["Field Number = 100"]
|
||||
A2["field_number = 100"] --> B2["<< 3"]
|
||||
B2 --> C2["800"]
|
||||
C2 --> D2["| wire_type 0"]
|
||||
D2 --> E2["tag = 800 = 0x320"]
|
||||
E2 --> F2["2 bytes ⚠️"]
|
||||
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 F1 fill:#d4edda
|
||||
style F2 fill:#fff3cd
|
||||
style BYTE1 fill:#d4edda
|
||||
style BYTE2 fill:#fff3cd
|
||||
```
|
||||
|
||||
> [!example] 公式
|
||||
> [!example] 手算验证公式
|
||||
> `tag = (field_number << 3) | wire_type`
|
||||
> - `<< 3` 等价于 `field_number × 8`,把高 5 位留给 field number
|
||||
> - 低 3 位存放 wire type(0=varint, 1=64-bit, 2=length-delimited, ...)
|
||||
> - **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* × 每秒百万调用 = 可观的带宽节省。这就是为什么建议 core fields 用 1~15。
|
||||
对于高频通信的消息体,节省 1 byte *per message* × 每秒百万调用 = 可观的带宽节省。这就是建议核心字段用 1~15 的根本原因。
|
||||
|
||||
### 合理的 Field Numbering 策略
|
||||
|
||||
@@ -128,27 +141,56 @@ message User {
|
||||
int32 age = 4;
|
||||
|
||||
// ---- 已废弃字段的编号保留 ----
|
||||
reserved "mobile"; // 之前叫 mobile 的字段已删除
|
||||
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] 思考题
|
||||
> 如果一个字段被删除了,但**没有**做 reserved,随后同事添加了 `string new_feature = 5;`,此时旧客户端读到 `new_feature` 的值时会发生什么?它会当成哪个字段的值?
|
||||
|
||||
### 删除字段的正确姿势
|
||||
|
||||
三步走,确保平滑过渡:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S1["📝 步骤 1: 用 reserved 占位"] --> S2["string old_field = 5;\n→\nreserved 5;"]
|
||||
S2 --> S3["📢 步骤 2: 协调消费方迁移"]
|
||||
S3 --> S4["(版本发布窗口内切换)"]
|
||||
S4 --> S5["✅ 步骤 3: 下次编译锁定\n有人复用 → compile error"]
|
||||
S5 --> Safe["后续正式移除"]
|
||||
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 S2 fill:#fff3cd
|
||||
style S4 fill:#fff3cd
|
||||
style Safe fill:#d4edda
|
||||
style S3 fill:#fff3cd
|
||||
style S4 fill:#e0e7ff
|
||||
style S5 fill:#d4edda
|
||||
```
|
||||
|
||||
> [!question] 为什么不能只删字段不 reserved?
|
||||
@@ -156,18 +198,61 @@ flowchart TD
|
||||
|
||||
## 兼容性矩阵(重点章节)
|
||||
|
||||
Protobuf 的设计确保了大部分 schema 变更不会破坏现有二进制协议:
|
||||
Protobuf 的 wire format 设计确保了大部分 schema 变更不会破坏现有二进制协议。核心原则是:**以 field number 为寻址键,而非字段名或类型**。
|
||||
|
||||
| 操作 | 向后兼容? | 向前兼容? | 说明 |
|
||||
|------|-----------|-----------|------|
|
||||
| 新增字段 | ✅ | ✅ | 老客户端忽略未知编号;新客户端用默认值 |
|
||||
| 删除字段 | ✅ | ✅ | 老客户端读取已有数据;新客户端用默认值 |
|
||||
| 修改字段类型 | ❌ | ❌ | 新旧对同一编号解读不同 |
|
||||
| 修改字段编号 | ❌ | ❌ | 同编号对应不同语义 |
|
||||
| 修改枚举值名称 | ✅ | ✅ | 枚举值名不影响 wire format(传输的是数值) |
|
||||
| 新增枚举值 | ✅ | ⚠️ | 旧客户端收到未知枚举值回退为 0(首个值) |
|
||||
| 删除枚举值 | ❌ | ⚠️ | 旧客户端收到未知枚举值回退为 0 |
|
||||
| 单个 repeated 改为 non-repeated | ⚠️ | ❌ | 有数据的单元素列表可互转,多元素场景不兼容 |
|
||||
### 一句话理解兼容性
|
||||
|
||||
```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,用新编号新类型定义新字段,等旧字段全部淘汰后再移除。
|
||||
|
||||
### 实战:安全地扩展消息
|
||||
|
||||
@@ -203,78 +288,45 @@ message GetUserResponse {
|
||||
```protobuf
|
||||
// v2 - ✅ 安全演进
|
||||
message GetUserResponse {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
|
||||
reserved 3; // 原 email 编号锁定,防止后人误用
|
||||
|
||||
string avatar_url = 4; // 新字段分配新编号
|
||||
User_Role role = 5;
|
||||
string avatar_url = 4; // 新字段分配新编号
|
||||
User_Role role = 5;
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] 正确的分步迁移方案
|
||||
> 1. 先加字段 `avatar_url = 4`、`role = 5`,email 继续保留。
|
||||
> 2. 服务端双写:同时返回 email 和 avatar_url。
|
||||
> 3. 客户端升级,切换到使用 avatar_url。
|
||||
> 4. 确认旧客户端已淘汰后,标记 email 为 reserved,下次发版正式移除。
|
||||
### 正确的分步迁移方案(附时间线)
|
||||
|
||||
## 版本演进策略
|
||||
|
||||
对于大型项目,建议使用 package-level versioning 来管理 schema 演进:
|
||||
|
||||
### 目录结构与命名约定
|
||||
|
||||
```
|
||||
protos/
|
||||
├── user/
|
||||
│ └── v1/
|
||||
│ ├── user.proto
|
||||
│ ├── auth.proto
|
||||
│ └── error.proto
|
||||
└── order/
|
||||
└── v1/
|
||||
├── order.proto
|
||||
└── payment.proto
|
||||
```
|
||||
|
||||
```protobuf
|
||||
// option go_package 包含版本路径
|
||||
option go_package = "github.com/example/service/user/v1;userpb";
|
||||
|
||||
// import 路径与目录结构一致
|
||||
import "user/v1/user.proto";
|
||||
```
|
||||
|
||||
### Major Version 迁移方案
|
||||
|
||||
当需要做不兼容变更时(如改字段类型、重构消息结构):
|
||||
直接删字段有风险——客户端可能还在发包含该字段的请求。正确的做法是四步走:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph A["方案 A: v2 独立演进 🌟 推荐"]
|
||||
direction TB
|
||||
A1["user/v1/user.proto"] --> A2["新旧并存\nGateway 层做转换"]
|
||||
A3["user/v2/user.proto"] --> A2
|
||||
A2 --> A4["迁移完成\n停用 v1"]
|
||||
end
|
||||
gantt
|
||||
title Email 字段平滑迁移时间线
|
||||
dateFormat YYYY-MM
|
||||
axisFormat %y-%m
|
||||
section Phase 1 (v2) 双发版: 加字段不删字段
|
||||
avatar_url,role :2026-01, 6M
|
||||
email :active, 2026-01, 6M
|
||||
|
||||
subgraph B["方案 B: 原地破坏 ❌ 高风险"]
|
||||
B1["user/v1/user.proto\n直接改"] --> B2["已部署端受影响"]
|
||||
end
|
||||
section Phase 2 (v3) 标记 email 为 reserved
|
||||
reserved 3 :2026-07, 1d
|
||||
email (保留但标记废弃) :2026-07, 3M
|
||||
|
||||
style A fill:#d4edda
|
||||
style B fill:#f8d7da
|
||||
style A4 fill:#28a745,color:#fff
|
||||
style B2 fill:#dc3545,color:#fff
|
||||
section Phase 3 (v4) 正式移除 email
|
||||
旧客户端 < 1% :2026-10, 1d
|
||||
移除 email 声明 :2026-10, 1d
|
||||
```
|
||||
|
||||
| 维度 | 方案 A(v2 独立文件) | 方案 B(原地修改) |
|
||||
|------|---------------------|-------------------|
|
||||
| 风险等级 | 低 | **极高** |
|
||||
| 线上影响 | Gateway 透明转换 | 所有端同时断裂 |
|
||||
| 回滚成本 | 切回 v1 即可 | 几乎无法回滚 |
|
||||
| 适用场景 | 所有已发布服务 | 仅限内部未发布 proto |
|
||||
具体步骤:
|
||||
|
||||
| 阶段 | 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] 灰度策略:双字段过渡法
|
||||
> 如果需要在同一消息中过渡一个新字段到旧字段,分三阶段进行:
|
||||
@@ -293,10 +345,41 @@ flowchart LR
|
||||
## 最佳实践
|
||||
|
||||
- **为每个 microservice 预留独立 namespace**:`package service_name.version`。
|
||||
- **不要重复使用 field numbers**:即使在同一个文件中删除了字段也要 reserved。
|
||||
- **核心高频字段编号保持在 1~15**:节省 wire 编码开销。
|
||||
- **重大变更走 v2 而不是改现有文件**:降低线上风险。
|
||||
- **在 CI 中加入 proto linter**(如 buf lint):自动化检查编号冲突和命名规范。
|
||||
- **不要重复使用 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 + 灰度下线,别暴力改造 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
|
||||
Reference in New Issue
Block a user