跳到正文

目录

gRPC 深度拆解:现代高性能 RPC 框架的事实标准

gRPC 深度拆解:现代高性能 RPC 框架的事实标准

面向服务端工程师、微服务架构设计与排障人员。前置知识:HTTP/1.1 的基本请求响应模型、JSON API 的日常使用、任意一门后端语言。

读完本文你应该能够:

  • 说清 gRPC 的三个底层组合(HTTP/2、Protocol Buffers、四种流式模型)各自解决了什么问题
  • 解释为什么 gRPC 把一个方法调用映射到一个 HTTP/2 stream,以及每条消息前的 5 字节帧头怎么排
  • 写出一个可运行的 unary 与 streaming 服务,并在服务端接上拦截器、deadline、健康检查
  • 在 gRPC、REST、GraphQL、Thrift 之间做有依据的选型,而不是凭印象
  • 用 grpcurl 调试一个没有接口文档的 gRPC 服务(依赖 server reflection)
  • 定位 gRPC 的常见故障:deadline 超时、消息超过默认上限、代理不支持 HTTP/2、证书不匹配

目录

  1. 核心判断 — gRPC 到底赢在哪
  2. 项目坐标 — 仓库、版本、生态
  3. 为什么是 HTTP/2 — 传输层解决了什么
  4. Protocol Buffers 作为 IDL — 契约、演进、wire format
  5. 一条 gRPC 消息在线上长什么样 — 帧格式、路径、trailers
  6. 四种通信模式 — unary / server / client / bidirectional
  7. 生产工程实践 — 错误码、拦截器、deadline、负载均衡、mTLS、重试、健康检查、反射
  8. 与 REST / GraphQL / Thrift 的取舍
  9. gRPC-Web — 浏览器侧怎么接
  10. 常见问题与排查
  11. 何时用 / 何时不用
  12. 实战起步建议
  13. 自测题
  14. 练习
  15. 继续深入
  16. 参考资源

核心判断

RPC 本身不是新东西——DCOM、CORBA、RMI、Thrift 都做过。gRPC 站稳脚跟靠的是三件事的组合:

  1. HTTP/2 作传输层。多路复用、头部压缩、二进制分帧都是现成的,不需要自造一套传输协议。
  2. Protocol Buffers 作 IDL(接口描述语言)。编译期类型检查、严格的字段演进规则、跨语言代码生成,一套契约多语言共享。
  3. 四种流式模型。unary、服务端流、客户端流、双向流一次定义齐活,从"一次调用"到"实时双向通道"覆盖完整。

三条合起来,gRPC 既适合微服务之间 A→B 的单次调用,也适合视频帧、IoT 上报、协同编辑这类长连接场景。Google 内部用它替换了自研的 Stubby,2015 年开源后逐步成为微服务通信的事实标准。

一个前提先讲清楚:gRPC 面向"服务到服务",不是"浏览器到服务"。前端直连的场景走 REST 或 gRPC-Web,取舍细节在"与 REST / GraphQL / Thrift 的取舍"一节展开。

项目坐标

维度数据
仓库github.com/grpc/grpc(C/C++ 核心库);各语言 SDK 独立成库:grpc-go、grpc-java、grpc-node 等
Stars约 45.3k(核心库);grpc-go 约 23k;grpc-web 约 9.3k
主语言C/C++(核心库),各语言有官方 SDK
LicenseApache 2.0
创建时间2014 年 12 月
当前版本核心库 1.8x(v1.81.x,2026-06 发布);各语言 SDK 版本独立推进,grpc-go 已到 1.83.x
协议HTTP/2 + Protocol Buffers v3

版本号只反映各库自己的发布节奏,不代表功能边界。核心库与 grpc-go 的版本号各自独立,排查问题时以你实际用的那个 SDK 的 release note 为准。

为什么是 HTTP/2

