选型复盘:为何从RESTful迁移至gRPC

前年我们那个日活 120 万左右的电商订单系统,经历了一次比较痛苦的性能瓶颈。当时订单服务和库存服务之间的调用全是基于 Spring Cloud Netflix 那套 RESTful API,用的还是 JSON 序列化。平时 QPS 在 2000 左右的时候还没啥感觉,一到大促峰值冲到 1.5 万 QPS,订单创建接口的平均响应时间直接从 120ms 飙到了 800ms 多,数据库连接池倒是没满,CPU 倒是先扛不住了。

后来我带着两个后端同学花了两个周末做压测分析,发现一个很要命的问题:订单服务调库存服务,一次扣减请求光是 JSON 序列化/反序列化就占了 35% 的 CPU 时间。而且 HTTP/1.1 本身没有多路复用,每个请求都得建一个连接或者走连接池,在高并发下连接池的锁竞争也挺严重。我们当时算过一笔账,一次库存查询请求,JSON 包体大小平均是 2.3KB,而换成 Protobuf 之后直接压到了 400 字节出头,网络传输的耗时直接砍掉了一半多。

迁移到 gRPC 之后,我们用的版本是 v1.62.0(现在最新已经是 v1.64.0 了,当时刚上线时还没更新到最新),效果确实立竿见影。同样的压测场景下,平均延迟降到了 120ms 左右,CPU 使用率也从原来的 85% 掉到了 40% 不到。这里我给个我们当时做的真实对比数据:

| 指标 | 迁移前 (REST/JSON) | 迁移后 (gRPC/Protobuf) |

| :--- | :--- | :--- |

| 峰值 QPS | 15,000 | 28,000 |

| 平均延迟 | 820ms | 115ms |

| 单次请求包大小 | 2.3KB | 0.4KB |

| CPU 使用率 (4C8G 容器) | 85% | 38% |

其实一开始团队里也有争议,有人觉得 RESTful 大家都熟,gRPC 学习成本高。但我坚持换的原因很简单:我们内部服务调用根本不需要浏览器直接访问,也不需要那些花里胡哨的 JSON 可读性,要的就是极致的性能和紧凑的传输。

这里顺便提一个我们实际遇到的问题。刚切 gRPC 第一版上线时,我们发现偶尔会有请求超时,排查了很久。最后发现是我们在 Go 客户端里没设置合理的 context.Deadline。gRPC 原生支持超时与取消机制,如果你不传截止时间,一旦后端库存服务卡住,请求就会一直挂着,资源耗尽只是时间问题。后来我们在代码里统一加了 500ms 的硬性超时:

// 订单服务调用库存服务的实际代码片段 func (o *OrderService) DeductInventory(ctx context.Context, req *pb.DeductReq) (*pb.DeductResp, error) { // 关键:设置 500ms 的截止时间,防止库存服务卡死导致资源耗尽 ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() // 调用 gRPC 库存服务 resp, err := o.inventoryClient.Deduct(ctx, req) if err != nil { // 这里得处理 status.FromError,别直接打 err.Error() st, ok := status.FromError(err) if ok { log.Printf("库存扣减失败, code: %v, msg: %s", st.Code(), st.Message()) } return nil, err } return resp, nil }

另外,gRPC 默认基于 HTTP/2,支持头部压缩,这对我们这种请求头带一堆链路追踪 ID 和鉴权信息的系统来说,省下的流量也不少。不这么做的话,HTTP/1.1 每次都要传完整的文本头,积少成多也是一笔开销。

Proto3契约优先:定义多语言异构系统的强类型IDL设计模式

我们现在的系统挺杂的,核心订单和支付是 Go 写的,推荐算法那块是 Python,还有一部分老的管理后台是 Java。以前用 RESTful 的时候,大家各自定义接口,光一个“用户结构体”就能变出十几种花样,字段名一会儿叫 user_id 一会儿叫 userId,搞得对接的时候经常扯皮。

