1. 为什么服务间通信最终选了gRPC,而不是继续用HTTP JSON
之前做一个内部订单系统的时候,我遇到过一个很头疼的问题:订单服务要同时给库存服务、支付服务、用户服务提供接口,一开始图省事,全用HTTP JSON。结果高峰期一到,订单查询接口的CPU占用直接飙到70%,查了一圈,大部分开销都花在JSON序列化和HTTP头解析上。后来我们把服务间调用从HTTP JSON整体切到gRPC,同样的业务逻辑,CPU占用降了大概30%,P99延迟也明显改善。这个项目让我把gRPC和Protobuf从入门到能写生产代码完整摸了一遍,这篇就把整个过程拆开讲。
先说结论:如果你在两个服务之间做通信,尤其是内部服务、低延迟要求高、字段经常变的场景,gRPC是比HTTP JSON更合适的选择。它不是一个新语言,也不是新的部署架构,而是一套RPC框架——你定义一个接口,生成客户端和服务端代码,调用远程方法就像调用本地方法一样。底层走HTTP/2,传输的数据是Protobuf二进制格式,天生带流式支持,还能双向推送。
这篇文章面向的读者是:会写基本Go代码、知道什么是HTTP接口,但没用过gRPC的人。跟着走一遍,你能得到一个完整的、能跑的gRPC服务,同时理解每个环节为什么这么设计,避开那些文档里不会明说的坑。
先说清楚gRPC和REST的本质差异。REST面向资源,你用GET去读,POST去创建,状态码表达结果,数据格式是JSON,可读性好但是解析开销大。gRPC面向方法,你直接调用一个叫CreateOrder的方法,参数是一个结构化的订单对象,返回也是一个结构化对象。这套映射关系不是靠程序员手工约定,而是由proto文件这个IDL(接口定义语言)定义出来的,客户端和服务端共享同一份proto文件,字段对不上编译就报错,不存在“文档和实际接口不一致”这种事。
另一个容易忽略的点是HTTP/2的多路复用。HTTP/2在同一个连接上可以并发处理多个请求,不像HTTP/1.1那样一个连接同时只能处理一个请求,需要靠连接池来弥补。gRPC天然利用了这个特性,所以高并发场景下,连接数量少很多,内核态开销也低。这不是说REST不能做,而是中间的网络库、代理、负载均衡器都得配套配置,才能达到同样的效果。
那什么时候不该用gRPC?如果你的调用方是浏览器,那还是老老实实HTTP JSON。因为浏览器不能直接控制HTTP/2的帧,也没有内置gRPC客户端,虽然现在有grpc-web可以绕,但体验一般。还包括对外提供的OpenAPI接口,生态和工具链上都还不适合直接上gRPC。我的原则是:外部接口用HTTP JSON,内部服务间调用用gRPC。这个边界一开始划清楚,后面省很多事。
2. 搭建环境与写出第一个proto文件
2.1 必备工具链:protoc和两个Go插件
写gRPC服务先不急着写Go代码,先把环境搭好。需要三样东西:protoc编译器、protoc-gen-go插件、protoc-gen-go-grpc插件。
protoc是个通用的IDL编译器,它本身不懂Go,是通过插件来生成Go代码的。protoc-gen-go负责生成消息体(就是你的数据结构)的代码,protoc-gen-go-grpc负责生成服务接口代码。这两个插件是分开的,因为很多场景下你只需要消息体,不需要生成服务代码——比如你只是想用Protobuf做数据序列化,而不是完整启用gRPC。
安装命令如下:
# mac brew install protobuf # ubuntu/debian apt install -y protobuf-compiler # Go插件 go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest # 设为PATH export PATH="$PATH:$(go env GOPATH)/bin"这里要重点提醒一个问题:protoc和Go插件的版本兼容性。如果你在公司项目里发现生成代码报错,八成是protoc版本太旧,装了新的Go插件却不支持旧语法。建议装好之后先跑一下protoc --version确认版本,至少是3.x版本,别用2.x的。
2.2 写一个最小可用的订单服务定义
新建一个order.proto文件。我建议每个服务单独建一个proto目录,比如api/proto/v1/,目录里只放proto文件,生成的Go代码放在api/gen/里,与手写的逻辑代码分离。
最小示例:
syntax = "proto3"; package order.v1; option go_package = "github.com/yourname/orderdemo/api/gen/order/v1;orderpb"; message Order { string order_id = 1; string user_id = 2; repeated string item_ids = 3; double total_amount = 4; int64 create_time = 5; } service OrderService { rpc GetOrder(GetOrderRequest) returns (Order); } message GetOrderRequest { string order_id = 1; }这段文件里有几个关键信息。syntax = "proto3"声明使用proto3语法,proto2最大的区别是proto3字段没有required和optional之分,所有字段都是可选的,默认值为零值。option go_package控制生成的Go包路径和包名,这行不写的话,生成的代码会缺少package声明,根本编译不过。message描述数据结构,service描述RPC接口。
准备工作做好后,执行生成命令:
protoc --go_out=. --go_opt=paths=source_relative \ --go-grpc_out=. --go-grpc_opt=paths=source_relative \ api/proto/v1/order.protopaths=source_relative是按proto文件路径生成,保持目录结构一致,我强烈建议这个参数。如果不加,生成的文件路径会跟着go_package走,很容易生成到意想不到的位置。
生成之后你的目录里出现两个文件:order.pb.go和order_grpc.pb.go。前者是消息体代码,后者是服务接口代码。提醒一下:这两个文件不要手动改,所有变更都改proto文件,再重新生成。
2.3 生成代码里到底长什么样
新手第一次看生成的代码容易懵,因为太长了。但核心就是三块。
消息体结构体的定义,字段名全部转成了首字母大写:
type Order struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"` UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` ItemIds []string `protobuf:"bytes,3,rep,name=item_ids,json=itemIds,proto3" json:"item_ids,omitempty"` TotalAmount float64 `protobuf:"varint,4,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"` CreateTime int64 `protobuf:"varint,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` }注意字段名从order_id变成了OrderId,json tag也加了omitempty。repeated string对应Go里的[]string,这是proto3的数组表达。服务端接口长这样:
type OrderServiceServer interface { GetOrder(context.Context, *GetOrderRequest) (*Order, error) mustEmbedUnimplementedOrderServiceServer() }mustEmbedUnimplementedOrderServiceServer()是grpc-go新版加进去的,目的是让老服务端在新增接口时不会被强制破坏实现。如果你没有实现某个方法,调用时会返回Unimplemented错误码,而不是编译错误。这个机制很贴心,但它也带来了一个隐蔽的坑:你写一个结构体嵌入了UnimplementedOrderServiceServer,却忘了实现某个方法,服务能正常启动,客户端一调用就直接报错。调试时如果发现“实现了但调用不了”,第一反应应该去看嵌入结构体。
3. Protobuf语法关键点:字段编号、内置类型与常见陷阱
3.1 字段编号不是随便填的,它们直接影响二进制大小
proto3里每个字段都有一个数字编号,这个编号不是给人看的序号,而是二进制编码中的字段标识。字段编号1到15占用1个字节,16到2047占用2个字节,编号越大,序列化后占用的空间越大。所以高频字段应该用小编号,低频字段用大编号。这不是强迫症,在字段特别多的message里,这种优化能省下不少带宽。
另一个规则是:字段编号一旦确定了,以后尽量不要改。因为线上跑着很多老客户端,老代码是用旧编号生成的序列化/反序列化逻辑,你把字段编号从2改成7,老客户端解析新数据时会直接认为这是一个未知字段,静默丢弃。这就是为什么proto文件要写字段注释、要做评审,改编号在大多数团队里属于需要重点提示的变更。
3.2 proto3的默认值处理:为什么“字段好像没传”
这一块是团队协作里最容易产生分歧的地方。proto3里所有字段都是可选的,如果发送方没有设值,接收方拿到的就是零值。int类型是0,string类型是空字符串,bool是false,repeated是空的slice。
问题来了:你根本无法区分“用户真的填了0”和“用户没填”。比如一个订单金额字段,如果为0表示“免单”,还是表示“尚未计算”?业务上有这种二义性需求的时候,proto3不能直接表达,你有两个选择:用wrapper类型(google.protobuf.Int64Value这类),把字段包成一个对象,对象为nil就是没设值;或者用oneof包一层。前者最常用,代价是多了几字节的嵌套开销。
3.3 bytes和string:传输文件别选string
proto3的string类型必须是UTF-8编码,这是protobuf实现层会校验的。如果你要传文件内容、图片数据、加密串,用bytes。bytes的序列化开销和string一样,但不会做UTF-8校验,更安全。而且用bytes时生成代码对应Go的[]byte,内存拷贝上也没那么多隐性问题。
3.4 oneof和map的使用边界
oneof表示一组字段在同一时刻只能有一个有值,类似Go里的union语义。典型的场景是支付回调:可能是支付宝回调,可能是微信回调,但不会同时出现。用oneof包的字段在生成的Go代码里体现为isCallback_Oneof接口加具体实现类,在读取时得用type switch判断,比普通字段麻烦一点,但语义更准确。
map类型在处理动态键值对时很有用,比如扩展属性。但注意:map里的value不能是另一个map,也就是说不能嵌套map。而且map在序列化时是无序的,所以不能依赖顺序。如果是要保持插入顺序的字典,只能退回到repeated一个key-value的message。
3.5 enum的坑:未知枚举值
proto3里的枚举字段,如果收到一个未定义的枚举值,默认把它当成0处理。这就是为什么我建议每个枚举都显式定义0值,比如UNKNOWN_STATUS = 0,否则你收到一个0时根本不知道数据是缺失还是异常。这和你字段默认值的问题其实是同源的,设计proto时要养成习惯:0值永远代表“未知”或“默认”,不要承担具体的业务含义。
4. gRPC的核心机制:四种通信模式与适用场景
4.1 Unary RPC:最简单也最常用
服务端实现和客户端调用代码最直接:
// 服务端 func (s *OrderServer) GetOrder(ctx context.Context, req *orderpb.GetOrderRequest) (*orderpb.Order, error) { order := fetchFromDB(req.OrderId) return order, nil } // 客户端 conn, _ := grpc.NewClient("localhost:8080") client := orderpb.NewOrderServiceClient(conn) order, err := client.GetOrder(ctx, &orderpb.GetOrderRequest{OrderId: "123"})注意新版grpc-go推荐用grpc.NewClient()而不是grpc.Dial(),后者在新版本里已经被标记为废弃了。核心逻辑没有太多可讲的,和普通函数调用几乎一样。但在生产环境里,首选要考虑的是这个调用什么时候会超时,客户端一定要传一个带超时时间的ctx,不然一个服务端卡死会连带一堆调用方卡住。
4.2 Server-side streaming:典型的下发数据流
服务端持续向客户端返回数据。典型场景是订阅事件、数据批量导出。定义方式:
rpc ListOrders(ListOrdersRequest) returns (stream Order);客户端不能等一个完整的响应,而要循环Recv(),每次收到一个Order就处理一个。实现要点在于客户端读循环:
stream, _ := client.ListOrders(ctx, &orderpb.ListOrdersRequest{UserId: "u1"}) for { resp, err := stream.Recv() if err == io.EOF { break // 服务端主动关闭,流结束 } if err != nil { log.Fatal(err) } handleOrder(resp) }io.EOF在客户端读流时代表的是“正常结束”,这是一个容易误解的地方,很多人会把它当成错误处理。
4.3 Client-side streaming:适合大包传小回
客户端持续上传,服务端最后返回一个汇总结果。场景包括日志批量上报、文件分块上传、批量数据导入。定义方式是rpc Upload(stream Chunk) returns (UploadResult)。
客户端调用时通过stream.Send()一条一条发送,发完后调用stream.CloseAndRecv()获得最终结果。如果中途出错,要调用stream.CloseAndSend()再通过stream.RecvMsg()处理对方返回的错误。这里有一个很重要的细节:CloseAndRecv()调用之后就不能再向流里发送数据了,如果你想重发数据,需要重新建一个流。
4.4 Bidirectional streaming:最强大但最难调试的模式
双向流是gRPC最吸引人的特性,客户端和服务端可以同时向对方发送数据,真正实现消息的实时双向推送。语音通话、AI对话、实时监控都是它的场景。
实现上需要注意:客户端如果直接写:
for { req := <-requestCh stream.Send(req) } resp, _ := stream.Recv()这样是很危险的。因为没有并发控制,如果请求发得比服务端处理快,内部缓冲会越积越多。我自己的做法是起两个goroutine,一个负责Send,一个负责Recv,用select监控错误。核心要点是:在同一个goroutine里先Send再Recv,是很多线上问题的根源。
这四种模式不是说哪种高级就用哪种,而是要根据业务特点去选。请求-响应用Unary,数据源集中在服务端用Server streaming,客户端生成海量数据用Client streaming,需要实时交互才是Bidirectional。我见过团队一上来就搞双向流,结果只是为了一个轮询能拿到最新状态,白白增加了异常的排查难度。
5. 完整实现一个订单服务:从proto到可跑通的代码
5.1 服务端实现完整流程
完整的服务端分为三块:定义业务逻辑、启动gRPC Server、监听端口并注册服务。下面这个示例是一个能直接跑起来的骨架:
package main import ( "context" "log" "net" "google.golang.org/grpc" orderpb "github.com/yourname/orderdemo/api/gen/order/v1" ) type orderServer struct { orderpb.UnimplementedOrderServiceServer } func (s *orderServer) GetOrder(ctx context.Context, req *orderpb.GetOrderRequest) (*orderpb.Order, error) { return &orderpb.Order{ OrderId: req.OrderId, UserId: "u100", ItemIds: []string{"i1", "i2"}, TotalAmount: 19.9, CreateTime: 1700000000, }, nil } func main() { lis, err := net.Listen("tcp", ":8080") if err != nil { log.Fatalf("failed to listen: %v", err) } s := grpc.NewServer() orderpb.RegisterOrderServiceServer(s, &orderServer{}) log.Println("server listening at ", lis.Addr()) if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) } }启动顺序有坑:net.Listen必须在grpc.NewServer之前,如果反过来,listen失败时服务已经创建好了,却没人负责释放资源,容易泄漏fd。s.Serve(lis)是一个阻塞调用,所以服务启动一般放在一个goroutine里,配合信号量优雅关停。
5.2 客户端完整实现
package main import ( "context" "log" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" orderpb "github.com/yourname/orderdemo/api/gen/order/v1" ) func main() { conn, err := grpc.NewClient("localhost:8080", grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() client := orderpb.NewOrderServiceClient(conn) ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() resp, err := client.GetOrder(ctx, &orderpb.GetOrderRequest{OrderId: "123"}) if err != nil { log.Fatalf("request failed: %v", err) } log.Printf("got order: %+v", resp) }这里有两个细节值得解释。
grpc.WithTransportCredentials(insecure.NewCredentials())是明文传输。本地开发可以这么干,但生产环境如果不做TLS,内部数据就是裸奔。某些公司内部网络足够安全,就会省掉TLS,用明文在内网跑。如果你做的是跨网络的系统,请务必配置TLS,具体用credentials.NewServerTLSFromFile加载证书。
conn.Close()这个defer在真实项目里要打一个问号。如果客户端是个常驻进程,关闭连接反而是错的,因为连接是复用的,每次RPC都会复用同一个conn。只有在进程要退出的地方才需要关闭conn,所以不要在每次请求的函数里去defer Close,那等于把连接池全部销毁重建。
5.3 注册与启动的完整链路验证
跑起来之后,你可以用命令行工具grpcurl验证服务是否正常:
grpcurl -plaintext -d '{"order_id":"123"}' localhost:8080 order.v1.OrderService/GetOrder我最担心的是很多人跑完这一步就以为完事了,把proto文件、生成的代码、服务代码一股脑提交了。实践建议是:生成代码要不要入库,你们团队得有个共识。我个人的习惯是生成的pb.go文件单独放一个package,入库,因为可以让所有同事在编译时不依赖本机的protoc版本,配合CI去校验proto文件和提交的pb.go是否同步。
6. 生产环境必备的拦截器、错误码与超时重试设计
6.1 用拦截器统一处理日志、鉴权和panic恢复
生产级服务不会在每个方法里写重复的日志和鉴权逻辑,gRPC提供了拦截器机制。它和HTTP middleware的概念一样,在请求前后插入公共逻辑。有Unary拦截器和Stream拦截器两种,前者处理普通RPC,后者对流式RPC有效。
一个简单的服务端Unary拦截器:
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, 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 } s := grpc.NewServer(grpc.UnaryInterceptor(loggingInterceptor))gRPC的UnaryInterceptor只能注册一个,如果日志、鉴权、恢复要做三件事,不要叠加注册,因为后面的会把前面的覆盖掉。用grpc.ChainUnaryInterceptor()把它们串起来,这个API是官方专门为了组合拦截器提供的。
panic恢复在默认情况下不会做,服务端goroutine一旦panic,整个进程都会崩。我强烈建议第一个被链接的拦截器就是recover,对整个服务的稳定性是决定性的。生产环境里你不希望某个请求数据异常就把整个服务打挂。
6.2 错误码设计:grpc status code怎么用
gRPC自带一组标准状态码,从0到16。核心几个:OK(0)、InvalidArgument(3)、NotFound(5)、AlreadyExists(6)、PermissionDenied(7)、DeadlineExceeded(4)、Unavailable(14)。这些码和HTTP状态码的语义不完全一样,但可以类比理解。
服务端返回错误:
import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) func (s *orderServer) GetOrder(ctx context.Context, req *orderpb.GetOrderRequest) (*orderpb.Order, error) { order := fetchFromDB(req.OrderId) if order == nil { return nil, status.Error(codes.NotFound, "order not found") } return order, nil }客户端匹配错误:
_, err := client.GetOrder(ctx, &orderpb.GetOrderRequest{OrderId: "123"}) if status.Code(err) == codes.NotFound { // 处理订单不存在 }最关键的原则是:编码错误不要用String拼,直接用status.Error()。你如果返回一个普通的fmt.Errorf,客户端收到的code永远都是Unknown,排查问题时毫无头绪。
6.3 超时与重试:从客户端和服务端两个视角看
超时设置要分两层。客户端context.WithTimeout是总超时,它控制从发起请求到拿到响应的最长时间;还有每层调用会有各自的超时策略,比如连接建立的超时。
服务端的处理也要考虑:如果客户端断了,handler里的ctx会被取消。所以写代码时要注意,不要把ctx传给慢速的下游,然后再加一个context.WithTimeout(time.Second*5)来保护。推荐写法是newCtx, cancel := context.WithTimeout(ctx, 3*time.Second),用子ctx去控制下游调用的超时。
gRPC本身自带重试机制,但默认是关闭的。配置方式:
grpc.NewClient("localhost:8080", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultServiceConfig(`{ "methodConfig": [{ "name": [{"service": "order.v1.OrderService"}], "retryPolicy": { "maxAttempts": 3, "initialBackoff": "0.1s", "maxBackoff": "1s", "backoffMultiplier": 2.0, "retryableStatusCodes": ["UNAVAILABLE"] } }] }`), )这里有一个关键的心得:重试策略里只把UNAVAILABLE作为可重试状态码,千万不要把NotFound、InvalidArgument这些放进去。幂等的理念不只在HTTP场景成立,gRPC一样。尤其是服务端已经收到请求并开始写数据库了,客户端重试就会造成重复数据。如果你的业务在服务端不做幂等校验,那宁可不要简单地依赖自动重试。
7. 进阶必做的三件事:连接复用、服务发现与流控
7.1 连接管理:conn不是越多越好
gRPC的连接在一个grpc.ClientConn上对所有RPC复用。一个conn内部维护一组HTTP/2连接,每个连接有并发流上限。在高并发下,不要创建一个新的conn去发请求,而是复用同一个conn。你如果自己实现了连接池,也要确认grpc-go内部没有对conn做重复创建。
连接关闭时的状态也很重要。如果你的客户端是一个长驻服务,持有conn不放,服务端重启了你没感知到,第一次请求就会拿到一个连接错误。gRPC的WithConnectParams可以配置重连参数,比如:
grpc.WithConnectParams(grpc.ConnectParams{ MinConnectTimeout: 5 * time.Second, })也可以主动监听连接状态变化。实现grpc.WaitForStateChange或者用conn.GetState() == connectivity.TransientFailure来判断。这是排查基础设施问题时重要的调试入口。
7.2 服务发现与负载均衡:不只是DNS轮询
当服务有多实例时,客户端需要知道应该往哪个实例上发请求。最简单的机制是DNS轮询,但DNS TTL长,实例上下线感知慢,不适合容器化频繁变动的环境。
比较常见的方案是:服务端启动后把地址注册到注册中心,客户端监听注册中心,拿到实例列表,交给grpc的负载均衡器来分配。grpc-go的resolver和balancer接口为这类扩展预留了空间。如果你团队用某个特定的注册中心,通常都有对应的官方或社区实现。
在还没有上注册中心的阶段,至少要做的一件事是:客户端启动时通过多个地址列表创建conn。这个大家都知道的grpc.Dial("ip1:port,ip2:port")方式在新版本中已经不支持直接传逗号分隔的多地址了,要用WithDefaultServiceConfig配合round_robin负载均衡策略,把实例列表塞到resolver里面。这块非常容易踩版本差异的坑,做之前先确认grpc-go的版本。
7.3 流量控制与背压:双向流里最容易失控的地方
HTTP/2的流控是自带的能力,gRPC每个流都有流入窗口和流出窗口。默认的窗口大小是64KB,当客户端读得慢、服务端发得猛时,TCP级别的背压会通过HTTP/2的流控体现出来。
如果你做双向流,尤其涉及大消息量,建议调大初始窗口大小和连接窗口大小。配置方法是WithInitialWindowSize(1<<26)和WithInitialConnWindowSize(1<<30),单位是字节。经验之谈:当窗口只有64KB而单个消息就超过这个值时,消息会被卡死等待窗口刷新,表现为流上长时间没有数据,同时CPU却不怎么占用。看一眼这两个参数就能定位问题。
另一个边界是单条消息的大小。gRPC默认单条消息最大4MB,超过直接报ResourceExhausted错误。如果要传大文件,正常的做法是分片流式上传,而不是硬调MaxRecvMsgSize。这个参数不是不能调,而是你一旦依赖了很大的消息体,又跨机房传输,内存会成倍增长,反而容易触发更严重的问题。
8. 我踩过的坑和值得单独记录的经验
8.1 protoc和Go插件版本不匹配,生成代码报错
最开始我在本机装的是protoc 3.19,然后新换了protoc-gen-go插件,一生成代码就报错。这是因为新插件生成的代码要求proto的runtime版本足够新,而protoc可能不认识新版语法。解决方案就是:升级protoc到最新release,或者使用和插件匹配的固定版本。最好在仓库里用Makefile固定工具版本,避免每个同事本机环境不同导致生成结果也不同。
8.2 grpc.Dial被废弃:尽早切换到grpc.NewClient
新版grpc-go把grpc.Dial标记为废弃之后,用老代码示例训练出来的习惯在新版本里发现编译虽然没问题,但IDE一直有横线提示。grpc.NewClient的语义是惰性连接,不像Dial那样会立刻建立连接,这使得某些依赖连接建立的初始化逻辑需要调整。实际影响并不大,但切到新API可以让代码更符合长期方向。
8.3 拦截器顺序:recover要放在最外层
拦截器的执行顺序符合洋葱模型,最先注册的拦截器是外层,后注册的是内层。如果recover在最内层,panic发生后虽然被捕获了,但外层的日志可能已经输出了一半,导致日志缺失或顺序错乱。所以recover必须最早注册,才能包住后面所有的逻辑。
8.4 流式接收的EOF判断
服务端在调用stream.Send(msg)之后,如果客户端已经调用了stream.Context()取消,继续Send会返回context.Canceled。你在服务端写发送循环时,不能只盯着Send的返回值,也要在每轮循环里检查stream.Context().Err(),否则可能白白消耗CPU,直到连接彻底断开。
这一小节也不能漏提:gRPC服务对外暴露健康检查是很有价值的。现在很多主流微服务治理体系都需要gRPC健康检查协议来判定节点状态,标准做法是用grpc-health-probe配合服务端注册健康检查服务,这套机制虽然简单,但它决定了你的实例是否被剔除出流量池。我是建议从一开始就加上。
9. 后续可以怎么扩展这套代码
跑通一个服务只是起点。按照我现在的习惯,接下去一般会做三件事。
第一件事是引入优雅关停。用signal.NotifyContext监听SIGTERM,收到信号后调用grpc.Server.GracefulStop(),让正在处理的请求完成再退出。框架自带的GracefulStop能等所有RPC处理完,但如果有长轮询或流式请求挂着,需要设置一个超时兜底,否则退出流程会被拖死。
第二件事是给proto文件加上版本管理。我见过很多团队proto目录越塞越乱,最后都不知道哪个文件是线上真实在跑的。比较好的方式是按api/v1/、api/v2/这样划分目录,一个目录里文件只做增量修改,不能破坏兼容性,breaking change就开新版本目录。配合CI里的buf lint检查,基本能防止接口腐化。
第三件事是加上可观测性。前置的拦截器可以顺手把method、code、duration这些指标交给Prometheus,gRPC官方有对应的metrics包可以直接用。你只要把统一拦截器写好,后面每个服务都复制这套逻辑就能拥有可观测性。这比在业务代码里打点要干净得多。
最后分享一个让初学者上手最快的方法:别从空项目开始,先拿官方给的helloworld例子改,把一个字段、一个方法逐步换成你要的业务字段,每换一次就重新生成代码,编译一次。整个过程跑三到五遍,你对gRPC的这套流程会形成肌肉记忆,后面的踩坑概率会大幅降低。