在微服务场景下,HTTP/1.1 的短板被放大:

  • 连接数:一个请求一个连接(或 keep-alive 但串行),QPS 高时端口和连接都吃紧
  • 队头阻塞:单连接上请求按顺序响应,前一个慢,后一个排队等
  • 头部冗余:每个请求都带完整 headers,认证、Cookie 这类重复数据反复传

HTTP/2 换了一套机制:

  • 多路复用:单条 TCP 连接上承载多个并发流(stream),互不阻塞
  • 二进制分帧:帧(frame)是最小传输单位,不再是文本
  • 头部压缩:HPACK 增量压缩,重复 header 只传差异
  • 服务器推送:服务端可主动推资源,但主流浏览器已弃用,gRPC 也不依赖它

gRPC 把每个方法调用映射到一个 HTTP/2 stream。一条连接上可以同时跑上千个并发调用,这是它扛高 QPS 的传输层基础。

补充一个现实约束:HTTP/2 需要 TLS 协商(h2)或明文升级(h2c)。负载均衡器、反向代理如果只支持 HTTP/1.1,会直接压掉 gRPC 的流式能力,这一点在"常见问题与排查"里再提。

Protocol Buffers 作为 IDL

先定义契约:

syntax = "proto3";

package helloworld.v1;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
  rpc Chat (stream ChatMessage) returns (stream ChatReply);  // bidirectional streaming
}

message HelloRequest {
  string name = 1;
  int32 age = 2;
}

message HelloReply {
  string message = 1;
}

生成代码:

# 安装 protoc 与 Go 插件
brew install protobuf
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

# 生成 Go 代码
protoc --go_out=. --go-grpc_out=. helloworld.proto

生成物包含:客户端 stub、服务端待实现的接口、消息的序列化/反序列化代码、方法描述符(供反射和负载均衡使用)。

Protobuf 为什么用字段编号而不是字段名?三个原因:

  1. wire format 紧凑。每个字段用"编号 + 类型"的 tag 标识,传输时只带编号不带名字,省字节。
  2. 演进安全。加字段用新编号、删字段保留编号但不复用——旧客户端读到未知字段跳过,新客户端读到缺失字段用默认值,向前向后兼容。
  3. 语言无关。同一个编号在各语言里映射成各自类型,代码生成保证两端对齐。

二进制相比 JSON 平均小 3-11 倍(字段越数字密集差距越大,字符串密集时差距收窄),序列化也更快。但这是基准结论,真实项目里 payload 占比多大、CPU 是不是瓶颈,都要在自己环境里测。

一条 gRPC 消息在线上长什么样

这一节讲清楚"方法调用到字节流"之间发生了什么,排障时最有用。

一个 unary 调用的完整链路:

  1. 客户端与服务端建立 HTTP/2 连接,请求路径是 /包名.服务名/方法名(例如 /helloworld.v1.Greeter/SayHello),content-typeapplication/grpc(可加 +proto 后缀)。
  2. 每条请求消息前有一个 5 字节帧头:第 1 字节是压缩标志(0 表示未压缩),后 4 字节是消息长度(大端序)。
  3. 方法名与元数据走 HTTP/2 的 HEADERS 帧,消息体走 DATA 帧。
  4. 响应状态码 grpc-status 和错误描述 grpc-message 放在 HTTP/2 的 trailers(尾帧)里返回——这是浏览器拿不到、必须靠代理转换的原因之一。

帧头示意:

+------------+----------------+
| 压缩标志 1B | 消息长度 4B    |   <-- 每条消息前的 5 字节帧头
+------------+----------------+
| 消息体(protobuf 编码)       |
+-----------------------------+

服务端对请求路径有严格要求:/Service/Method 必须以斜杠开头。grpc-go 曾在路由校验上出过安全漏洞(缺少前导斜杠的路径可绕过基于路径的授权拦截器),1.79.3 已修复。这提醒两点:路径校验别自己写,SDK 该升级就升级。

四种通信模式

service Chat {
  rpc Unary (Msg) returns (Reply);                     // 一元调用
  rpc ServerStream (Msg) returns (stream Reply);      // 服务端流
  rpc ClientStream (stream Msg) returns (Reply);      // 客户端流
  rpc Bidirectional (stream Msg) returns (stream Reply);  // 双向流
}

