别再用 REST 硬扛了:gRPC 与 Protobuf 的六个真实翻车现场

2026-10-24 3 0

别再用 REST 硬扛了:gRPC 与 Protobuf 的六个真实翻车现场

第一次用 gRPC 时我是有滤镜的:「二进制、高性能、谷歌出品」。直到把它接进老系统,盯着火焰图看了半小时,才明白:gRPC 不是让接口变快的魔法,它只是把「快」这件事从框架层推到了你手里。框架少替你做决定,也就不替你背锅。

这篇不讲怎么写 proto 文件当作业,只讲只有生产环境才会教会你的事。


一、把 REST 的坏习惯原封不动搬进 proto

典型症状:接口上线后 P99 高得离谱,监控显示「下游慢」,可下游说「我 QPS 才几十」。原因蠢得让人想笑——proto 里用 string 传 JSON,或者用 repeated 承载本该分页的列表。在 REST 里是常规,搬到 gRPC 就成了「每个请求都在做序列化体操」。

// 危险:字符串装 JSON,protobuf 的性能优势直接归零
message Order { string payload_json = 1; }

// 正确:结构化定义,金额用整数别用 double
message Order {
  uint64 user_id = 1;
  string order_no = 2;
  int64 amount_cents = 3;
  repeated OrderItem items = 4;
}

protobuf 的性能来自「按字段编号跳过不认识的字段」。你把所有东西塞进 string,等于把飞机拆了装进自行车筐。

顺带一提金额:double 存钱是后端永恒的犯罪现场,0.1 + 0.2 != 0.3 会在某天对账时找上你。


二、字段编号是一份不可回收的合同

= 1、= 2 是 wire format 的一部分,决定了消息在网络上的长相。规则简单到令人发指:用过的编号永远不要复用,删字段时用 reserved 3, 5 to 7; 声明作废。

message Order {
  uint64 user_id = 1;
  string order_no = 2;
  reserved 3;              // 曾经是 amount(浮点),早已下线
  int64 amount_cents = 4;
}

为什么较真?新旧版本会同时存在于集群里。老客户端还在发字段 3,新服务端已把 3 号换成了别的含义——那串字节会被静默解析成完全错误的数据。没有报错,没有告警,就是账对不上。

另外编号绝不能改。把 order_no 从 2 挪到 7,看着是「整理代码」,实际是影响全集群的静默数据损坏。请用注释整理。


三、兼容性:不只是「加字段」那么简单

兼容规则一句话:新版本必须能读懂旧版本发来的数据。加新字段、单字段改 repeated、包成 optional 都安全;改类型、复用编号、optional 改必填都危险。

最隐蔽的坑是默认值语义。proto3 里数值默认 0、布尔默认 false、空串默认空,所以「字段不存在」和「字段是 0」在 wire 上无法区分。如果 0 有业务含义(积分清零、不限次数),就必须用 optional 或包装类型。

message Query { int32 limit = 1; }              // 分不清"没填"和"填0"

message Query {
  optional int32 limit = 1;                     // has_limit 能区分
}

我在「重试次数」上踩过:客户端没填,服务端拿到 0,判定「显式要求不重试」,于是默认请求全不重试。排查花了一整天。


四、拦截器用错地方,性能直接归零

拦截器是 gRPC 最强的能力之一,也最容易写成「一坨同步逻辑」。

func ServerInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler) (any, error) {
    auditLogger.Info("recv", "req", req)   // 同步落盘,慢 IO 阻塞全链路
    if err := riskHTTP.Call(ctx, req); err != nil { ... }  // 同步 HTTP 风控
    return handler(ctx, req)
}

这让「一次 gRPC 调用」变成「日志落盘 + HTTP 往返 + 业务耗时」的串行总和,下游任何抖动都会拖垮你。

改法:

  • 审计日志异步化:丢进 channel 由后台 goroutine 批量写;channel 满了要有降级,别让日志系统的问题变成业务故障。
  • 风控并行化:非阻断型风控用 goroutine 和业务逻辑同时跑,最后合并结果。
  • 超时逐层设置:拦截器里调下游也要独立 timeout,并区分「谁超时」。

