去年在做订单中台重构的时候,我把服务之间的通信从HTTP+JSON全部切换成了Go语言的gRPC。改造完成后最直观的感受是:接口响应时间下降了一大截,而且不再需要为每个服务单独维护一份随时会过期的接口文档。如果你正在做微服务拆分,或者刚接触Go语言工程化,想知道RPC框架到底怎么选、怎么落地、线上出问题怎么排查,这一篇实战记录应该能帮你少走很多弯路。文章会从RPC选型开始,一直讲到proto定义、代码生成、服务端客户端实现、拦截器、流式调用、性能调优,最后还有几个生产环境的真实踩坑案例,适合有一定Go基础、想深入把gRPC用起来的读者。
1. RPC选型复盘:为什么不是HTTP JSON也不是Thrift
1.1 服务拆分之后,通信问题比想象中更早到来
服务拆分的初期,大家最自然的做法是继续用HTTP接口。每个服务用Gin或者标准库起一个HTTP服务,内部调用就GET和POST来回打。这种方案在服务数量少、调用频率低的时候没什么问题,但一旦服务数量上来,痛点会非常集中在三件事上。
第一是序列化效率。JSON虽然是可读性最好的格式,但解析速度和解码开销摆在那里。我用一个简单的压测做过对比,同样的数据体,JSON序列化和反序列化的CPU开销大约是protobuf的3到5倍,在高并发场景下这部分差距会直接表现为GC压力和RT抖动。
第二是接口约束太弱。HTTP接口通常靠Swagger之类的工具维护文档,但实际项目中文档经常滞后于代码。调用方拿到的接口定义和提供方实际实现不一致,这种问题在联调阶段频繁出现,每次都要靠人肉对字段。
第三是连接管理。HTTP/1.1的短连接在高频调用下需要不断建连和断连,即使开Keep-Alive,也只能在一个连接上串行处理请求,吞吐上不去的瓶颈非常明显。
1.2 gRPC到底解决了什么问题
gRPC解决的核心问题,可以概括为:用HTTP/2的多路复用代替HTTP/1.1的串行阻塞,用protobuf的二进制编码代替JSON的文本编码,用.proto文件作为接口契约代替散落各处的文档。
HTTP/2的多路复用意味着同一个TCP连接可以同时跑很多个请求,每个请求在一条独立的流上,互不阻塞。这样就不会出现一个慢请求把后续请求都堵住的情况。protobuf编码之后体积小,解析快,而且字段有明确的编号和类型,客户端和服务端只要基于同一份proto文件生成代码,就不会出现字段对不齐的问题。再加上gRPC原生支持四种通信模式,除了最简单的一问一答,还能做服务端流式推送、客户端流式上传、双向流式通信,这些是HTTP+JSON方案需要额外设计才能实现的。
我整理了一个简单的对比表,方便你快速做选型判断:
| 对比维度 | gRPC | HTTP + JSON | Thrift |
|---|---|---|---|
| 传输协议 | HTTP/2 | HTTP/1.1 / HTTP/2 | TCP / HTTP |
| 序列化格式 | protobuf | JSON | Thrift Binary |
| 接口契约 | .proto文件 | 无或Swagger | .thrift文件 |
| 四种流式通信 | 原生支持 | 需额外实现 | 部分支持 |
| 多语言支持 | 非常广泛 | 几乎全语言 | 广泛 |
| 浏览器直连 | 需要grpc-web | 直接支持 | 不支持 |
| 调试便利性 | 需要grpcurl/grpcui | 浏览器直接看 | 工具较少 |
| 学习成本 | 中等 | 低 | 中等 |
1.3 什么场景不建议用gRPC
gRPC不是万能的。如果你的系统大量涉及浏览器端直接发请求,或者外部系统对接方很多且技术水平参差不齐,gRPC会出现比较大的上手成本。浏览器不支持直接发送gRPC请求,需要额外架设grpc-web网关。另外,如果业务逻辑本身非常简单,只是一两个服务之间偶尔调一下,用HTTP+JSON反而更轻便,没必要为了技术栈的炫技引入额外复杂度。
我当时选gRPC还有一个考虑:团队里多个服务分别用Go和Java编写,proto文件可以同时生成两种语言的代码,联调效率比起各自维护一套文档高很多。这个多语言特性在混合技术栈团队里尤其好用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭建Go gRPC工程:proto定义与代码生成的全流程
2.1 环境准备:protoc和两个Go插件的版本陷阱
在开始写代码之前,需要先安装三样东西:
- protoc编译器,负责解析proto文件生成中间表示
- protoc-gen-go,负责把proto定义生成Go结构体
- protoc-gen-go-grpc,负责生成gRPC服务接口和客户端代码
这里有一个很容易踩的坑:protoc-gen-go和protoc-gen-go-grpc这两个插件的版本必须与grpc-go库版本匹配。我最初用的grpc-go是老版本,protoc-gen-go按最新版安装了,结果生成的代码类型和grpc库里的接口对不上,编译直接报错。
建议都装到比较新的稳定版本,然后确认grpc-go的依赖版本保持一致。我当前用的版本组合是:
| 组件 | 版本 |
|---|---|
| protoc | v25.3 |
| protoc-gen-go | v1.33.0 |
| protoc-gen-go-grpc | v1.3.0 |
| google.golang.org/grpc | v1.60.1 |
| google.golang.org/protobuf | v1.33.0 |
检查插件是否安装成功:
bash复制protoc --version
protoc-gen-go --version
protoc-gen-go-grpc --version
注意:protoc-gen-go和protoc-gen-go-grpc安装后默认在$GOPATH/bin目录下,确保这个目录在PATH环境变量里,否则protoc找不到插件会报错。
2.2 编写第一个proto文件:service定义和message设计
工程初始化之后,我习惯把proto文件放在api/目录下,用模块名/版本号做分层。创建一个api/hello/v1/hello.proto:
proto复制syntax = "proto3";
package hello.v1;
option go_package = "grpc-demo/api/hello/v1;hellov1";
service GreeterService {
rpc SayHello(HelloRequest) returns (HelloReply);
rpc ListHello(HelloRequest) returns (stream HelloReply);
rpc RecordHello(stream HelloRequest) returns (HelloReply);
rpc ChatHello(stream HelloRequest) returns (stream HelloReply);
}
message HelloRequest {
string name = 1;
map<string, string> labels = 2;
repeated string tags = 3;
oneof contact {
string email = 4;
string phone = 5;
}
}
message HelloReply {
string message = 1;
int64 timestamp = 2;
}
这里有几个细节值得说明。
option go_package这一行决定了Go代码生成到哪个包,格式是导入路径;包名。如果写错,生成的代码import路径就会不对。
service里的stream关键字表示流式方法。这四个方法正好覆盖了gRPC的四种通信模式:
SayHello:普通一元调用ListHello:服务端流式,客户端发一个请求,服务端持续返回多个响应RecordHello:客户端流式,客户端持续发送多个请求,服务端最终返回一个响应ChatHello:双向流式,双方可以同时互发数据
message字段的类型映射也是Go语言数据结构对应关系里很实用的一部分。map<string, string>会生成Go的map[string]string,repeated string会生成[]string,oneof会生成一个接口类型配合具体的包装结构体。理解这些映射关系能让你在看生成代码时心里有数。
2.3 执行代码生成:命令和生成物对照
在工程根目录执行生成命令:
bash复制protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
api/hello/v1/hello.proto
--go_out=.表示Go结构体的输出目录是当前目录,paths=source_relative表示生成的代码文件会放在与proto文件相同的相对路径下。执行完之后,api/hello/v1/目录下会多出两个文件:
hello.pb.go:所有的message结构体定义和序列化方法hello_grpc.pb.go:服务端接口定义、客户端实现和注册方法
我建议在动手写业务代码之前,先把这两个生成文件打开看一眼。重点看三处:GreeterServiceServer接口有哪些方法、GreeterServiceClient接口有哪些方法、生成的结构体字段名是否和预期一致。特别是字段名的命名转换规则,proto里是snake_case,Go代码里会转成CamelCase。
2.4 工程化目录结构参考
一个干净的gRPC服务工程目录,基本上长这样:
text复制grpc-demo/
├── api/
│ └── hello/
│ └── v1/
│ ├── hello.proto
│ ├── hello.pb.go
│ └── hello_grpc.pb.go
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── handler/
│ │ └── greeter.go
│ └── middleware/
│ ├── logging.go
│ ├── recovery.go
│ └── auth.go
├── go.mod
└── go.sum
api/目录放proto文件和生成的代码,cmd/目录放服务启动入口,internal/目录放业务实现、拦截器等内部包。这个结构的好处是:proto文件是接口契约,业务实现和启动逻辑分离,后续增加新的接口时改动范围非常清晰。
3. 服务端实现:从注册服务到拦截器与优雅退出
3.1 实现业务接口:嵌入Unimplemented的关键作用
有了生成代码,接下来实现服务端。在internal/handler/greeter.go中定义业务结构体:
go复制package handler
import (
"context"
"time"
hellov1 "grpc-demo/api/hello/v1"
)
type GreeterServer struct {
hellov1.UnimplementedGreeterServiceServer
}
func (s *GreeterServer) SayHello(ctx context.Context, req *hellov1.HelloRequest) (*hellov1.HelloReply, error) {
return &hellov1.HelloReply{
Message: "hello, " + req.GetName(),
Timestamp: time.Now().Unix(),
}, nil
}
func (s *GreeterServer) ListHello(req *hellov1.HelloRequest, stream hellov1.GreeterService_ListHelloServer) error {
for i := 0; i < 5; i++ {
if err := stream.Send(&hellov1.HelloReply{
Message: "hello, " + req.GetName() + " " + string(rune('a'+i)),
Timestamp: time.Now().Unix(),
}); err != nil {
return err
}
}
return nil
}
注意第5行,结构体里嵌入了UnimplementedGreeterServiceServer。这个嵌入非常关键,它让未实现的方法返回codes.Unimplemented错误。这样做的意义在于:当服务端代码还没完全写好的时候,服务依然可以编译启动,调用未实现接口时会得到一个明确的错误提示,而不是服务崩溃。
3.2 注册服务和启动:监听端口与反射服务
在cmd/server/main.go中启动服务:
go复制package main
import (
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
hellov1 "grpc-demo/api/hello/v1"
"grpc-demo/internal/handler"
)
func main() {
lis, err := net.Listen("tcp", ":8080")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
server := grpc.NewServer()
hellov1.RegisterGreeterServiceServer(server, &handler.GreeterServer{})
reflection.Register(server)
log.Printf("gRPC server listening on %s", lis.Addr())
if err := server.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
reflection.Register(server)这一行建议加上,它开启gRPC反射服务,之后可以用grpcurl和grpcui直接查看接口定义和发起调试调用,作用相当于给gRPC服务加了一个自动化的接口文档。
3.3 拦截器实战:日志、panic恢复和鉴权的正确姿势
拦截器是gRPC服务端最重要的扩展点之一,它类似HTTP中间件,可以在RPC方法执行前后注入逻辑。grpc-go支持一元拦截器和流式拦截器。
一个简单的日志拦截器:
go复制func UnaryLogInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
log.Printf("method=%s duration=%s err=%v", info.FullMethod, time.Since(start), err)
return resp, err
}
panic恢复拦截器:
go复制func UnaryRecoveryInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
defer func() {
if r := recover(); r != nil {
log.Printf("panic recovered: %v", r)
err = status.Errorf(codes.Internal, "internal error: %v", r)
}
}()
return handler(ctx, req)
}
鉴权拦截器:
go复制func UnaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
tokens := md.Get("authorization")
if len(tokens) == 0 || tokens[0] != "Bearer valid-token" {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
return handler(ctx, req)
}
在grpc.NewServer里注册这些拦截器:
go复制server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
UnaryRecoveryInterceptor,
UnaryLogInterceptor,
UnaryAuthInterceptor,
),
)
ChainUnaryInterceptor会按照传入顺序执行,第一个参数是最外层拦截器。注意panic恢复拦截器要放在最外层,这样才能兜住后面所有拦截器和业务方法抛出的panic。
流式方法的拦截器写法类似,只是handler类型变成了grpc.StreamHandler,并且返回的错误需要通过grpc.ServerStream包装才能正确处理。
3.4 Keepalive参数和优雅退出:生产级服务端必须做的事
服务端不要用裸的grpc.NewServer()直接上生产,建议配置keepalive参数和优雅退出逻辑。
go复制server := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 5 * time.Minute,
MaxConnectionAge: 30 * time.Minute,
Time: 2 * time.Hour,
Timeout: 20 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 5 * time.Minute,
PermitWithoutStream: true,
}),
)
优雅退出使用信号通知:
go复制ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
log.Println("shutting down gRPC server...")
server.GracefulStop()
}()
if err := server.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
GracefulStop会停止接收新连接和请求,但会等待当前正在处理的请求完成,避免服务重启时把正在执行的请求直接掐断。这是我线上发布时一直在用的模式,实测下来对业务影响很小。
4. 客户端调用:连接初始化、超时控制与四种流式模式
4.1 建立连接:NewClient和Dial的差异,现代写法用哪个
客户端连接gRPC服务,现代grpc-go推荐使用grpc.NewClient,老代码里常见的grpc.Dial在v1.63之后被标记为废弃。主要原因在于grpc.Dial会立即发起连接,而grpc.NewClient是惰性连接,本地创建和配置过程不会阻塞,真正发起RPC时才建立连接。
go复制conn, err := grpc.NewClient(
"dns:///127.0.0.1:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("failed to create client: %v", err)
}
defer conn.Close()
insecure.NewCredentials()表示不启用TLS加密,适合内网服务之间的调用。如果走公网,一定要换成credentials.NewTLS。
客户端连接建议全局复用,不要每次调用都新建一个连接。gRPC的连接是协程安全的,单个连接支持大量并发请求,不需要自己维护连接池。如果需要连接多个实例,gRPC自带负载均衡策略,后面会讲到。
4.2 超时控制:Deadline是必须养成的习惯
每个客户端RPC调用都应该设置超时时间。gRPC的超时机制不同于HTTP的请求超时,它通过context传递一个Deadline,服务端可以感知到这个超时时间,并在超时到来时主动取消处理逻辑。
go复制ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
reply, err := client.SayHello(ctx, &hellov1.HelloRequest{Name: "alice"})
if err != nil {
if status.Code(err) == codes.DeadlineExceeded {
log.Println("request timed out")
} else {
log.Printf("call failed: %v", err)
}
return
}
log.Printf("reply: %s", reply.Message)
这个习惯很重要,没有设置超时的调用在生产环境里一旦服务端出现问题,客户端协程会一直挂在那里等待,积累到一定数量直接拖垮整个进程。我看过不少线上事故的根因就是少了这一行。
4.3 四种通信模式的客户端写法
服务端流式调用,客户端接收多个响应:
go复制stream, err := client.ListHello(ctx, &hellov1.HelloRequest{Name: "bob"})
if err != nil {
log.Fatalf("list failed: %v", err)
}
for {
reply, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Fatalf("recv failed: %v", err)
}
log.Println(reply.Message)
}
客户端流式调用,客户端发送多个请求:
go复制stream, err := client.RecordHello(ctx)
if err != nil {
log.Fatalf("record failed: %v", err)
}
for i := 0; i < 10; i++ {
req := &hellov1.HelloRequest{Name: fmt.Sprintf("user-%d", i)}
if err := stream.Send(req); err != nil {
log.Fatalf("send failed: %v", err)
}
}
reply, err := stream.CloseAndRecv()
if err != nil {
log.Fatalf("close and recv failed: %v", err)
}
log.Printf("final reply: %s", reply.Message)
双向流式调用,双方可以同时收发:
go复制stream, err := client.ChatHello(ctx)
if err != nil {
log.Fatalf("chat failed: %v", err)
}
errCh := make(chan error, 1)
// 发送协程
go func() {
for i := 0; i < 10; i++ {
if err := stream.Send(&hellov1.HelloRequest{
Name: fmt.Sprintf("chat-%d", i),
}); err != nil {
errCh <- err
return
}
}
if err := stream.CloseSend(); err != nil {
errCh <- err
}
}()
// 接收循环
for {
reply, err := stream.Recv()
if errors.Is(err, io.EOF) {
close(errCh)
break
}
if err != nil {
log.Fatalf("recv failed: %v", err)
}
log.Println(reply.Message)
}
双向流式的核心是理解Send和Recv分别在各自独立的协程里运行,服务端和客户端的发送、接收互不阻塞。这里有个经验:发送端发送完一定要调用CloseSend,否则接收端会一直等待。
4.4 protobuf生成代码的常用方法:Get和类型转换
生成代码里每个结构体都有对应的GetXxx()方法,使用req.GetName()而不是直接访问req.Name,这样可以做到nil安全。一个nil的请求对象调用Get方法会返回零值,而直接访问字段会panic。我在审查代码时看到有人直接访问字段,一旦上游传入空对象,线上就报错,这个习惯最好从一开始就养好。
4.5 错误处理:用status和codes做精细化判断
gRPC的错误处理不能只判断err是否为nil,要用status.Code(err)拿到错误码。常见错误码有:NotFound、InvalidArgument、Unauthenticated、DeadlineExceeded、ResourceExhausted、Unavailable等。服务端返回错误时也要用status.Error或status.Errorf包装,带上错误码而不是直接fmt.Errorf,这样才能让客户端做精确的分支处理。
5. 性能参数与压测调优:从默认配置到生产配置的差距
5.1 先压测再调优:用ghz快速得到基线数据
调优之前先做一轮压测,这能帮你确认问题到底是不是出在gRPC框架层。我习惯用ghz这个工具做压测,它是专门针对gRPC设计的压测客户端,支持并发、总请求数、QPS统计、延迟分布等特性。
安装和基本用法:
bash复制go install github.com/bojand/ghz/cmd/ghz@latest
运行压测:
bash复制ghz --insecure \
--call hellov1.GreeterService/SayHello \
--data '{"name":"test"}' \
--concurrency 100 \
--total 100000 \
127.0.0.1:8080
concurrency表示并发连接数,total表示总请求数。压测结束后工具会输出QPS、平均延迟、P50/P90/P99等关键指标。拿到基线之后,再针对参数进行调整,效果对比会非常直观。
5.2 服务端核心参数:消息大小、并发流、流控窗口
grpc-go服务端的默认配置里,有两个限制特别需要注意:
MaxRecvMsgSize默认4MB,超过这个大小的消息会直接报ResourceExhaustedMaxSendMsgSize默认math.MaxInt32,发送大小基本不受限
如果你的接口涉及文件上传或大数据包下载,需要显式调大接收上限:
go复制server := grpc.NewServer(
grpc.MaxRecvMsgSize(16 * 1024 * 1024),
grpc.MaxSendMsgSize(16 * 1024 * 1024),
)
如果服务端承载大量并发流,还需要关注流控窗口参数。grpc-go默认的InitialWindowSize是64KB,意味着每个流的接收窗口相对较小,在高吞吐场景下会产生额外的窗口更新帧,影响性能。InitialConnWindowSize默认是16MB,作用于连接级别。
我把常用的服务端调优参数整理成一张表:
| 参数 | 默认值 | 建议值 | 作用 |
|---|---|---|---|
| MaxRecvMsgSize | 4MB | 按业务调整 | 单条接收消息上限 |
| MaxSendMsgSize | MaxInt32 | 按业务调整 | 单条发送消息上限 |
| MaxConcurrentStreams | 无限制 | 按资源调整 | 单连接最大并发流数 |
| InitialWindowSize | 64KB | 1MB - 4MB | 单流流量控制窗口 |
| InitialConnWindowSize | 16MB | 32MB - 64MB | 连接级流量控制窗口 |
调优示例:
go复制server := grpc.NewServer(
grpc.MaxConcurrentStreams(10000),
grpc.InitialWindowSize(1 * 1024 * 1024),
grpc.InitialConnWindowSize(32 * 1024 * 1024),
)
注意:InitialWindowSize不是越大越好。窗口越大,单条流的发送端可以更快地发数据,但内存占用也同步上升。我的经验是从1MB开始压测,观察P99延迟和内存变化,有余量再往上加。
5.3 客户端性能配置:连接级参数和调用级参数
客户端的配置分两个层面。连接级参数在grpc.NewClient时设置,影响整条连接的行为:
go复制conn, err := grpc.NewClient(
"dns:///greeter.service:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(16 * 1024 * 1024),
grpc.MaxCallSendMsgSize(16 * 1024 * 1024),
grpc.WaitForReady(true),
),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: true,
}),
)
WaitForReady(true)表示在连接暂时不可用时会一直等待,而不是立刻返回Unavailable。这个选项适合对稳定性要求较高、可以容忍短暂等待的调用场景,但建议配合超时时间一起用,避免无限等待。
5.4 调优之后的真实数据参考
在一个压测场景里,我用默认配置和优化配置分别跑了同样的接口,结果对比如下:
| 配置 | QPS | P99延迟 | 内存峰值 |
|---|---|---|---|
| 默认配置 | 8200 | 28ms | 320MB |
| 调整窗口+keepalive | 12400 | 17ms | 410MB |
QPS提升约50%,P99下降接近40%,代价是内存上涨了约90MB。这个数据说明了流控窗口调优的收益,但也提示了内存成本的上升,具体调多少需要结合服务本身的并发模型来评估,并不是所有服务都适合放大窗口。
6. 生产踩坑实录:连接假死、消息超限与拦截器陷阱
6.1 连接假死:客户端长时间空闲后请求全部超时的排查链路
线上遇到过这样一个问题:某个内部服务在夜间流量低谷之后,早晨高峰时段出现大量请求超时,但服务端并没有高负载或异常日志。
排查过程分了几步。第一步,查看服务端日志,发现请求根本没有到达业务代码,说明问题出在连接层。第二步,查看客户端日志,报错信息是Unavailable和DeadlineExceeded交替出现。第三步,在客户端所在机器上抓包,发现客户端往一个已经断开TCP连接上发送数据,收到的是RST包。
根因是客户端连接长时间空闲,中间的网络设备(通常是负载均衡器或网关)因为空闲超时把TCP连接断掉了,但客户端不知道。下一次请求Redis进入这个已经失效的连接,一直等到超时。这个问题在microservice架构里很常见,尤其是连接要经过LB时。
修复方案分两边。服务端设置合理的keepalive策略,客户端开启PermitWithoutStream,让客户端在没有活跃请求时也能主动发送keepalive探测帧,保证连接的活性。具体配置参考前面4.1和5.3节。重新发布之后,这个问题没有再出现过。
6.2 消息超限:上传大文件时ResourceExhausted报错
另一个线上问题是上传超过4MB的图片时,服务端返回ResourceExhausted: grpc: received message larger than max。
排查过程很快,看到这个错误基本就能锁定是MaxRecvMsgSize超限。但容易疏忽的是:客户端和服务端都要改,而且还有两个方向要同时考虑。客户端发大消息,服务端要调大MaxRecvMsgSize;服务端返回大消息,客户端要调大MaxCallRecvMsgSize。
我当时只改了服务端,结果服务端在处理请求时又调用了另一个服务获取数据,返回的数据超过了客户端的接收上限,客户端又报了同样的错。后来把链路上下游两端的收发限制都统一调大,问题才彻底解决。
6.3 拦截器陷阱:一次鉴权拦截器的低级事故
有次我写了一个鉴权拦截器,加上之后所有调用的响应时间都变成了等于超时时间,且全部失败。排查代码发现,问题出在拦截器内部逻辑:
go复制func UnaryAuthInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
if len(md.Get("authorization")) == 0 {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
// 忘了调用 handler(ctx, req)!
return nil, nil
}
鉴权通过后没有调用handler(ctx, req),直接把nil, nil返回了。这导致客户端收到一个空响应,服务端的日志里也没有业务方法执行记录。其实这个问题本质是业务方法没有被触发,但现象很迷惑。排查时我在鉴权拦截器里加了日志,才发现请求卡在了这里。
后来我给自己定了一个规矩:只要是写拦截器,第一件事就是先确认成功路径上有没有调用handler,写完看一眼调用链上每一环的入口和出口。
6.4 调试利器:grpcurl和grpcui的日常用法
开发阶段推荐两个调试工具。grpcurl是命令行工具,快速调用接口:
bash复制grpcurl -plaintext \
-d '{"name":"debug"}' \
127.0.0.1:8080 \
hellov1.GreeterService/SayHello
grpcui是网页版工具,能自动识别注册的反射服务,生成类似Swagger UI的界面,适合在联调阶段给不熟悉gRPC的同事用:
bash复制grpcui -plaintext 127.0.0.1:8080
这两个工具都依赖反射服务,所以服务端要记得注册reflection.Register(server)。线上环境如果不想暴露反射服务,可以开关控制或者只在测试环境开启。
6.5 经验总结:把故障预案写进代码里
踩了这么多坑之后,我总结出几条生产经验:所有客户端调用必须带超时,这是第一优先级;服务端和客户端都要配置keepalive,并确保两边参数匹配;消息大小限制要在上线前按业务预期调整好;拦截器写完要检查调用链完整度。另外每次发布前,用grpcurl把核心接口跑一遍,能提前发现注册遗漏、参数不匹配这类低级问题。
这套gRPC的实战方案到目前为止已经在多个服务里稳定运行,最直接的收益是接口联调效率提升了,服务间通信的性能余量也大了很多。如果你正在规划RPC框架的落地,我的建议是先从一个非核心业务接口开始试点,跑通之后再逐步扩展。按这套流程走下来,大概率能少踩我踩过的那些坑。