四种模式各对应一个 HTTP/2 stream,多个流跑在同一条 TCP 连接上。

Unary(一元调用)

经典 RPC:客户端发一个请求,服务端返回一个响应。GetUser(id) → User 是最常见的形态。

Server streaming(服务端流)

客户端发一个请求,服务端返回多个消息:

func (s *server) Subscribe(req *pb.Req, stream pb.Chat_SubscribeServer) error {
    for {
        select {
        case <-stream.Context().Done():
            // 客户端取消或 deadline 到,退出循环
            return stream.Context().Err()
        case msg := <-s.updates:
            if err := stream.Send(msg); err != nil {
                return err
            }
        }
    }
}

适用场景:股票行情推送 Subscribe(symbol) → stream<Tick>、日志聚合 Tail(filter) → stream<LogEntry>、大数据集分页 ListAll() → stream<Item>

Client streaming(客户端流)

客户端发多个消息,服务端汇总后返回一个响应。适用场景:IoT 设备批量上传遥测、文件分片上传、客户端日志上报。

Bidirectional streaming(双向流)

两端各自维护独立的消息流,读写互不阻塞。适用场景:实时聊天、协同编辑、游戏状态同步。

// 双向流服务端:把收到的每条消息回显
func (s *server) Chat(stream pb.Chat_BidirectionalServer) error {
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        if err := stream.Send(&pb.Reply{Message: "echo: " + in.Text}); err != nil {
            return err
        }
    }
}

生产工程实践

错误码体系

gRPC 定义了 16 个状态码(code),语义比 HTTP 状态码更细。下面是高频用到的几个:

Code含义常见触发点
OK成功
CANCELLED客户端主动取消请求方调用了 cancel
INVALID_ARGUMENT客户端参数错误入参校验失败
DEADLINE_EXCEEDED截止时间到服务端没在 deadline 内返回
NOT_FOUND资源不存在查无此人/订单
ALREADY_EXISTS资源已存在重复创建
PERMISSION_DENIED权限不足认证通过但无权操作
RESOURCE_EXHAUSTED配额或资源耗尽消息超过大小上限、限流
UNAVAILABLE服务暂时不可用连接失败、服务重启中
INTERNAL服务端内部错误panic、未处理异常
UNAUTHENTICATED未认证或凭证无效缺 token、token 过期

UNAVAILABLE 和 INTERNAL 的区别要分清:前者是"暂时不行,重试可能成功",后者是"服务端坏了,重试也白搭"。重试策略只该对 UNAVAILABLE 这类码生效。

拦截器(Interceptor)

拦截器在调用链上统一处理认证、日志、监控、限流。grpc-go 提供 unary 和 streaming 两套拦截器,服务端和客户端各有一份。

func loggingUnaryInterceptor(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
}

s := grpc.NewServer(grpc.ChainUnaryInterceptor(loggingUnaryInterceptor))

ChainUnaryInterceptor 支持按顺序叠加多个拦截器,从第一个开始逐层包裹。

超时与截止时间(Deadline)

每个 gRPC 调用都有 deadline,它是绝对时间点,不是超时时长。客户端设置,服务端读取并响应:

// 客户端
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: "world"})
if status.Code(err) == codes.DeadlineExceeded {
    // 处理超时
}
// 服务端:监听 ctx 是否已过期
if err := ctx.Err(); err != nil {
    return nil, status.Error(codes.DeadlineExceeded, "call deadline exceeded")
}

deadline 会通过 grpc-timeout 头传播到下游服务,调用链上的每一跳共享同一个截止时间。设置原则:在调用链最外层按 SLA 设,不要在服务端硬编码。

负载均衡

gRPC 客户端内置几种 LB 策略:

  • pick_first:逐个尝试连接,直到成功(默认)
  • round_robin:轮询多个地址
  • grpclb:从外部 control plane 拉取 LB 决策
  • xDS:与 Envoy / Istio 等数据面集成的动态配置

