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 幂等设计与去重)。序列化格式反而可以晚点换——先保证“不重复、不无限等、能定位”。

关联