今天聊一个我最近在项目里反复用到的通信模式:gRPC双向数据流。说实话,我在刚接触gRPC的时候,总觉得四种调用方式里最难理解的就是这种“客户端和服务端同时收发”的形态,但真正动手跑通一个demo之后,会发现它比想象中简单,而且非常适合实时推送、聊天、中间层透传这类场景。
这篇文章的目标很明确:从一个proto文件开始,完整走一遍双向流的服务端和客户端代码,再把运行结果、报错排查、进阶调优都讲清楚。适合刚接触gRPC、想快速体验双向流而没有被长篇文档劝退的人,也适合已经在用一元调用、想扩展实时通信能力的开发者。
1. 双向流到底是什么:先认全四种接口形态
1.1 一目了然的四种调用方式
要理解双向数据流,先得把gRPC的四种接口形态放在一起看。它们全部由proto文件里的关键字组合出来,没有任何特殊魔法:
| 调用方式 | proto 写法 | 数据方向 | 典型场景 |
|---|---|---|---|
| 一元调用 | rpc GetUser(UserReq) returns (UserResp) | 请求一次,响应一次 | 常规查询接口 |
| 服务端流 | rpc WatchLog(LogReq) returns (stream LogResp) | 客户端发一次,服务端持续返回 | 日志订阅、状态推送 |
| 客户端流 | rpc Upload(stream Chunk) returns (UploadResp) | 客户端持续发送,服务端最终统一响应 | 文件上传、批量上报 |
| 双向流 | rpc Chat(stream Msg) returns (stream Msg) | 双方都可随时收发 | 实时聊天、协同编辑、流式透传 |
双向流在代码上只比前几种多了一个“两端都加stream”的关键字,但语义上有一层关键变化:调用双方从“请求-响应”的问答模式,变成了一个可以被两端持续写入和读取的双向通道。
1.2 双向流背后做了什么
很多人第一次接触双向流时会有个错觉:以为它就是建立了一条TCP连接,然后双方像普通Socket一样往里面写数据。实际不是这样。gRPC的双向流跑在HTTP/2之上,HTTP/2天然支持多路复用和双向帧传输。一条长连接上可以同时存在多个独立的流,每个流内部又分为发送方向(客户端到服务端)和接收方向(服务端到客户端)。
这个细节解释了双向流里一个非常经典的语义:半关闭(half-close)。客户端可以把“发送方向”关掉,但“接收方向”依然是打开的,仍然能继续收消息。很多新手在这里绕不出来,总觉得CloseSend之后整个调用就结束了。后面客户端实现部分我会专门演一遍。
从工程角度看,gRPC双向流其实只是在HTTP/2的框架下,把两个方向的独立数据流组合成了一个逻辑上的双向通道。数据仍然是协议带格式化的二进制帧,仍然受流控窗口约束,并不会因为叫“双向流”就变成裸TCP那种随心所欲的全双工。
1.3 什么场景真正需要双向流
判断一个场景要不要用双向流,我自己的标准很简单:如果双方需要“协作式地连续交换数据”,而不是“巧合地连续发出多个请求”,那就值得用。
举几个实际例子:
- 实时聊天:客户端发出自己的消息,同时持续接收对方消息,两种数据交织在同一通道上。
- AI对话流式输出:用户边输入边修改问题,服务端边接收边生成回答,而不是必须等用户把一整段话说完再开始处理。
- 实时监控面板:客户端发起订阅请求后,服务端持续推送指标数据,同时客户端还能中途追加或取消订阅项。
- 中间层透传:网关在一个流里不断接收下游数据,又不断把处理结果写回去,避免频繁建连。
每次看到有人用一元调用“轮询”硬撑实时场景,我都想劝他试试双向流。轮询的问题不只是延迟高,还有频繁建连、服务端无法主动下发、状态难以维护这些连锁反应。双向流的优势是:一次建连、双向复用、服务端可以随时主动推。
2. 快速准备:环境、proto定义与代码生成
2.1 工具链安装
这次体验用Go语言,主要原因有两个:一是gRPC官方对Go的支持非常成熟,代码生成一条命令搞定;二是Go的并发模型特别适合展示双向流里“一收一发同时进行”的编程方式。
需要准备三样东西:protoc编译器、protoc-gen-go插件、protoc-gen-go-grpc插件。
- protoc是pb编译器本体,负责把.proto文件解析并生成目标语言代码。
- protoc-gen-go负责生成消息结构体(Message)的Go代码。
- protoc-gen-go-grpc负责生成Service接口的客户端、服务端代码。
后两个插件名字长,容易搞混,但缺一不可。安装方式:
# protoc 直接走系统包管理器 apt install protobuf-compiler # 或者 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安装完确认一下PATH环境变量里包含Go的bin目录,否则后面protoc会报plugin not found。我常用go env GOPATH查看路径,然后把$(go env GOPATH)/bin加进PATH,这个细节能省掉很多排查时间。
2.2 定义第一个双向流接口
为了不引入业务复杂度,我用一个聊天接口作为例子。它的路由定义非常直白:进入流之后,客户端任意发消息,服务端每收到一条就回一条。
创建一个chat.proto文件:
syntax = "proto3"; package demo; option go_package = "demo/bidirectional"; service ChatService { rpc Chat(stream ChatMessage) returns (stream ChatMessage); } message ChatMessage { string user = 1; string content = 2; int64 timestamp = 3; }接口设计上最重要的就一句话:方法的请求和响应都用了stream关键字。option go_package也很关键,它决定了生成代码的Go包路径,这里先记下demo/bidirectional,后面编译和导入都会用到。
2.3 生成代码:protoc 命令详解
在chat.proto所在的目录执行:
protoc --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ chat.proto执行完目录下会多出两个文件:
bidirectional/ ├── chat.pb.go # 消息结构体 └── chat_grpc.pb.go # 服务端/客户端接口paths=source_relative的意思是生成文件放在与proto相同的相对路径下,而不是按照go_package全路径创建目录。这样目录结构比较干净,不会被一串完整导入路径糊一脸。
两个生成文件里的内容可以大致扫一眼,不需要全部读懂。重点看chat_grpc.pb.go里生成了什么,刷新一下认知:
- 服务端接口
ChatServiceServer,要求实现一个Chat(stream ChatService_ChatServer) error方法。 - 客户端接口
ChatServiceClient,会得到一个Chat(context.Context, ...) (ChatService_ChatClient, error)方法。 ChatService_ChatServer和ChatService_ChatClient分别封装了服务端视角和客户端视角的流对象,各自带Send和Recv方法。
从这一步开始,一段代码就同时跑在了两端。
3. 服务端实现:循环 Recv 与 Send 的完整逻辑
3.1 服务端完整代码
我的工程结构是分目录的:生成的pb代码放在bidirectional/,服务端放server/main.go,客户端放client/main.go。这样两个package main互不干扰,运行也清楚。
// server/main.go package main import ( "log" "net" "time" "google.golang.org/grpc" pb "demo/bidirectional" ) // chatServer 实现 pb.ChatServiceServer 接口 type chatServer struct { pb.UnimplementedChatServiceServer } // Chat 是双向流的核心方法 func (s *chatServer) Chat(stream pb.ChatService_ChatServer) error { for { // 接收客户端消息 msg, err := stream.Recv() if err != nil { // 客户端正常关闭发送方向时,会得到 io.EOF // 直接返回 nil,表示整个流正常结束 if err.Error() == "EOF" { log.Println("客户端已关闭发送方向,流结束") return nil } log.Printf("接收出错: %v", err) return err } log.Printf("收到来自 %s 的消息: %s", msg.User, msg.Content) // 回一条消息给客户端 replyErr := stream.Send(&pb.ChatMessage{ User: "server", Content: "收到: " + msg.Content, Timestamp: time.Now().Unix(), }) if replyErr != nil { log.Printf("发送出错: %v", replyErr) return replyErr } } } func main() { lis, err := net.Listen("tcp", ":50051") if err != nil { log.Fatalf("监听失败: %v", err) } s := grpc.NewServer() pb.RegisterChatServiceServer(s, &chatServer{}) log.Println("双向流服务端启动,监听 :50051") if err := s.Serve(lis); err != nil { log.Fatalf("服务异常退出: %v", err) } }这里有个非常容易踩的细节:stream.Recv()返回的io.EOF在go-grpc版本里是真正返回io.EOF这个错误,直接判断err == io.EOF就可以。我写成err.Error() == "EOF"是为了演示日志里常见表现,实际工程中更推荐直接用io.EOF做相等判断,同时把io包引进来。为什么这里不直接写io.EOF?因为在打印日志时,用户看到的是字符串EOF,很多初次调试的人看到这段日志会误以为是出错了。但代码里判空就用标准写法,不要图省事。
服务端结束一个双向流有三种常见方式,需要区分清楚:
- 正常结束:客户端先CloseSend,服务端Recv到EOF后return nil。
- 异常终止:服务端Recv或Send出错,直接return err,整个流以错误状态结束。
- 主动结束:服务端业务逻辑判断不需要继续了,直接return一个状态码,客户端会收到对应错误。
3.2 服务端必须处理的两个终止条件
第一个终止条件就是stream.Recv()返回错误。这个错误可能是客户端正常关闭发送方向带来的EOF,也可能是网络异常、上下文取消、对端RST等。把EOF和普通错误区分开是很有必要的:EOF是“正常的半关闭”,其余错误应该尽量把原始错误返回给调用方,让对端知道发生了什么。
第二个终止条件是上下文的取消。双向流本质上是一个长生命周期调用,客户端随时可能因为超时、用户退出等原因取消Context。服务端如果只闷头干活不看上下文状态,容易出现“客户端已经跑了,服务端还在Send”的尴尬局面。判断方法很简单,用stream.Context().Done():
select { case <-stream.Context().Done(): log.Println("客户端已取消上下文,流终止") return stream.Context().Err() default: // 继续处理业务 }通常我会在主循环里周期性检查一次Context,尤其是处理耗时较长的任务时,这个习惯能避免大量悬空流占着资源不释放。gRPC虽然会在客户端断连后由服务端底层感知,但自己在业务层尽早退出,处理释放更干净。
3.3 服务端边收边发的并发模型
上面的示例代码是“收一条、回一条”的串行模型,逻辑最简单,教学友好。但真实场景没这么听话,真实场景往往是:客户端一边发,服务端一边同时要处理主动推送、广播、定时任务等。这种需求下,服务端通常会拆成两个角色:一个角色专管Recv,一个角色专管Send。
由于gRPC的流对象允许“一个goroutine同时只做一次Send”和“另一个goroutine同时只做一次Recv”,所以可以这样组织:
func (s *chatServer) Chat(stream pb.ChatService_ChatServer) error { recvCh := make(chan *pb.ChatMessage, 16) errCh := make(chan error, 1) // goroutine A:只负责收 go func() { for { msg, err := stream.Recv() if err != nil { errCh <- err close(recvCh) return } recvCh <- msg } }() // goroutine B:只负责发 go func() { for msg := range recvCh { // 这里可以塞业务逻辑、过滤、广播 if sendErr := stream.Send(&pb.ChatMessage{ User: "server", Content: "收到: " + msg.Content, }); sendErr != nil { errCh <- sendErr return } } }() select { case err := <-errCh: if err == io.EOF { return nil } return err } }这个模式的要点是:Recv和Send分别独占一个goroutine,避免了并发调用同一个方向操作带来的不确定行为。至于把Recv到的消息转成要下发的内容,中间可以再插一个处理channel,结构可以从一收一发扩展成“收-处理-发”的流水线。
注意:grpc-go的Stream对象有一条并发约定——允许一个goroutine在调用Send、另一个goroutine同时在调用Recv,但绝不允许两个goroutine同时Send,或者两个goroutine同时Recv。超出这个约定轻则消息顺序混乱,重则直接panic。
4. 客户端实现:发送与接收并行跑起来
4.1 客户端完整代码
客户端是体验双向流最直观的部分。代码里我开了两个执行线:主线负责连续发送,另一个分支负责持续接收服务端回应。这样发送和接收互不阻塞,能清楚看到一条流里数据同时双向流动。
// client/main.go package main import ( "context" "io" "log" "strconv" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" pb "demo/bidirectional" ) func main() { // 如果用老版本 grpc-go,可用 grpc.Dial conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { log.Fatalf("连接失败: %v", err) } defer conn.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() stream, err := pb.NewChatServiceClient(conn).Chat(ctx) if err != nil { log.Fatalf("创建流失败: %v", err) } // 接收协程:一直读服务端下发内容 go func() { for { reply, err := stream.Recv() if err == io.EOF { log.Println("服务端关闭了流") return } if err != nil { log.Printf("接收出错: %v", err) return } log.Printf("来自服务端 %s: %s", reply.User, reply.Content) } }() // 主线:连续发送 5 条消息 for i := 1; i <= 5; i++ { msg := &pb.ChatMessage{ User: "client", Content: "第" + strconv.Itoa(i) + "条消息", Timestamp: time.Now().Unix(), } if err := stream.Send(msg); err != nil { log.Fatalf("发送失败: %v", err) } log.Printf("客户端发送: %s", msg.Content) time.Sleep(500 * time.Millisecond) } // 半关闭:告诉服务端“我发完了”,但接收方向不关闭 stream.CloseSend() // 给接收协程一点时间处理完剩余的响应 select { case <-time.After(2 * time.Second): case <-ctx.Done(): } }运行前确认go.mod里module名是demo,pb包的导入路径是demo/bidirectional。如果目录结构不同,这里的import路径要跟着改,否则编译器会直接报找不到包。
4.2 CloseSend 不是关闭连接,而是“半关闭”
这是双向流里最容易搞混的操作。CloseSend()之后,客户端就再也不能往流里发消息了,但接收方向依然是开着的,服务端依然可以继续向客户端推数据。
这正好对应了HTTP/2的流方向独立语义。发完消息之后,我在代码里让主流程等待2秒,就是为了让服务端把最后几条回应都发完。如果直接defer cancel()退出,接收协程可能来不及读完最后的响应,日志会少几条。
另外要理解:客户端不能自己主动关闭“接收方向”。接收方向要关闭,通常要么等服务端也结束流,要么取消Context让整个调用终结。所以客户端常规的收尾流程就是:CloseSend + 继续Recv到EOF或错误,或者主动取消Context强制关闭整个双向流。
4.3 客户端到底能不能并发 Send
很多人在做类似聊天上报的场景时,会在多个goroutine里直接调用stream.Send,结果发现消息顺序严重错乱,甚至运行时直接报错。
结论是:不能并发Send。grpc-go的Stream.SendMsg不是并发安全的。并发调用时,多个消息可能同时进入HTTP/2帧队列,顺序完全不受控制。
正确做法是把所有需要发送的请求通过一个channel收拢,由一个发送goroutine统一调用stream.Send:
sendCh := make(chan *pb.ChatMessage, 10) go func() { for msg := range sendCh { if err := stream.Send(msg); err != nil { log.Printf("发送失败: %v", err) return } } }() // 任意业务goroutine往 sendCh 写消息即可 sendCh <- &pb.ChatMessage{User: "client", Content: "hello"}这个模式对服务端同样适用:Send方永远只保留一个执行入口,其他业务模块通过channel把消息投递进来。这既解决了并发安全问题,也天然做了缓冲,一举两得。
5. 运行验证与常见问题排查实录
5.1 一次完整的运行输出
开两个终端,一遍启动服务端,一遍启动客户端,实测效果大致如下。
服务端输出:
2025/05/06 14:30:01 双向流服务端启动,监听 :50051 2025/05/06 14:30:02 收到来自 client 的消息: 第1条消息 2025/05/06 14:30:03 收到来自 client 的消息: 第2条消息 2025/05/06 14:30:04 收到来自 client 的消息: 第3条消息 2025/05/06 14:30:05 收到来自 client 的消息: 第4条消息 2025/05/06 14:30:06 收到来自 client 的消息: 第5条消息 2025/05/06 14:30:06 客户端已关闭发送方向,流结束客户端输出:
2025/05/06 14:30:02 客户端发送: 第1条消息 2025/05/06 14:30:02 来自服务端 server: 收到: 第1条消息 2025/05/06 14:30:03 客户端发送: 第2条消息 2025/05/06 14:30:03 来自服务端 server: 收到: 第2条消息 2025/05/06 14:30:04 客户端发送: 第3条消息 2025/05/06 14:30:04 来自服务端 server: 收到: 第3条消息 2025/05/06 14:30:04 客户端发送: 第4条消息 2025/05/06 14:30:04 来自服务端 server: 收到: 第4条消息 2025/05/06 14:30:04 客户端发送: 第5条消息 2025/05/06 14:30:04 来自服务端 server: 收到: 第5条消息因为每条消息打印顺序受两端打印时机影响,看起来“回包”有时会乱,这是正常的,实际数据帧没有错。如果想精确看到消息配对效果,可以把每条消息里带上一个本地递增序号,这样在日志里能确认顺序。
5.2 报错速查表
我在实际体验和主打排查中,遇到频率最高的几类报错,整理成一张速查表:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
protoc-gen-go: program not found | Go插件没装或不在PATH | 确认$(go env GOPATH)/bin在PATH里,重新go install |
connection closed before server preface received | 服务端没启动,或端口不通 | 先启动服务端,telnet一下端口确认监听 |
rpc error: code = DeadlineExceeded | 业务处理超过Deadline | 用context.WithTimeout适当拉长超时,或排查服务端阻塞点 |
received message larger than max (5404321 vs. 4194304) | 单条消息超过4MB默认限制 | 服务端和客户端同时调大MaxRecvMsgSize |
grpc: the client connection is closing | 在连接关闭后继续调用 | 检查defer conn.Close()与业务goroutine的先后关系 |
| 客户端一直卡在Recv不返回 | 服务端还在维持流,客户端半关闭后没等到对方结束 | 检查服务端逻辑是否return,或主动给整个调用设超时 |
5.3 三个高频问题细说
第一个高频问题:插件找不到。多数人装完protoc-gen-go和protoc-gen-go-grpc后,马上跑protoc,结果报program not found。原因十有八九是PATH里没有Go bin目录。执行which protoc-gen-go如果没结果,就把go env GOPATH的值加上/bin再试。这类问题不算难,但特别容易在第一轮就把人卡住。
第二个高频问题是消息超过4MB。gRPC默认限制单条消息不超过4MB,双向流里这个限制依然存在。很多人在流式上传、批量数据下发时突然看到ResourceExhausted报错,就是这个原因。解决要两端一起改:
// 服务端 grpc.NewServer(grpc.MaxRecvMsgSize(16 << 20)) // 客户端 grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(16<<20)))值得提醒的是:发送方向也要注意。如果客户端发送超大消息,客户端会先被自己的MaxCallSendMsgSize拦下来。所以遇到消息大小相关报错,先看清是哪个方向触发的限制,再决定改服务端还是客户端。
第三个高频问题:客户端等待服务端响应,但服务端一直没有返回。双向流里Recv都是阻塞的,如果没有明确的生命周期约定,服务端可能一直维持着流不退出,客户端就一直等。排查思路是:给整个调用设置合理的Deadline,同时明确流结束的协议。比如约定“服务端收到EOF后必须回完数据并结束流”,或者“客户端发完消息后,服务端在x秒内必须返回”。没有明确结束条件的长流,既是功能隐患,也是资源泄漏温床。
6. 从快速体验走向真实项目:几点进阶建议
6.1 别忽略流控和连接保活
双向流是长连接,长连接最怕的就是中间链路断掉后两端感知不到。尤其是在云上网络环境里,NAT网关的空闲超时会悄悄把连接回收,但两端的gRPC并不知道,直到下一次Send或Recv才突然报错。
常用做法是开启keepalive,让两端定期发Ping帧维持连接:
// 服务端 import "google.golang.org/grpc/keepalive" s := grpc.NewServer( grpc.KeepaliveParams(keepalive.ServerParameters{ Time: 10 * time.Second, Timeout: 5 * time.Second, }), ) // 客户端 grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 10 * time.Second, Timeout: 5 * time.Second, PermitWithoutStream: true, }), )PermitWithoutStream: true表示即使当前没有活跃的流也允许发送保活心跳。这对双向流很关键,因为客户端可能建立一个流后,长时间没有数据往来,如果没有这个参数,连接仍然可能被回收。
流控方面,双向流受HTTP/2流控窗口影响。默认窗口是足够常规需求的,但如果一边高速推送、另一边处理缓慢,gRPC的流控会自动让发送方停下来等接收方消费。工程上不必刻意调整窗口,但要理解:Recv慢会反馈到对端的Send阻塞,这是正常现象,不是程序卡死。调试时可以观察是否大量时间消耗在阻塞Send上。
6.2 为你的流定义心跳与业务消息模型
把双向流真正应用到生产,不建议直接把业务对象放在ChatMessage里来回传。我习惯在一个流里定义一个统一的消息信封,里面带消息类型、序列号、发送时间、载荷。这样后续扩展能力非常方便:协议只定义一种双向流RPC,业务语义完全由消息类型区分。
比如可以这样改造proto:
message StreamMessage { string msg_type = 1; // "heartbeat" / "chat" / "ack" string msg_id = 2; // 消息序号,便于对账 int64 timestamp = 3; bytes payload = 4; // 具体业务内容由 msg_type 决定 } service StreamService { rpc Stream(stream StreamMessage) returns (stream StreamMessage); }心跳消息也用这个信封,通过对端返回的Ack检测链路健康度。带上msg_id之后,还能做乱序检测和重传去重。这个设计思路适用于几乎所有实时通信类需求,是比示例里简单的ChatMessage更抗打的形态。
6.3 更进一步的扩展方向
快速体验跑通之后,可以继续往这几个方向挖一挖,它们都是双向流项目里实打实会遇到的增强点:
- 消息广播与多路复用:在一个服务端流上管理多个客户端的订阅,按用户维度路由下发。
- 拦截器统计与限流:用gRPC拦截器统一统计双向流消息量、耗时、在线数,给长连接接入提前做限流。
- 断线重连与消息补齐:维护本地消息序号,断线重连后把断点期间的遗漏消息补发回来。
- 多路数据汇合:把服务端的普通接口调用结果、定时任务事件、外部消息队列事件全部汇合成一个Send入口,写到客户端。
这些能力没有一项是双向流强制的,但你会发现双向流真实项目里都会碰到。
我个人在实际项目中的体会是,gRPC双向流最大的价值不仅仅是“服务端主动推送”,而是它为两端建立了一种长生命周期、多消息、可协商的通信模型。在习惯了它之后,再回到“一问一答”的模式,你会明显觉得少了点灵活。如果你也在做实时推送、聊天、协同编辑、流式AI交互这类功能,建议从本文的demo开始,20分钟内跑通一次,再去处理你真实业务里的消息模型与流生命周期,会顺手非常多。