选型参考:

  • K8s 内服务间调用:用 xDS 或服务网格(Istio / Linkerd)做 LB,节点变化由控制面下发
  • 直连 IP 列表:round_robin 够用

注意一个 gRPC 的特有坑:HTTP/2 是长连接,连接一旦建立会持续复用。只靠 K8s Service 的四层转发,后端实例变化时旧连接不会自动重建。要优雅地滚动重启,得配合健康检查(见下)和连接 draining。

mTLS(双向 TLS)

服务间通信要加密加认证:

creds, err := credentials.NewServerTLSFromFile("server-cert.pem", "server-key.pem")
if err != nil {
    log.Fatalf("failed to load credentials: %v", err)
}
s := grpc.NewServer(grpc.Creds(creds))

证书轮换和发放可以考虑 SPIFFE / SPIRE 这类自动身份管理,避免手工维护证书文件。

重试策略

gRPC 支持在 service config 里声明式配置重试,客户端按方法匹配:

{
  "methodConfig": [{
    "name": [{"service": "helloworld.v1.Greeter"}],
    "retryPolicy": {
      "maxAttempts": 3,
      "initialBackoff": "0.1s",
      "maxBackoff": "1s",
      "backoffMultiplier": 2,
      "retryableStatusCodes": ["UNAVAILABLE"]
    }
  }]
}

retryableStatusCodes 只列可以重试的状态码。重试要配合幂等设计,否则一个"已创建"的请求被重试两次,就产生两笔订单。

健康检查(Health Checking)

gRPC 有标准的健康检查协议 grpc.health.v1.Health/Check,K8s 的探针、负载均衡、服务网格都认这个协议:

grpcurl -plaintext -d '{}' localhost:50051 grpc.health.v1.Health/Check

返回的 serving status 是 SERVINGNOT_SERVINGSERVICE_UNKNOWN。服务端需要自己实现并注册 Health 服务,启动时上报 SERVING,退出前先切到 NOT_SERVING——这样负载均衡会在连接断开前先把流量摘走。

服务反射(Server Reflection)

没有接口文档也能调试:服务端开启 reflection 后,grpcurl 能动态发现服务和方法。

import "google.golang.org/grpc/reflection"

s := grpc.NewServer()
reflection.Register(s)
# 列出所有服务
grpcurl -plaintext localhost:50051 list

# 查看某个服务的方法签名
grpcurl -plaintext localhost:50051 describe helloworld.v1.Greeter

# 直接调用
grpcurl -plaintext -d '{"name":"world"}' localhost:50051 helloworld.v1.Greeter/SayHello

生产环境默认关掉 reflection,避免把接口面暴露出去;内部调试环境再开。

消息大小上限

grpc-go 默认收发上限都是 4 MB(MaxRecvMsgSize / MaxSendMsgSize)。传大文件或大结果集时容易踩 RESOURCE_EXHAUSTED,按需调大:

conn, err := grpc.NewClient("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(32*1024*1024)),
)

比无脑调大更合理的是改协议:大对象该拆成流式传输或先落对象存储再传引用。

与 REST / GraphQL / Thrift 的取舍

维度gRPCRESTGraphQLThrift
性能★★★★★★★★★★★★★★
浏览器直连❌(需 gRPC-Web)
流式✅ 四种模式subscription(单连接)有限
Schema 演进protobuf 严格OpenAPI(弱)SDL(强)Thrift IDL(强)
多语言✅ 11+ 语言任何 HTTP 客户端任何 HTTP 客户端
调试工具grpcurl、grpcuicurl、Postmangraphql-playgroundThrift 工具链
学习曲线
服务间调用★★★★★★★★★★★

选型建议:

  • 内部微服务 + 多语言 + 高 QPS → gRPC
  • 前端直接调用 + 公开 API → REST 或 GraphQL
  • 已有 Thrift 资产 → 继续用 Thrift,迁移成本常常高过收益
  • 流式为主(视频、IoT、协同)→ gRPC 或 WebSocket
  • 调试友好优先、对外生态优先 → REST