迁移 gRPC 的时候,我定的规矩是:一切从 .proto 文件出发。我们直接用 Protocol Buffers v3 (proto3) 语法,这也是 gRPC 核心项目现在的默认标准。我不会先写代码再生成接口,而是先写 .proto 文件,然后通过 protoc 生成各语言的代码。

这样做最大的好处是“契约先行”。比如我们要定义一个库存服务的接口,我是这么写的:

syntax = "proto3"; package inventory.v1; option go_package = "github.com/our-company/ecommerce/api/inventory/v1;inventoryv1"; option java_package = "com.ourcompany.ecommerce.inventory.v1"; option java_multiple_files = true; // 库存服务定义 service InventoryService { // 查询库存 rpc QueryStock(QueryStockRequest) returns (QueryStockResponse); // 扣减库存 rpc DeductStock(DeductStockRequest) returns (DeductStockResponse); } // 请求结构 message QueryStockRequest { repeated int64 product_ids = 1; // 支持批量查询,注意用 repeated } message DeductStockRequest { int64 order_id = 1; // 幂等性设计,防止重复扣减 int64 product_id = 2; int32 quantity = 3; string operator = 4; // 操作人 } // 响应结构 message QueryStockResponse { map<int64, int32> stocks = 1; // key: product_id, value: stock_count } message DeductStockResponse { bool success = 1; int32 remaining = 2; // 扣减后剩余库存 }

这里我得分享一个我们踩过的细节。刚开始写 proto 的时候,我有个字段定义成了 int32,后来业务扩展需要存一个可能超过 2 亿的数字(库存流水 ID),结果上线后发现 Python 端解析出来是负数。查了半天,原来是 proto3 里 int32 是变长编码,范围不够用了。后来全量排查改成了 int64。这种强类型语言里的坑,如果在 RESTful 里用 JSON 的 number 可能没这么明显,但在 gRPC 里,IDL 定义错了就是全语言的兼容性问题。

还有,为什么我坚持用 map 而不是重复定义子结构?因为在我们查询库存的场景里,就是一个简单的 product_id 对应 stock 的映射,用 map 生成的代码在 Java 和 Go 里操作起来都极其方便,不需要再手动做循环转换。

生成代码的时候,我们 CI 里用的是 protoc-gen-goprotoc-gen-go-grpc 的版本是 v1.3.0 左右。Java 那边用的是 protobuf-java 4.x 版本。只要 proto 文件不变,两边生成的代码接口是绝对一致的。

说实话,对于多语言团队,这种“先写合同再干活”的模式虽然前期会稍微慢一点,但省去了后期无穷无尽的联调时间。你不这么做的话,Go 服务改了个字段类型,Java 服务那边编译不出错,跑起来才报错,这种在线上就是事故。

深度解析:基于HTTP/2的多路复用与四种通信模式实战

很多人知道 gRPC 快是因为基于 HTTP/2,但具体怎么个快法,我得结合我们那个实时物流轨迹推送的场景聊聊。HTTP/1.1 最大的痛点是队头阻塞,一个 TCP 连接同时只能处理一个请求。而 HTTP/2 的多路复用允许我们在一条连接上并发处理多个请求和响应。

在我们的物流系统里,司机端 APP 需要实时上报位置,同时后台也要实时把订单状态推给司机。如果按以前 HTTP/1.1 的思路,要么长轮询,要么就得保持好多长连接。现在用 gRPC 的双向流(Bidirectional Streaming),一个连接就能搞定上下行。

gRPC 的四种模式里,普通 RPC 和单向流大家用的多,双向流是最容易让人懵的。我先说个结论:双向流不是简单的“你发一个我回一个”,而是两个独立的流在同一个 TCP 连接上并行跑。

下面是我们物流服务的 .proto 定义,用的是双向流:

