主题
HTTP 与 RPC 的区别
本质定位不同
- HTTP 是一种应用层通信协议,定义了客户端和服务端之间请求/响应的格式规范(请求行、Header、Body 等)。它是面向资源的,核心语义是对资源的 CRUD 操作(GET/POST/PUT/DELETE)。
- RPC(Remote Procedure Call) 是一种远程调用范式,目标是让调用远程服务的方法像调用本地函数一样自然。它是面向动作/方法的,核心语义是"调用某个服务的某个方法,传入参数,拿到返回值"。
关键差异对比
| 维度 | HTTP(RESTful) | RPC |
|---|---|---|
| 抽象层次 | 传输协议 | 调用范式(可以基于 HTTP、TCP 等传输) |
| 语义模型 | 面向资源:GET /users/123 | 面向方法:UserService.GetUser(id=123) |
| 协议格式 | 文本为主(HTTP/1.1)、有大量冗余 Header | 通常采用紧凑的二进制协议(如 Protobuf),头部精简 |
| 性能 | 文本解析开销大,Header 冗余多 | 二进制编码,消息体积小,解析快 |
| 连接模型 | 短连接为主(HTTP/1.1 Keep-Alive 可复用) | 通常长连接 + 多路复用,一条连接并发多个请求 |
| 服务发现 | 通过 URL + DNS | 需要专门的服务注册/发现机制 |
| 适用场景 | 对外 API、浏览器交互、跨组织通信 | 内部微服务间高频、低延迟通信 |
两者的关系
RPC 和 HTTP 并非互斥的概念。RPC 是一种调用思想,HTTP 可以作为 RPC 的传输层。例如:
- gRPC 底层使用 HTTP/2 作为传输协议
- miniRPC 的 Day 5 中实现了通过 HTTP CONNECT 承载 RPC 调用
简单来说:HTTP 关注"怎么传",RPC 关注"怎么调"。
RPC 交互的流程解析
以 miniRPC 为例,一次完整的 RPC 交互分为握手阶段和请求/响应阶段两部分:
1. 握手阶段(协商编解码方式)
客户端 服务端
|--- Option (JSON编码) ------------>|
| |-- 校验 MagicNumber (0x3bef5c)
| |-- 根据 CodecType 创建对应的 Codec- 客户端建立 TCP 连接后,首先发送一个 JSON 编码的
Option结构体 Option包含MagicNumber(魔数,用于校验协议合法性)和CodecType(指定后续通信使用哪种编解码方式,如application/gob)- 服务端读取
Option,验证MagicNumber是否正确,然后根据CodecType从NewCodecFuncMap中获取对应的 Codec 构造函数,创建 Codec 实例
2. 请求/响应阶段(循环处理)
客户端 服务端
|--- Header + Body (Codec编码) ---->| 请求
| |-- ReadHeader: 解析 ServiceMethod、Seq
| |-- ReadBody: 反序列化参数
| |-- 处理请求(调用对应方法)
|<-- Header + Body (Codec编码) -----| 响应每次 RPC 调用的详细步骤:
- 客户端发送请求:通过 Codec 将 Header(包含
ServiceMethod如"Foo.Sum"、Seq序列号)和 Body(调用参数)编码后发送 - 服务端读取请求:调用
Codec.ReadHeader()读取 Header,获取要调用的方法名和序列号;调用Codec.ReadBody()反序列化参数 - 服务端处理请求:根据
ServiceMethod找到对应的服务和方法,通过反射调用(Day 4 实现) - 服务端发送响应:调用
Codec.Write()将响应 Header(携带同一Seq,如有错误则填充Error字段)和 Body(返回值)编码发送 - 客户端接收响应:通过
Seq序列号将响应匹配到对应的请求,完成调用
关键设计点
- 一条连接,多次调用:握手只需一次,之后在同一连接上可以发送多个请求/响应
- Seq 序列号:客户端为每次调用分配递增的序列号,用于在并发场景下将响应匹配到正确的请求
- 有序发送:服务端使用
sync.Mutex保证响应的有序写入,避免多个 goroutine 同时写连接导致数据交错
Codec 的职责是什么?如何实现的?
职责
Codec(编解码器)负责 RPC 消息的序列化(编码)和反序列化(解码),是 RPC 框架的传输基础层。具体来说:
- 编码(Write):将 Header 和 Body 结构化数据序列化为字节流写入网络连接
- 解码(Read):从网络连接的字节流中反序列化出 Header 和 Body
- 资源管理(Close):关闭底层连接
Codec 的存在使得 RPC 框架可以解耦消息格式与传输逻辑——上层只需关心"发送/接收一个 Header 和一个 Body",不需要关心具体用什么格式编码。
接口定义
go
type Codec interface {
io.Closer
ReadHeader(*Header) error
ReadBody(interface{}) error
Write(*Header, interface{}) error
}四个方法职责清晰:
ReadHeader:从连接中读取并解析消息头ReadBody:从连接中读取并解析消息体(参数或返回值)Write:将消息头和消息体一起编码写入连接Close:关闭底层连接
工厂模式注册
通过 NewCodecFunc 函数类型和 NewCodecFuncMap 全局映射表实现工厂模式:
go
type NewCodecFunc func(io.ReadWriteCloser) Codec
var NewCodecFuncMap map[Type]NewCodecFunc
func init() {
NewCodecFuncMap = make(map[Type]NewCodecFunc)
NewCodecFuncMap[GobType] = NewGobCodec
NewCodecFuncMap[JsonType] = NewJsonCodec
NewCodecFuncMap[ProtobufType] = NewProtobufCodec
}服务端收到 Option.CodecType 后,直接从 map 中取出构造函数创建 Codec,实现了开放-封闭原则:新增编解码方式只需注册新的构造函数,无需修改已有代码。
三种实现
1. GobCodec(默认)
使用 Go 标准库 encoding/gob,采用流式编解码:
- 读取端使用
gob.Decoder从连接流中逐个解码 - 写入端使用
gob.Encoder编码到bufio.Writer,通过缓冲合并多次小写入,最后Flush()一次性发送,减少系统调用次数 - 优点:Go 内部通信性能最优,无需 Schema
- 缺点:仅 Go 语言可用,不跨语言
2. JsonCodec
使用标准库 encoding/json,同样采用流式编解码,结构与 GobCodec 完全对称:
- 也使用
bufio.Writer做写缓冲 - 优点:人类可读,便于调试,跨语言通用
- 缺点:文本编码体积大
3. ProtobufCodec
使用 google.golang.org/protobuf,采用长度前缀帧(Length-Delimited Framing):
[4字节 Header长度][Header JSON数据][4字节 Body长度][Body proto数据]- Header 使用 JSON 编码(字段少且固定,JSON 足够高效)
- Body 若实现了
proto.Message接口则用proto.Marshal,否则降级为 JSON - 不使用
bufio.Writer,因为长度前缀帧本身已有明确的帧边界 - 读取时先读 4 字节大端序长度,再按长度读取数据,保证帧完整性
- 优点:消息体积最小,跨语言生产首选
- 缺点:需要
.protoSchema 文件,开发成本略高
详细介绍这三种编码方式的区别,包括包如何定义的,传输时候的形态
一、公共基础:Header 与 Codec 接口
三种编码方式共享同一套 Header 定义和 Codec 接口,差异仅在于"如何将 Header 和 Body 编码为字节流"。
go
// 消息头 —— 所有 Codec 共用
type Header struct {
ServiceMethod string // "Service.Method",标识调用目标
Seq uint64 // 序列号,用于匹配请求和响应
Error string // 服务端错误信息,空串表示成功
}
// Codec 接口 —— 三种实现都必须满足
type Codec interface {
io.Closer
ReadHeader(*Header) error
ReadBody(interface{}) error
Write(*Header, interface{}) error
}二、GobCodec —— Go 原生二进制流式编码
包与结构体定义
go
import (
"bufio"
"encoding/gob"
)
type GobCodec struct {
conn io.ReadWriteCloser // 底层网络连接
buf *bufio.Writer // 写缓冲,合并多次小写入
dec *gob.Decoder // 流式解码器,绑定 conn
enc *gob.Encoder // 流式编码器,绑定 buf(而非 conn)
}关键点:gob.Encoder 写到 bufio.Writer 而不是直接写 conn,这样 Header 和 Body 的编码结果先攒在缓冲区里,Write 结束后一次 Flush() 发出,减少系统调用。
传输时的线上形态
Gob 是 Go 私有的自描述二进制格式,编码器会在首次遇到某个类型时自动发送类型描述信息,后续相同类型的消息只发送数据。
连接上的字节流(不可人眼阅读):
┌──────────────────────────────────────────────────────────────────┐
│ [Gob 类型描述 + Header 数据] [Gob 类型描述 + Body 数据] │ 第 1 条消息
├──────────────────────────────────────────────────────────────────┤
│ [Header 数据(类型已知,省略描述)] [Body 数据] │ 第 2 条消息
├──────────────────────────────────────────────────────────────────┤
│ ... │
└──────────────────────────────────────────────────────────────────┘以 Header{ServiceMethod: "Foo.Sum", Seq: 1} + Body: "hello" 为例,线上大致为:
字节流(十六进制示意,实际由 gob 内部决定):
[类型ID][字段数=3][string len=7]["Foo.Sum"][uint64=1][string len=0] ← Header
[类型ID][string len=5]["hello"] ← Body特征:
- 没有显式的帧长度前缀,
gob.Decoder内部维护状态机,知道每条消息在哪里结束 - 二进制紧凑,但仅 Go 能解码
- 首条消息因携带类型描述而略大,后续消息更小
读写流程
Write(Header, Body):
enc.Encode(Header) → 写入 bufio.Writer
enc.Encode(Body) → 写入 bufio.Writer
buf.Flush() → 一次性发送到 conn
ReadHeader(&h):
dec.Decode(&h) ← 从 conn 流式读取
ReadBody(&body):
dec.Decode(&body) ← 从 conn 流式读取三、JsonCodec —— 文本流式编码
包与结构体定义
go
import (
"bufio"
"encoding/json"
)
type JsonCodec struct {
conn io.ReadWriteCloser
buf *bufio.Writer
dec *json.Decoder // 流式 JSON 解码器
enc *json.Encoder // 流式 JSON 编码器
}结构与 GobCodec 完全对称,唯一区别是把 gob.Encoder/Decoder 换成了 json.Encoder/Decoder。
传输时的线上形态
JSON 是文本格式,每个 Encode() 调用输出一个完整的 JSON 对象 + 换行符 \n。
TCP 连接上实际传输的字节流(wire format),用文本形式展示(因为 JSON 本身就是人眼可读的):
{"ServiceMethod":"Foo.Sum","Seq":1,"Error":""}\n ← Header
"hello"\n ← Body
{"ServiceMethod":"Bar.Mul","Seq":2,"Error":""}\n ← 下一条 Header
42\n ← 下一条 Body
...上面的文本展示和下面的十六进制展示是同一份数据的两种表示方式——JSON 编码恰好是 UTF-8 文本,所以可以直接用文本展示,而十六进制是将每个字节的原始值列出来:
同一份数据的原始字节(十六进制):
7B 22 53 65 72 76 ... → {"ServiceMethod":"Foo.Sum","Seq":1,"Error":""}
0A → \n(换行,json.Encoder 自动追加)
22 68 65 6C 6C 6F 22 → "hello"
0A → \n特征:
- 每条消息就是一行 JSON 文本,用换行符
\n作为隐式分隔 json.Decoder依赖 JSON 语法边界({}、""等)来判断一条消息的结束- 字段名每条消息都重复传输(
"ServiceMethod"、"Seq"、"Error"),冗余最大 - 人类可直接阅读和调试(
telnet、curl、nc即可查看)
读写流程
Write(Header, Body):
enc.Encode(Header) → 输出 JSON + \n → 写入 bufio.Writer
enc.Encode(Body) → 输出 JSON + \n → 写入 bufio.Writer
buf.Flush() → 一次性发送到 conn
ReadHeader(&h):
dec.Decode(&h) ← 读取一个 JSON 对象
ReadBody(&body):
dec.Decode(&body) ← 读取一个 JSON 值四、ProtobufCodec —— 长度前缀帧 + 二进制编码
包与结构体定义
go
import (
"encoding/binary"
"encoding/json"
"google.golang.org/protobuf/proto"
)
type ProtobufCodec struct {
conn io.ReadWriteCloser // 仅持有连接,没有 bufio 和流式编解码器
}与前两者的根本差异:ProtobufCodec 没有 bufio.Writer、没有流式 Encoder/Decoder。它采用"先序列化为 []byte,再用长度前缀帧写入"的方式。
传输时的线上形态
每条消息严格按以下帧格式传输:
┌──────────────────┬──────────────────┬──────────────────┬──────────────────┐
│ Header 长度 │ Header 数据 │ Body 长度 │ Body 数据 │
│ (4 bytes, BE) │ (JSON 编码) │ (4 bytes, BE) │ (proto/JSON) │
└──────────────────┴──────────────────┴──────────────────┴──────────────────┘以 Header{ServiceMethod: "Foo.Sum", Seq: 1} + Body: wrapperspb.String("hello") 为例:
线上字节流(十六进制):
Header 帧:
00 00 00 2F ← 4字节大端序,值=47(Header JSON 长度)
7B 22 53 65 72 76 69 63 65 4D 65 74 68 6F 64 22 3A ← {"ServiceMethod":
22 46 6F 6F 2E 53 75 6D 22 2C 22 53 65 71 22 3A 31 ← "Foo.Sum","Seq":1
2C 22 45 72 72 6F 72 22 3A 22 22 7D ← ,"Error":""}
Body 帧:
00 00 00 07 ← 4字节大端序,值=7(Body proto 长度)
0A 05 68 65 6C 6C 6F ← proto 编码: field1=string "hello"Header 为什么用 JSON 而非 Protobuf? Header 结构固定且字段少(3 个),JSON 编码足够高效,而且不需要为 Header 定义 .proto 文件。
Body 的双模编码:
- 如果 Body 类型实现了
proto.Message接口 → 使用proto.Marshal(二进制,最紧凑) - 否则 → 降级为
json.Marshal(兼容普通 Go 类型)
go
// 写入时的类型判断
func (c *ProtobufCodec) Write(h *Header, body interface{}) error {
writeJSONFrame(c.conn, h) // Header 始终 JSON
if pb, ok := body.(proto.Message); ok {
return writeProtoFrame(c.conn, pb) // Body 是 proto 类型
}
return writeJSONFrame(c.conn, body) // Body 降级为 JSON
}帧读写辅助函数
go
// 写一帧:先写 4 字节长度,再写数据
func writeJSONFrame(w io.Writer, v interface{}) error {
data, _ := json.Marshal(v)
binary.Write(w, binary.BigEndian, uint32(len(data))) // 4 字节长度前缀
w.Write(data) // 数据本体
}
// 读一帧:先读 4 字节长度,再按长度读数据
func readJSONFrame(r io.Reader, v interface{}) error {
size, _ := readFrameSize(r) // 读 4 字节大端序
data := make([]byte, size)
io.ReadFull(r, data) // 精确读取 size 字节
json.Unmarshal(data, v)
}
// 丢弃一帧(body 为 nil 时使用)
func discardFrame(r io.Reader) error {
size, _ := readFrameSize(r)
io.CopyN(io.Discard, r, int64(size))
}五、三者对比总结
| 维度 | GobCodec | JsonCodec | ProtobufCodec |
|---|---|---|---|
| 底层包 | encoding/gob | encoding/json | google.golang.org/protobuf + encoding/json |
| 编码模式 | 流式(Encoder/Decoder) | 流式(Encoder/Decoder) | 帧式(先 Marshal 为 []byte,再长度前缀写入) |
| 写缓冲 | bufio.Writer,Flush 合并写 | bufio.Writer,Flush 合并写 | 无需,长度前缀自带帧边界 |
| 帧分隔方式 | gob 内部状态机自动处理 | JSON 语法边界 + \n | 显式 4 字节大端序长度前缀 |
| Header 编码 | Gob 二进制 | JSON 文本 | JSON 文本(长度前缀帧) |
| Body 编码 | Gob 二进制 | JSON 文本 | proto.Marshal 二进制(或降级 JSON) |
| 线上可读性 | 不可读 | 人类可读 | 不可读 |
| 消息体积 | 中(首条大,后续小) | 最大(字段名重复传输) | 最小(proto 变长编码) |
| 跨语言 | 仅 Go | 通用 | 通用 |
| 空 Body 处理 | 编码器内部处理 | 编码器内部处理 | 写入 length=0 帧,读取时 discardFrame |
六、同一消息三种编码的线上对比
假设发送 Header{ServiceMethod: "Foo.Sum", Seq: 1} + Body: "hello miniRPC":
GobCodec 线上字节
[gob 类型描述][Header 二进制数据][gob 类型描述][Body 二进制数据]
总大小:约 80~120 字节(首次含类型描述),后续约 40~60 字节
不可人眼阅读JsonCodec 线上字节
{"ServiceMethod":"Foo.Sum","Seq":1,"Error":""}\n"hello miniRPC"\n
总大小:约 62 字节
人眼可直接阅读ProtobufCodec 线上字节
[00 00 00 2F][{"ServiceMethod":"Foo.Sum","Seq":1,"Error":""}][00 00 00 0E]["hello miniRPC"]
总大小:约 70 字节(Header JSON + Body JSON 降级)
若 Body 为 proto.Message 类型,Body 部分会更小七、网络传输的本质:全部都是二进制
一个常见的误解是"JSON 传的是文本,Protobuf 传的是二进制"。实际上:
网络上传输的永远是二进制字节流,没有例外。
TCP 连接里流动的只有一种东西:字节(byte),即 0~255 的数字。所谓"JSON 是文本格式",并不是说它不走二进制,而是说它产生的字节恰好对应人类可读的 ASCII/UTF-8 字符。
以传输字符串 "hello" 为例,三种 Codec 产生的字节对比:
┌─────────────┬────────────────────────────────┬──────────────────┐
│ Codec │ 实际发出的字节(十六进制) │ 人眼能不能读? │
├─────────────┼────────────────────────────────┼──────────────────┤
│ JsonCodec │ 22 68 65 6C 6C 6F 22 0A │ 能!恰好是 │
│ │ │ "hello"\n │
├─────────────┼────────────────────────────────┼──────────────────┤
│ GobCodec │ 07 06 00 05 68 65 6C 6C 6F │ 不能,有控制字节 │
├─────────────┼────────────────────────────────┼──────────────────┤
│ Protobuf │ 00 00 00 07 0A 05 68 65 6C │ 不能,有长度前缀 │
│ │ 6C 6F │ 和 proto tag │
└─────────────┴────────────────────────────────┴──────────────────┘三者都是字节,都是二进制。0x41 对应字符 A,人眼能认;0x07 不对应可打印字符,人眼看到的是乱码。所谓"文本 vs 二进制"的区别,本质上只是字节是否恰好可读。
八、三种字节组织方式的优劣势
既然都是字节,那差异就在于"怎么排列这些字节"。以传输 Header{ServiceMethod: "Foo.Sum", Seq: 1} 为例:
JSON:自描述文本,用字段名标识每个值
{"ServiceMethod":"Foo.Sum","Seq":1,"Error":""}
字节构成:
"ServiceMethod" ← 13 字节,字段名(每条消息都重复传输)
":" ← 1 字节,分隔符
"Foo.Sum" ← 7 字节,有效数据
"Seq" ← 3 字节,又一个字段名
":" ← 1 字节
1 ← 1 字节,数字用十进制字符表示
有效数据 ~10 字节 / 总传输 ~47 字节 → 有效率 ~21%- 优势:人眼可读,调试极方便;自描述,不需预先约定 Schema;新增字段旧版自动忽略,天然向前兼容
- 劣势:体积最大(字段名每条都重复);解析最慢(逐字符扫描);数字精度可能丢失
Gob:带类型描述的二进制,首次传结构定义,后续只传数据
首条消息:[类型描述 ~40字节][数据 ~15字节] = ~55 字节
后续消息:[数据 ~15字节] = ~15 字节(类型描述不再重复)- 优势:后续消息极紧凑;Go 标准库原生支持,零依赖;流式编解码,不需要自己管帧边界
- 劣势:仅 Go 可用,其他语言没有成熟解码库;首条消息比 JSON 还大;字段重命名或类型变更可能导致解码失败
Protobuf:基于 Tag 的极致紧凑编码,用小整数替代字段名
核心思想:发送方和接收方提前约定好"字段编号 ↔ 字段含义"的映射
JSON 之所以体积大,是因为每条消息都要把完整的字段名(如 "ServiceMethod")传过去,接收方靠字段名知道"这个值是什么"。Protobuf 的做法是:双方提前共享一份 .proto 文件,约定好 1 号字段是 ServiceMethod、2 号字段是 Seq……传输时只传编号,不传名字。
第一步:写 .proto 文件(双方共享的"合同")
protobuf
message Header {
string ServiceMethod = 1; // 1 号字段 → 字符串类型
uint64 Seq = 2; // 2 号字段 → 无符号整数
string Error = 3; // 3 号字段 → 字符串类型
}这个文件不会在网络上传输,它只是编译时的约定。发收送方和接方各自拿这个文件生成代码(protoc 编译器),生成的代码知道"1 号字段是 string 类型的 ServiceMethod"。
第二步:编码——把结构体变成字节
以 Header{ServiceMethod: "Foo.Sum", Seq: 1, Error: ""} 为例,proto.Marshal 产生的字节:
原始字节(十六进制):0A 07 46 6F 6F 2E 53 75 6D 10 01
逐字节拆解:
┌─── 字段 1(ServiceMethod = "Foo.Sum")───────────────────────┐
│ │
│ 0A │
│ ├─ 高 5 位 = 00001 → field_number = 1(对应 .proto 里的 =1) │
│ └─ 低 3 位 = 010 → wire_type = 2(表示"后面跟一个长度+数据")│
│ │
│ 07 → 长度 = 7(后面有 7 个字节是这个字段的值) │
│ │
│ 46 6F 6F 2E 53 75 6D → "Foo.Sum" 的 UTF-8 编码 │
│ │
└──────────────────────────────────────────────────────────────┘
┌─── 字段 2(Seq = 1)─────────────────────────────────────────┐
│ │
│ 10 │
│ ├─ 高 5 位 = 00010 → field_number = 2(对应 .proto 里的 =2) │
│ └─ 低 3 位 = 000 → wire_type = 0(表示"后面是一个 varint") │
│ │
│ 01 → varint 编码的值 = 1 │
│ │
└──────────────────────────────────────────────────────────────┘
(字段 3 Error = "",空字符串不占任何字节,直接省略!)关键机制解释:
Tag 字节:每个字段的开头是一个 tag 字节,把
field_number和wire_type压缩进一个字节里。公式是tag = (field_number << 3) | wire_type0A=(1 << 3) | 2=0x0A,表示"1 号字段,类型是带长度的数据"10=(2 << 3) | 0=0x10,表示"2 号字段,类型是 varint 整数"
wire_type:告诉解码器"后面的数据怎么读"
0(Varint):后面是一个变长整数,逐字节读直到某字节最高位为 02(Length-delimited):后面先读一个长度值,再读对应长度的数据
Varint 变长编码:小数字用更少字节。值
1只需 1 字节;值300需要 2 字节;值100000需要 3 字节。而 JSON 中数字100000要 6 字节(字符'1','0','0','0','0','0')空值省略:
Error = ""是零值,Protobuf 直接不传输,节省空间
第三步:解码——接收方还原结构体
接收方拿到 0A 07 46 6F 6F 2E 53 75 6D 10 01 这串字节后:
1. 读第一个字节 0A → field_number=1, wire_type=2(带长度的数据)
2. 读长度字节 07 → 后面 7 字节是数据
3. 读 7 字节 → "Foo.Sum"
4. 查 .proto → 1 号字段是 ServiceMethod → 赋值 header.ServiceMethod = "Foo.Sum"
5. 读下一个字节 10 → field_number=2, wire_type=0(varint)
6. 读 varint → 值 = 1
7. 查 .proto → 2 号字段是 Seq → 赋值 header.Seq = 1
8. 没有更多字节了 → 3 号字段 Error 保持零值 ""
还原完成:Header{ServiceMethod: "Foo.Sum", Seq: 1, Error: ""}与 JSON 的对比
JSON 传同样的数据:
{"ServiceMethod":"Foo.Sum","Seq":1,"Error":""} = 47 字节
^^^^^^^^^^^^^^ 字段名占了大量空间
^^^^^^^ 空值也要传
Protobuf 传同样的数据:
0A 07 Foo.Sum 10 01 = 11 字节
^^ 1字节 tag 替代 13字节字段名
空值直接省略
体积缩小到 JSON 的 23%总结:Protobuf 为什么小又快
| 省在哪 | JSON 的做法 | Protobuf 的做法 |
|---|---|---|
| 字段标识 | 传完整字段名 "ServiceMethod" = 15 字节 | 传 1 字节 tag 0A |
| 数字编码 | 十进制字符串 "12345" = 5 字节 | varint 二进制 = 2 字节 |
| 空值 | 必须传 "Error":"" = 10 字节 | 直接省略 = 0 字节 |
| 结构符号 | { } : , " 等 = 多字节 | 无,全靠 tag+wire_type 定界 |
- 优势:体积最小;跨语言通用(官方支持十几种语言);通过 field number 标识字段,加新字段不影响老版本
- 劣势:不可读,调试需专门工具;必须写
.protoSchema 文件并编译;需引入外部依赖库
量化对比
| 维度 | JSON | Gob | Protobuf |
|---|---|---|---|
| 同一 Header 体积 | ~47 字节 | 首条 ~55,后续 ~15 字节 | ~11 字节 |
数字 12345 占用 | 5 字节(字符 '1','2','3','4','5') | 8 字节(固定 uint64) | 2 字节(varint) |
| 字段标识开销 | 每条都传完整字段名 | 首条传类型描述,后续省略 | 每条 1 字节 tag |
| 解析方式 | 逐字符扫描 | 按类型描述直接读取 | 按 tag + wire type 直接读取 |
一句话总结
JSON 用空间和性能换可读性和通用性;Gob 用跨语言能力换 Go 生态的极致便利;Protobuf 用 Schema 的复杂度换最小体积和最广泛的跨语言支持。 没有最好的,只有最适合场景的。
miniRPC 中编码(序列化)与解码(反序列化)的实现
整体架构:数据的完整生命周期
发送方 接收方
Go 结构体 Go 结构体
Header{ServiceMethod:"Foo.Sum", Seq:1} Header{ServiceMethod:"Foo.Sum", Seq:1}
Body: "hello" Body: "hello"
│ ▲
│ 编码(序列化) │ 解码(反序列化)
│ Codec.Write(header, body) │ Codec.ReadHeader(&h) + ReadBody(&body)
▼ │
字节流 ──────── TCP 连接 ────────────────────────────────► 字节流编码 = 把内存中的结构体变成字节流(Marshal / Encode) 解码 = 把字节流还原成内存中的结构体(Unmarshal / Decode)
下面逐一看三种 Codec 的具体实现。
一、GobCodec 的编解码实现
初始化:创建编解码器
go
func NewGobCodec(conn io.ReadWriteCloser) Codec {
buf := bufio.NewWriter(conn)
return &GobCodec{
conn: conn,
buf: buf,
dec: gob.NewDecoder(conn), // 解码器:直接从连接读
enc: gob.NewEncoder(buf), // 编码器:写到缓冲区(不是直接写连接)
}
}关键设计:编码器写到 bufio.Writer,而非直接写 conn。
为什么?因为一次 Write 要编码 Header 和 Body 两次,如果直接写 conn,就是两次系统调用(两次 write 系统调用穿过内核态)。加一层缓冲区后,两次编码的结果先攒在用户态内存里,最后 Flush() 一次系统调用发出去。
编码过程(Write)
go
func (c *GobCodec) Write(h *Header, body interface{}) error {
defer func() {
_ = c.buf.Flush() // ④ 最后一次性把缓冲区的数据冲到 TCP 连接
}()
if err := c.enc.Encode(h); err != nil { // ① 把 Header 编码为 gob 二进制 → 写入 buf
return err
}
if err := c.enc.Encode(body); err != nil { // ② 把 Body 编码为 gob 二进制 → 写入 buf
return err
}
return nil // ③ 函数返回前,defer 触发 Flush
}执行流程图:
用户态内存
Header 结构体 ┌─────────────────────────┐
│ │ bufio.Writer (4KB 缓冲) │
│ enc.Encode(h) │ │ TCP 连接
└───────────────►│ [gob Header 字节...] │ │
│ │ Flush() │
Body 值 │ │ ──────────►│ 一次系统调用
│ enc.Encode(b) │ │ │ 发出全部数据
└───────────────►│ [gob Body 字节...] │ │
└─────────────────────────┘gob.Encoder.Encode() 内部做的事情:
- 检查这个类型是否第一次编码,如果是,先写入类型描述信息(字段名、类型、顺序)
- 把结构体的每个字段值按 gob 二进制规则序列化
- 输出到绑定的
io.Writer(这里是bufio.Writer)
解码过程(ReadHeader + ReadBody)
go
func (c *GobCodec) ReadHeader(h *Header) error {
return c.dec.Decode(h) // 从 conn 中读取一个 gob 编码的对象,填充到 h
}
func (c *GobCodec) ReadBody(body interface{}) error {
return c.dec.Decode(body) // 从 conn 中读取下一个 gob 编码的对象,填充到 body
}执行流程图:
TCP 连接 用户态内存
│
│ dec.Decode(&h) Header 结构体
│ ◄──── 读若干字节 ─────► h.ServiceMethod = "Foo.Sum"
│ gob 解码器内部知道 h.Seq = 1
│ 这个类型需要读多少 h.Error = ""
│
│ dec.Decode(&body) Body 值
│ ◄──── 读若干字节 ─────► body = "hello"
│gob.Decoder.Decode() 内部做的事情:
- 从流中读取消息头,判断类型 ID
- 如果是新类型,先解析类型描述并缓存
- 按照类型描述的字段顺序,逐个读取并填充到目标结构体的字段中
注意:解码器不需要 bufio.Reader,因为 gob.Decoder 内部已经有自己的缓冲机制。
二、JsonCodec 的编解码实现
初始化
go
func NewJsonCodec(conn io.ReadWriteCloser) Codec {
buf := bufio.NewWriter(conn)
return &JsonCodec{
conn: conn,
buf: buf,
dec: json.NewDecoder(conn), // 流式 JSON 解码器
enc: json.NewEncoder(buf), // 流式 JSON 编码器 → 写到缓冲区
}
}结构与 GobCodec 完全对称,只是换了编解码库。
编码过程(Write)
go
func (c *JsonCodec) Write(h *Header, body interface{}) error {
defer func() {
_ = c.buf.Flush()
}()
if err := c.enc.Encode(h); err != nil { // Header → JSON 文本 → buf
return err
}
if err := c.enc.Encode(body); err != nil { // Body → JSON 文本 → buf
return err
}
return nil
}json.Encoder.Encode() 内部做的事情:
- 通过反射遍历结构体的每个字段
- 将字段名和值序列化为 JSON 文本(如
{"ServiceMethod":"Foo.Sum","Seq":1,"Error":""}) - 自动追加一个
\n换行符(这是json.Encoder与json.Marshal的区别) - 输出到绑定的
io.Writer
buf 中的内容(Flush 前):
{"ServiceMethod":"Foo.Sum","Seq":1,"Error":""}\n"hello"\n
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Header 的 JSON Body 的 JSON
↑ ↑
自动 \n 自动 \n解码过程(ReadHeader + ReadBody)
go
func (c *JsonCodec) ReadHeader(h *Header) error {
return c.dec.Decode(h)
}
func (c *JsonCodec) ReadBody(body interface{}) error {
return c.dec.Decode(body)
}json.Decoder.Decode() 内部做的事情:
- 从流中逐字符扫描,跳过空白字符
- 遇到
{开始读取一个 JSON 对象,遇到"开始读取一个字符串…… - 读到一个完整的 JSON 值后停止(不会多读下一条消息的数据)
- 将 JSON 值反序列化到目标结构体中
流式解码器的关键:json.Decoder 维护一个内部读缓冲区,它能精确地从字节流中切分出一个完整的 JSON 值,不会"多读"也不会"少读"。这就是为什么不需要显式的长度前缀或分隔符。
三、ProtobufCodec 的编解码实现
初始化
go
func NewProtobufCodec(conn io.ReadWriteCloser) Codec {
return &ProtobufCodec{conn: conn} // 只保存连接,没有流式编解码器
}与前两者的根本区别:没有 bufio.Writer,没有 Encoder/Decoder。因为 Protobuf 不是流式编解码,而是"先序列化为完整的 []byte,再手动管理帧边界"。
编码过程(Write)
go
func (c *ProtobufCodec) Write(h *Header, body interface{}) error {
if err := writeJSONFrame(c.conn, h); err != nil { // ① 写 Header 帧
return err
}
if pb, ok := body.(proto.Message); ok {
return writeProtoFrame(c.conn, pb) // ② 写 Body 帧(proto 编码)
}
return writeJSONFrame(c.conn, body) // ② 写 Body 帧(JSON 降级)
}分两步写两帧。每一帧的写入过程(以 writeJSONFrame 为例):
go
func writeJSONFrame(w io.Writer, v interface{}) error {
data, err := json.Marshal(v) // 第 1 步:序列化为 []byte
if err != nil {
return err
}
binary.Write(w, binary.BigEndian, uint32(len(data))) // 第 2 步:写 4 字节长度前缀
_, err = w.Write(data) // 第 3 步:写数据本体
return err
}完整写入流程图:
Header 结构体
│
│ json.Marshal(h)
▼
[]byte: {"ServiceMethod":"Foo.Sum","Seq":1,"Error":""} 长度 = 47
│
│ 写入 TCP 连接
▼
┌──────────┬──────────────────────────────────────────────┐
│ 00 00 00 │ {"ServiceMethod":"Foo.Sum","Seq":1,...} │
│ 2F │ │
│ (长度=47)│ (47 字节 JSON 数据) │
└──────────┴──────────────────────────────────────────────┘
Body (proto.Message 类型)
│
│ proto.Marshal(body)
▼
[]byte: 0A 05 68 65 6C 6C 6F 长度 = 7
│
│ 写入 TCP 连接
▼
┌──────────┬──────────────┐
│ 00 00 00 │ 0A 05 68 65 │
│ 07 │ 6C 6C 6F │
│ (长度=7) │ (7 字节 proto)│
└──────────┴──────────────┘为什么不用 bufio.Writer? 因为 Protobuf 是先完整序列化到 []byte,然后"长度 + 数据"一起写出,每帧的数据已经是完整的,不存在 Gob/JSON 那种"多次小写入"的问题。
解码过程(ReadHeader + ReadBody)
go
func (c *ProtobufCodec) ReadHeader(h *Header) error {
return readJSONFrame(c.conn, h) // Header 始终用 JSON 解码
}
func (c *ProtobufCodec) ReadBody(body interface{}) error {
if body == nil {
return discardFrame(c.conn) // body 为 nil → 丢弃这一帧
}
if pb, ok := body.(proto.Message); ok {
return readProtoFrame(c.conn, pb) // proto 类型 → proto 解码
}
return readJSONFrame(c.conn, body) // 非 proto → JSON 降级解码
}每一帧的读取过程(以 readJSONFrame 为例):
go
func readJSONFrame(r io.Reader, v interface{}) error {
size, err := readFrameSize(r) // 第 1 步:读 4 字节 → 得到数据长度
if err != nil {
return err
}
data := make([]byte, size) // 第 2 步:分配对应大小的缓冲区
if _, err := io.ReadFull(r, data); err != nil { // 第 3 步:精确读取 size 字节
return err
}
return json.Unmarshal(data, v) // 第 4 步:反序列化为结构体
}完整读取流程图:
TCP 连接中的字节流
┌──────────┬──────────────────────────────────────────────┐
│ 00 00 00 │ {"ServiceMethod":"Foo.Sum","Seq":1,...} │
│ 2F │ │
└──────────┴──────────────────────────────────────────────┘
│ │
│ readFrameSize() │ io.ReadFull(r, data)
│ 读 4 字节 → size=47 │ 精确读 47 字节
▼ ▼
size = 47 data = [47 字节 JSON]
│
│ json.Unmarshal(data, &h)
▼
Header{ServiceMethod:"Foo.Sum", Seq:1, Error:""}特殊处理:丢弃帧(body 为 nil)
当服务端不需要读 Body 时(例如方法解析失败),传 nil 进来,此时只需跳过这一帧的数据:
go
func discardFrame(r io.Reader) error {
size, _ := readFrameSize(r) // 读 4 字节长度
if size == 0 { return nil } // 如果长度是 0,不需要跳
io.CopyN(io.Discard, r, int64(size)) // 读 size 字节丢掉,保证流的位置正确
return nil
}这一步很关键:如果不丢弃,Body 的字节会残留在流中,导致下一次 ReadHeader 读到的是上一条消息的 Body 数据,整个通信就错位了。
四、三种实现的编解码对比总结
GobCodec / JsonCodec 的路径:
结构体 ──Encoder.Encode()──► bufio.Writer 缓冲 ──Flush()──► TCP 连接
结构体 ◄──Decoder.Decode()──────────────────────────────── TCP 连接
特点:流式,编解码器自己知道消息边界在哪里
ProtobufCodec 的路径:
结构体 ──Marshal()──► []byte ──写长度+数据──► TCP 连接
结构体 ◄──Unmarshal()── []byte ◄──读长度→读数据── TCP 连接
特点:帧式,靠 4 字节长度前缀来标记消息边界| 维度 | GobCodec | JsonCodec | ProtobufCodec |
|---|---|---|---|
| 序列化方式 | gob.Encoder.Encode() 流式写入 | json.Encoder.Encode() 流式写入 | json/proto.Marshal() 一次性生成 []byte |
| 反序列化方式 | gob.Decoder.Decode() 流式读取 | json.Decoder.Decode() 流式读取 | 先读长度再 io.ReadFull + Unmarshal |
| 消息边界 | 编解码器内部状态机 | JSON 语法边界({}、"") | 显式 4 字节长度前缀 |
| 写缓冲 | bufio.Writer 合并两次小写入 | bufio.Writer 合并两次小写入 | 不需要,每帧已经是完整 []byte |
| 空 Body | 编码一个 nil 值 | 编码一个 null 值 | 写长度=0 的帧 / discardFrame 跳过 |
MagicNumber 的作用
是什么
MagicNumber 是一个写死在代码里的固定整数值:
go
const MagicNumber = 0x3bef5c它被放在 Option 结构体的第一个字段中,在 TCP 连接建立后、正式通信前发送给服务端。
解决什么问题
一个 TCP 端口可能收到各种各样的连接:浏览器发来的 HTTP 请求、别的程序发来的乱七八糟的数据、扫描器的探测包……服务端需要一种方式在第一时间判断"这个连接是不是我的 miniRPC 客户端发来的"。
MagicNumber 就是这个"接头暗号"。
工作流程
客户端 服务端
建立 TCP 连接
│ │
│ 发送 Option(JSON 编码) │
│ {"MagicNumber":3928924, "CodecType":...} │
│ ──────────────────────────────────────────► │
│ │
│ 读取 Option │
│ 检查 MagicNumber
│ │
│ ┌────────┴────────┐
│ │ │
│ == 0x3bef5c != 0x3bef5c
│ (暗号对上了) (暗号不对)
│ │ │
│ 继续处理 直接关闭连接
│ 创建 Codec 打印日志警告
│ 进入请求循环对应的服务端代码:
go
func (server *Server) ServeConn(conn io.ReadWriteCloser) {
var opt codec.Option
json.NewDecoder(conn).Decode(&opt) // 读取 Option
if opt.MagicNumber != codec.MagicNumber { // 校验 MagicNumber
log.Printf("rpc server: invalid magic number %x", opt.MagicNumber)
return // 不匹配 → 直接断开
}
// 匹配 → 继续正常处理
f := codec.NewCodecFuncMap[opt.CodecType]
server.serveCodec(f(conn), &opt)
}为什么不用更复杂的验证方式
MagicNumber 的目的是快速过滤明显不是 miniRPC 的连接,而不是安全认证。它的优势在于:
- 极其轻量:只是一个整数比较,几乎零开销
- 第一道防线:在解析任何复杂数据之前就能拒绝无效连接,避免浪费资源
- 协议标识:很多协议都用 MagicNumber 来标识自己,如 HTTP/2 的
PRI * HTTP/2.0、Java class 文件的0xCAFEBABE、PNG 文件的0x89504E47
如果需要真正的安全认证(防止恶意伪造),那是鉴权层(如 TLS、Token)的职责,不是 MagicNumber 该做的事。
miniRPC 的实现 vs 生产级 RPC 框架
MagicNumber 这个概念是业内通用的,但 miniRPC 的具体实现方式比生产级框架简单很多。
各框架的协议识别方式对比
| RPC 框架 | 识别方式 | 复杂度 |
|---|---|---|
| miniRPC | JSON 编码的 Option 里放一个 MagicNumber | 最简单,仅判断"是不是 miniRPC" |
| Dubbo | 16 字节固定二进制帧头:2 字节 Magic 0xDABB + 标志位 + 状态码 + 请求 ID + Body 长度 | 中等,帧头里包含完整元信息 |
| gRPC | 复用 HTTP/2 协议(24 字节连接前言 + content-type: application/grpc) | 复杂,站在 HTTP/2 肩膀上 |
| Thrift | 4 字节帧头含版本号 + 消息类型 + 方法名 + 序列号 | 中等 |
| BRPC(百度) | 前 4 字节 Magic 标识协议类型(PRPC/HTTP/HULU),同一端口自动检测多种协议 | 较复杂 |
差异在哪
以 Dubbo 为例,它的 16 字节固定帧头把所有元信息压缩进了二进制位:
Dubbo 帧头(固定 16 字节):
┌────────┬────────┬────────┬────────┬──────────────────┬──────────────┐
│ 0xDABB │ 标志位 │ 状态码 │ 请求ID │ Body 长度 │ Body 数据... │
│ 2字节 │ 1字节 │ 1字节 │ 8字节 │ 4字节 │ │
└────────┴────────┴────────┴────────┴──────────────────┴──────────────┘
│ │
│ ├─ bit7: 请求 or 响应?
│ ├─ bit6: 需要响应吗?
│ ├─ bit5: 是心跳包吗?
│ └─ bit0~4: 序列化方式(hessian2/json/protobuf...)
│
└─ 是不是 Dubbo?
miniRPC 的 Option(JSON 编码,变长):
┌──────────────────────────────────────────────────────┐
│ {"MagicNumber":3928924,"CodecType":"application/gob"}│
└──────────────────────────────────────────────────────┘Dubbo 用固定 16 字节解决所有问题,miniRPC 用一整段 JSON 文本——简单易懂但效率低(JSON 解析开销、变长、字段名冗余)。
为什么 miniRPC 选择简单方案
- 学习项目:JSON 传 Option 最直观易懂,降低入门门槛
- 握手只发生一次:Option 只在连接建立时发一次,不在每条消息中重复,性能影响可忽略
- 渐进式设计:先跑通流程,后续可优化为固定长度的二进制帧头
Codec 过程中涉及到压缩和解压过程吗?
简短回答:miniRPC 当前实现中没有压缩/解压步骤
三种 Codec 的数据流都是:
结构体 ──序列化──► 字节流 ──直接写入──► TCP 连接 (没有压缩)
结构体 ◄──反序列化── 字节流 ◄──直接读取── TCP 连接 (没有解压)编码(Encode/Marshal)≠ 压缩(Compress),这是两个不同的概念:
| 编码/序列化 | 压缩 | |
|---|---|---|
| 目的 | 把结构化数据变成字节流,使其可以在网络上传输 | 减小字节流的体积,节省带宽 |
| 可逆性 | 解码后得到完全相同的结构体 | 解压后得到完全相同的字节流 |
| 是否改变语义 | 不改变,只是换一种表示形式 | 不改变,只是减小体积 |
| 例子 | json.Marshal → {"Seq":1} | gzip.Write → 压缩后的二进制 |
如果要加压缩,在哪里加?
压缩是一个独立于编码的步骤,可以在编码和 TCP 之间插入一层。完整的数据流会变成:
当前(无压缩):
结构体 ──编码──► 字节流 ──────────────────────► TCP
加压缩后:
结构体 ──编码──► 字节流 ──压缩──► 压缩字节流 ──► TCP
结构体 ◄──解码── 字节流 ◄──解压── 压缩字节流 ◄── TCP在 Go 中,可以利用 io.ReadWriteCloser 的组合来透明地加入压缩层,不需要修改 Codec 本身。思路如下:
go
// 伪代码示意:在连接上包装一层 gzip
import "compress/gzip"
// 发送端:conn → gzip.Writer → Codec 写到这里
gzipWriter := gzip.NewWriter(conn)
// 接收端:conn → gzip.Reader → Codec 从这里读
gzipReader, _ := gzip.NewReader(conn)
// 把压缩层包装成 ReadWriteCloser,传给 Codec
compressedConn := &CompressedConn{
Reader: gzipReader, // 读 = 自动解压
Writer: gzipWriter, // 写 = 自动压缩
Closer: conn,
}
codec := NewGobCodec(compressedConn) // Codec 完全不知道有压缩,照常工作这样 Codec 的代码一行都不用改——它只管从 io.ReadWriteCloser 读写,至于底层是直接的 TCP 还是经过了 gzip,它并不关心。这就是 Go io.Reader/Writer 接口组合的威力。
为什么 miniRPC 没有加压缩
- 内网通信为主:RPC 框架通常用于内网微服务间通信,带宽充足,压缩的收益有限
- CPU 开销:压缩和解压需要额外的 CPU 计算,对于高频 RPC 调用可能得不偿失
- 消息体小:RPC 的单条消息通常很小(几十到几百字节),小数据压缩后可能反而变大(压缩算法本身有开头元数据)
- 框架简洁:miniRPC 是学习项目,保持核心链路简单清晰
在生产级 RPC 框架中(如 gRPC),压缩是可选的——客户端在请求头中声明 grpc-encoding: gzip,服务端据此决定是否解压,且只对较大的消息体启用压缩。
Seq 的作用
问题:为什么需要序列号?
一条 TCP 连接上,客户端可以连续发送多个请求,不必等待前一个请求的响应。这就带来一个问题:响应回来的时候,怎么知道它是哪个请求的?
客户端 服务端
├─ 请求 A(Seq=1, "Foo.Add")────►│
├─ 请求 B(Seq=2, "Foo.Mul")────►│ 三个请求几乎同时发出
├─ 请求 C(Seq=3, "Bar.Get")────►│
│ │
│◄─ 响应 ?(Seq=?, ...)───────────│ 响应不一定按请求顺序返回
│◄─ 响应 ?(Seq=?, ...)───────────│ (因为服务端并发处理,快的先回来)
│◄─ 响应 ?(Seq=?, ...)───────────│如果没有 Seq,客户端收到三个响应,根本无法区分哪个是 Foo.Add 的结果、哪个是 Foo.Mul 的结果。
Seq 就是用来解决这个匹配问题的唯一标识。
工作机制
发送端:分配递增的 Seq
go
// client.go 中的 registerCall
func (client *Client) registerCall(call *Call) (uint64, error) {
call.Seq = client.seq // 为本次调用分配当前序列号
client.pending[call.Seq] = call // 存入 pending 表:seq → Call
client.seq++ // 序列号递增,下一次调用用下一个号
return call.Seq, nil
}客户端维护一个递增计数器 seq,每次发起调用时分配一个唯一编号,并将 seq → Call 的映射存入 pending 表。
传输中:Seq 跟随 Header 传输
请求:Header{ServiceMethod: "Foo.Add", Seq: 1, Error: ""} + Body{args}
^^^^
Seq 被编码在 Header 中发给服务端
响应:Header{ServiceMethod: "Foo.Add", Seq: 1, Error: ""} + Body{reply}
^^^^
服务端原样带回同一个 Seq服务端收到请求后,处理完毕将结果连同同一个 Seq 写入响应 Header,原样返回。
接收端:通过 Seq 匹配响应
go
// client.go 中的 receive
func (client *Client) receive() {
for {
var h codec.Header
client.cc.ReadHeader(&h) // 读取响应 Header
call := client.removeCall(h.Seq) // 用 Seq 从 pending 表中找到对应的 Call
// ^^^^
// 关键:通过 Seq 匹配
switch {
case call == nil:
// 该调用已被移除(如超时取消),丢弃 Body
case h.Error != "":
// 服务端返回了错误,通知调用方
call.Error = fmt.Errorf(h.Error)
call.done()
default:
// 正常响应,将 Body 反序列化到 call.Reply
client.cc.ReadBody(call.Reply)
call.done()
}
}
}完整流程图
客户端 pending 表 服务端
Call("Foo.Add", args1)
│ registerCall → Seq=1
│ {1: Call_A}
│── Header{Seq:1} + Body ──────────────────────────────►│
│ │
Call("Foo.Mul", args2) │
│ registerCall → Seq=2 │
│ {1: Call_A, 2: Call_B} │
│── Header{Seq:2} + Body ──────────────────────────────►│
│ │
│ │ 并发处理
│ │ Seq=2 先处理完
│ │
│◄── Header{Seq:2} + Body ──────────────────────────────│
│ removeCall(2) → 找到 Call_B │
│ 填充 Call_B.Reply │
│ {1: Call_A} │
│ │
│◄── Header{Seq:1} + Body ──────────────────────────────│
│ removeCall(1) → 找到 Call_A │
│ 填充 Call_A.Reply │
│ {}(空) │即使 Seq=2 的响应先于 Seq=1 返回,客户端也能正确地将各自的结果匹配到对应的调用。
为什么用递增整数而不是 UUID
| 方案 | 体积 | 生成开销 | 匹配开销 |
|---|---|---|---|
| 递增 uint64 | 1~8 字节(varint)或固定 8 字节 | seq++,一条 CPU 指令 | map 的整数 key 查找,O(1) |
| UUID 字符串 | 36 字节 | 随机数生成 + 格式化 | map 的字符串 key 查找,涉及哈希计算 |
递增整数在体积、生成速度、匹配效率上全面碾压 UUID,对于连接内部的请求标识完全够用。
与 HTTP 的对比
HTTP/1.1 没有 Seq 的概念——它用"请求-响应必须按顺序"的方式来隐式匹配(队头阻塞问题的根源)。HTTP/2 引入了 Stream ID,本质上就是 miniRPC 的 Seq:
| miniRPC Seq | HTTP/2 Stream ID | |
|---|---|---|
| 分配方 | 客户端递增 | 客户端奇数递增 |
| 传输位置 | Header.Seq 字段 | 帧头的 Stream Identifier |
| 匹配方式 | pending map 查找 | Stream 状态机查找 |
| 目的 | 并发请求的响应匹配 | 多路复用的流标识 |
一句话总结:Seq 是一条连接上的"请求身份证号",让客户端能在并发场景下把乱序返回的响应正确匹配到对应的请求。