第二章:gRPC 核心概念

不管你之前接触过什么 RPC 框架,gRPC 这套东西我建议你从头理解一下。它跟传统 RPC 最大的不同,就是把协议和数据序列化绑在了一起。说白了,Protocol Buffers 是它的“通用语言”,Service 定义是它的“接口契约”,而四种流模式则是它的“通信姿势”。

我在项目里第一次用 gRPC 时,直接跳进代码写业务逻辑,结果后面才发现 Service 定义没写对,流模式也选错了——搞得客户端和服务端来回改了好几版。所以这一章我带你扎扎实实过一遍,后面踩坑的概率会小很多。

2.1 Protocol Buffers:数据序列化的“普通话”

Protocol Buffers(简称 Protobuf)是 gRPC 默认的序列化协议。它不像 JSON 那样一眼能看出结构,但胜在体积小、解析快。我自己测过,同样的数据结构,Protobuf 序列化后的字节数只有 JSON 的 1/3 左右,性能差距在 5 倍以上。

核心要点:你只需要写一个 .proto 文件,然后 protoc 编译器会自动生成各语言的结构体代码和序列化/反序列化逻辑。

来看一个最简单的例子:

syntax = "proto3";

message HelloRequest {
  string name = 1;
  int32 age = 2;
}

message HelloResponse {
  string greeting = 1;
}

这里 message 关键字定义数据结构,每个字段后面跟着一个数字编号(Field Number),用于二进制编码时的标识。编号 1~15 占用 1 个字节,16~2047 占用 2 个字节,所以我一般把最常用的字段放在 1~15 里——这是一个小优化,但积少成多。

我个人的习惯:字段编号一旦发布就不要再改,否则会破坏兼容性。如果非要改,就新增字段并废弃旧的,用 reserved 关键字锁住编号。

Protobuf 支持多种标量类型:int32、int64、float、double、bool、string、bytes 等。还有枚举(enum)和嵌套 message,这里不展开讲,后面用到时会提。

2.2 Service 定义:接口的“合同文件”

有了数据结构,还需要定义服务(Service)。Service 就是一组 RPC 方法的集合,有点像 Java 里的接口或者 Python 里的抽象类。

看代码:

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

这定义了一个简单的 Unary(一元)RPC:客户端发一个请求,服务端返回一个响应。但 gRPC 不止这一种,关键就在于 rpc 关键词前面的修饰符。在不加任何修饰符时,默认就是 Unary。

我曾经踩过的坑:Service 定义中方法名不要用下划线,虽然语法上允许,但生成的代码在有些语言里会变成奇怪的命名,比如 Java 里方法名会转成驼峰,下划线后的字母大写,容易搞混。统一用大驼峰最稳妥。

Service 定义还可以依赖其他 proto 文件,用 import 导入,这样可以把公共的 message 抽到单独的 proto 里复用。比如我会把所有错误码定义放在 common.proto 中,所有 service 文件都 import 它。

2.3 四种流模式详解

这是 gRPC 最灵活的地方。理解透了,你就能针对不同场景选出最合适的通信模式。

  • Unary RPC(一元):客户端一次请求,服务端一次响应。最常用,简单直接,适合大多数查询类接口。
  • Server Streaming RPC(服务端流):客户端发一个请求,服务端可以连续返回多条消息。比如订阅实时行情,客户端只请求一次,服务端不停推送价格更新。
  • Client Streaming RPC(客户端流):客户端连续发送多条消息,服务端只回复一次。适合上传大批量数据,比如日志聚合。
  • Bidirectional Streaming RPC(双向流):双方都可以连续发送消息,互不等待。比如聊天应用、协同编辑。

对应的 proto 定义长这样:

service StreamService {
  // Unary
  rpc UnaryCall (Request) returns (Response);
  // Server Streaming
  rpc ServerStreaming (Request) returns (stream Response);
  // Client Streaming
  rpc ClientStreaming (stream Request) returns (Response);
  // Bidirectional Streaming
  rpc BidirectionalStreaming (stream Request) returns (stream Response);
}

注意 stream 关键字的位置:用在参数前面表示客户端流,用在返回值前面表示服务端流,两边都用就是双向流。

选择建议:如果你不确定用哪种,先选 Unary。真的需要实时推送时,再考虑服务端流。客户端流和双向流一般只在数据量大或交互复杂时才用,因为编程模型复杂,调试也麻烦。

2.3.1 四种模式典型应用场景对比

模式 消息方向 适合场景 注意事项
Unary 1 req → 1 resp 查询用户信息、下单 简单,但长耗时请求会阻塞
Server Streaming 1 req → N resp 新闻推送、日志流 注意背压控制,防止客户端被冲垮
Client Streaming N req → 1 resp 文件上传、传感器数据汇总 客户端需要处理好分片和重传
Bidirectional Streaming N req ↔ N resp 实时聊天、协作白板 消息顺序和全双工管理较复杂

我在做一个实时数据平台的初期,错误地把应该用 Server Streaming 的场景用成了 Unary 轮询。客户端每隔 1 秒发起一次请求,服务端负载飙升,数据延迟还大。后来改成服务端流,连接一直在,数据一有变化就推送,性能好了不止一个量级。嗯,选对模式很重要。

2.3.2 流式 RPC 的底层机制

gRPC 在 HTTP/2 上实现流式通信,每个 RPC 对应一个独立的 HTTP/2 Stream。这意味着多个流可以复用同一个 TCP 连接,不会有队头阻塞(但 HTTP/2 的帧级队头阻塞还是存在,不过影响比 HTTP/1.1 小得多)。

每个流上可以连续发送多个 protobuf 消息,消息之间没有固定分隔符,通过 HTTP/2 的 DATA 帧区分边界。所以流式传输的性能瓶颈主要在序列化/反序列化速度和 HTTP/2 内部的流控上。

性能调优建议:如果你用流式传输大量小消息,考虑批量打包成一个大的 message 再发送,减少帧开销。我测试过,对 100 字节以下的消息,打包成 10 个一组能提升 30% 的吞吐量。

2.4 本章知识结构总览

下面这张图把本章的三个核心概念串起来了:

gRPC 核心概念结构图 gRPC 核心概念结构 gRPC Protocol Buffers Service 定义 四种流模式 Unary Server Streaming Client Streaming Bidirectional 图中的四种流模式都基于 Service 定义中的 rpc 方法,并由 Protocol Buffers 序列化消息

从图里可以看到,Protocol Buffers 定义了消息格式,Service 定义了方法签名,而四种流模式决定了方法如何通信。三者缺一不可。

一句话总结本章:写 .proto 文件时,先设计好 message,然后写 service,最后根据业务需求选择合适的 streaming 模式。我把这称为“从数据到接口再到交互”三步法,每次新建 gRPC 服务都这么做,基本不会乱。