gRPC-Web:浏览器侧怎么接

浏览器不能用原生 gRPC,原因是浏览器 API 读不到 HTTP/2 trailers,而 gRPC 把状态码放在 trailers 里。gRPC-Web 是官方子项目,用不同的 content-type(application/grpc-web+proto)和帧格式绕开这个限制:

Browser (gRPC-Web client)
    ↓ HTTP/1.1 + gRPC-Web framing
Envoy proxy (gRPC-Web → gRPC translation)
    ↓ HTTP/2 + 原生 gRPC
gRPC server

注意 Envoy 的 gRPC-Web 过滤器和 gRPC server 要配套开启。如果不想引入代理,可以看看 ConnectRPC——它原生实现了 gRPC-Web 协议,浏览器直连后端而无需代理。

实际项目里更常见的布局是:浏览器 → REST/HTTP 网关 → 服务间 gRPC。gRPC-Web 只在"前端要直连 gRPC 后端"这种强约束下才值得引入。

常见问题与排查

Q1:grpc-go 里 grpc.WithInsecure() 提示已废弃

1.63 起 grpc.WithInsecure() 被废弃,改用:

conn, err := grpc.NewClient("localhost:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()))

Q2:grpc.Dial 也提示废弃了?

grpc.Dial 同样自 1.63 起废弃,推荐 grpc.NewClient。区别在于 NewClient 默认走 DNS 解析 + 负载均衡,行为更贴近生产;Dial 的即时连接语义容易在连接失败时直接报错。升级后留意 WaitForReady 这类选项的默认值变化。

Q3:频繁 DEADLINE_EXCEEDED,但服务端看起来没事

先确认 deadline 设在了哪一层。常见原因:最外层没设 deadline,内层某个调用的超时被层层累加;或者服务端用了阻塞操作(比如同步等锁、串行消费队列)拖过了 deadline。用拦截器打印每次调用的 duration,能快速定位是哪一跳最慢。

Q4:报错 RESOURCE_EXHAUSTED: received message larger than max

默认收发上限 4 MB。小概率是配置问题,大概率是协议设计问题——大 payload 该走流式或对象存储。先按上文调大上限临时止血,再把协议改对。

Q5:代理 / 负载均衡不支持 HTTP/2

Nginx 需要 grpc_pass(独立于 proxy_pass),Envoy 需要 http2_protocol_options。如果代理只支持 HTTP/1.1,gRPC 的流式能力会退化成不可用。排查时先确认链路上每一跳是否真的协商成了 h2。

Q6:浏览器里调 gRPC 一直失败

浏览器发的是 application/grpc-web+proto 请求,后端得走 gRPC-Web 协议或经过转换代理。直接用原生 gRPC 后端接浏览器请求,即使连上也会因为读不到 trailers 而报错。

Q7:怎么在没有文档的情况下摸清一个 gRPC 服务

先确认服务端开了 reflection,然后 grpcurl -plaintext <addr> list 列出服务,describe 看方法签名,-d '{}' 传 JSON 直接调用。没开 reflection 的话,只能拿到 .proto 文件后用 protoc --descriptor_set_out 生成描述符再调。

Q8:证书报错 handshake failed 之类

服务端要求 TLS 但客户端用明文连,或证书 CN/SAN 与目标地址不匹配。先确认两端协议一致(都用 TLS 或都用 insecure),再看证书的 SAN 是否包含目标 host。

何时用 / 何时不用

适合

  • 内部微服务 A→B 调用(高 QPS、低延迟要求)
  • 多语言系统(C++ / Go / Python / Java 互相调用)
  • 流式数据(实时消息、IoT、视频帧)
  • 移动 App 与后端长连接

不适合

  • 公开 API 给第三方开发者(REST / GraphQL 更友好)
  • 浏览器为主的前端场景(走 gRPC-Web 或 REST)
  • 极小项目(REST 一个文件就够,不值得引入代码生成)
  • 单语言系统(没有 IDL 多语言生成的需求)

实战起步建议

  1. 先定义 proto:服务接口 + 消息体,字段编号从小号开始排
  2. 用 buf 管理 proto 仓库:buf 提供 lint 和 breaking change 检测,提交前自动挡掉破坏性变更
  3. 生成多语言 stub:每个服务方各生成一份对应语言的代码
  4. 服务端先实现 unary:跑通最小闭环,再上 streaming
  5. 加拦截器:认证、日志、监控一次配齐
  6. 用 grpcurl 调试grpcurl -plaintext localhost:50051 list
  7. 生产环境:TLS + 健康检查 + 负载均衡(xDS 或服务网格)+ 可观测性

自测题

答案都在对应章节里,不另给标准答案。

原理层

  1. gRPC 为什么选择 HTTP/2 而不是自研传输协议?多路复用和队头阻塞分别解决了什么问题?
  2. Protobuf 为什么用字段编号而不是字段名?删掉一个字段后,为什么不能把它的编号给新字段用?
  3. 一条 gRPC 消息的帧头是哪 5 个字节?grpc-status 为什么放在 trailers 而不是 headers 里?
  4. gRPC 的 deadline 是怎么从客户端传播到服务端,再传播到下游服务的?

工程层

  1. 服务端把响应码设为 INTERNAL,客户端重试三次能解决问题吗?什么码才适合放进 retryableStatusCodes
  2. 一个服务要优雅滚动重启,K8s 探针和 gRPC 健康检查协议各起什么作用?顺序上谁先谁后?
  3. grpcurl 能列出某个 gRPC 服务的全部方法,前提是什么?这个能力在生产环境默认该开还是关?
  4. 默认收发消息上限是多少?传大对象时,调大上限和改用流式传输,你选哪个、为什么?

选型层

  1. 一个公开的第三方 API,数据以读为主、客户端种类繁多,选 gRPC 还是 REST?理由是什么?
  2. 内部系统需要实时双向推送(协同编辑),gRPC 的双向流、WebSocket、轮询各有什么代价?
  3. 浏览器要直连 gRPC 后端,有哪两条可行路径?各自引入什么额外组件?

练习

  1. protoc-gen-goprotoc-gen-go-grpc 从零生成一个 Greeter 服务的两端代码,把 unary 服务跑通。
  2. 在服务端加一个日志拦截器,调用几次后用 grpcurl 观察日志里的 method 和 duration。
  3. 给客户端设一个 100ms 的 deadline,在服务端人为 sleep 200ms,观察返回码并从拦截器日志确认超时位置。
  4. 开启 reflection,用 grpcurl 的 listdescribe 摸清一个已有服务的完整接口面。
  5. 写一个双向流服务,客户端循环发送消息、服务端回显,验证流的两端互不阻塞。

继续深入

  • 读 gRPC 官方文档的 protocol 一节,把 HTTP/2 帧与 gRPC 帧的关系彻底理清
  • 读 buf 的 breaking change 检测规则,理解 proto 演进里哪些改动是安全的、哪些会破坏兼容
  • 对比 xDS 与服务网格方案,搞清 gRPC 在 K8s 下的连接管理与故障转移机制
  • 读 gRPC 的性能基准(grpc 官方 benchmark 脚本),理解不同负载下序列化和吞吐的真实分布

参考资源

  • 官方文档:https://grpc.io/docs/
  • 语言教程:https://grpc.io/docs/languages/
  • Protocol Buffers 指南:https://protobuf.dev/
  • gRPC-Web:https://github.com/grpc/grpc-web
  • ConnectRPC(原生 gRPC-Web 实现):https://connectrpc.com/
  • buf(proto lint / breaking change 检测):https://buf.build
  • 《gRPC: Up and Running》(Kasun Indrasiri 著,O’Reilly)

参与讨论

使用 GitHub 登录。欢迎补充事实、异议与实践。