42.1 RPC 原理与 Stub
一句话
RPC 的本质是“把一次跨进程调用伪装成本地函数调用”,伪装只做三件事——把方法名和参数序列化、把字节按帧发到对端、把响应还原成返回值;Stub 就是这层伪装,它的每一步开销都直接变成 RPC 的性能上限。
一次 RPC 的七步
客户端 服务端
① 序列化(参数 → 字节)
② 组帧(长度前缀 + 字节)
③ 传输 ──────────────────────► ④ 拆帧、反序列化
⑤ 按方法名分发(dispatch)
⑥ 执行业务函数
⑧ 反序列化结果 ◄────────────── ⑦ 序列化返回值、组帧、回传
| 步骤 | 谁来做 | 开销来源 | 能优化什么 |
|---|---|---|---|
| 序列化 | Stub(生成的代码) | CPU、内存分配、字段名重复 | 换二进制编码、减少字段 |
| 组帧 | Stub / 框架 | 拷贝次数 | 零拷贝、复用 buffer |
| 传输 | 网络栈 | 握手、RTT、包数 | 连接复用、多路复用 |
| 分发 | 服务端框架 | map 查找、反射 | 静态分发表、代码生成 |
| 执行 | 业务 | — | 业务本身 |
| 回传 | 同左 | 同一份开销再来一遍 | — |
🔑 Stub 不是“胶水代码”,是协议的另一半:客户端 Stub 决定“怎么编码”,服务端 Stub 决定“怎么解码和分发”,两者的版本必须能对上。这就是 IDL(接口描述语言)存在的原因——它让两端从同一份定义生成代码。
为什么必须有长度前缀
TCP 是字节流,没有消息边界。三种分帧方式:
| 方式 | 做法 | 问题 |
|---|---|---|
| 固定长度 | 每条消息定长 | 字段一变就废,浪费带宽 |
| 分隔符 | \n / \0 结尾 | 内容里出现分隔符就要转义,转义又增加成本 |
| 长度前缀 | 先写 4 字节长度,再写消息体 | 一次读满即可,无转义;长度字段本身 4 字节 |
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
w.Write(hdr[:]) // 先告诉对方「后面有多少字节」
w.Write(body) // 再发消息体
长度前缀的代价是每条消息多 4 字节。对小消息(比如 20 字节的心跳)这是 20% 的额外开销,所以高频小消息的协议通常会把长度字段压到 2 字节或 varint。
序列化:同一个对象,52 字节 vs 17 字节
以 user{ID:1001, Name:"张三", Age:32, City:"上海"} 为例:
| 编码 | 字节数 | 内容 | 特点 |
|---|---|---|---|
| JSON | 52 | {"ID":1001,"Name":"张三","Age":32,"City":"上海"} | 可读、自描述、字段名占一半 |
| 二进制(长度前缀 + 原值) | 17 | ID 2B + 名字 1B 长度 + 6B + 年龄 1B + 城市 1B + 6B | 紧凑、需要 schema |
3.1 倍的差距主要来自“字段名”和“数字转字符串”。消息越小,元数据占比越高:一个 Add(2,3) 的请求,JSON 要 44 字节,其中真正的参数只有 16 字节。
| 场景 | 选 JSON | 选二进制 |
|---|---|---|
| 对外 API、调试友好 | ✅ | |
| 内网高频 RPC(> 1 万 QPS) | ✅ | |
| 跨语言、弱类型 | ✅ | |
| 大对象、带宽敏感 | ✅ | |
| 需要人肉排查 | ✅(配合抓包工具) | 需要 schema 才能解 |
注意“序列化省下的流量”要按请求 + 响应算:每次 120 字节 vs 40 字节,100 万次调用是 229 MiB vs 76 MiB。在 10 万 QPS 下,这是每天几十 TB 的差别。
连接模式:7 个包 vs 2 个包
| 模式 | 每次调用的包 | 100 万次调用 | 连接数 |
|---|---|---|---|
| 短连接 | 3 次握手 + 2 次挥手 + 请求 + 响应 = 7 包 | 700 万包 | 每次一条 |
| 长连接 | 握手/挥手只付一次 + 请求 + 响应 = 2 包 | 约 200 万包(29%) | = 并发数(约 200) |
| 长连接 + 多路复用 | 2 包,多个请求共享一条连接 | 同长连接 | 1~几条 |
短连接的问题不只是包数:每条连接都要经历 TIME_WAIT(见 8.3 连接复用与 TIME_WAIT),端口耗尽、握手延迟都随之而来。在 0.5ms RTT 下,10 万 QPS 的短连接意味着同时有 50 个连接卡在握手里。
多路复用(HTTP/2、gRPC stream)把“连接数 = 并发数”降为“连接数 = 几条”,代价是队头阻塞:一条 TCP 连接上一个丢包会拖慢所有流。所以生产上常见“连接池 + 适度多路复用”,而不是一味压到 1 条。
Stub 的两种形态
| 形态 | 生成方式 | 优点 | 缺点 |
|---|---|---|---|
| 静态 Stub(代码生成) | 从 .proto / .thrift 生成 | 类型安全、分发快、可读 | 改接口要重新生成、多语言工具链 |
| 动态调用(泛化调用) | 运行时按方法名+参数反射 | 不改代码就能调新接口 | 无类型检查、性能差、调试难 |
| 手写 Stub | 自己封装 call(method, args) | 简单 | 每个接口重复、易漏超时/重试 |
分发表是 Stub 的核心数据结构:map[string]func(ctx, body) (reply, error)。它的查找是 O(1),但每次调用一次 map 查找 + 一次接口断言,在百万 QPS 下也是可观的开销——所以高性能框架用“方法 ID(数字)+ switch/数组索引”代替字符串方法名。
可运行的 Stub 与协议开销
下面这段代码用 net.Pipe(内存管道)跑一次真实的帧协议调用,排除网络噪声,只看协议本身;再算三种连接模式和两种序列化的成本。
package main
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
)
// ---------- 帧格式:4 字节长度 + JSON 体 ----------
type frame struct {
Method string `json:"method"`
Payload json.RawMessage `json:"payload"`
}
type addArgs struct{ A, B int64 }
type addReply struct{ Sum int64 }
func writeFrame(w io.Writer, v any) (int, error) {
body, err := json.Marshal(v)
if err != nil {
return 0, err
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
if _, err := w.Write(hdr[:]); err != nil {
return 0, err
}
n, err := w.Write(body)
return 4 + n, err
}
func readFrame(r io.Reader, v any) (int, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return 0, err
}
n := binary.BigEndian.Uint32(hdr[:])
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return 0, err
}
return 4 + int(n), json.Unmarshal(body, v)
}
// server 只做一件事:按 method 分发到注册的函数。
func serve(conn net.Conn) {
defer conn.Close()
for {
var req frame
if _, err := readFrame(conn, &req); err != nil {
return
}
switch req.Method {
case "Add":
var a addArgs
json.Unmarshal(req.Payload, &a)
writeFrame(conn, addReply{Sum: a.A + a.B})
default:
writeFrame(conn, map[string]string{"error": "unknown method"})
}
}
}
func main() {
client, server := net.Pipe() // 内存管道:去掉网络噪声,只看协议本身
go serve(server)
// Stub:把「方法名 + 参数」序列化,写一帧,读一帧
reqSize, _ := writeFrame(client, frame{Method: "Add", Payload: must(json.Marshal(addArgs{2, 3}))})
var reply addReply
respSize, _ := readFrame(client, &reply)
fmt.Printf("① 一次 Add(2,3):请求 %d 字节 + 响应 %d 字节,结果 %d\n", reqSize, respSize, reply.Sum)
// ② 两代序列化:JSON vs 手写 varint(模拟 protobuf 的编码思路)
type user struct {
ID int64
Name string
Age int32
City string
}
u := user{1001, "张三", 32, "上海"}
js, _ := json.Marshal(u)
var bin []byte
bin = append(bin, byte(u.ID), byte(u.ID>>8)) // 简化:ID 固定 2 字节
bin = append(bin, byte(len(u.Name)))
bin = append(bin, u.Name...)
bin = append(bin, byte(u.Age))
bin = append(bin, byte(len(u.City)))
bin = append(bin, u.City...)
fmt.Printf("② 同一个 user:JSON %d 字节,二进制 %d 字节(%.1f 倍)\n",
len(js), len(bin), float64(len(js))/float64(len(bin)))
fmt.Printf(" JSON:%s\n", js)
// ③ 连接模式:100 万次调用,统计包数与握手次数
const calls = 1_000_000
const conns = 200 // 长连接的并发连接数 = 并发请求数
short := calls * 7
long := calls*2 + conns*5
fmt.Printf("③ 100 万次调用的网络成本:\n")
fmt.Printf(" 短连接:握手 %d 次(3 包)+ 挥手 %d 次(2 包)+ 数据 %d 包 → 共 %d 包\n",
calls, calls, calls*2, short)
fmt.Printf(" 长连接:握手/挥手各 %d 次(%d 条连接)+ 数据 %d 包 → 共 %d 包\n",
conns, conns, calls*2, long)
fmt.Printf(" 长连接把 5 个控制包/次 摊到 1 次,包数降到 %.0f%%\n", float64(long)/float64(short)*100)
// ④ 序列化开销:每次 120 字节 vs 40 字节,100 万次调用差多少流量
for _, size := range []int{120, 40} {
mib := float64(calls) * float64(size) * 2 / 1024 / 1024
fmt.Printf("④ 每次请求+响应 %d 字节 → 100 万次 = %.0f MiB\n", size, mib)
}
}
func must(b []byte, err error) []byte {
if err != nil {
panic(err)
}
return b
}
输出:
① 一次 Add(2,3):请求 44 字节 + 响应 13 字节,结果 5
② 同一个 user:JSON 52 字节,二进制 17 字节(3.1 倍)
JSON:{"ID":1001,"Name":"张三","Age":32,"City":"上海"}
③ 100 万次调用的网络成本:
短连接:握手 1000000 次(3 包)+ 挥手 1000000 次(2 包)+ 数据 2000000 包 → 共 7000000 包
长连接:握手/挥手各 200 次(200 条连接)+ 数据 2000000 包 → 共 2001000 包
长连接把 5 个控制包/次 摊到 1 次,包数降到 29%
④ 每次请求+响应 120 字节 → 100 万次 = 229 MiB
④ 每次请求+响应 40 字节 → 100 万次 = 76 MiB
RPC 层必须补齐的四件事
| 事项 | 为什么在 Stub 层做 | 参数怎么定 |
|---|---|---|
| 超时 | 每个调用都要有上限,不能继承“无限等” | 按剩余预算切,见 35.5 超时、重试与退避 |
| 重试 | 只在 Stub 知道哪些错误可重试 | 幂等才重试,且受预算约束 |
| 幂等 | 重试会重复执行,键要由调用方生成 | 见 25.4 幂等设计与去重 |
| 可观测 | trace id 要在帧里传递,否则跨进程链路断 | 见 50.3 链路 (Trace / Span / 上下文传播 / 采样) |
四件事的最小实现(Go)
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// 错误分类:只有“明确没执行”的错误才允许重试。
var (
ErrConnect = errors.New("connect failed")
ErrRejected = errors.New("server rejected")
ErrBusiness = errors.New("business error")
)
// Budget 限制“重试请求 / 正常请求”的比例,避免故障时 3 倍放大。
type Budget struct {
mu sync.Mutex
spent int
limit int
}
func (b *Budget) take() bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.spent >= b.limit {
return false
}
b.spent++
return true
}
type Client struct{ budget *Budget }
func (c *Client) Call(ctx context.Context, fn func(context.Context) error) error {
// 1) 超时:继承父 context 的剩余预算,绝不新开一个“无限等”。
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
var err error
for attempt := 0; attempt < 3; attempt++ {
err = fn(ctx)
if err == nil || !retryable(err) {
return err
}
// 2) 预算:拿不到令牌就放弃,快速失败。
if !c.budget.take() {
return err
}
select {
case <-ctx.Done(): // 3) 剩余时间不够一次退避,别再重试。
return ctx.Err()
case <-time.After(time.Duration(attempt+1) * 50 * time.Millisecond):
}
}
return err
}
func retryable(err error) bool {
return errors.Is(err, ErrConnect) || errors.Is(err, ErrRejected)
}
func main() {
c := &Client{budget: &Budget{limit: 100}}
_ = c.Call(context.Background(), func(context.Context) error { return ErrBusiness })
fmt.Println("retryable(connect) =", retryable(ErrConnect))
}
四件事各自的验收标准:
| 事项 | 验收标准 | 怎么验 |
|---|---|---|
| 超时 | 每次调用都有 deadline,且子调用继承剩余预算 | 注入 2s 延迟,调用方 800ms 必须返回 DeadlineExceeded |
| 重试 | 只重试 ErrConnect / ErrRejected;重试量 ≤ 预算 | 故障演练时观察总 QPS 放大倍数 < 1.2 |
| 幂等 | 重试请求带同一个幂等键 | 同一键重放两次,DB 只有一条记录 |
| 可观测 | 帧里带 trace id;每次重试记 attempt | 链路里一次用户请求对应 N 个 span,且能看出重试 |
⚠️ 最常见的翻车:外层超时 3s、内层每个 RPC 也 3s。第一个 RPC 卡满 3s,后面已经没有预算,但代码仍会发起第二个 3s 调用——总耗时 6s,调用方早就放弃了。deadline 必须“从剩余时间里切”,不是每层各给一个固定值。
反模式
| 反模式 | 后果 |
|---|---|
| 用分隔符分帧且不转义 | 消息体里出现分隔符就截断,偶发解析错误 |
| 每条消息新建连接 | 7 个包/次 + TIME_WAIT 堆积,QPS 上不去 |
| 小消息用 JSON 且不开压缩 | 元数据比数据大,带宽浪费 3 倍 |
| 无限多路复用压到 1 条连接 | 一个丢包阻塞所有流(队头阻塞) |
| Stub 里不做超时,靠业务层 | 一处阻塞拖垮整个调用链 |
| 方法名用字符串每次 map 查找 | 百万 QPS 下多出可观的 CPU |
| 手写 Stub 遗漏幂等键 | 重试变成重复扣款 |
| 序列化直接暴露内部结构体 | 改一个字段就破坏兼容性 |
自检
Q1 同一个 Add(2,3) 调用,JSON 请求 44 字节,而参数只有 16 字节。多出来的 28 字节是什么?在什么规模下值得换成二进制?
▸ 答案:多出来的是方法名、字段名、JSON 标点和引号:{"method":"Add","payload":{"A":2,"B":3}} 里 method、payload、A、B 的键名和引号占了大头。单看一次 28 字节无所谓,但按 10 万 QPS 算,每秒多 2.8 MB、每天 240 GB;如果算上响应侧同样的字段名,翻倍。经验阈值:内网 QPS 过万、或 P99 对序列化 CPU 敏感时换二进制;对外 API 保持 JSON 更划算(可读、可调试、生态成熟)。
Q2 短连接 1 次调用是 7 个包,长连接是 2 个包,为什么长连接不是“2/7 的成本”?
▸ 答案:因为包数只是成本的一部分。长连接还省掉了每连接的内核对象分配、TLS 握手(如果走 TLS,是 2 个 RTT)、TIME_WAIT 端口占用,以及服务端的 accept 队列压力。反过来,长连接有额外成本:连接常驻占内存、需要心跳保活、负载均衡要考虑连接数而非请求数。所以真实收益通常大于 7/2 的表面比例,但在连接数极多、流量极小的场景(如 IoT 心跳),长连接的常驻成本可能反超。
Q3 为什么 RPC 的超时、重试、幂等要放在 Stub/框架层,而不是每个业务函数自己写?
▸ 答案:三件事都需要统一的上下文才能做对:超时要拿“剩余预算”并沿调用链传递;重试要判断错误是否可重试、是否幂等、预算是否够;幂等键要在“第一次尝试”就生成并在重试时复用。业务函数只看到“参数和返回值”,拿不到这些信息。放在 Stub 层还有一个好处:所有调用路径行为一致,不会出现“这个接口忘了配超时”的漏网之鱼。
Q4 多路复用把连接数从 200 降到 1,为什么生产上反而常见“连接池 + 适度复用”?
▸ 答案:多路复用的收益是握手与连接数,代价是队头阻塞:一条 TCP 连接上的一个丢包会让所有流一起等重传(HTTP/2 over TCP 的固有问题;HTTP/3 用 QUIC 缓解)。此外单连接还受拥塞窗口共享、单核处理能力的限制。所以常见做法是每条连接承载几十到几百个流,池子里放几条到几十条连接,既摊薄握手成本,又避免“一条连接挂掉全挂”。
Q5 如果让你给一个已有系统加 RPC,最先补的三件事是什么?
▸ 答案:① 长度前缀分帧 + 明确的错误码(否则粘包与错误分类都无从谈起);② 超时与调用链上下文传递(deadline + trace id,见 35.5 超时、重试与退避);③ 幂等键与重试策略(哪些错误可重试、键由谁生成,见 25.4 幂等设计与去重)。序列化格式反而可以晚点换——先保证“不重复、不无限等、能定位”。