gRPC 流式通信实战:从一元调用到双向流,吃透四种 RPC 模式(Go 版)

导语

做微服务时,最常遇到的不是"怎么调一次接口",而是"怎么持续地传数据":服务端要不停推送行情、客户端要批量上报日志、两边要像聊天一样来回发消息。用传统的"一问一答" HTTP 接口去硬套,要么轮询刷屏,要么把请求体撑成巨无霸。

gRPC 把这类需求做成了一等公民:一套 .proto 定义,靠一个 stream 关键字就能在四种通信模式间切换。这篇文章把一元、服务端流、客户端流、双向流四种模式,从定义对照到可运行的 Go 代码逐一拆开,并给出选型与踩坑清单。

声明:本文基于个人使用体验,非商业推广。
摘要 :gRPC 的流式能力建立在 HTTP/2 多路复用与 protobuf 之上。一条 .protostream 关键字的位置即可定义四种 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:四种模式一行声明搞定

所有差异,源头都在 .protorpc 声明里。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;
}

生成命令(需要装好 protocprotoc-gen-goprotoc-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 的原因。同一侧既 SendRecv 还想串行等待,极易死锁。让收发各占一条 goroutine 最稳。

坑 4:永远检查 Send/Recv 的错误。 网络抖动、对端断开、context 取消都会通过错误返回,不能 if err != nilcontinue。特别是 Recv 返回 io.EOF 应作为"正常结束"处理,其他错误要认真返回。

调试方面,三个工具够用:grpcurl 可以像 curl 一样命令行调用 gRPC 接口(支持服务端流,肉眼可见推送);grpclog 打开 gRPC 内部日志看连接与流状态;需要深究时,用 Wireshark 抓 HTTP/2 帧,能看到真正的多路复用与流帧时序。

总结

四种 RPC 模式本质上是同一个问题"谁在持续产生数据"的四个答案:一元(各一次)、服务端流(服务端持续)、客户端流(客户端持续)、双向流(两端持续)。它们的差别在 .proto 里只是一个 stream 关键字的位置,在 Go 代码里则是 Send/Recv/CloseAndRecv/CloseSend 的组合。

动手时记住三条:用 context 管流的生死、用 goroutine 分离双向流的收发、对每个 Send/Recv 错误都别放过。把这几条焊死,流式 RPC 就不会再是"能编译但跑起来就卡"的黑盒。

参考资料

© 2026 | 转载请注明出处

相关推荐
uoKent12 小时前
计网中的HTTP/HTTPS
网络协议·http·https
明月_清风14 小时前
从二叉树到 B+ 树:一文搞懂工程中「树」的演化之道
数据结构·算法·go
野熊佩骑14 小时前
Kubernetes实战系列文章(三) 之 K8S运维常用命令
linux·运维·docker·微服务·云原生·容器·kubernetes
江湖十年15 小时前
在 Go 中使用 dyno 包处理动态对象
后端·面试·go
a1879272183115 小时前
【算法】回溯算法(三):三记重锤与 N 皇后——记忆化、状态设计与三层漏斗
算法·leetcode·go·剪枝·回溯·n皇后·算法讲解
纪卓志George16 小时前
打破语言范式:在 Go 里用动态代理实现 AOP
架构·go
明达智控技术16 小时前
告别停机内卷!热插拔远程IO,解锁工控运维新范式
分布式·物联网·自动化
ZYJCSZKJ16 小时前
基于微服务架构的本地生活POI团购系统设计与高并发实践
微服务·架构·生活
漂着的圆木17 小时前
MCP 2026-07-28 长任务改造:别再用 HTTP 超时判断任务失败
分布式·架构·状态模式·ai agent