跳转到正文
zeno's blog
返回

Go RPC:gRPC、HTTP/2 与 proto 契约

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 + JSONgRPC
契约漂移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 POSTcontent-type)只传索引
流控每个 stream 独立流控,慢消费者不阻塞快生产者
TrailersgRPC 状态码通过 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.Dialgrpc.DialContext 从 v1.63 起已废弃。

grpc.Dial(废弃)grpc.NewClient(推荐)
默认 resolverpassthroughdns
连接时机立即开始连接不做 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-middlewaregithub.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 完整表

CodeHTTP 对应使用场景
OK0200成功
InvalidArgument3400客户端参数错误(无论系统状态)
NotFound5404实体不存在
AlreadyExists6409创建冲突
PermissionDenied7403已认证但无权限
Unauthenticated16401未认证
ResourceExhausted8429配额/限流
FailedPrecondition9400系统状态不满足前置条件(如删除非空目录)
Aborted10409并发冲突(乐观锁失败)
Unavailable14503临时故障,客户端应重试
DeadlineExceeded4504超时
Internal13500内部 bug
Unimplemented12501方法未实现
DataLoss15500不可恢复的数据丢失

创建和解析错误

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 映射原则

反模式:不要盲目传播下游 status code。 A 调 B 得到 InvalidArgument,对 A 的调用者来说这是 Internal(A 构造了错误的请求,是 A 的 bug)。

生态工具

工具用途
grpc-gateway从 proto annotation 生成 REST → gRPC 反向代理
grpcurlgRPC 的 curl,配合 reflection 可以探索和调用任何 gRPC 服务
grpc_health_v1标准健康检查协议,K8s gRPC 探针用
Reflection一行代码启用,支持 grpcurl 和 Postman
ConnectRPCBuf 出品,一个 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

维度gRPCRESTGraphQLConnectRPC
序列化Protobuf(二进制)JSON(文本)JSON(文本)Protobuf 或 JSON
传输仅 HTTP/2HTTP/1.1 或 2HTTP/1.1 或 2HTTP/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 MinTimePermitWithoutStream 两端一致。

7. 不要盲目传播下游 Status Code

A 调 B,B 返回 InvalidArgument。如果 A 原封不动传给 A 的调用者 → 调用者以为自己传错了参数。实际上是 A 构造请求时有 bug。应该包装为 Internal

生产 Checklist

Server

Client

错误处理

可观测性

负载均衡

测试


分享这篇文章:

上一篇
C++ 工程化(二):命名规范没有统一标准但有底线规则
下一篇
C++ 工程化(一):Modern CMake 的核心是 target-based 与传递性语义