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 面向的是资源的表述与缓存,天然适配浏览器和 CDN;gRPC 面向的是服务之间的高频、强类型、双向通信。你拿 gRPC 去给浏览器直连,还得靠一层网关转码,反而多一跳。所以选型的时候别只盯着性能数字,先看调用方是谁。
1.2 gRPC 与 REST/JSON 的取舍对照
我把这几年实际做过的项目里两边的情况整理成一张表,你可以对照自己的场景打分。注意表里的结论是有前提的,都是内部服务间调用、几十到几百 QPS 这个量级,不是压测极限值。
| 维度 | gRPC + Protobuf | REST + JSON |
|---|---|---|
| 接口契约 | .proto单一真源,可编译校验 | OpenAPI 文档,靠工具或人工同步 |
| 序列化体积 | 二进制,字段名不传输,约为 JSON 的 1/3 到 1/8 | 文本,字段名每次都传 |
| 序列化耗时 | 编解码接近内存拷贝,纳秒到微秒级 | 字符串解析 + 反射,开销明显更高 |
| 传输协议 | HTTP/2,多路复用、头部压缩 | HTTP/1.1 为主,连接数受限 |
| 流式能力 | 原生支持四种流式模式 | 需要 SSE、WebSocket 或分块传输自己拼 |
| 浏览器直连 | 需要网关转码 | 原生支持 |
| 可读性调试 | 需要专用工具(如 grpcurl) | curl 一把梭 |
| 跨语言代码生成 | 官方支持十几种语言,质量较整齐 | 依赖各家 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 的编码格式叫 TLV(Tag-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 个字节(因为符号扩展),所以负数场景要用sint32(ZigZag 编码),正负交替的数值用它能省一半。
字符串和嵌套消息则是长度前缀的,先写长度再写内容。repeated的标量字段在 proto3 里默认是 packed 编码,也就是所有元素连在一起,只在前面写一次长度,比每个元素单独带 tag 省得多。
注意:
proto3里所有字段都是可选的,没有 required 的概念。标量字段如果没赋值,序列化时直接不写入,接收方读到的就是零值。这意味着你没法区分"用户传了 0"和"用户没传",需要区分就加optional关键字(proto3 从 3.15 起支持),它会给字段生成has_xxx方法。
线类型这块补充一句,常用的有 0(Varint,用于 int32/int64/bool/enum)、1(64 位定长,用于 fixed64/double)、2(长度前缀,用于 string/bytes/嵌套消息/packed 数组)、5(32 位定长,用于 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_STREAM),gRPC 的超时取消就是靠这个实现的:客户端设了 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-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest # 确认 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_opt=paths=source_relative让生成文件的目录结构跟 proto 文件一致,不加的话会按go_package的完整路径去建目录,经常生成到奇怪的地方。--go-grpc_opt=paths=source_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; map<string, 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_id="d-01", page_size=20) # 一元调用:务必带 timeout try: batch = stub.GetReadings(req, timeout=2.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, timeout=10.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_id="d-01", ts=1700000000 + i, value=i * 0.1) try: resp = stub.UploadReadings(gen(), timeout=30.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/grpcurl@latest # 列出所有服务(需要服务端开了 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 调 B,B 调 C,A 给了 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 默认单条消息接收上限是 4MB(4194304字节),这个值是社区权衡出来的,既够大多数场景用,又不至于让一条消息把内存吃爆。超了会报:
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设回 false,Time调大 |
first record does not look like a TLS handshake | 明文客户端连了 TLS 端口(或反之) | 核对insecure_channel与服务端是否启用 TLS |
transport 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.yaml:
version: v2 lint: use: - DEFAULT breaking: use: - FILE然后 CI 里跑两条命令:
# 检查命名规范、字段编号使用等 buf lint # 跟主分支比对,有任何破坏性变更就失败 buf breaking --against '.git#branch=main'breaking这一条是关键。它会把当前 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为什么会让你的服务看起来只用了一个实例,那基本就算入门了。剩下的深度,都是在具体业务的坑里泡出来的。