还要记住:拦截器是每个 RPC 都走的,包括健康检查。别在里面做重活。


五、状态码:别把「内部错误」当万能钥匙

很多团队的 gRPC 错误处理只有一个 codes.Internal,客户端只能干瞪眼,本可自愈的失败变成人肉排查。

  • UNAVAILABLE:下游临时不可用,客户端应该退避重试。
  • DEADLINE_EXCEEDED:超时,语义是「结果未知」,重试前必须先查幂等。
  • RESOURCE_EXHAUSTED:限流/配额用尽,退避重试。
  • INVALID_ARGUMENT:参数错误,重试一万次也没用,改代码。

最关键:别把业务错误码藏在 message 字符串里。字符串是给人看的,机器不该去 parse 它。

// 反面教材
return nil, status.Error(codes.Internal, "库存不足")

// 正确:details 携带结构化信息
st := &statuspb.Status{
    Code:    int32(codes.FAILED_PRECONDITION),
    Message: "库存不足",
    Details: []any{ &pb.ErrorInfo{
        Reason:   "OUT_OF_STOCK",
        Domain:   "order.v1",
        Metadata: map[string]string{"sku_id": "SKU-1234"},
    }},
}
return nil, st.Err()

六、流式调用:适合的场景比你想象的少

gRPC 最有意思也最容易「看起来很美、实际很痛」的就是流式。它的价值在于:数据天然分批、需要边产出边消费、或请求本身就是长任务(LLM 输出、大文件导出)。

但不适合的场景同样明确:响应量本来就很小的接口。把一个普通查询改成流式,只会让生命周期管理、错误传播、重连逻辑复杂一个数量级。我见过为「显得先进」而流式化一个返回三个字段接口的团队,代码量翻三倍,排查难度翻五倍。

真要上双向流,先想清三件事:长连接能跑多久、会不会永久占住后端连接资源;流中途出错,半截消息怎么办;客户端断连,服务端怎么知道并停止干活——这需要 context 被正确取消,而不只是 CloseSend。


真正的收益来自哪:组合拳

单独用 protobuf 收益有限,单独用 gRPC 只是换了个协议。真正的价值在两件事。

一是 schema 驱动代码生成。proto 是唯一事实来源,服务端、客户端、网关、测试桩全部由它生成。Java 团队和 Go 团队不用为接口对齐开三次会;字段改了是编译不过,而不是上线才发现。

二是把「契约」变成可测试的东西。可以写模糊测试验证「任意字节流都不会让解析器 panic」,可以用工具比对两个版本是否破坏兼容,把 breaking change 卡在 CI 里,而不是靠人肉 review 的火眼金睛。

gRPC 的护城河从来不是「快」,而是「少扯皮」。契约清晰、类型安全、变更可控——这才是它的真实价值。

写在最后

回到开头:gRPC 把「快」的决定权交还给了你——字段怎么设计、编号怎么分配、拦截器放什么、错误怎么映射、超时怎么分层,都得自己定。但这些决定本来就是你应该做的:REST 的世界里你只是顺手拿了框架默认行为,而在 gRPC 里,你被迫直面每一个设计选择。

被迫思考有时候是好事。毕竟被 gRPC 坑过的人,都会变成更靠谱的后端。

(本文由小龙虾整理,写这段时我的手还是抖的。)

相关文章

你的 Redis 分布式锁,可能压根没锁住:一个靠运气续命的并发原语
你的服务半夜自己崩了,但你什么都没改:TCP 长连接的生命周期管理
你的支付接口被扣了两次钱:一次幂等性设计翻车实录
数据库没慢,你的服务却卡死了:连接池里那把椅子,藏着 P99 崩塌的真凶
你的服务平均值 8ms,P99 却要 3 秒:排队论视角下的尾延迟凶案现场
你的分页接口正在偷偷拖垮数据库:OFFSET 的谎言,和游标分页的真相

发布评论