syntax = "proto3"; package logistics.v1; service LocationService { // 双向流:司机实时上报位置,服务端实时下发调度指令 rpc StreamLocation(stream LocationUpdate) returns (stream DispatchCommand); } message LocationUpdate { int64 driver_id = 1; double lat = 2; double lng = 3; int64 timestamp = 4; } message DispatchCommand { int64 order_id = 1; string command = 2; // 比如 "PICKUP", "DELIVER" string payload = 3; }

Go 语言的服务端实现大概长这样,这是我们线上跑的代码简化版:

func (s *LocationServer) StreamLocation(stream pb.LocationService_StreamLocationServer) error { // 这里 stream 是一个双向的管道 for { // 接收司机发来的位置更新(客户端流) loc, err := stream.Recv() if err == io.EOF { // 客户端发完了 return nil } if err != nil { log.Printf("接收位置失败: %v", err) return err } // 模拟处理逻辑:根据位置判断是否需要下发指令 if shouldDispatch(loc) { cmd := &pb.DispatchCommand{ OrderId: generateOrderId(), Command: "PICKUP", Payload: "新订单,请前往指定地点", } // 发送指令给司机(服务端流) if err := stream.Send(cmd); err != nil { log.Printf("下发指令失败: %v", err) return err } } } }

客户端(司机端)的代码逻辑是:开两个 goroutine(如果是 Go 的话),一个负责 Send,一个负责 Recv

这里有个非常实际的性能点。因为基于 HTTP/2,这些 SendRecv 都是在同一个连接上复用的。我们司机端平时在线大概 5 万台设备,如果按 HTTP/1.1 做长轮询,光建立连接的开销和端口占用就能把负载均衡打挂。用了 gRPC 双向流,连接数直接降了一个数量级。

关于 负载均衡,这也是个大坑。gRPC 因为是长连接,传统的基于 HTTP/1.1 的 L7 负载均衡(比如 Nginx 老版本)没法很好地做 gRPC 的负载均衡,因为它是把连接作为最小粒度的。我们现在的做法是客户端侧负载均衡(Lookaside Balancing),客户端自己从注册中心拉取节点列表,然后轮询或者加权随机选择一个节点建立长连接。

还有一次线上事故,我发现某个节点的内存占用特别高。排查下来是因为双向流里,如果客户端突然断网(不是优雅关闭),服务端这边 stream.Recv() 可能会因为拿不到 EOF 而一直阻塞,导致 goroutine 堆积。后来我给所有的流操作都加了一个带 context 的控制,利用 gRPC 的取消机制,一旦检测到主 context 被取消(比如收到客户端的 RST 或者超时),服务端这边的流也会自动结束,这才解决了 goroutine 泄漏的问题。

如果你现在的微服务内部通信还卡在 JSON 和 HTTP/1.1 上,真的建议试试 gRPC。特别是现在 gRPC over HTTP/3 (QUIC) 也在逐步落地,对于弱网环境下的移动端性能提升会更大。我们在测试环境试过 QUIC 版本,在模拟 20% 丢包的网络下,双向流的恢复速度比 HTTP/2 快了不是一点半点。

4. 生产级进阶:K8s环境下的gRPC健康检查与客户端侧负载均衡配置

我们去年把支付服务从 REST 切到 gRPC 的时候,在 Kubernetes 里踩了个大坑。当时用的 v1.60.0 版本的 gRPC,Pod 启动后总是要等好几分钟才能接流量,有时候甚至直接被健康检查打死。后来我才反应过来,K8s 默认的 tcpSocket 检查对 gRPC 来说太不靠谱了,它只能看端口通不通,根本不知道服务是不是真的准备好了。

后来我们直接上了 gRPC 原生的健康检查协议。gRPC 其实内置了一个标准的健康检查服务(grpc.health.v1.Health),你只要在服务端实现它,K8s 那边用 grpc-health-probe 这个工具去探测就行了。这比自己写个 /health 接口要标准得多,尤其是在多语言环境下,Go 和 Java 服务都能统一用这一套。

K8s 健康检查配置

我们在 Deployment 里是这样配的,直接用 grpc-health-probe 作为 sidecar 或者直接打进基础镜像里:

apiVersion: apps/v1 kind: Deployment metadata: name: payment-service spec: template: spec: containers: - name: payment-service image: myrepo/payment-service:v1.2.0 ports: - containerPort: 50051 readinessProbe: exec: command: ["/bin/grpc_health_probe", "-addr=:50051", "-service=payment.PaymentService"] initialDelaySeconds: 5 periodSeconds: 10 livenessProbe: exec: command: ["/bin/grpc_health_probe", "-addr=:50051"] initialDelaySeconds: 15 periodSeconds: 20

这里有个细节,readinessProbe 里我加了一个 -service=payment.PaymentService 参数。这是因为我们服务启动时要加载很多商户配置到内存,虽然端口开了,但业务逻辑还没热好。如果不指定具体的服务名,探针只会检查服务器是不是活着,而不是能不能干活。

客户端侧负载均衡

很多人以为 gRPC 在 K8s 里跑,直接配个 Service 做负载均衡就行了。其实不行,因为 gRPC 是基于 HTTP/2 的长连接。一旦客户端连上了 Service 背后的某个 Pod,这个连接就会一直保持,后续的请求全都会打在那个 Pod 上,导致负载极度不均。我之前就见过一个集群,3 个实例,有 2 个 CPU 不到 10%,另一个却跑满了。

解决办法是用 客户端侧负载均衡。我们在 Go 客户端里是这样配的,利用 grpc-goresolverbalancer

package main import ( "context" "log" "time" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/resolver" "google.golang.org/grpc/balancer/roundrobin" ) func main() { // 这里的 dns:/// 是关键,告诉 gRPC 使用 DNS 解析器 // 它会定期解析 DNS A 记录,感知到 Pod 的扩缩容 target := "dns:///payment-service.default.svc.cluster.local:50051" conn, err := grpc.Dial( target, grpc.WithTransportCredentials(insecure.NewCredentials()), // 指定负载均衡策略为轮询(Round Robin) // 如果不指定,默认是 pick_first,也就是只连第一个解析出来的 IP grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`), // 开启健康检查,自动剔除不健康的后端节点 grpc.WithUnaryInterceptor(healthCheckInterceptor), ) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() // 模拟调用 client := pb.NewPaymentServiceClient(conn) for i := 0; i < 10; i++ { resp, err := client.ProcessPayment(context.Background(), &pb.PaymentRequest{Amount: 100}) if err != nil { log.Printf("Error: %v", err) continue } log.Printf("Payment ID: %s", resp.TransactionId) time.Sleep(1 * time.Second) } }

这段代码里最关键的其实是 grpc.WithDefaultServiceConfig。如果你不显式指定 round_robin,gRPC 默认的策略是 pick_first,它会从 DNS 结果里挑第一个 IP 连到底,根本达不到负载均衡的效果。我们当时就是因为少了这一行,导致新扩容的 Pod 完全没有流量进来,老 Pod 快被压垮了。

5. 踩坑实录:一次gRPC连接耗尽导致的线上OOM排查与调优过程

那是一个周五的下午,我们的订单中心突然开始疯狂报警,JVM 的堆内存飙升到了 8G 上限,紧接着就是 Full GC 和 OOM。当时我们的订单服务用的是 grpc-java1.58.x 版本,调用下游的库存服务。

排查过程

我第一反应是看监控,发现堆内存里全是 io.grpc.netty.shaded.io.netty.channel.Channel 相关的对象。这很明显是连接没释放。我们当时的代码是这样写的:

// 错误示范:每次请求都创建新的 Channel public class OrderService { public void createOrder(Item item) { // 这是大忌!每次调用都 new 一个 ManagedChannel ManagedChannel channel = Grpc.newChannelBuilder("inventory-service:50051", InsecureTrustManagerFactory.INSTANCE).build(); InventoryServiceGrpc.InventoryServiceBlockingStub stub = InventoryServiceGrpc.newBlockingStub(channel); try { stub.deductStock(...); } finally { // 虽然关了,但在高并发下关闭不及时,或者忘了关,就是灾难 channel.shutdown(); } } }

我盯着代码看了半天,发现问题出在 ManagedChannel 的创建上。虽然我们在 finally 里调用了 shutdown(),但在高并发(当时峰值 QPS 大概 3000)下,创建 Channel 的成本极高(TCP 握手 + HTTP/2 握手),而且如果请求处理得快,Channel 还没来得及关闭,新的又来了。这就导致了 Netty 的 Channel 对象堆积,直接把老年代塞满了。

解决方案

解决这个问题其实很简单,就是 复用 Channel。在 Java 里,ManagedChannel 是线程安全的,你应该把它定义成单例或者在应用启动时初始化。

@Component public class GrpcClientManager { private ManagedChannel inventoryChannel; @PostConstruct public void init() { // 全局只初始化一个 Channel // 设置空闲超时,避免一直占用资源 // 设置最大并发流,防止单个连接被打爆 this.inventoryChannel = Grpc.newChannelBuilder("inventory-service:50051", InsecureTrustManagerFactory.INSTANCE) .defaultLoadBalancingPolicy("round_robin") .idleTimeout(30, TimeUnit.SECONDS) // 空闲30秒自动关闭连接,有请求时再唤醒 .maxInboundMessageSize(10 * 1024 * 1024) // 限制最大消息10MB .build(); } public InventoryServiceGrpc.InventoryServiceBlockingStub getInventoryStub() { return InventoryServiceGrpc.newBlockingStub(inventoryChannel); } @PreDestroy public void destroy() { if (inventoryChannel != null && !inventoryChannel.isShutdown()) { inventoryChannel.shutdownNow(); } } }

改完之后,我们把 Channel 的创建从每次请求变成了应用级别的单例。这下内存直接降下来了,稳定在 1.5G 左右。其实 gRPC 的 Channel 设计就是让你复用的,它内部已经帮你管理好了连接池和 HTTP/2 的多路复用。如果你不这么做,不仅内存会炸,因为频繁建连,接口耗时也从原来的平均 50ms 涨到了 300ms+。

还有一个细节,我后来把 idleTimeout 设成了 30 秒。这是因为我们的流量在凌晨会有一个低谷,如果不设超时,那些空闲连接会一直挂着,浪费文件描述符。设了之后,凌晨时段的连接数从 200 多降到了 10 几个,非常清爽。

6. 未来演进:gRPC over HTTP/3 (QUIC) 与 AI 模型服务标准化趋势

最近我在研究 v1.64.0(2024年5月发布的那个版本)的更新日志时,发现 gRPC 对 HTTP/3 的支持已经越来越成熟了。说实话,在移动端场景下,HTTP/2 的队头阻塞(Head-of-Line Blocking)问题确实挺让人头疼的。

gRPC over HTTP/3 (QUIC)

我们之前有个 IoT 项目,设备经常在网络信号不好的地方(比如电梯里)上报数据。用 HTTP/2 的时候,一旦丢包,整个 TCP 连接上的所有流都会卡住。后来我们测试了 gRPC over QUIC(也就是 HTTP/3),效果立竿见影。

QUIC 是基于 UDP 的,它把丢包的影响隔离在了单个流里。比如流 A 丢了一个包,流 B 还是能照常传输。我们在测试环境模拟了 5% 的丢包率,HTTP/2 的延迟直接飙升到了 2 秒以上,而 HTTP/3 还能稳定在 200ms 左右。

虽然现在 gRPC 官方对 HTTP/3 的支持还在实验阶段,但配置起来已经很简单了。在 Go 客户端里,你可以通过环境变量或者代码指定启用实验特性:

package main import ( "context" "log" "os" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/examples/helloworld/helloworld" ) func main() { // 启用 HTTP/3 实验特性 // 注意:这是基于当前版本 v1.64.0 的实验性配置 os.Setenv("GRPC_EXPERIMENTAL_ENABLE_HTTP3", "true") // 假设服务端已经支持 HTTP/3 creds := credentials.NewClientTLSFromCert(nil, "") conn, err := grpc.NewClient("api.example.com:50051", grpc.WithTransportCredentials(creds)) if err != nil { log.Fatalf("failed to connect: %v", err) } defer conn.Close() client := helloworld.NewGreeterClient(conn) resp, err := client.SayHello(context.Background(), &helloworld.HelloRequest{Name: "World"}) if err != nil { log.Fatalf("could not greet: %v", err) } log.Printf("Greeting: %s", resp.GetMessage()) }

对于做移动端或者弱网环境的同学,这个特性绝对值得关注。我估计到 2025 年,HTTP/3 就会成为 gRPC 的默认选项之一了。

AI 模型服务标准化

另一个让我觉得有意思的趋势是 gRPC 在 AI 圈子的普及。最近在搞大模型推理服务的时候,我发现 TensorFlow Serving 和 Triton Inference Server 这些主流框架,对外暴露的 API 全是 gRPC。

这其实很好理解。AI 推理通常涉及到大量的张量数据传输,动辄几十 MB 甚至上百 MB。如果用 JSON,光序列化这些浮点数就能把 CPU 跑满。而 Protobuf 的二进制传输不仅体积小,解析速度也快得多。

我们在部署一个图像识别模型时做过对比,同样的请求数据,REST (JSON) 的序列化耗时大概是 15ms,而 gRPC (Protobuf) 只需要 2ms。别小看这 13ms,当 QPS 上到 500 的时候,这就是 6.5 秒的延迟差距。

而且,AI 模型的输入输出结构通常非常固定(比如固定的 Tensor 维度),这正好符合 gRPC 强类型 IDL 的特性。你只要定义一个 .proto 文件,就能自动生成 Python、Go、Java 的客户端,这对于多语言协作的 AI 团队来说太友好了。

syntax = "proto3"; package inference; service PredictService { rpc Predict (PredictRequest) returns (PredictResponse) {} } message PredictRequest { repeated float input_tensor = 1 [packed=true]; // 使用 packed 编码节省空间 int32 batch_size = 2; } message PredictResponse { repeated float output_tensor = 1 [packed=true]; float confidence = 2; }

现在社区里讨论的很多是如何把 gRPC 和 Service Mesh(比如 Istio 的 Ambient Mesh)结合起来,让 AI 推理服务也能享受到流量管理和安全加密,而不用改业务代码。我觉得这会是接下来一两年里微服务架构里最热闹的一块。

站长实战手记

两年前我接手过一个电商的订单中台,当时系统日活已经跑到百万级别,服务之间全靠 HTTP/JSON 互相调用。那时候最头疼的不是业务逻辑,而是每天早上打开监控,总能看到一堆超时告警,下游服务稍微抖一下,上游就跟着雪崩。

我硬着头皮把核心链路迁到了 gRPC。当时选 Proto3 做契约,第一版 IDL 设计得特别复杂,把订单、支付、库存全塞进一个大文件,结果每次改一个字段,十几个服务全部要重新发版。后来我学乖了,把 IDL 拆成独立的小模块,每个服务只维护自己的契约,升级的时候只升需要的那几个,发布压力小了很多。

上线后延迟确实降了,P99 从原来的 120ms 掉到了 40ms 左右,但没高兴两天,线上就出了一次 OOM。查了半天才发现是客户端连接池没限制,流量一抖,瞬时建了几千个连接,直接把内存打爆。后来我给每个客户端加了 MaxConcurrentStreams 和合理的空闲超时,才算彻底稳住。

说实话,我现在对 gRPC 的态度很明确:

  • 如果你服务多、调用频繁、对延迟敏感,它真的省心,尤其是多语言团队,Proto 就是最好的文档
  • 但如果你的系统就三五个服务,调用量也不大,硬上 gRPC 只会让自己加班改 IDL,REST 足够简单够用

另外提醒一句,别被 HTTP/2 的多路复用忽悠了,连接管理、超时、重试这些事,框架不会替你全搞定,真上线前一定要压测自己的场景。

学 gRPC 的时候,别一上来就追流式通信那些花活,先把 一元 RPC 和 IDL 设计 搞明白,能让你少掉很多头发。