四种 gRPC 流式服务端,我们踩坑后终于知道什么时候该用哪种了
刚开始用 gRPC 的时候,我们团队四个人在会议室白板上画了整整两小时,讨论一个订单推送系统到底该用哪种流模式。结果你猜怎么着?上线第二天就挂了——客户端内存飙升到 4GB,因为服务端疯狂推送历史数据,客户端根本消费不过来。这就是没搞清楚四种模式各自该用在什么地方的代价。
现在回头来看,gRPC 的四种服务端类型其实一点都不复杂:Unary 适合一问一答、Server Streaming 适合数据流推送、Client Streaming 适合批量上传、Bidirectional Streaming 适合实时双向对话。但魔鬼藏在细节里,实际选型的时候,业务场景稍微拐个弯,选错的概率就很大。
Unary:别小看最简单的一问一答
大多数 gRPC 调用就该是 Unary。一个请求,一个响应,结束。我们内部 80% 的微服务调用用的都是这种模式。
它的核心优势不是简单,而是天然的背压控制。客户端发一个请求就等着,服务端处理完返回,不存在消息堆积的问题。我们在一个支付网关里对比过:同样的业务逻辑,用 Unary 的 P99 延迟是 47ms,用 Streaming 徒增了连接管理开销,P99 反而飙到 83ms。
什么时候该硬着头皮不用 Unary?当你发现请求或响应的体积大到需要分块传输的时候。我们有个报表服务,单次查询结果可能有 20MB 的 JSON,用 Unary 的话,服务端要全部序列化完才能开始传输,客户端要全部接收完才能开始解析。这个场景我们后来切到了 Server Streaming,首字节时间从 3.2 秒降到了 400ms。
但别过早优化。Unary 能解决的问题,别用 Streaming 给自己加戏。我们踩过一个坑:一个简单的用户信息查询,某位同事觉得"以后可能会扩展",直接上了 Server Streaming,结果 gRPC 的流式连接数在并发 5000 的时候把 Linux 的文件描述符打满了,查了两天才定位到。
Server Streaming:推送神器,但得管住它的嘴
Server Streaming 是我最喜欢的模式,也是我们踩坑最多的模式。它适合的场景很明确:服务端有一串数据要源源不断地推给客户端,而且通常是客户端发起一次请求后就持续接收。
最经典的案例就是我们的订单状态推送。客户端订阅某个商户的订单流,服务端通过 Server Streaming 持续推送新订单。听起来很完美对吧?问题出在"持续"两个字上。
我们第一版实现极其天真:客户端连上来,服务端查数据库,然后一股脑把所有符合条件的历史订单全推进去。上线那天早上九点,有个大商户的客户端重启了,历史订单有 47 万条。服务端不管三七二十一,照着 stream.Send() 就狂写。客户端那边呢?接收协程处理不过来,gRPC 底层的接收缓冲区堆到了 2GB,最后 OOM 被杀。
这个坑教会我们一件事:Server Streaming 必须配合客户端反馈机制。虽然 Server Streaming 在协议层面是单向的,但你可以让客户端通过另一个 Unary 调用来告知消费进度,或者更简单的——让客户端在达到一定量后主动断开重连,带上 offset 参数。我们后来加了分页逻辑,每次流式连接最多推送 1000 条,客户端处理完后断开,带上最后一条的 sequence_id 重新订阅。虽然多了连接开销,但内存曲线终于平了。
另一个教训是 Server Streaming 的 goroutine 泄漏。如果你的 stream.Send() 阻塞了(因为客户端消费慢),而你又没有设置 deadline,这个服务端 goroutine 就会一直挂着。我们在一个日志推送服务里,因为客户端网络抖动,峰值时有 3400 个 goroutine 卡在 Send 上。修复方式是给每个 Send 加 context timeout:ctx, cancel := context.WithTimeout(stream.Context(), 5*time.Second),超时就断连,让客户端重试。
Client Streaming:批量上传的正确姿势
Client Streaming 的场景非常聚焦:客户端有一大批数据要上传,服务端等全部收完后再统一处理。我们主要用在两个地方:埋点数据批量上报和文件分块上传。
这种模式最大的坑在于"全部收完再处理"这个语义。很多开发者(包括我们)一开始会把它当成一个"边收边处理"的管道,但 gRPC 的 Client Streaming 在服务端收到 EOF 之前,你是拿不到完整的请求的——你可以在接收过程中做一些预处理,但最终的业务逻辑必须等 stream.Recv() 返回 io.EOF。
我们有个埋点上报服务,客户端每 10 秒攒一批数据,通过 Client Streaming 发上来。一开始我们在服务端每收到一条就写一次数据库,结果发现数据不完整的问题——如果客户端在中途因为网络原因断开了,已经写入的那部分数据就成了孤儿,因为这次流式调用最终会返回错误,但数据库里已经留下了部分数据。
正确的做法是:在内存里攒着,收到 EOF 后开一个数据库事务,一次性写入。如果中途失败,直接丢弃这批数据,让客户端整体重传。我们改成这样后,数据一致性问题彻底消失。代价是服务端内存占用会高一些,所以我们限定了每批最多 5000 条、单条不超过 4KB。
还有一个性能细节:Client Streaming 的吞吐量并不一定比多次 Unary 调用高。我们做过压测对比,客户端发 10000 条消息:
- 用 Client Streaming 一次性发送:耗时 1.8 秒,QPS 约 5555
- 用 100 个并发的 Unary 调用(每次 100 条):耗时 1.2 秒,QPS 约 8333
原因在于 gRPC 的流式传输在 HTTP/2 层面还是单链路,受 TCP 拥塞窗口限制。而多个 Unary 调用可以利用 HTTP/2 的多路复用,在同一个连接上并发传输。所以如果数据量不是特别大,而且对顺序没要求,多个 Unary 反而更快。
Bidirectional Streaming:灵活,但容易写出屎山
双向流是四种模式里最灵活也最容易失控的。它适合客户端和服务端需要持续双向通信的场景,而且双方的消息速率不一定匹配。
我们最成功的应用是一个实时协作编辑的冲突检测服务。多个客户端通过双向流连接到服务端,每个客户端的编辑操作实时发送给服务端,服务端做冲突检测后,把合并结果推给所有相关客户端。这个场景用双向流几乎是唯一解——你没法用 Unary 做实时推送,Server Streaming 只能单向推,Client Streaming 只能单向收。
但这个项目的第一个版本,代码惨不忍睹。因为双向流要求你同时管理发送和接收两个协程,而这两个协程的生命周期往往不一样。我们遇到的核心问题是:当一端关闭发送方向时,另一端怎么感知到?
gRPC 的双向流允许你通过 stream.CloseSend() 关闭客户端的发送方向,告诉服务端"我说完了,但还能继续收"。在我们的协作编辑场景里,客户端关闭文档时,会先 CloseSend,然后继续接收服务端的最后一批推送。但服务端怎么知道客户端什么时候彻底断开?答案是监听 stream.Context().Done()。我们一开始没做这个,导致服务端的发送协程在客户端断开后还尝试 Send,触发错误后才退出,中间有长达 30 秒的泄漏窗口。
另一个血泪教训是:双向流里不要用同步的请求-响应模式。我们有一个需求是客户端发一个查询,服务端返回结果。用双向流实现的话,客户端发完查询后要阻塞等待响应,这本质上就是把 Unary 强行塞进双向流里,不仅代码复杂,而且由于 HTTP/2 的单流顺序性,如果一个响应慢了,会阻塞后续所有消息的处理。后来我们把这个场景拆成了两个 Unary 调用 + 一个独立的 Server Streaming 推送通道,代码量少了 40%,性能反而更好。
选型决策树
经过这几年的踩坑,我们内部形成了一个简单的决策流程:
- 先问自己:这次通信是单次请求-响应吗?是 → Unary。别犹豫。
- 如果不是,再问:数据主要是从服务端流向客户端吗?是 → Server Streaming。但记得加上分页和超时。
- 如果数据主要是从客户端流向服务端,而且需要服务端收完后统一处理 → Client Streaming。注意事务边界。
- 如果双方需要持续、独立的双向通信,且消息速率不对等 → Bidirectional Streaming。做好协程生命周期管理。
这个决策树帮我们避免了至少三次选型错误。最近的一次是一个新项目,同事想把所有接口都做成 Bidirectional Streaming,"因为灵活"。我让他按这个流程走了一遍,最后 12 个接口里,9 个用了 Unary,2 个用了 Server Streaming,1 个用了 Client Streaming,双向流一个都没用。
选型这件事,少即是多。
常见问题
Server Streaming 和多次 Unary 调用到底哪个快?
实测数据说话:传输 10000 条记录(每条 1KB),Server Streaming 总耗时 2.1 秒,100 个并发 Unary(每次 100 条)耗时 1.5 秒。Unary 更快,因为利用了 HTTP/2 多路复用。但如果客户端需要严格按顺序处理,Server Streaming 的代码会简单很多——你不需要在客户端维护顺序逻辑。所以这不是纯性能问题,是代码复杂度换性能的权衡。
Bidirectional Streaming 里怎么处理半关闭状态?
客户端调用 CloseSend() 后,服务端的 Recv() 会返回 io.EOF,但服务端还能继续 Send。服务端判断客户端完全断开的方法是同时监听 stream.Context().Done(),这个 channel 在客户端完全断开或取消时关闭。实际代码里我们会在发送协程里用 select 同时监听 ctx.Done() 和要发送的消息 channel,这样客户端断开时能立即清理资源。
流式调用出现 INTERNAL 错误、客户端报 RST_STREAM 怎么排查?
90% 的情况是服务端 panic 了或者超过了 deadline。先看服务端日志有没有 panic 栈,如果没有,检查服务端是否设置了 grpc.MaxRecvMsgSize 和 grpc.MaxSendMsgSize,默认都是 4MB,超过就会报这个错。我们遇到过客户端上传 5MB 的 protobuf 消息,服务端直接 RST_STREAM,改大这两个参数就好了。另外 HTTP/2 的流控窗口默认 65535 字节,如果消息体很大,可能需要调大 grpc.InitialWindowSize。