gRPC 深度拆解:现代高性能 RPC 框架的事实标准
posts posts 2026-07-11T02:50:00+08:00gRPC 是 Google 开源的现代 RPC 框架,基于 HTTP/2 + Protocol Buffers + 多语言代码生成,已成为微服务通信的事实标准。本文拆解其传输层、IDL、消息帧格式、四种 streaming 模型,以及生产工程的常见取舍。技术笔记gRPC, 微服务, Protocol Buffers, HTTP/2gRPC 深度拆解:现代高性能 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、证书不匹配
目录
- 核心判断 — gRPC 到底赢在哪
- 项目坐标 — 仓库、版本、生态
- 为什么是 HTTP/2 — 传输层解决了什么
- Protocol Buffers 作为 IDL — 契约、演进、wire format
- 一条 gRPC 消息在线上长什么样 — 帧格式、路径、trailers
- 四种通信模式 — unary / server / client / bidirectional
- 生产工程实践 — 错误码、拦截器、deadline、负载均衡、mTLS、重试、健康检查、反射
- 与 REST / GraphQL / Thrift 的取舍
- gRPC-Web — 浏览器侧怎么接
- 常见问题与排查
- 何时用 / 何时不用
- 实战起步建议
- 自测题
- 练习
- 继续深入
- 参考资源
核心判断
RPC 本身不是新东西——DCOM、CORBA、RMI、Thrift 都做过。gRPC 站稳脚跟靠的是三件事的组合:
- HTTP/2 作传输层。多路复用、头部压缩、二进制分帧都是现成的,不需要自造一套传输协议。
- Protocol Buffers 作 IDL(接口描述语言)。编译期类型检查、严格的字段演进规则、跨语言代码生成,一套契约多语言共享。
- 四种流式模型。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 |
| License | Apache 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 为什么用字段编号而不是字段名?三个原因:
- wire format 紧凑。每个字段用"编号 + 类型"的 tag 标识,传输时只带编号不带名字,省字节。
- 演进安全。加字段用新编号、删字段保留编号但不复用——旧客户端读到未知字段跳过,新客户端读到缺失字段用默认值,向前向后兼容。
- 语言无关。同一个编号在各语言里映射成各自类型,代码生成保证两端对齐。
二进制相比 JSON 平均小 3-11 倍(字段越数字密集差距越大,字符串密集时差距收窄),序列化也更快。但这是基准结论,真实项目里 payload 占比多大、CPU 是不是瓶颈,都要在自己环境里测。
一条 gRPC 消息在线上长什么样
这一节讲清楚"方法调用到字节流"之间发生了什么,排障时最有用。
一个 unary 调用的完整链路:
- 客户端与服务端建立 HTTP/2 连接,请求路径是
/包名.服务名/方法名(例如/helloworld.v1.Greeter/SayHello),content-type是application/grpc(可加+proto后缀)。 - 每条请求消息前有一个 5 字节帧头:第 1 字节是压缩标志(0 表示未压缩),后 4 字节是消息长度(大端序)。
- 方法名与元数据走 HTTP/2 的 HEADERS 帧,消息体走 DATA 帧。
- 响应状态码
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 是 SERVING、NOT_SERVING 或 SERVICE_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 的取舍
| 维度 | gRPC | REST | GraphQL | Thrift |
|---|---|---|---|---|
| 性能 | ★★★★★ | ★★ | ★★★ | ★★★★ |
| 浏览器直连 | ❌(需 gRPC-Web) | ✅ | ✅ | ❌ |
| 流式 | ✅ 四种模式 | ❌ | subscription(单连接) | 有限 |
| Schema 演进 | protobuf 严格 | OpenAPI(弱) | SDL(强) | Thrift IDL(强) |
| 多语言 | ✅ 11+ 语言 | 任何 HTTP 客户端 | 任何 HTTP 客户端 | ✅ |
| 调试工具 | grpcurl、grpcui | curl、Postman | graphql-playground | Thrift 工具链 |
| 学习曲线 | 中 | 低 | 中 | 中 |
| 服务间调用 | ★★★★★ | ★★ | ★ | ★★★★ |
选型建议:
- 内部微服务 + 多语言 + 高 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 多语言生成的需求)
实战起步建议
- 先定义 proto:服务接口 + 消息体,字段编号从小号开始排
- 用 buf 管理 proto 仓库:buf 提供 lint 和 breaking change 检测,提交前自动挡掉破坏性变更
- 生成多语言 stub:每个服务方各生成一份对应语言的代码
- 服务端先实现 unary:跑通最小闭环,再上 streaming
- 加拦截器:认证、日志、监控一次配齐
- 用 grpcurl 调试:
grpcurl -plaintext localhost:50051 list - 生产环境:TLS + 健康检查 + 负载均衡(xDS 或服务网格)+ 可观测性
自测题
答案都在对应章节里,不另给标准答案。
原理层
- gRPC 为什么选择 HTTP/2 而不是自研传输协议?多路复用和队头阻塞分别解决了什么问题?
- Protobuf 为什么用字段编号而不是字段名?删掉一个字段后,为什么不能把它的编号给新字段用?
- 一条 gRPC 消息的帧头是哪 5 个字节?
grpc-status为什么放在 trailers 而不是 headers 里? - gRPC 的 deadline 是怎么从客户端传播到服务端,再传播到下游服务的?
工程层
- 服务端把响应码设为
INTERNAL,客户端重试三次能解决问题吗?什么码才适合放进retryableStatusCodes? - 一个服务要优雅滚动重启,K8s 探针和 gRPC 健康检查协议各起什么作用?顺序上谁先谁后?
- grpcurl 能列出某个 gRPC 服务的全部方法,前提是什么?这个能力在生产环境默认该开还是关?
- 默认收发消息上限是多少?传大对象时,调大上限和改用流式传输,你选哪个、为什么?
选型层
- 一个公开的第三方 API,数据以读为主、客户端种类繁多,选 gRPC 还是 REST?理由是什么?
- 内部系统需要实时双向推送(协同编辑),gRPC 的双向流、WebSocket、轮询各有什么代价?
- 浏览器要直连 gRPC 后端,有哪两条可行路径?各自引入什么额外组件?
练习
- 用
protoc-gen-go和protoc-gen-go-grpc从零生成一个 Greeter 服务的两端代码,把 unary 服务跑通。 - 在服务端加一个日志拦截器,调用几次后用 grpcurl 观察日志里的 method 和 duration。
- 给客户端设一个 100ms 的 deadline,在服务端人为 sleep 200ms,观察返回码并从拦截器日志确认超时位置。
- 开启 reflection,用 grpcurl 的
list和describe摸清一个已有服务的完整接口面。 - 写一个双向流服务,客户端循环发送消息、服务端回显,验证流的两端互不阻塞。
继续深入
- 读 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 登录。欢迎补充事实、异议与实践。
讨论暂时无法加载。