微服务架构把一个系统拆成多个独立部署的服务,服务之间势必要跨网络调用彼此。**RPC(remote procedure call,远程过程调用)**要解决的问题,是让这种跨网络调用写起来尽量像调用一个本地函数——调用端不需要手动处理连接、序列化、封包格式,只要像调用函数一样传参数、拿返回值。gRPC 是目前最常见的 RPC 框架之一,这篇文章先说明 RPC 的基本模型,再拆解 gRPC 具体怎么实现它。

1. RPC 想解决的问题

1.1 用本地调用的外观包住远程调用

一次 RPC 调用的基本流程是:调用端调用一个看起来像本地函数的”stub”,stub 把参数序列化成字节、通过网络送到服务器;服务器端收到后反序列化,调用真正的实现函数,把返回值序列化后送回;调用端的 stub 再把收到的字节反序列化成返回值,交给调用者。整个序列化、传输、反序列化的过程对调用者是隐藏的,写起来就像 result = greeter.SayHello(request) 这样一行本地调用。

1.2 隐藏得了语法,藏不住失败模式

RPC 能让语法长得像本地调用,却无法让失败模式跟本地调用一样。本地函数调用几乎不会”调用到一半失去联系”,但网络调用可能超时、可能封包丢失后重传、可能服务器收到请求却在响应送达前崩溃——调用端这时完全无法分辨”请求根本没送到”还是”请求送到了、也执行了,只是响应丢失了”。这是分布式系统里著名的**部分失败(partial failure)**问题:调用端只能选择重试、放弃、或想办法让重复执行也安全(幂等),没有办法让网络调用的失败模式完全等同本地调用。理解这个限制,是正确使用任何 RPC 框架的前提,而不是框架的缺陷。

2. gRPC 具体怎么运作

2.1 用 Protocol Buffers 定义接口

gRPC 用 **Protocol Buffers(protobuf)**当作接口定义语言:先写一份 .proto 文件,描述有哪些远程方法、每个方法的请求与响应长什么样子。

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

protoc 编译器会从同一份 .proto 文件,生成多种语言(Go、Python、Java、C++ 等)各自的客户端 stub 与服务器端骨架代码。这代表接口定义只需要写一次,不同语言写成的服务之间也能对得上:不会有”客户端以为字段是字符串,服务器端却读成整数”这类手写序列化代码常见的错误。消息里每个字段都有一个数字标签(例如 string name = 1; 里的 1),这个数字才是字段在线上格式里的真正标识符,之后新增字段只要用新的数字、不要重复使用已经弃用的字段编号,新旧版本的客户端和服务器就能继续互相兼容。

2.2 建立在 HTTP/2 之上

gRPC 的传输层是 HTTP/2,直接继承了几个特性:同一条 TCP 连接可以多路复用多个并行调用,不会像 HTTP/1.1 那样互相排队;请求头用 HPACK 压缩,减少重复元数据占用的带宽;HTTP/2 原生支持双向的流式传输,这也是 gRPC 能实现双向流式调用的基础(见第 3 节)。

3. 四种调用模式

gRPC 定义了四种调用模式,差别在请求和响应各自是”一个”还是”一串”:Unary 是最基本的一对一调用,一个请求换一个响应,跟一般函数调用的直觉完全一致。Server streaming 是一个请求换回一串响应,例如订阅某个资源的更新,服务器会持续推送新的消息。Client streaming 反过来,客户端送出一连串请求(例如分批上传数据),服务器等收完之后只回一个总结性的响应。Bidirectional streaming 让两边都能各自持续发送消息,不需要一方等另一方讲完,适合实时互动的场景,例如聊天室或实时语音转文字。四种模式共用同一套接口定义语法,差别只在 rpc 声明里是否加上 stream 关键字标注请求或响应。

4. gRPC 与 REST 的取舍

REST(通常搭配 JSON)的优势是人类可读、任何 HTTP 客户端都能直接调用、可以直接利用 HTTP 既有的缓存语义(例如 GET 天生可缓存);缺点是 JSON 序列化体积较大、解析较慢,也没有强制的类型契约,客户端跟服务器对字段类型的认知不一致时通常要到运行期才会发现。gRPC 用二进制的 protobuf 编码,体积小、解析快,且接口契约由 .proto 文件强制生成代码,类型不一致在编译期就会出错;但浏览器没办法直接发出原生的 gRPC 调用(需要通过 gRPC-Web 加一层代理转换),二进制格式也不像 JSON 那样可以直接用肉眼读懂封包内容,调试通常需要额外工具(例如 grpcurl)。实务上,对外的公开 API 常用 REST 或 GraphQL 求取兼容性和可读性,服务之间的内部调用则更常用 gRPC 换取效率与类型安全。

5. 常见踩坑

**忘记设置超时(deadline)。**RPC 调用默认不会自动超时,一旦没有主动设置 deadline,遇上网络异常或服务器挂住时,调用端会无限期等待,占用的资源(线程、连接)也跟着没有释放,这正是第 1.2 节提到的部分失败问题在实务上最常踩到的地方。

**负载均衡失效。**gRPC 把多个逻辑调用多路复用在同一条长连接上,如果负载均衡器只在 TCP 连接建立时决定要转发到哪一台后端(传统的第四层负载均衡常是这样运作),之后同一条连接上的所有调用都会一直打到同一台后端,其他后端实例完全分不到流量。解法是改用能理解 HTTP/2、逐一调用层级做负载均衡的代理(例如 Envoy),或者在客户端本身实现服务发现与负载均衡逻辑。

**改字段编号或类型破坏兼容性。**protobuf 的兼容性设计建立在字段编号不变的前提上;重新使用已弃用的字段编号,或者把一个已经在使用的字段类型改掉,会让新旧版本互相误读对方的数据,却不一定会立即报错,是特别难排查的一类版本兼容性问题。新增字段、保留旧字段编号不重复使用,才是安全的 schema 演进方式。