Table of contents
Open Table of contents
TL;DR
gRPC 用 .proto 文件定义服务契约,代码生成器产出强类型的 server 接口和 client stub,通过 HTTP/2 实现单连接多路复用、头部压缩和四种通信模式(unary/server-stream/client-stream/bidi-stream)。核心价值不是”比 REST 快”,而是契约即代码、跨语言一致、streaming 原生支持。
解决什么问题
REST + JSON 在微服务场景下的三个系统性问题:
| 问题 | REST + JSON | gRPC |
|---|---|---|
| 契约漂移 | OpenAPI spec 手动维护,和代码可能不一致 | .proto 文件生成代码,编译不过就是不一致 |
| 连接开销 | HTTP/1.1 每个请求一个连接(或 pipeline 限制) | HTTP/2 单连接上千并发 RPC |
| 缺乏 streaming | 需要 WebSocket/SSE 等 workaround | 四种通信模式原生支持 |
| 跨语言成本 | 每种语言手写 HTTP client + JSON 解析 | protoc-gen-xxx 自动生成各语言 client |
| 错误模型 | HTTP status code 语义模糊(400 是格式错还是业务错?) | 17 个精确 status code + 结构化错误详情 |
gRPC 不适合的场景: 浏览器直接调用(需要 grpc-web 代理或 ConnectRPC)、公开 API 给不可控的第三方(REST 的通用性更好)、简单 CRUD 且无增长预期的项目(工具链 overhead 不值得)。
底层原理:gRPC 如何映射到 HTTP/2
HTTP/2 给 gRPC 的关键能力
| HTTP/2 特性 | gRPC 如何利用 |
|---|---|
| 多路复用 | 一个 TCP 连接上跑成百上千个并发 RPC(每个 RPC = 一个 HTTP/2 stream) |
| 二进制分帧 | 帧级别解析,比 HTTP/1.1 文本协议更快 |
| HPACK 头部压缩 | 同一连接的 RPC 共享头部表,重复 header(:method POST、content-type)只传索引 |
| 流控 | 每个 stream 独立流控,慢消费者不阻塞快生产者 |
| Trailers | gRPC 状态码通过 trailer 传输——允许先 stream 数据再报错 |
一次 gRPC 调用的 HTTP/2 映射
Phase 1: Request Headers(HEADERS 帧)
:method POST
:path /im.user.v1.UserService/Register
:scheme https
content-type application/grpc+proto
te trailers ← 检测不兼容的代理
grpc-timeout 5S ← 可选,deadline 传播
grpc-encoding gzip ← 可选,压缩算法
Phase 2: Request Data(DATA 帧)
┌────────────────┬──────────────────────────┐
│ 5-byte header │ Protobuf payload │
├────────────────┼──────────────────────────┤
│ 00 │ 00 00 00 0A │ ...protobuf bytes...
│ compressed=no │ length=10 (big-endian) │
└────────────────┴──────────────────────────┘
Phase 3: Response Trailers(HEADERS 帧 + END_STREAM)
grpc-status 0 ← 0 = OK
grpc-message (percent-encoded 错误消息)
grpc-status-details-bin (base64-encoded 富错误详情)
关键设计决策:gRPC 状态在 trailer 里,HTTP 状态码始终 200。 这使得 server 可以先 stream 数据再决定最终状态——如果 status 在 response header 里,第一个字节发出后就不能改了。
gRPC Wire Format(每条消息)
字节 0: Compressed Flag(0=未压缩,1=按 grpc-encoding 压缩)
字节 1-4: Message Length(big-endian uint32)
字节 5+: Protobuf 编码的消息体
HTTP/2 DATA 帧边界和 gRPC 消息边界无关——一个 DATA 帧可能包含多条 gRPC 消息,一条 gRPC 消息也可能跨多个 DATA 帧。
四种通信模式
service RouteGuide {
rpc GetFeature(Point) returns (Feature) {} // Unary
rpc ListFeatures(Rectangle) returns (stream Feature) {} // Server streaming
rpc RecordRoute(stream Point) returns (RouteSummary) {} // Client streaming
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {} // Bidirectional
}
Unary(一请求一响应)
// Server
func (s *server) GetFeature(ctx context.Context, point *pb.Point) (*pb.Feature, error) {
feature, err := s.repo.FindByLocation(ctx, point)
if err != nil {
return nil, status.Errorf(codes.NotFound, "feature not found")
}
return feature, nil
}
// Client
feature, err := client.GetFeature(ctx, &pb.Point{Latitude: 409146138})
用途: CRUD、认证、所有传统 request-response 场景。
Server Streaming(一请求多响应)
// Server — 返回 nil 关闭 stream(状态 OK)
func (s *server) ListFeatures(rect *pb.Rectangle, stream pb.RouteGuide_ListFeaturesServer) error {
for _, feature := range s.features {
if inRange(feature.Location, rect) {
if err := stream.Send(feature); err != nil {
return err
}
}
}
return nil
}
// Client — io.EOF 表示 stream 正常结束
stream, err := client.ListFeatures(ctx, rect)
for {
feature, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
log.Println(feature)
}
用途: 大结果集、实时事件推送、长操作进度通知、日志 tail。
Client Streaming(多请求一响应)
// Server — SendAndClose 返回最终响应
func (s *server) RecordRoute(stream pb.RouteGuide_RecordRouteServer) error {
var count int32
for {
point, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&pb.RouteSummary{PointCount: count})
}
if err != nil {
return err
}
count++
}
}
// Client — CloseAndRecv 结束发送并等响应
stream, err := client.RecordRoute(ctx)
for _, point := range points {
stream.Send(point)
}
reply, err := stream.CloseAndRecv()
用途: 文件上传、批量数据摄入、IoT 传感器数据聚合。
Bidirectional Streaming(双向独立流)
// Server — 读写独立,不必一一对应
func (s *server) RouteChat(stream pb.RouteGuide_RouteChatServer) error {
for {
note, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// 可以发回多条,也可以不发
for _, prev := range s.notesByLocation[key(note.Location)] {
if err := stream.Send(prev); err != nil {
return err
}
}
}
}
// Client — 读写分两个 goroutine
stream, err := client.RouteChat(ctx)
done := make(chan struct{})
go func() {
for {
msg, err := stream.Recv()
if err == io.EOF {
close(done)
return
}
log.Println(msg)
}
}()
for _, note := range notes {
stream.Send(note)
}
stream.CloseSend()
<-done
用途: 聊天/IM、协作编辑、实时游戏状态同步。
Go 核心 API(google.golang.org/grpc)
Server
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(loggingInterceptor, authInterceptor),
grpc.ChainStreamInterceptor(streamLogging),
grpc.MaxRecvMsgSize(16 << 20), // 16 MB(默认 4 MB)
)
pb.RegisterUserServiceServer(s, &userServer{})
reflection.Register(s) // 开发环境启用,支持 grpcurl
lis, _ := net.Listen("tcp", ":50051")
s.Serve(lis)
Client:用 grpc.NewClient(不是 grpc.Dial)
grpc.Dial 和 grpc.DialContext 从 v1.63 起已废弃。
grpc.Dial(废弃) | grpc.NewClient(推荐) | |
|---|---|---|
| 默认 resolver | passthrough | dns |
| 连接时机 | 立即开始连接 | 不做 I/O,首次 RPC 时才连 |
WithBlock | 生效 | 被忽略 |
conn, err := grpc.NewClient("your.server.com:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
defer conn.Close()
client := pb.NewUserServiceClient(conn)
WithBlock 是反模式——“拨号时连上了”不代表 RPC 时还连着。让 RPC 自己处理连接生命周期。
Interceptor(拦截器)
四种类型,按 server/client × unary/stream 组合:
// Server Unary Interceptor 签名
type UnaryServerInterceptor func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo, // FullMethod 等元信息
handler grpc.UnaryHandler, // 下一个 interceptor 或真正的 handler
) (interface{}, error)
链式注册(左到右执行):
s := grpc.NewServer(
grpc.ChainUnaryInterceptor(validation, logging, auth, recovery),
)
go-grpc-middleware(github.com/grpc-ecosystem/go-grpc-middleware)提供生产级的 logging、auth、recovery、retry、prometheus、OpenTelemetry interceptor。
Metadata(类似 HTTP headers)
import "google.golang.org/grpc/metadata"
// Client 发送
ctx := metadata.AppendToOutgoingContext(ctx,
"authorization", "Bearer token",
"request-id", "abc-123",
)
resp, err := client.SomeRPC(ctx, req)
// Server 接收
md, ok := metadata.FromIncomingContext(ctx)
tokens := md.Get("authorization")
// Server 发送(header 在第一条响应前,trailer 在 stream 结束后)
grpc.SetHeader(ctx, metadata.Pairs("response-id", "xyz"))
grpc.SetTrailer(ctx, metadata.Pairs("checksum", "abc"))
二进制 metadata key 以 -bin 结尾,值自动 base64 编码。
Deadline 传播
// Client 设 5 秒超时
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, req)
// 超时 → err 的 code 是 codes.DeadlineExceeded
Deadline 自动跨服务传播。 A 调 B 时设 5 秒,A 自己花了 2 秒,B 收到的 deadline 是 3 秒。通过 grpc-timeout header 实现。
Keepalive
import "google.golang.org/grpc/keepalive"
// Server
s := grpc.NewServer(
grpc.KeepaliveParams(keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Minute, // 空闲连接关闭时间
MaxConnectionAge: 30 * time.Minute, // 最大连接存活时间(L4 LB 场景关键)
MaxConnectionAgeGrace: 5 * time.Second, // 关闭前的宽限期
Time: 5 * time.Second, // 无活动时发 ping
Timeout: 1 * time.Second, // 等 ping 回复
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 5 * time.Second, // client ping 最小间隔
PermitWithoutStream: true, // 无 stream 时允许 ping
}),
)
// Client
conn, err := grpc.NewClient(target,
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 10 * time.Second, // 无活动时发 ping(最小 10s)
Timeout: 3 * time.Second,
PermitWithoutStream: true,
}),
)
Client ping 频率 < Server MinTime → Server 发 GOAWAY(ENHANCE_YOUR_CALM)关闭连接。 两端必须配合。
错误处理
Status Code 完整表
| Code | 值 | HTTP 对应 | 使用场景 |
|---|---|---|---|
OK | 0 | 200 | 成功 |
InvalidArgument | 3 | 400 | 客户端参数错误(无论系统状态) |
NotFound | 5 | 404 | 实体不存在 |
AlreadyExists | 6 | 409 | 创建冲突 |
PermissionDenied | 7 | 403 | 已认证但无权限 |
Unauthenticated | 16 | 401 | 未认证 |
ResourceExhausted | 8 | 429 | 配额/限流 |
FailedPrecondition | 9 | 400 | 系统状态不满足前置条件(如删除非空目录) |
Aborted | 10 | 409 | 并发冲突(乐观锁失败) |
Unavailable | 14 | 503 | 临时故障,客户端应重试 |
DeadlineExceeded | 4 | 504 | 超时 |
Internal | 13 | 500 | 内部 bug |
Unimplemented | 12 | 501 | 方法未实现 |
DataLoss | 15 | 500 | 不可恢复的数据丢失 |
创建和解析错误
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/genproto/googleapis/rpc/errdetails"
)
// Server:返回带结构化详情的错误
st := status.New(codes.InvalidArgument, "invalid request")
st, _ = st.WithDetails(&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{Field: "email", Description: "must be a valid email"},
},
})
return nil, st.Err()
// Client:解析错误
st := status.Convert(err)
for _, detail := range st.Details() {
switch t := detail.(type) {
case *errdetails.BadRequest:
for _, v := range t.GetFieldViolations() {
log.Printf("Field %q: %s", v.GetField(), v.GetDescription())
}
}
}
Domain Error 映射原则
- 参数校验失败 →
InvalidArgument - 实体不存在 →
NotFound - 创建重复 →
AlreadyExists - 未认证 →
Unauthenticated - 无权限 →
PermissionDenied - 限流 →
ResourceExhausted(附RetryInfo) - 乐观锁冲突 →
Aborted - 前置条件不满足 →
FailedPrecondition - 上游服务挂了 →
Unavailable - 你的 bug →
Internal
反模式:不要盲目传播下游 status code。 A 调 B 得到 InvalidArgument,对 A 的调用者来说这是 Internal(A 构造了错误的请求,是 A 的 bug)。
生态工具
| 工具 | 用途 |
|---|---|
| grpc-gateway | 从 proto annotation 生成 REST → gRPC 反向代理 |
| grpcurl | gRPC 的 curl,配合 reflection 可以探索和调用任何 gRPC 服务 |
| grpc_health_v1 | 标准健康检查协议,K8s gRPC 探针用 |
| Reflection | 一行代码启用,支持 grpcurl 和 Postman |
| ConnectRPC | Buf 出品,一个 handler 同时说 gRPC + gRPC-Web + Connect 协议,浏览器原生兼容 |
// 健康检查
import "google.golang.org/grpc/health"
healthServer := health.NewServer()
grpc_health_v1.RegisterHealthServer(s, healthServer)
healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
// Reflection(开发/测试环境)
import "google.golang.org/grpc/reflection"
reflection.Register(s)
gRPC vs REST vs GraphQL vs ConnectRPC
| 维度 | gRPC | REST | GraphQL | ConnectRPC |
|---|---|---|---|---|
| 序列化 | Protobuf(二进制) | JSON(文本) | JSON(文本) | Protobuf 或 JSON |
| 传输 | 仅 HTTP/2 | HTTP/1.1 或 2 | HTTP/1.1 或 2 | HTTP/1.1 或 2 |
| 浏览器 | 不行(需代理) | 原生 | 原生 | 原生 |
| Streaming | 四种模式 | 无(需 workaround) | Subscription(WS) | 完整支持 |
| 调试 | 困难(二进制) | 简单(curl) | 中等 | 简单(unary 是纯 JSON POST) |
选择 gRPC: 内部微服务通信,控制两端。选择 REST: 公开 API,第三方集成。选择 ConnectRPC: 需要 gRPC 的好处但也要浏览器兼容,新 Go 项目。
Pitfalls
1. 浏览器无法直接调用 gRPC
gRPC 依赖 HTTP/2 trailers 传输 grpc-status。浏览器 fetch() API 无法访问 trailers——这不是限制,是架构上不可能。
解法: grpc-web + Envoy 代理,或 ConnectRPC(换一种不依赖 trailer 的编码方式,无需代理)。
2. L4 负载均衡下的流量倾斜
gRPC 在单个 HTTP/2 连接上复用所有 RPC。L4 负载均衡(K8s 默认 ClusterIP)按 TCP 连接分发——Client A 的所有 RPC 去同一个 backend。长连接场景下部分 backend 过载、部分闲置。
解法: 用 L7 负载均衡(Envoy/Istio/Linkerd,按 RPC 分发);或 server 端配置 MaxConnectionAge 强制重连让 L4 有机会重新分配。
3. 默认 4 MB 消息大小限制
MaxRecvMsgSize 默认 4 MB。开发时一切正常,生产环境数据量增长后响应超 4 MB → ResourceExhausted 错误,且错误消息不明显。
解法: 显式配置大小限制(server 和 client 都要设)。但更应该反思:是否应该用 streaming 或分页替代大消息。
4. Streaming 错误处理和 Unary 不同
Unary 的错误随响应返回。Stream 的错误可能在任何时刻出现——连接断了、server 报错了、context 取消了。开发者把 stream 当”重复的 unary”会写出微妙的 bug。
规则: io.EOF = 正常结束,其他 error = 异常。Bidirectional 的读写 goroutine 各自处理错误。客户端必须调 CloseSend()。
5. Context Values 不跨服务传播
context.WithValue 存的数据只在本进程内。想让 request ID 传到下游服务,必须放进 gRPC metadata。
解法: Interceptor 里把 context value 写入 metadata(发送端),从 metadata 读到 context(接收端)。分布式追踪用 OpenTelemetry。
6. Client-Server Keepalive 配置不匹配
Client ping 频率 > Server EnforcementPolicy.MinTime(默认 5 分钟) → Server 用 ENHANCE_YOUR_CALM 杀连接。表现为莫名的连接断开,错误信息不指向 keepalive。
规则: Client Time >= Server MinTime。PermitWithoutStream 两端一致。
7. 不要盲目传播下游 Status Code
A 调 B,B 返回 InvalidArgument。如果 A 原封不动传给 A 的调用者 → 调用者以为自己传错了参数。实际上是 A 构造请求时有 bug。应该包装为 Internal。
生产 Checklist
Server
-
MaxRecvMsgSize显式设置 - Keepalive
MaxConnectionAge配置(L4 LB 环境必须) - 注册 health check service
- 开发环境注册 reflection
- Interceptor 链:validation → logging → auth → recovery
Client
- 用
grpc.NewClient不用grpc.Dial - 每次 RPC 调用设 deadline(
context.WithTimeout) - Keepalive 配置与 server 匹配
-
ClientConn全局创建一次、共享,不要每次请求创建
错误处理
- 返回语义精确的 status code,不要所有错误都用
Internal - 用
errdetails提供结构化错误信息 - 不盲目传播下游 status code
可观测性
- 从第一天起集成 OpenTelemetry
- Prometheus interceptor 采集 request count、latency、error rate
- 通过 metadata 传播 trace context
负载均衡
- 用 L7 负载均衡或客户端负载均衡,不要单独依赖 L4
- 客户端 LB:
{"loadBalancingConfig": [{"round_robin": {}}]}
测试
- 用
bufconn做进程内测试(无真实网络,不占端口)(「确定」) - 用
grpcurl做集成测试 - 测试 deadline 跨服务传播
- 测试下游服务不可用时的降级行为