导语
做微服务时,最常遇到的不是"怎么调一次接口",而是"怎么持续地传数据":服务端要不停推送行情、客户端要批量上报日志、两边要像聊天一样来回发消息。用传统的"一问一答" HTTP 接口去硬套,要么轮询刷屏,要么把请求体撑成巨无霸。
gRPC 把这类需求做成了一等公民:一套 .proto 定义,靠一个 stream 关键字就能在四种通信模式间切换。这篇文章把一元、服务端流、客户端流、双向流四种模式,从定义对照到可运行的 Go 代码逐一拆开,并给出选型与踩坑清单。
声明:本文基于个人使用体验,非商业推广。
摘要 :gRPC 的流式能力建立在 HTTP/2 多路复用与 protobuf 之上。一条.proto用stream关键字的位置即可定义四种 RPC 模式,Go 代码通过Send/Recv/CloseAndRecv等方法收发消息。本文给出完整.proto与两段可运行示例,并总结按"谁持续产生数据"选型的决策思路与四个常见坑。
一、为什么 gRPC 能"流式":HTTP/2 + protobuf 的底层支撑
要理解流式 RPC,先得看它脚下的两层地基。没有它们,"流式"只是概念,跑不起来。
HTTP/2 的多路复用是流式的前提。一条 TCP 连接上可以同时跑多个独立的、双向的"数据流(stream)",请求和响应不再是"一发一收"地排队,而是各自占用一个逻辑通道。这正是 gRPC 能做到全双工(同时收发)的根本原因------同一个连接上,客户端和服务端可以互不干扰地往对方的流里塞帧。

protobuf(Protocol Buffers) 在这里承担 IDL(接口定义语言,即声明服务与消息格式的契约)的角色。你在 .proto 文件里声明服务方法、请求与响应的消息结构,然后用 protoc 生成各语言代码。流式方向由一个 stream 关键字标记,位置不同,模式就不同。
流式相对传统"请求-响应"的价值,主要落在三类场景:
- 实时推送:服务端持续产生数据(行情、监控指标、事件流),需要主动往客户端推,而不是等客户端轮询。
- 批量上传 :客户端有大量小数据(日志、埋点、文件分片),逐个开连接成本高,用一条流循环
Send更省。 - 双向交互:像聊天、协同编辑,两边都在持续产生消息,需要一条流里同时收发。
剩下要做的,就是把"谁在持续产生数据"这件事,映射到正确的模式上。
二、四种 RPC 模式总览:一元 / 服务端流 / 客户端流 / 双向流
gRPC 官方(grpc.io 的 concepts 文档)明确定义了四种服务方法,区别只在"客户端发几次、服务端回几次"。
Unary(一元):最普通的一次请求一次响应,和我们熟悉的普通函数调用没两样。
Server streaming(服务端流式) :客户端发一次请求,服务端返回一串响应,用单独的流逐个推送,直到 io.EOF。
Client streaming(客户端流式):客户端用一条流循环发多个请求,发完关闭发送端,服务端收齐后回一个响应。
Bidirectional streaming(双向流式):两端各持一条流,同时收发,没有固定先后,典型如实时聊天。
一张维度对比表,把四种模式放一起看最清楚:
| 模式 | 客户端发送 | 服务端返回 | 典型场景 |
|---|---|---|---|
| 一元 Unary | 1 次 | 1 次 | 普通查询、命令调用 |
| 服务端流 Server streaming | 1 次 | N 次 | 行情推送、监控指标 |
| 客户端流 Client streaming | N 次 | 1 次 | 日志批量上报、文件上传 |
| 双向流 Bidirectional | N 次 | N 次 | 实时聊天、协同编辑 |

