做过几次 gRPC 流式接口之后,我最大的感受是:只要把 proto 里那几个 stream 关键字的位置搞明白,后面写代码基本就是照着套路填。但偏偏很多人卡在第一步——不知道四种流式模式分别在什么场景用,或者照着官方示例跑通了 hello world,一上真实业务就各种踩坑:消息太大被拒、双向流莫名其妙断连、goroutine 泄漏。这篇文章我就拿一个完整的示例项目来讲,从 proto 定义到四种流式模式的代码实现,再到我实际调试中遇到的问题和排查思路,一次性把这套东西讲透。
这个示例的核心是一个数据服务,包含三组接口:服务端流式推送行情、客户端流式批量上报指标、双向流式实时聊天。覆盖了三种真正要用的流式模式(一元流式 + 这三种,gRPC 一共四种组合,其中只有"一元请求 + 一元响应"不涉及流)。无论你是刚接触 gRPC 的新手,还是已经写过一元 RPC、想进一步做实时通信的开发者,这篇文章都能给你一套可以直接抄的代码骨架,以及踩过坑之后才知道的那些细节。
1. 流式通信到底解决了什么问题:从轮询到推送的演进逻辑
先别急着写代码。很多人在选型阶段就搞错了方向——明明业务只需要客户端主动拉一次数据,非得上服务端流式;或者实时性要求根本不高的场景,硬要上双向流,结果把服务端连接管理和消息顺序处理搞得一团糟。搞清楚四种模式各自解决什么问题,比会写代码重要得多。
1.1 一元 RPC 的瓶颈在哪里
普通的 gRPC 调用(Unary RPC)和 HTTP POST 本质上没有太大区别:客户端发一个请求,服务端处理完返回一个响应,连接就完成了这次使命。这种模式的问题是,如果客户端需要持续获取数据,就得不停地发请求。比如一个股票行情客户端,每秒要刷新一次价格,如果用一元 RPC,客户端每秒得发起一次完整的请求-响应周期,每次都要经历序列化、网络传输、反序列化、业务处理。
这里面的浪费是双重的。第一是网络开销,虽然 HTTP/2 有连接复用,但每个请求都有自己的头部和元数据,高频小请求的头部开销占比会非常难看。第二是延迟,无论服务端处理多快,客户端拿到的数据至少隔了一个 RTT(往返时间),因为客户端必须先发出请求才能收到响应。行情数据这种场景,延迟直接决定了数据的可用性。
1.2 四种流式模式的分工
gRPC 基于 HTTP/2 的流式能力,把通信模式扩展成了四种,划分的依据就两个维度:请求是不是流的,响应是不是流的。
| 模式 | 请求形态 | 响应形态 | 典型场景 |
|---|---|---|---|
| 一元 RPC | 单个消息 | 单个消息 | 普通查询、写入 |
| 服务端流式 | 单个消息 | 消息流 | 订阅推送、日志下发、分页拉取 |
| 客户端流式 | 消息流 | 单个消息 | 批量上传、聚合计算、文件传输 |
| 双向流式 | 消息流 | 消息流 | 实时聊天、协同编辑、游戏同步 |
判断自己该用哪种模式,有一个很朴素的标准:如果客户端需要的数据不是一次能给完的,且产生数据的时间点由服务端决定,用服务端流式;如果客户端手里有一批数据要发给服务端,服务端攒齐了再统一回应,用客户端流式;如果双方要持续对话,消息发生的顺序和时间都不确定,用双向流式。
我见过最典型的误用是拿服务端流式当长连接心跳用,客户端并不关心流里的每条消息,只是想让连接保持活跃。这种情况下 better way 是 gRPC 的 keepalive 机制,而不是业务层的流式接口。后面我会专门讲 keepalive 配置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 示例项目的设计与工具链准备
这个示例我们不搞花架子,就做一个真实可跑的数据服务。我选择了三个接口分别演示三种流式模式,同时把它们放在同一个 proto 文件里,这样你还能看到不同接口风格在同一个服务中共存的写法。
2.1 proto 文件定义
新建 proto/data.proto,内容如下:
proto复制syntax = "proto3";
package streamdemo;
option go_package = "streamdemo/proto";
// 行情订阅请求
message SubscribeRequest {
repeated string symbols = 1; // 股票代码列表
}
// 行情报价
message Quote {
string symbol = 1;
double price = 2;
int64 timestamp = 3;
}
// 单条指标数据
message Metric {
string name = 1;
double value = 2;
map<string, string> labels = 3;
}
// 批量上报的结果
message UploadSummary {
int32 total = 1;
int32 failed = 2;
}
// 聊天消息
message ChatMessage {
string user = 1;
string text = 2;
int64 timestamp = 3;
}
service DataService {
// 服务端流式:订阅行情,服务端持续推送
rpc SubscribeQuotes(SubscribeRequest) returns (stream Quote);
// 客户端流式:批量上报指标,客户端持续发送
rpc UploadMetrics(stream Metric) returns (UploadSummary);
// 双向流式:实时聊天
rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
三个接口摆在一起,差异一目了然:stream 关键字出现在 returns 后面就是服务端流式,出现在 rpc 的请求参数位置就是客户端流式,两边都有就是双向流式。写 proto 的时候要养成一个好习惯:每个字段都写注释,特别是 repeated 字段和 map 类型,因为生成的 Go 结构体里它们的类型不一样,提前标注清楚能省很多沟通成本。
2.2 工具链版本选择:这是新手最容易翻车的地方
生成 gRPC 代码需要三个东西:protoc 编译器、protoc-gen-go 插件、protoc-gen-go-grpc 插件。版本匹配是最大的坑。
我推荐直接用最新稳定版,然后统一用 go install 安装插件:
bash复制# 安装 protoc(macOS 示例)
brew install protobuf
# 安装 Go 插件
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
这里有个非常关键的版本纪律:protoc-gen-go 和 protoc-gen-go-grpc 必须同时安装,且 grpc 依赖库的版本需要和插件生成代码的版本兼容。我踩过的坑是:用 protoc-gen-go-grpc@v1.3.0 生成的代码,项目里却引用了 google.golang.org/grpc@v1.4x 的老接口,编译直接报错,提示 ServerStream 接口多了个方法没实现。解决方案是生成代码时锁定 go.mod 里的 grpc 版本,或者反过来,升级插件重新生成。
确认 PATH 里能找到插件:
`
