资讯详情

gRPC与Protobuf生产实践:契约、HTTP/2、超时重试与兼容性

📅 2026/10/27 12:28:23 | 华诺云谱 👁 阅读
gRPC与Protobuf生产实践:契约、HTTP/2、超时重试与兼容性
1. 先搞懂 gRPC 会替你处理哪些环节再动手敲代码上个月帮一个团队排查线上问题起因很简单服务 A 调用服务 B 的一个查询接口返回的 JSON 里某个字段时有时无两边的解析逻辑各写各的一个把它当空字符串一个把它当零结果报表数据差了将近三个百分点。翻代码才发现接口文档是半年前手写的字段早就加了。这种问题在接口数量上来之后会反复出现跟开发者水平关系不大跟契约放在哪里关系很大。gRPC就是用来把这份契约从文档里抽出来、变成可编译代码的一套远程调用框架它把接口定义、序列化格式、传输协议三件事捆在一起让改字段忘了通知对方这类事在编译期就暴露出来。它到底能做什么我用一句话概括你用一份.proto文件描述服务有哪些方法、每个方法的入参和出参长什么样剩下的桩代码、序列化、连接管理、超时传递工具链和运行时全包了。你写的是普通函数调用底下跑的是 HTTP/2 上的二进制帧。适合谁看这篇如果你写过 REST 接口被字段类型对不上、文档过期、跨语言联调来回扯皮折磨过或者你的服务之间调用量大、对延迟敏感、需要流式传输比如持续推送指标、上传大文件、实时同步变更那这套东西值得花两天时间啃下来。反过来如果你只是给前端暴露三五个查询接口、调用量一天几千次那继续用 JSON 也挺好别为了时髦给自己加负担。我见过太多人入门 gRPC 的方式是直接抄一份 demo 跑通就完事结果一上生产就卡在超时、重试、消息大小、连接抖动这些地方。所以这篇我按先讲清楚它为什么这么设计再带你跑通一个能用的 Demo最后把生产环境踩过的坑摊开的顺序来写中间会穿插一些参数的计算过程和取舍理由你可以直接抄也可以按自己场景改。1.1 一次契约失效的完整复盘回到开头那个例子问题真正的根因不是某个人粗心而是接口契约这件事在 REST JSON 的体系里没有一个唯一的、机器可校验的载体。文档是文本代码是另一份文本两边靠人同步。你能做的补救是加一层 JSON Schema 校验但那玩意儿写起来比接口本身还长而且跨语言支持参差不齐。gRPC 的做法是把.proto当唯一真源服务端和客户端都从它生成代码字段类型、必填与否、编号是多少全部固化在生成物里。有人改了字段类型但没重新生成编译直接不过有人删了字段还复用编号后面的兼容性检查工具会拦下来。这里有个容易被忽略的细节.proto里的字段不是靠名字通信的是靠字段编号。也就是说user_name这个名字你随便改只要编号还是 1线上就不会出问题但你要是把编号从 1 改成 2那老客户端发过来的数据在新服务端眼里就是一个未知字段会被直接丢掉而且不报错。这个特性既是 gRPC 兼容性好的根基也是新手最容易踩的雷。我在第 5 节会用一张表把字段演进的规则列清楚。另外要说清楚一个认知gRPC 不是更快版本的 REST。它俩的设计目标不一样。REST 面向的是资源的表述与缓存天然适配浏览器和 CDNgRPC 面向的是服务之间的高频、强类型、双向通信。你拿 gRPC 去给浏览器直连还得靠一层网关转码反而多一跳。所以选型的时候别只盯着性能数字先看调用方是谁。1.2 gRPC 与 REST/JSON 的取舍对照我把这几年实际做过的项目里两边的情况整理成一张表你可以对照自己的场景打分。注意表里的结论是有前提的都是内部服务间调用、几十到几百 QPS 这个量级不是压测极限值。维度gRPC ProtobufREST JSON接口契约.proto单一真源可编译校验OpenAPI 文档靠工具或人工同步序列化体积二进制字段名不传输约为 JSON 的 1/3 到 1/8文本字段名每次都传序列化耗时编解码接近内存拷贝纳秒到微秒级字符串解析 反射开销明显更高传输协议HTTP/2多路复用、头部压缩HTTP/1.1 为主连接数受限流式能力原生支持四种流式模式需要 SSE、WebSocket 或分块传输自己拼浏览器直连需要网关转码原生支持可读性调试需要专用工具如 grpcurlcurl 一把梭跨语言代码生成官方支持十几种语言质量较整齐依赖各家 OpenAPI 生成器质量参差缓存不适合 HTTP 缓存语义天然适配 CDN 与代理缓存从这张表能看出来gRPC 的优势集中在内部、高频、强类型、多语言这四个关键词上。如果你的调用方是移动端 App那还得考虑包体积和弱网重连gRPC 的移动端支持是有的但调试成本比 REST 高团队得有心理准备。如果调用方是浏览器我的建议是老老实实走 REST 或者用网关转码别硬上。还有一点值得算一下。假设一条消息有 6 个字段JSON 里平均每个字段名 8 个字符加上引号冒号逗号光字段名就得 60 字节左右再加值的文本表示整条消息 150 字节很正常。换成 Protobuf每个字段一个 tag 字节编号 1 到 15 的情况加值的变长编码同样内容大概 30 到 40 字节。按一天 5000 万次调用算一天省下来的流量就是 5GB 上下一年接近 2TB。这个量级在跨可用区调用的时候流量费是能看见的。当然如果你一天只有几万次调用这点差异完全可以忽略别为了省流量把团队的学习成本搭进去。1.3 哪些场景我会建议先不要上 gRPC说点反向的经验。有几种情况我会明确建议团队继续用 REST至少别急着换。第一种是接口极少、调用方固定、团队里没人熟悉 HTTP/2 排查手段。gRPC 出问题的时候报错信息往往是一句transport is closing你得会用抓包、看得懂 HTTP/2 帧、知道怎么看连接状态这些门槛是真实存在的。接口就三个还上 gRPC出了问题排查两小时收益完全覆盖不了成本。第二种是强依赖 HTTP 语义的场景比如需要 CDN 缓存、需要走企业代理、需要浏览器直接下载。gRPC 的响应没法被普通缓存层理解中间设备也不一定放行 HTTP/2 的content-type: application/grpc。第三种是团队里有大量动态语言的老代码、又没人愿意维护代码生成流程。gRPC 的价值很大一部分来自生成物跟着 proto 走如果生成这一步是手工的、偶尔才跑一次那契约的优势就没了反而多了一层生成代码的维护负担。第四种是消息体特别大、又不分片的场景。gRPC 默认单条消息上限 4MB超了直接报ResourceExhausted。虽然可以调大但大消息会阻塞同一条连接上的其他流HTTP/2 的流控是连接级的这时候更适合用分块上传或者对象存储传引用。这个我在第 4 节会展开讲怎么算。2. 三个核心机制它凭什么比你想的快很多人对 gRPC 的印象停留在用了 Protobuf 所以快这话对了一半。真正让它跑起来的其实是三件事配合紧凑的二进制编码、HTTP/2 的连接复用、以及代码生成省掉的运行时反射。这三件事单独拿出来都不新鲜凑在一起才产生了那个数量级的差异。我把这三块拆开讲你理解了原理后面调参数的时候才知道每个旋钮在拧什么。先说序列化。Protobuf 的编码格式叫 TLVTag-Length-Value的变体实际是字段编号 线类型打包成一个字节当 tag后面跟值。关键在于它不传字段名只传编号而且整数用了变长编码小数字占的字节少。这套设计的代价是必须提前知道 schema 才能解码好处是体积小、解析快、还能跳过不认识的字段。再说传输。HTTP/2 在一条 TCP 连接上跑多个并行的流每个流有自己的 ID互不阻塞应用层的队头阻塞被消除了TCP 层的还在这是 HTTP/2 的固有局限。同时头部用 HPACK 做压缩相同请求头第二次发的时候只发一个索引号。这两点对 gRPC 特别重要因为 gRPC 的元数据metadata每次调用都带如果不压缩光是content-type、user-agent、grpc-timeout这些就够呛。最后说代码生成。REST 框架在运行时通常要做路由匹配、参数绑定、JSON 反序列化这些都是反射或字符串比较量大了就是开销。gRPC 的桩代码是编译期生成的调用路径上基本没有反射参数直接赋给结构体字段。这个差异在 P99 延迟上体现得比较明显。2.1 Protobuf 的编码细节Varint 与字段编号的经济学先把最有用的部分讲透为什么字段编号要用 1 到 15。Protobuf 的 tag 由(field_number 3) | wire_type组成占一个字节的话能表示的范围就是编号 1 到 15因为高 5 位放编号低 3 位放线类型。编号 16 到 2047 就需要两个字节。所以高频字段尽量用 1 到 15这是实打实省字节的。一个服务如果每条消息都带id、status、created_at这三个高频字段把它们放在 1、2、3比放在 20、21、22 每条消息能省 3 个字节乘以调用量就不是小数了。整数编码用的是 Varint规则是每个字节的最高位当还有后续的标志低 7 位存数据。所以 0 到 127 只占一个字节128 到 16383 占两个字节。举个例子int32字段值 300二进制是 100101100拆成两组 7 位是 0101100 和 0000010加上延续位反过来就是0xAC 0x02两个字节。而如果这个字段类型写成int32但实际存的是负数那对不起负数在 Varint 里会被编码成 10 个字节因为符号扩展所以负数场景要用sint32ZigZag 编码正负交替的数值用它能省一半。字符串和嵌套消息则是长度前缀的先写长度再写内容。repeated的标量字段在 proto3 里默认是 packed 编码也就是所有元素连在一起只在前面写一次长度比每个元素单独带 tag 省得多。注意proto3里所有字段都是可选的没有 required 的概念。标量字段如果没赋值序列化时直接不写入接收方读到的就是零值。这意味着你没法区分用户传了 0和用户没传需要区分就加optional关键字proto3 从 3.15 起支持它会给字段生成has_xxx方法。线类型这块补充一句常用的有 0Varint用于 int32/int64/bool/enum、164 位定长用于 fixed64/double、2长度前缀用于 string/bytes/嵌套消息/packed 数组、532 位定长用于 fixed32/float。定长类型适合那些数值总是很大的字段比如时间戳用fixed64就比int64稳定占 8 字节而int64存毫秒时间戳要 6 到 7 个字节差距不大别过度优化。2.2 HTTP/2 的复用与头部压缩带来了什么HTTP/1.1 时代一个连接同时只能处理一个请求想并发就得开多个连接每个连接一个 TCP 握手还得处理慢启动。浏览器给同域名开 6 个连接的上限就是这么来的。HTTP/2 把这个问题从根上解决了一条连接上可以同时跑很多个流每个流有独立的 ID服务端处理完哪个就先回哪个不用排队。对 gRPC 来说这个特性直接带来了两个好处。第一客户端和服务端之间通常只需要维护少量长连接连接数从几百降到几个服务端的文件描述符压力和 TLS 握手开销都下来了。第二同一个连接上的多个调用互不阻塞一个慢查询不会把后面的快请求堵住——当然这是应用层的TCP 层如果丢包还是会影响所有流这是 HTTP/2 已知的局限HTTP/3 用 QUIC 才彻底解决。头部压缩用的是 HPACK。它维护一张动态表第一次发送content-type: application/grpc、user-agent: grpc-go/1.x这些头会完整发一遍后续的请求如果头没变只发一个索引号几个字节搞定。gRPC 每次调用都要带 metadata如果没有 HPACK光头部开销就能占掉小消息的一半体积。还有一个容易被忽略的点是流的生命周期。HTTP/2 的流是可以被任意一端重置的RST_STREAMgRPC 的超时取消就是靠这个实现的客户端设了 500 毫秒超时到点本地直接取消同时给服务端发一个 RST_STREAM服务端那边的 context 会立刻变成 canceled。这意味着超时是端到端传递的服务端不需要自己再设一遍。这个机制很多人不知道导致服务端写了一堆本地超时反而把上游给的剩余时间覆盖掉了。2.3 四种服务方法分别适合什么活.proto里定义方法的时候有四种形态新手常常只用第一种其实后三种在很多场景下更合适。一元调用Unary就是一问一答最常见的形态。适合查询、写入这类有明确请求响应边界的事情。服务端流Server streaming是客户端发一个请求服务端回一串消息。适合结果集很大、要分批给或者持续推送变化的场景。比如导出百万行数据一次返回会撑爆消息上限用流式分页就自然多了。再比如订阅配置变更服务端有新版本就推一条。客户端流Client streaming是客户端发一串服务端最后回一个汇总。适合批量上报、大文件分片上传。它的价值在于省掉了分批调用 服务端拼装的逻辑而且流的生命周期内服务端可以维护状态。双向流Bidirectional streaming两边都可以随时发。适合实时协作、长连接的会话类业务。要注意双向流不是必须交替两边是完全独立的所以你得自己设计消息语义别搞成死锁一边在等对方先说话另一边也在等。选型的时候有个经验能用一元就别上流式。流式的错误处理、重连、半关闭状态管理都比一元复杂得多而且很多网关和负载均衡器对长连接的流支持不好会莫名其妙地断。真要用流务必在客户端实现断了重连 断点续传/去重不然数据就丢了。3. 从零跑通一个能用的 Demo原理讲完动手。为了不让例子太空中楼阁我编一个贴近实际的场景一个设备读数服务设备定期上报读数客户端可以查历史、可以订阅实时推送、可以批量上传。这个场景刚好能把四种方法都覆盖到。先说明一下环境我用的是 Go 做服务端、Python 做客户端故意跨语言因为 gRPC 最大的价值之一就是跨语言你在同一个 demo 里体验一次比看十篇介绍管用。工具链版本要对齐这是新手第一个坎。3.1 工具链准备与版本对齐需要装的东西有四个protoc编译器、Go 的两个插件、Python 的 grpcio 和 grpcio-tools。protoc是编译器本体两个 Go 插件分别生成消息结构和服务桩代码。# protoc 本体用包管理器装最省事 brew install protobuf # macOS apt install -y protobuf-compiler # Debian/Ubuntu # Go 的两个生成插件 go install google.golang.org/protobuf/cmd/protoc-gen-golatest go install google.golang.org/grpc/cmd/protoc-gen-go-grpclatest # 确认 GOPATH/bin 在 PATH 里否则 protoc 找不到插件 export PATH$PATH:$(go env GOPATH)/bin # Python 侧 pip install grpcio grpcio-tools版本对齐这件事我要重点提醒。protoc-gen-go-grpc生成的代码里会引用一个版本常量比如grpc.SupportPackageIsVersion9如果你的google.golang.org/grpc库版本比较老不认识这个常量编译就会报undefined: grpc.SupportPackageIsVersion9。解决办法就一个把 grpc 库升到与插件匹配的版本。我一般的做法是先把go.mod里的 grpc 升到最新再跑一遍生成如果还报错就降插件版本。提示生成代码不要手改也不要提交到代码仓库之后再改。把它当成构建产物每次改完 proto 重新生成。团队里最好用一个脚本或者 Makefile 把生成命令固定下来避免每个人生成的参数不一样。命令行参数里有两个容易踩的点。--go_optpathssource_relative让生成文件的目录结构跟 proto 文件一致不加的话会按go_package的完整路径去建目录经常生成到奇怪的地方。--go-grpc_optpathssource_relative同理。Python 侧用-I指定 proto 的搜索根目录--python_out和--grpc_python_out指向输出目录注意输出目录不会自动创建得先mkdir -p。3.2 写 .proto字段编号的三条铁律下面是这个 demo 的 proto 文件我加了注释说明每一处的考虑。syntax proto3; package meter.v1; option go_package example.com/demo/gen/meter/v1;meterv1; message ReadingRequest { string device_id 1; // 高频字段放 1-15 省 tag 字节 int64 start_ts 2; int64 end_ts 3; int32 page_size 4; string page_token 5; } message Reading { string device_id 1; int64 ts 2; double value 3; mapstring, string labels 4; } message ReadingBatch { int64 count 1; string next_token 2; repeated Reading items 3; } service MeterService { rpc GetReadings(ReadingRequest) returns (ReadingBatch); rpc StreamReadings(ReadingRequest) returns (stream Reading); rpc UploadReadings(stream Reading) returns (ReadingBatch); }三条铁律我按踩坑概率排序。第一编号一旦上线永远不要改、不要删了复用。删字段的时候把编号和名字用reserved声明掉防止后来的人手贱复用message Reading { reserved 5, 6; reserved old_value, legacy_tag; }为什么这么严格因为改编号之后老客户端发来的数据在新服务端会被当成未知字段静默丢弃不报错、不打日志数据就这么少了排查起来能要人命。第二编号 1 到 15 留给最高频的字段原理在 2.1 节算过了。另外 19000 到 19999 是保留区间别用。第三枚举的第一个值必须是 0而且建议命名为XXX_UNSPECIFIED。proto3 的枚举默认值就是 0如果 0 对应一个业务上有意义的状态比如PENDING那么没设置和设置了 PENDING就分不清了。还有一个.proto的组织习惯包名带上版本号meter.v1而不是meter这样以后做破坏性变更可以并行发一个meter.v2两版同时在线客户端慢慢迁。这个习惯我认为非常值得坚持后患无穷的场景我见过不止一次。3.3 服务端实现一元调用与两种流式生成命令跑完之后Go 侧会得到meter.pb.go消息和meter_grpc.pb.go服务接口。先看服务端骨架。package main import ( context io log net time google.golang.org/grpc google.golang.org/grpc/reflection meterv1 example.com/demo/gen/meter/v1 ) type server struct { meterv1.UnimplementedMeterServiceServer } func (s *server) GetReadings(ctx context.Context, req *meterv1.ReadingRequest) (*meterv1.ReadingBatch, error) { size : int(req.GetPageSize()) if size 0 || size 500 { size 100 } items : make([]*meterv1.Reading, 0, size) now : time.Now().Unix() for i : 0; i size; i { items append(items, meterv1.Reading{ DeviceId: req.GetDeviceId(), Ts: now - int64(i*60), Value: float64(i) * 0.5, }) } return meterv1.ReadingBatch{Count: int64(size), Items: items}, nil } func (s *server) StreamReadings(req *meterv1.ReadingRequest, stream meterv1.MeterService_StreamReadingsServer) error { ctx : stream.Context() for i : 0; i 10; i { select { case -ctx.Done(): return ctx.Err() // 客户端取消或超时立刻退出 default: } if err : stream.Send(meterv1.Reading{ DeviceId: req.GetDeviceId(), Ts: time.Now().Unix(), Value: float64(i), }); err ! nil { return err // 通常是连接断了直接返回 } time.Sleep(200 * time.Millisecond) } return nil } func (s *server) UploadReadings(stream meterv1.MeterService_UploadReadingsServer) error { var count int64 for { _, err : stream.Recv() if err io.EOF { return stream.SendAndClose(meterv1.ReadingBatch{Count: count}) } if err ! nil { return err } count } } func main() { lis, err : net.Listen(tcp, :9090) if err ! nil { log.Fatal(err) } s : grpc.NewServer( grpc.MaxRecvMsgSize(16 20), // 接收上限 16MB grpc.MaxSendMsgSize(16 20), // 发送上限 16MB ) meterv1.RegisterMeterServiceServer(s, server{}) reflection.Register(s) // 让 grpcurl 能自省 log.Println(listening on :9090) if err : s.Serve(lis); err ! nil { log.Fatal(err) } }几个细节值得说。嵌入UnimplementedMeterServiceServer是必须的它给未实现的方法提供了默认返回返回Unimplemented错误。proto 以后加了新方法你没实现也不会导致编译不过老服务继续跑这个设计叫前向兼容。StreamReadings里那个select ctx.Done()是关键。流式方法里服务端如果在一个死循环里不停地发客户端已经取消了它却不知道就会一直往里写最后错误越积越多。正确的是每轮循环前检查 context一旦取消就立刻返回。UploadReadings用io.EOF判断客户端是否发完了。客户端调CloseAndRecv或者流对象被关闭服务端Recv就会拿到io.EOF这时候才能SendAndClose返回最终结果。顺序反了会报send on closed stream。reflection.Register只是开发期方便调试用的生产环境如果不想暴露服务列表可以注掉但内部服务我一般都留着排查问题太方便了。3.4 客户端调用超时、流式读取与资源释放Python 客户端代码如下。我用的是同步 API入门阶段同步更直观异步的坑更多等熟悉了再换。import grpc from meter.v1 import meter_pb2, meter_pb2_grpc def main(): # insecure_channel 用于明文生产环境换成 secure_channel with grpc.insecure_channel(127.0.0.1:9090) as channel: stub meter_pb2_grpc.MeterServiceStub(channel) req meter_pb2.ReadingRequest(device_idd-01, page_size20) # 一元调用务必带 timeout try: batch stub.GetReadings(req, timeout2.0) print(count , batch.count) except grpc.RpcError as e: print(code , e.code(), detail , e.details()) # 服务端流用 for 迭代异常同样要捕获 try: for reading in stub.StreamReadings(req, timeout10.0): print(reading.ts, reading.value) except grpc.RpcError as e: print(stream error:, e.code(), e.details()) # 客户端流生成器喂数据最后 CloseAndRecv 拿结果 def gen(): for i in range(50): yield meter_pb2.Reading(device_idd-01, ts1700000000 i, valuei * 0.1) try: resp stub.UploadReadings(gen(), timeout30.0) print(uploaded , resp.count) except grpc.RpcError as e: print(upload error:, e.code(), e.details()) if __name__ __main__: main()timeout参数千万别省。不传就是无限等待连接半死不活的时候线程会一直挂着最后整个进程内存爆掉。我一般按 P99 延迟的 2 到 3 倍来定比如正常调用 50 毫秒超时设 200 毫秒。设太松等于没设设太紧会误杀正常的慢请求得看具体业务。with grpc.insecure_channel(...)这种写法把 channel 当上下文管理器退出时自动关闭。channel 是重量级的内部维护连接池和 HTTP/2 会话应该复用而不是每次调用新建。我见过有人在每个请求里grpc.insecure_channel()一次QPS 一上来直接端口耗尽。正确的做法是在应用启动时建一个全局 channel所有 stub 共用。流式客户端还有个细节迭代过程中如果提前break了Python 的生成器需要显式调用cancel()否则流不会立刻关服务端会继续发到超时。同步 API 里上下文管理器退出时会处理但如果你在循环里 break 之后还要干别的事最好手动取消。3.5 用 grpcurl 在命令行自测写完服务端第一件事是验证服务确实起来了、方法签名对得上。这时候grpcurl比写客户端快得多。# 安装 go install github.com/fullstorydev/grpcurl/cmd/grpcurllatest # 列出所有服务需要服务端开了 reflection grpcurl -plaintext 127.0.0.1:9090 list # 看某个服务的方法和消息结构 grpcurl -plaintext 127.0.0.1:9090 describe meter.v1.MeterService # 调一元方法-d 后面是 JSON 形式的请求体 grpcurl -plaintext -d {device_id:d-01,page_size:5} \ 127.0.0.1:9090 meter.v1.MeterService/GetReadings # 调服务端流会连续打印多条 grpcurl -plaintext -d {device_id:d-01} \ 127.0.0.1:9090 meter.v1.MeterService/StreamReadings如果服务端没开 reflection就得带上 proto 文件-import-path ./proto -proto meter/v1/meter.proto。这个参数顺序有讲究-import-path是搜索根-proto是相对根的路径路径写错会报file not found而且报错信息里不会告诉你在哪找的容易卡住。grpcurl还有个好用的地方是能带 metadata-H authorization: Bearer xxx调试鉴权的时候很方便。它还可以用-format text输出更紧凑的格式或者用-v看完整的请求响应头和 trailer排查grpc-status的时候很有用。到这里一个能跑通的闭环就完成了。但能跑通离能用还差很远下面这块才是真正值钱的部分。4. 上生产前必须处理的几件事我在几个项目里推过 gRPC上线初期遇到的问题高度雷同基本集中在超时重试、连接管理、消息大小、负载均衡这几块。这一节按问题出现的频率排每条都给具体的配置和背后的理由。4.1 截止时间与重试只对幂等方法动手超时的传递机制在 2.2 节说过客户端设的 deadline 会通过grpc-timeout头传给服务端服务端拿到的 context 就是这个 deadline 的子 context。所以服务端的正确写法是不要自己再设一个更长的超时直接用传进来的 ctx 就行。如果非要在服务端加保护也应该取两者中较小的那个。// 服务端从 ctx 里读剩余时间用于给下游调用分配预算 if dl, ok : ctx.Deadline(); ok { remain : time.Until(dl) log.Printf(remaining budget: %v, remain) }这个剩余预算的概念在多层调用里特别重要。A 调 BB 调 CA 给了 500 毫秒B 自己处理花了 100 毫秒那调 C 的时候就应该用剩下的 400 毫秒减去一点安全边际而不是重新给 C 一个 500 毫秒。不这么做的话A 早就超时返回了B 和 C 还在那儿慢慢算白烧 CPU。重试这块我的原则是只对幂等方法重试。查询可以重试写入要看情况创建订单这种绝对不能无脑重试除非你有幂等键。gRPC 支持在 service config 里声明重试策略{ methodConfig: [{ name: [{service: meter.v1.MeterService, method: GetReadings}], retryPolicy: { maxAttempts: 4, initialBackoff: 0.1s, maxBackoff: 1s, backoffMultiplier: 2, retryableStatusCodes: [UNAVAILABLE, DEADLINE_EXCEEDED] } }] }这段配置在 Go 里通过grpc.WithDefaultServiceConfig(...)传给客户端。注意maxAttempts包含首次调用写 4 意味着最多调 4 次。还要注意重试是有预算的gRPC 内部有个重试限流器默认重试流量不能超过总流量的 10%超了就不再重试。这是为了防止服务端已经过载了客户端还拼命重试把它彻底压垮。注意DEADLINE_EXCEEDED是否可重试要想清楚。如果超时是因为服务端慢重试只会让它更慢。我的做法是只把UNAVAILABLE列为可重试超时交给上层决定。重试还有个大坑是非幂等操作重复执行。我见过一个服务把扣减库存的调用配了重试网络抖动的时候同一个请求执行了三次。解决办法是在 proto 里加一个request_id字段服务端做去重表或者干脆别配重试让业务层自己控制。4.2 拦截器日志、鉴权、链路追踪一次搞定拦截器Interceptor是 gRPC 的中间件机制一元调用和流式调用各有一套。日志、鉴权、指标、链路追踪这些东西用拦截器写一次全局生效比在每个方法里复制粘贴强太多。func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { start : time.Now() resp, err : handler(ctx, req) cost : time.Since(start) st, _ : status.FromError(err) log.Printf(method%s cost%s code%s err%v, info.FullMethod, cost, st.Code(), err) return resp, err } // 注册 s : grpc.NewServer( grpc.ChainUnaryInterceptor(loggingInterceptor, authInterceptor, traceInterceptor), )几个实践要点。第一拦截器有顺序ChainUnaryInterceptor按传入顺序从外往里包所以日志放最外层能记录到所有错误鉴权放外层能挡住无效请求不让它进业务。第二日志里一定要打info.FullMethod它包含包名、服务名、方法名比只打方法名好用得多。第三别在拦截器里做耗时操作比如同步写日志到远端、每次都查一次数据库鉴权这些会成为新的瓶颈该加缓存加缓存。流式拦截器的写法不太一样因为它包的是整个流的生命周期func streamLoggingInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { start : time.Now() err : handler(srv, ss) st, _ : status.FromError(err) log.Printf(stream method%s cost%s code%s, info.FullMethod, time.Since(start), st.Code()) return err }流式拦截器有个细节它只能记录整个流的开始和结束中间每条消息你没法直接拦。要记每条消息得包一层ServerStream重写RecvMsg和SendMsg稍微麻烦点一般只在调试阶段做。链路追踪的话标准做法是从 metadata 里读traceparent或者你自己的 trace id放进 context然后往下游传。跨语言场景下这个约定要对齐不然 Java 服务传的 header 名跟 Go 服务读的不一样链路就断了。4.3 连接与 keepalive那个让我熬到半夜的参数keepalive 是 gRPC 里最容易被忽视、出事又最难受的一块。它的作用是定期发 PING 帧探测连接是否还活着避免连接被中间设备NAT、防火墙、负载均衡器静默掐断。表现就是服务看起来好好的但长时间没请求之后第一次调用必然超时第二次就好了。这个现象如果你见过一次这辈子都忘不掉。Go 服务端的配置s : grpc.NewServer( grpc.KeepaliveParams(keepalive.ServerParameters{ MaxConnectionIdle: 5 * time.Minute, // 空闲这么久就发 GOAWAY MaxConnectionAge: 30 * time.Minute, // 连接最长活这么久强制重连做负载均衡 Time: 2 * time.Minute, // 这么久没数据就发 PING Timeout: 20 * time.Second, // PING 发出去多久没回应就断 }), grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: 1 * time.Minute, PermitWithoutStream: false, }), )参数的意义我逐个解释。MaxConnectionIdle和MaxConnectionAge是服务端主动踢连接用的前者处理空闲连接后者处理长连接。为什么要主动踢因为客户端负载均衡的时候连接建立之后就不再重新解析地址了服务端扩容了新的实例老客户端根本不知道。定期踢一下客户端重连时会重新解析自然就分到新实例上了。MaxConnectionAge的取值有讲究太短会导致频繁重连每次重连都有 TCP TLS 握手成本太长又起不到均衡效果。我一般按业务容忍度取 10 到 30 分钟再配一个MaxConnectionAgeGrace给正在处理的请求留出完成时间。客户端这边conn, err : grpc.NewClient(dns:///meter-svc:9090, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: 30 * time.Second, Timeout: 10 * time.Second, PermitWithoutStream: false, // 这个千万别设 true }), )PermitWithoutStream设成true是新手最爱踩的坑。它的意思是没有活跃流的时候也发 PING看起来更积极实际上会触发服务端的流控服务端有EnforcementPolicy.MinTime默认 5 分钟如果客户端发 PING 太频繁服务端会直接回一个GOAWAY错误码ENHANCE_YOUR_CALM日志里能看到too_many_pings。这个错误信息非常迷惑看起来像是网络问题实际是协议层面的惩罚。所以除非你明确知道自己在干什么PermitWithoutStream保持false。提示grpc.Dial和grpc.DialWithContext在新版本里已经标记为弃用用grpc.NewClient代替。区别是NewClient默认不阻塞、不做立即连接行为更像懒加载对启动速度友好但也意味着第一次调用会慢一点。4.4 负载均衡与服务发现默认策略会让你吃亏gRPC 的负载均衡有个反直觉的地方默认策略是pick_first也就是客户端建立连接后只用第一个能连上的地址其它地址全部当备胎。如果你的服务有 10 个实例客户端只用了 1 个剩下 9 个在那儿闲着。这个默认值坑过太多人。改成轮询需要显式声明conn, err : grpc.NewClient(dns:///meter-svc:9090, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultServiceConfig({loadBalancingConfig:[{round_robin:{}}]}), )注意 URL 前缀dns:///它告诉客户端用 DNS 解析器定期刷新地址列表。不带前缀的话某些场景下解析行为不一样。刷新间隔由 DNS 的 TTL 决定如果你用的是容器编排平台的服务发现TTL 可能会比较长实例扩缩容之后客户端要等一会儿才感知到这个延迟要纳入容量规划的考虑。round_robin之外还有grpclb需要在服务端部署一个专门的负载均衡服务和第三方实现的weighted_target、least_request等。对大多数团队来说round_robin加 DNS 就够了别上太复杂的。还有一个跨语言的差异要注意不是所有语言的 gRPC 实现都支持客户端负载均衡。Python 的支持相对弱一些很多人会用一层代理比如一个七层的 L7 代理或者服务网格来做这时候客户端就只管连一个地址均衡交给代理。这个架构的代价是多一跳网络好处是客户端简单、语言无关。4.5 大消息与并发4MB 上限背后的计算gRPC 默认单条消息接收上限是 4MB4194304字节这个值是社区权衡出来的既够大多数场景用又不至于让一条消息把内存吃爆。超了会报rpc error: code ResourceExhausted desc grpc: received message larger than max (5242880 vs. 4194304)解决办法有两个方向。一是调大上限服务端和客户端都要调// 服务端 grpc.MaxRecvMsgSize(32 20) // 客户端 grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(32 20))二是重新设计用分页或者流式。我更推荐第二种因为大消息的问题不只是能不能传还有同一条连接上的其他流会被这条大消息的传输占住HTTP/2 的流控是连接级的内存峰值会上升序列化耗时也会变成一个可感知的延迟尖刺。真要估算的话可以这么算一条读数记录大约 60 字节设备 ID 20 字节、时间戳 9 字节、数值 9 字节、几个标签4MB 大概能装 7 万条。业务上如果一批要传 10 万条就得拆成两批或者改用客户端流。这个数你可以自己算一遍心里有底。流式场景还有一个限制是最大并发流数。HTTP/2 在连接建立时通过 SETTINGS 帧协商SETTINGS_MAX_CONCURRENT_STREAMS不同实现给的默认值不一样很多网关默认 100。如果你的客户端在一条连接上开 500 个并发调用超出的部分会在客户端排队看起来像是调用变慢了实际是流控在起作用。排查的时候要看客户端是不是连接数太少、并发太高适当增加连接数或者调大服务端的并发流上限。5. 常见故障速查与协议演进最后一节我整理成两张表加一个工具链的做法都是能直接拿去用的东西。5.1 报错信息对照表下面这些错误我基本都亲身遇到过按看到什么 - 大概率是什么原因 - 怎么处理来组织。报错关键字大概率原因处理方式connection refused服务没起、端口写错、容器网络不通先用 telnet 或 nc 探端口再看服务日志context deadline exceeded真的超时或超时设太短看服务端耗时分布确认是业务慢还是网络慢received message larger than max超过消息上限调MaxCallRecvMsgSize或改分页/流式unknown service/unknown method包名或服务名不匹配或服务端没注册用 grpcurl list 核对完整服务名too_many_pings/ENHANCE_YOUR_CALM客户端 PING 太频繁把PermitWithoutStream设回 falseTime调大first record does not look like a TLS handshake明文客户端连了 TLS 端口或反之核对insecure_channel与服务端是否启用 TLStransport is closing连接被中间设备掐断或服务端重启查 keepalive 配置查是否有 NAT 空闲超时Unimplemented方法存在但服务端没实现返回了默认桩检查是否嵌入了Unimplemented*Server且忘了覆盖ResourceExhausted消息过大或触发了限流分别看错误详情里是否带 size 信息deadline exceeded但服务端日志正常客户端和服务端时钟不一致校准 NTP检查容器时间这张表里我想多说两句的是transport is closing。它的隐蔽之处在于偶发性一天可能就出现几次重试就好。根因八成是中间的负载均衡器或 NAT 有连接空闲超时常见的是 60 秒、300 秒空闲连接被静默回收但客户端不知道。解决办法就是把客户端 keepalive 的Time设得比这个超时短比如中间设备 60 秒回收客户端就设 30 秒发一次 PING。具体值只能靠试可以先抓包看断开的时间间隔是不是规律。5.2 proto 字段演进规则这张表是每次改 proto 之前我都会扫一眼的建议直接打印出来贴显示器边上。变更操作是否安全说明新增字段安全老客户端不认识会当未知字段忽略删除字段需谨慎必须加reserved占住编号和名字修改字段名安全传输只看编号不看名字修改字段编号危险等于换了一个字段数据会错位int32改int64安全都是 Varint兼容int32改string危险线类型不同解析直接失败int32改sint32危险编码方式变了值会错单值改repeated危险编码格式不同枚举删除值需谨慎用reserved占住且不要复用数值服务新增方法安全老客户端不受影响服务改方法签名危险相当于新方法老调用直接报错有一条规则表格里没写但极其重要不要复用已经删除的字段编号。哪怕你删掉的是个从来没用过的字段也老老实实reserved掉。因为你不知道有没有哪个客户端还拿着旧版本的 proto 在发数据。reserved的语法要记住两种一个是编号列表一个是名字列表不能混在一条语句里message Foo { reserved 3, 5, 9 to 11; reserved old_name, legacy_field; }5.3 用 buf 把兼容性检查塞进 CI靠人眼盯着上面那张表是不现实的尤其是多人协作、一周改好几次 proto 的团队。我的做法是在 CI 里跑自动检查用的是buf这个工具链它能做 lint 和破坏性变更检测。项目根目录放一个buf.yamlversion: v2 lint: use: - DEFAULT breaking: use: - FILE然后 CI 里跑两条命令# 检查命名规范、字段编号使用等 buf lint # 跟主分支比对有任何破坏性变更就失败 buf breaking --against .git#branchmainbreaking这一条是关键。它会把当前 proto 和主分支的版本做语义比对字段删了、类型改了、编号动了全都会被拦下来。我在一个项目里加了这个检查之后proto 相关的线上故障基本归零了因为不合规的改动根本进不了主分支。buf lint也值得开。它强制包名带版本号、枚举第一个值是XXX_UNSPECIFIED、字段用小写下划线命名。这些规范看起来琐碎但跨语言的时候能省很多事因为不同语言的代码生成器对命名的转换规则不一样命名规范统一了转换结果才可预期。顺手提一句格式化buf format -w可以把 proto 文件按统一风格重排跟gofmt一个思路。团队里如果有人喜欢用不同的缩进和对齐跑一下这个就统一了省得 code review 的时候为空格吵架。还有一个我最近才用上的功能是buf generate它用一个buf.gen.yaml管理所有语言的生成插件配置一条命令把 Go、Python、Java 的代码全生成了。比起每个项目各写一套protoc命令好处是插件版本和参数都固化下来新人拉代码之后跑一条命令就行不用对着 README 一个个装插件。到这里从原理、Demo 到生产配置和排查我认为入门需要的东西基本齐了。如果非要给一个学习路径的建议就是先照着第 3 节把 Demo 跑通然后把服务端故意搞挂、把超时调成 1 毫秒、把消息调到 5MB看看各种错误长什么样这一步比看十篇文章都管用。等你能一眼认出ResourceExhausted和Unavailable的区别知道pick_first为什么会让你的服务看起来只用了一个实例那基本就算入门了。剩下的深度都是在具体业务的坑里泡出来的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