gRPC折腾手记
先说清楚 gRPC 在协议层面的真实面貌:它是 HTTP/2 之上的 RPC 框架,使用 protobuf 作为接口定义语言(IDL)和序列化格式。
但"HTTP/2"这个前提容易被忽略。
为什么从 REST 换到 gRPC
最先触发我认真考虑 gRPC 的是流式传输。
那时候在做一个实时数据处理服务,需要从多个数据源持续接收更新。用 REST 的轮询或者 WebSocket 都不是最佳解:轮询延迟高、连接开销大;WebSocket 能双向通信但协议层面没有结构定义,全靠约定。
gRPC 的双向流正好卡在这个场景上。而且 protobuf 的二进制序列化确实比 JSON 紧凑不少,尤其是传输大量数值数据时。
另一个触发点是跨语言调用。Go 后端、Python 数据处理、Java 业务逻辑,三种语言的接口用 OpenAPI 定义倒也能跑,但每次改接口都要同步更新三方文档和代码生成,很容易出遗漏。protobuf 的 .proto 文件成了单一定义源,各语言代码生成统一管理,这块省了不少事。
当然,换技术栈从来不是纯粹技术问题。当时的团队已经习惯了 REST 的调试工具、链路追踪、监控指标,这些在 gRPC 体系下都变了。折腾下来,最大的感受是:技术升级容易,团队对齐难。
gRPC 协议其实没那么复杂
先说清楚 gRPC 在协议层面的真实面貌:它是 HTTP/2 之上的 RPC 框架,使用 protobuf 作为接口定义语言(IDL)和序列化格式。
但"HTTP/2"这个前提容易被忽略。很多人以为 gRPC 就是"二进制的 REST",其实它依赖 HTTP/2 的多路复用、头部压缩、服务器推送等特性。这就是为什么 gRPC 不会直接跑在 HTTP/1.1 上。
protobuf 的序列化性能确实不错,但不是所有场景都快。小消息时解析开销占比高,大消息时 protobuf 的紧凑格式才有明显优势。我们测试过:100 字节以内的消息,JSON 和 protobuf 的序列化时间差距在微秒级,但网络传输差异几乎可以忽略。
syntax = "proto3";
package example;
service DataStream {
// 单次请求-响应
rpc GetData (GetDataRequest) returns (GetDataResponse);
// 服务端流式
rpc StreamData (StreamRequest) returns (stream DataChunk);
// 客户端流式
rpc UploadData (stream DataChunk) returns (UploadResponse);
// 双向流式
rpc BidirectionalStream (stream StreamRequest) returns (stream DataChunk);
}
message GetDataRequest {
string data_id = 1;
int64 version = 2;
}
message GetDataResponse {
DataChunk data = 1;
int64 timestamp = 2;
}
message StreamRequest {
string filter = 1;
int32 batch_size = 2;
}
message DataChunk {
bytes payload = 1;
int32 chunk_id = 2;
}
message UploadResponse {
bool success = 1;
string message = 2;
int64 bytes_received = 3;
}
这个 .proto 文件定义了四种调用模式。实际用起来,单向请求响应最简单,但真正体现 gRPC 优势的是流式调用。数据管道、实时通知、增量同步,这些场景用流式比轮询或长轮询舒服太多。
// server.go
package main
import (
"context"
"log"
"net"
"time"
"google.golang.org/grpc"
pb "path/to/your/proto"
)
type server struct {
pb.UnimplementedDataStreamServer
}
func (s *server) GetData(ctx context.Context, req *pb.GetDataRequest) (*pb.GetDataResponse, error) {
// 模拟数据查询
data := &pb.DataChunk{
Payload: []byte("sample data"),
ChunkId: 1,
}
return &pb.GetDataResponse{
Data: data,
Timestamp: time.Now().Unix(),
}, nil
}
func (s *server) StreamData(req *pb.StreamRequest, stream pb.DataStream_StreamDataServer) error {
for i := 0; i < 10; i++ {
chunk := &pb.DataChunk{
Payload: []byte("chunk data"),
ChunkId: int32(i),
}
if err := stream.Send(chunk); err != nil {
log.Printf("Send failed: %v", err)
return err
}
time.Sleep(100 * time.Millisecond)
}
return nil
}
func (s *server) UploadData(stream pb.DataStream_UploadDataServer) error {
var totalBytes int64
var chunkCount int
for {
chunk, err := stream.Recv()
if err != nil {
break
}
totalBytes += int64(len(chunk.Payload))
chunkCount++
}
return stream.SendAndClose(&pb.UploadResponse{
Success: true,
Message: "Upload completed",
BytesReceived: totalBytes,
})
}
func (s *server) BidirectionalStream(stream pb.DataStream_BidirectionalStreamServer) error {
for {
req, err := stream.Recv()
if err != nil {
break
}
// 简单的 echo 逻辑
response := &pb.DataChunk{
Payload: []byte("echo: " + req.Filter),
ChunkId: int32(time.Now().Unix()),
}
if err := stream.Send(response); err != nil {
log.Printf("Send failed: %v", err)
return err
}
}
return nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterDataStreamServer(s, &server{})
log.Println("Server starting on :50051")
if err := s.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
服务端实现没什么花哨的,主要在流式处理的逻辑里。需要注意的点是:流式调用里错误处理要特别小心,一旦出错整个流可能中断,客户端和服务器都要有重连机制。
// client.go
package main
import (
"context"
"fmt"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "path/to/your/proto"
)
func main() {
conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
client := pb.NewDataStreamClient(conn)
// 测试单向调用
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
resp, err := client.GetData(ctx, &pb.GetDataRequest{
DataId: "test-123",
Version: 1,
})
if err != nil {
log.Fatalf("GetData failed: %v", err)
}
fmt.Printf("Received data: %v\n", resp)
// 测试服务端流
stream, err := client.StreamData(ctx, &pb.StreamRequest{
Filter: "sample",
BatchSize: 10,
})
if err != nil {
log.Fatalf("StreamData failed: %v", err)
}
for {
chunk, err := stream.Recv()
if err != nil {
break
}
fmt.Printf("Received chunk: %d\n", chunk.ChunkId)
}
// 测试客户端流
uploadStream, err := client.UploadData(ctx)
if err != nil {
log.Fatalf("UploadData failed: %v", err)
}
for i := 0; i < 5; i++ {
if err := uploadStream.Send(&pb.DataChunk{
Payload: []byte(fmt.Sprintf("chunk-%d", i)),
ChunkId: int32(i),
}); err != nil {
log.Fatalf("Send failed: %v", err)
}
}
uploadResp, err := uploadStream.CloseAndRecv()
if err != nil {
log.Fatalf("CloseAndRecv failed: %v", err)
}
fmt.Printf("Upload response: %v\n", uploadResp)
}
客户端代码也是直截了当。真正复杂的地方不在调用本身,而在连接管理、超时控制、重试策略这些外围机制上。
实际踩过的几个坑
HTTP/2 依赖不是虚的
刚把 gRPC 跑起来时,一切都挺顺利。直到部署到生产环境的 Nginx 后面,客户端一直报错:“http2: server sent GOAWAY and closed the connection”。
查了一圈才发现,Nginx 的默认配置对 HTTP/2 支持有限,尤其是反向代理 gRPC 时需要额外配置。加上这个配置后问题解决:
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
grpc_pass grpc://backend:50051;
# 关键配置
grpc_set_header Host $host;
grpc_set_header X-Real-IP $remote_addr;
grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
grpc_set_header X-Forwarded-Proto $scheme;
# 超时设置
grpc_read_timeout 300s;
grpc_send_timeout 300s;
}
}
教训很直接:不要假设网络基础设施天然支持 HTTP/2,尤其经过代理、防火墙、CDN 时。每次变更都要验证端到端的 HTTP/2 连通性。
消息大小限制吃瘪
有次客户端上传大文件,一直报错:“received message larger than max (4194304)"。
这是 gRPC 默认的消息大小限制(4MB)。改配置倒是简单:
opts := []grpc.ServerOption{
grpc.MaxRecvMsgSize(16 * 1024 * 1024), // 16MB
grpc.MaxSendMsgSize(16 * 1024 * 1024),
}
s := grpc.NewServer(opts...)
客户端也得对应调整:
opts := []grpc.DialOption{
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(16 * 1024 * 1024),
grpc.MaxCallSendMsgSize(16 * 1024 * 1024),
),
}
conn, err := grpc.NewClient("localhost:50051", opts...)
但更好的做法是避免大消息。分块传输、流式上传、改用共享存储,都比直接扩大消息限制来得优雅。我们后来改成了客户端流式上传,服务端边收边处理,内存占用平滑很多。
超时控制没对齐
最坑的一次是因为超时设置不一致导致的生产事故。
服务端设置的是全局超时 30 秒,但某些查询确实需要更长。客户端没意识到这个限制,一直在调用,但服务端提前超时返回。更糟糕的是,错误信息不够明确,客户端把超时错误当成了业务错误处理,导致数据不一致。
教训是:超时策略必须在服务契约里定义清楚,客户端和服务端要统一理解。最好在 .proto 文件的注释里说明各接口的预期耗时范围:
// GetData: 正常响应时间 < 1s,复杂查询可能到 10s
rpc GetData (GetDataRequest) returns (GetDataResponse);
// ProcessLargeData: 预期耗时 10s-60s,请设置合理超时
rpc ProcessLargeData (LargeDataRequest) returns (ProcessResponse);
客户端根据这些注释设置合理的超时,服务端在实现里也要监控耗时并报警。
序列化兼容性差点翻车
某次上线后,Python 客户端调用失败,Go 客户端正常。追查到 protobuf 版本不一致:服务端用 3.20,客户端用 3.15,某些字段的序列化行为变了。
其实 protobuf 有向后兼容性,但前提是遵守字段编号规则:不要复用已删除的字段编号,新增字段要选足够大的编号。我们之前清理无用字段时,删除了一些编号,后来又复用这些编号加了新字段,正好踩到了兼容性雷区。
修复方式很简单:永远不要复用字段编号,新字段从最大的可用编号开始。但如果服务已经部署了多个版本,升级时要特别注意灰度策略。
错误处理怎么做才对
gRPC 的错误处理跟 REST 的 HTTP 状态码机制不太一样,它有自己的一套状态码。
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *server) GetData(ctx context.Context, req *pb.GetDataRequest) (*pb.GetDataResponse, error) {
if req.DataId == "" {
return nil, status.Error(codes.InvalidArgument, "data_id cannot be empty")
}
data, err := s.queryData(ctx, req.DataId)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, status.Error(codes.NotFound, "data not found")
}
return nil, status.Error(codes.Internal, "internal error")
}
return &pb.GetDataResponse{
Data: data,
Timestamp: time.Now().Unix(),
}, nil
}
常用状态码有:
codes.InvalidArgument:参数错误codes.NotFound:资源不存在codes.AlreadyExists:资源已存在codes.PermissionDenied:权限不足codes.Unauthenticated:未认证codes.ResourceExhausted:资源耗尽(如限流)codes.FailedPrecondition:前置条件不满足codes.Aborted:操作冲突codes.OutOfRange:参数超出范围codes.Unimplemented:功能未实现codes.Internal:内部错误codes.Unavailable:服务不可用
客户端解析错误:
resp, err := client.GetData(ctx, req)
if err != nil {
st, ok := status.FromError(err)
if !ok {
log.Printf("Unknown error: %v", err)
return
}
switch st.Code() {
case codes.NotFound:
// 处理资源不存在
case codes.InvalidArgument:
// 处理参数错误
case codes.Internal:
// 处理内部错误,可能需要重试或报警
default:
log.Printf("Unexpected error: %s, %s", st.Code(), st.Message())
}
return
}
更高级的做法是自定义错误详情,这样可以在错误信息里附带结构化数据:
import (
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/status"
)
func (s *server) GetData(ctx context.Context, req *pb.GetDataRequest) (*pb.GetDataResponse, error) {
if req.DataId == "" {
st := status.New(codes.InvalidArgument, "data_id cannot be empty")
// 添加详细错误信息
details, err := st.WithDetails(
&errdetails.BadRequest{
FieldViolations: []*errdetails.BadRequest_FieldViolation{
{
Field: "data_id",
Description: "cannot be empty",
},
},
},
)
if err != nil {
return nil, st.Err()
}
return nil, details.Err()
}
// ...
}
客户端可以解析这些详情并给出更友好的错误提示。
监控和调试怎么办
gRPC 服务上线后,监控和调试也是重要环节。
Prometheus 指标采集需要 grpc-go 的拦截器:
import (
"github.com/prometheus/client_golang/prometheus"
"google.golang.org/grpc"
)
var (
requestsTotal = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "grpc_requests_total",
Help: "Total number of RPC requests.",
},
[]string{"method", "status"},
)
requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "grpc_request_duration_seconds",
Help: "RPC request duration in seconds.",
Buckets: prometheus.DefBuckets,
},
[]string{"method"},
)
)
func init() {
prometheus.MustRegister(requestsTotal)
prometheus.MustRegister(requestDuration)
}
func metricsInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
duration := time.Since(start).Seconds()
method := info.FullMethod
var status string
if err != nil {
st, ok := status.FromError(err)
if ok {
status = st.Code().String()
} else {
status = "unknown"
}
requestsTotal.WithLabelValues(method, status).Inc()
} else {
status = "OK"
requestsTotal.WithLabelValues(method, status).Inc()
}
requestDuration.WithLabelValues(method).Observe(duration)
return resp, err
}
// 使用拦截器
opts := []grpc.ServerOption{
grpc.ChainUnaryInterceptor(
loggingInterceptor,
metricsInterceptor,
recoveryInterceptor,
),
}
s := grpc.NewServer(opts...)
日志拦截器:
func loggingInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
start := time.Now()
log.Printf("gRPC call: %s started", info.FullMethod)
resp, err := handler(ctx, req)
duration := time.Since(start)
log.Printf("gRPC call: %s completed in %v, error: %v",
info.FullMethod, duration, err)
return resp, err
}
恢复拦截器防止 panic 导致整个服务崩溃:
import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func recoveryInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
defer func() {
if r := recover(); r != nil {
log.Printf("Recovered from panic: %v", r)
}
}()
return handler(ctx, req)
}
调试工具推荐 grpcurl,它是 gRPC 版的 curl:
# 列出服务
grpcurl -plaintext localhost:50051 list
# 列出方法
grpcurl -plaintext localhost:50051 list example.DataStream
# 描述方法
grpcurl -plaintext localhost:50051 describe example.DataStream.GetData
# 调用方法
grpcurl -plaintext -d '{"data_id":"test-123","version":1}' \
localhost:50051 example.DataStream.GetData
# 监控流式调用
grpcurl -plaintext -d '{"filter":"sample","batch_size":10}' \
localhost:50051 example.DataStream/StreamData
这趟折腾到底值不值
回看这两年从 REST 迁移到 gRPC 的过程,只能说"一半值,一半不值”。
值的地方:流式传输确实解决了实际问题,性能提升不是虚的,尤其是数据密集型的场景。跨语言调用也省了不少同步文档和接口约定的精力。
不值的地方:工具链复杂度高了不少,调试成本上升,团队学习曲线明显陡峭。如果只是简单 CRUD 场景,REST 加上 OpenAPI 其实足够用。
技术选型从来不是单纯的技术问题。团队背景、现有基础设施、维护成本,这些都要考虑。如果你的服务已经稳定运行,没有明确的痛点,贸然迁移 gRPC 的ROI可能不高。但如果你正好在搭建新的微服务体系,而且对流式传输、多语言调用、性能有明确需求,gRPC 值得一试。
最后说一句:协议、工具、框架都在变,但解决实际问题的初心不变。别为了"技术正确"而折腾,为了解决问题才折腾。
可用性说明:本文发布于 2021 年 6 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-gRPC折腾手记(https://blog.thinkmoon.cn/post/125-grpc-protocol-practice-guide/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。