在 Go 的生成代码里,这四种模式的签名差异非常规律。一元方法长这样:
go
// 服务端:普通方法签名
Unary(context.Context, *HelloRequest) (*HelloReply, error)
// 客户端:多一个 ...grpc.CallOption
Unary(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
而流式方法返回的是"流接口"而不是 *Reply:服务端流拿到 grpc.ServerStreamingServer[T](核心方法 Send),客户端流拿到 grpc.ClientStreamingServer[T, R](核心方法 Recv + SendAndClose),双向流则是 grpc.BidirectionalStreamingServer[T, R](既有 Send 也有 Recv)。后面会直接用上这些方法。
注:泛型版流式接口需要较新的
protoc-gen-go-grpc(开启泛型生成),老版本生成的是非泛型、需要手写类型断言的SendMsg/RecvMsg。本文示例基于泛型版,更贴近官方现行文档。
三、先写 .proto:四种模式一行声明搞定
所有差异,源头都在 .proto 的 rpc 声明里。stream 放在请求参数前,就代表"客户端用流发";放在 returns 的响应前,就代表"服务端用流回"。两者都有,就是双向流。
下面这个 Greeter 服务把四种模式一次性写全,可以直接拿去生成代码:
protobuf
syntax = "proto3";
package greet;
// go_package 决定生成代码的包路径与包名
option go_package = "example.com/greet/greetpb;greetpb";
// 服务定义:四个 rpc 方法对应四种模式
service Greeter {
// 一元:请求、响应都无 stream
rpc Unary (HelloRequest) returns (HelloReply) {}
// 服务端流:returns 前加 stream
rpc GetStream (HelloRequest) returns (stream HelloReply) {}
// 客户端流:请求参数前加 stream
rpc PutStream (stream HelloRequest) returns (HelloReply) {}
// 双向流:两端都加 stream
rpc AllStream (stream HelloRequest) returns (stream HelloReply) {}
}
// 请求消息
message HelloRequest {
string name = 1;
int32 idx = 2;
}
// 响应消息
message HelloReply {
string message = 1;
int32 count = 2;
}
生成命令(需要装好 protoc、protoc-gen-go、protoc-gen-go-grpc):
bash
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
greet.proto
执行后,greetpb 包里会多出 GreeterServer 接口(服务端要实现)、GreeterClient 接口(客户端拿到)以及配套的流式接口。UnimplementedGreeterServer 也一并生成,推荐在自定义实现里嵌入它 ,这样未来 .proto 新增方法时不会编译失败。
四、实战一:一元 RPC 与服务端流式 RPC(Go 可运行)
先看最容易上手的两个:一元和服务端流。它们共用第三节的 .proto,区别只在服务端方法里要不要循环 Send。
一元调用 最直白:客户端传一个 ctx 和请求对象,阻塞等一个响应。服务端方法签名就是普通函数。而服务端流 里,服务端拿到 grpc.ServerStreamingServer[T],用 Send 循环推送,客户端用 Recv 一直接,直到服务端发完、收到 io.EOF 才退出循环。
服务端实现(只贴关键部分,完整文件还需 package、import 与 main 注册):
go
package main
import (
"context"
"fmt"
"io"
"log"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"example.com/greet/greetpb"
)
// 嵌入 UnimplementedGreeterServer,未实现的方法自动走默认实现
type greeterServer struct {
greetpb.UnimplementedGreeterServer
}
// 一元:普通函数,一进一出
func (s *greeterServer) Unary(ctx context.Context, req *greetpb.HelloRequest) (*greetpb.HelloReply, error) {
return &greetpb.HelloReply{Message: "hello " + req.Name, Count: req.Idx}, nil
}
// 服务端流:用 stream.Send 循环推送,直到结束
func (s *greeterServer) GetStream(req *greetpb.HelloRequest, stream grpc.ServerStreamingServer[greetpb.HelloReply]) error {
for i := 0; i < 5; i++ {
if err := stream.Send(&greetpb.HelloReply{
Message: fmt.Sprintf("push %s #%d", req.Name, i),
Count: int32(i),
}); err != nil {
return err
}
time.Sleep(500 * time.Millisecond) // 模拟持续产生的数据
}
return nil // 服务端正常返回即关闭流,客户端 Recv 收到 io.EOF
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
greetpb.RegisterGreeterServer(s, &greeterServer{})
log.Println("serving on :50051")
log.Fatal(s.Serve(lis))
}
客户端调用(同样只贴关键逻辑)。注意 NewClient 是较新 grpc-go 的入口,配 insecure 凭据用于本地无 TLS 调试;生产环境应换成真实证书。
go
package main
import (
"context"
"io"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"example.com/greet/greetpb"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
c := greetpb.NewGreeterClient(conn)
// 一元调用
reply, err := c.Unary(ctx, &greetpb.HelloRequest{Name: "world", Idx: 7})
if err != nil {
log.Fatal(err)
}
log.Println("unary:", reply.Message, reply.Count)
// 服务端流:循环 Recv 直到 io.EOF
stream, err := c.GetStream(ctx, &greetpb.HelloRequest{Name: "world"})
if err != nil {
log.Fatal(err)
}
for {
reply, err := stream.Recv()
if err == io.EOF {
break // 服务端发完,流关闭
}
if err != nil {
log.Fatal(err)
}
log.Println("recv:", reply.Message, reply.Count)
}
}
实际跑起来,你会先看到一行一元结果,然后每半秒收到一条服务端推送。这类模式适合监控指标、行情推送------服务端产生数据、客户端被动接收,且要记得处理客户端中途断开(通过 ctx 取消感知)。
五、实战二:客户端流式与双向流式 RPC(Go 可运行)
后两种模式里,收发都在同一条流上,稍微多一点并发处理。核心规则记住一句:单次流内,读和写各自串行,但读和写可以并发(官方文档明确说明单一流内读写串行、收发可并发)。
客户端流式 :客户端用 stream.Send 循环发,发完调 CloseSend() 关闭自己的发送端;服务端用 Recv 循环收,直到 io.EOF,最后用 SendAndClose 回一个响应。适合日志批量上报------客户端攒一批、服务端聚合一次。
双向流式 :两端各自用 goroutine 收发,用 sync.WaitGroup 等发送端收尾。典型场景是实时聊天、协同编辑,两边都在持续产生消息。

服务端实现(接着第四节的 greeterServer 结构体补充):
go
// 客户端流:服务端 Recv 到 io.EOF 后,SendAndClose 回一个响应
func (s *greeterServer) PutStream(stream grpc.ClientStreamingServer[greetpb.HelloRequest, greetpb.HelloReply]) error {
var total int32
for {
req, err := stream.Recv()
if err == io.EOF {
// 客户端已关发送端,汇总后一次性返回
return stream.SendAndClose(&greetpb.HelloReply{
Message: "received all",
Count: total,
})
}
if err != nil {
return err
}
total++ // 每收到一条请求计数,模拟聚合
_ = req
}
}
// 双向流:发送、接收各跑一条 goroutine,WaitGroup 管理生命周期
func (s *greeterServer) AllStream(stream grpc.BidirectionalStreamingServer[greetpb.HelloRequest, greetpb.HelloReply]) error {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 3; i++ {
if err := stream.Send(&greetpb.HelloReply{
Message: fmt.Sprintf("pong %d", i),
Count: int32(i),
}); err != nil {
return
}
time.Sleep(500 * time.Millisecond)
}
}()
// 主 goroutine 负责收,直到客户端关闭发送端
for {
req, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
log.Println("server got:", req.Name, req.Idx)
}
wg.Wait() // 等发送 goroutine 把剩余消息发完
return nil
}
客户端对双向流的调用,关键是给发送单独开 goroutine,否则"先发完再收"会和自己死锁:
go
// 需在文件顶部 import 块补充 import "sync"(本片段用到 sync.WaitGroup)
// 双向流客户端
stream, err := c.AllStream(ctx)
if err != nil {
log.Fatal(err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 3; i++ {
if err := stream.Send(&greetpb.HelloRequest{Name: "client", Idx: int32(i)}); err != nil {
log.Println("send err:", err)
return
}
time.Sleep(300 * time.Millisecond)
}
stream.CloseSend() // 发完关闭发送端,告知服务端
}()
for {
reply, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
log.Println("client recv:", reply.Message, reply.Count)
}
wg.Wait()
这里把"发送"放进 goroutine、CloseSend 也由它负责,主 goroutine 只管 Recv。如果改成串行------先循环 Send 再循环 Recv------而服务端也在等你的消息才回,就会互相等待,直接卡死。
六、选型与避坑:四种模式到底怎么选
模式本身不难,难在"这个需求该用哪种"。一句话决策:看谁在持续产生数据。

- 客户端问、服务端答,各一次 → 一元。绝大多数 CRUD、命令调用都够用,别什么都上流。
- 服务端持续产生、客户端被动收 → 服务端流。行情、监控、事件订阅。
- 客户端持续产生、服务端最后聚合一次 → 客户端流。日志批量上报、大文件分片上传。
- 两端都持续产生、来回交互 → 双向流。聊天、协同编辑、流式代理。
落到代码里,有几个反复踩的坑,提前记下来:
坑 1:用 context 管生命周期。 流的开始、取消、超时都由 context.Context 承载。客户端取消或超时,服务端 Recv/Send 会立刻返回错误,别忘了在 select 里监听 ctx.Done(),否则 goroutine 会泄漏。
坑 2:HTTP/2 流控与背压。 gRPC 底层有流控窗口,发送方写太快而被接收方来不及消费时,会自然阻塞在 Send 上。这不是 bug,是背压(backpressure,即下游来不及处理时上游自动减速)。别用 select + default 去"跳过"发送,那会丢消息。
坑 3:单次流内读写串行,收发要分 goroutine。 这正是第五节双向流用 WaitGroup 的原因。同一侧既 Send 又 Recv 还想串行等待,极易死锁。让收发各占一条 goroutine 最稳。
坑 4:永远检查 Send/Recv 的错误。 网络抖动、对端断开、context 取消都会通过错误返回,不能 if err != nil 就 continue。特别是 Recv 返回 io.EOF 应作为"正常结束"处理,其他错误要认真返回。
调试方面,三个工具够用:grpcurl 可以像 curl 一样命令行调用 gRPC 接口(支持服务端流,肉眼可见推送);grpclog 打开 gRPC 内部日志看连接与流状态;需要深究时,用 Wireshark 抓 HTTP/2 帧,能看到真正的多路复用与流帧时序。
总结
四种 RPC 模式本质上是同一个问题"谁在持续产生数据"的四个答案:一元(各一次)、服务端流(服务端持续)、客户端流(客户端持续)、双向流(两端持续)。它们的差别在 .proto 里只是一个 stream 关键字的位置,在 Go 代码里则是 Send/Recv/CloseAndRecv/CloseSend 的组合。
动手时记住三条:用 context 管流的生死、用 goroutine 分离双向流的收发、对每个 Send/Recv 错误都别放过。把这几条焊死,流式 RPC 就不会再是"能编译但跑起来就卡"的黑盒。
参考资料
- gRPC 官方概念文档:https://grpc.io/docs/guides/concepts/
- CSDN 实战参考(Go 四种流式模式实现):https://blog.csdn.net/landcc/article/details/148743160
© 2026 | 转载请注明出处