3.2 gRPC 上手:用 protobuf 定义接口

场景:HTTP+JSON 调用开始扛不住

注册发现问题解决后,调用方式还是老一套:订单服务用 http.Get 拿商品信息,返回 JSON 再反序列化。随着调用链变长,三个问题逐渐浮出水面。

第一是文档永远落后于代码。商品服务改了字段名,订单服务编译期毫无感知,直到上线后某个字段变成零值才被发现,排查半小时。第二是编解码开销:大促时商品列表的 JSON 有几百 KB,光序列化就占了接口耗时的三分之一,这在 秒杀削峰 的场景里特别扎眼。第三是错误处理全靠约定:有人返回 200 加错误码,有人直接 500,客户端要写一堆 if else 兜底。

我们决定服务间调用全部换成 gRPC,对外仍保留 HTTP 接口给客户端,这个分层思路在 拆分边界 里讨论过。

HTTP 与 gRPC 的选型对照

维度 HTTP+JSON gRPC+protobuf
接口定义 口头约定加文档 proto 文件强约束
编解码性能 反射解析,较慢 二进制,快 3 到 5 倍
类型安全 运行时才发现错 编译期暴露
流式通信 需要额外改造 原生支持
浏览器直连 支持 需要 grpc-web

选 gRPC 主要是为了类型安全和性能,代价是调试不如 HTTP 直观,需要 grpcurl 这类工具配合。

proto 规范:把契约管起来

接口定义集中在一个独立 repo 里,而不是散落在各个服务仓库,这是我们从第一周就定下的规矩。好处有两个:调用方升级依赖时能一次看到全部变更;接口评审可以集中进行,避免每个团队各写一套风格。proto 仓库的目录长这样:

proto/
  product/v1/product.proto
  order/v1/order.proto
  user/v1/user.proto
  buf.gen.yaml
  buf.yaml

生成代码由 CI 统一跑,产物作为一个独立 module 发布版本号。这样调用方 go get 的是确定性的版本,而不是各自本地生成出五花八门的代码。

标量类型选择也有几个不成文的规矩,列出来供参考:

场景 推荐类型 原因
金额 int64 加单位注释 浮点数有精度问题,统一存分
枚举 enum 比裸 int 可读
时间戳 google.protobuf.Timestamp 跨语言一致
外部 id string 避免溢出与耦合
大文本 string 超大字段考虑单独 rpc 流式拉

金额用 int64 存分这条规矩,是从一次价格对账事故里学来的:JSON 时代 double 直接舍入,两百块的商品少算一分,财务那边对不上。

落地:四步把接口定义跑通

第一步,写 proto 文件。这是整个改造里最重要的一步,接口长什么样全由它说了算:

syntax = "proto3";

package product.v1;

option go_package = "gen/product/v1;productv1";

service ProductService {
  rpc GetProduct(GetProductRequest) returns (GetProductResponse);
  rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
}

message GetProductRequest {
  int64 id = 1;
}

message GetProductResponse {
  int64 id = 1;
  string name = 2;
  int64 price_cents = 3;
  int32 stock = 4;
}

第二步,生成代码。统一用 buf 管理生成命令,避免每个人生成的版本不一致:

buf generate

生成的代码同时给服务端和客户端用,这一步是类型安全的来源:proto 改了字段,所有调用方重新生成后编译直接报错,想偷偷不改都不行。

第三步,服务端实现接口:

type productServer struct {
    productv1.UnimplementedProductServiceServer
    repo ProductRepo
}

func (s *productServer) GetProduct(
    ctx context.Context, req *productv1.GetProductRequest,
) (*productv1.GetProductResponse, error) {
    p, err := s.repo.Find(ctx, req.GetId())
    if err != nil {
        return nil, status.Error(codes.NotFound, "product not found")
    }
    return &productv1.GetProductResponse{
        Id: p.ID, Name: p.Name, PriceCents: p.PriceCents, Stock: p.Stock,
    }, nil
}

第四步,客户端调用。配合上一节的 Consul,地址用服务名而不是 IP:

resp, err := client.GetProduct(ctx, &productv1.GetProductRequest{Id: 1234})
if err != nil {
    st, _ := status.FromError(err)
    log.Printf("code=%v msg=%s", st.Code(), st.Message())
}

错误码用 gRPC 标准状态码,不用再猜对方返回体长什么样。这套调用的网络底座就是我们 上云时配好的 CVM 集群,机器本身的采购可以看 腾讯云 CVM 新用户特惠。

踩坑记录

第一个坑没加超时。gRPC 默认没有截止时间,某个下游慢了,上游连接池被占满,整个服务雪崩。后来所有 rpc 调用统一包一层带 timeout 的 context:

ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resp, err := client.GetProduct(ctx, req)

并且约定 timeout 在网关入口就设好,每往下一层减掉已耗时的一部分,形成层级递减的预算,大促前还专门做了慢调用的降级演练,演练方式参考 链路追踪 一节。

第二个坑是 proto 字段编号冲突。有人在 message 中间插了新字段,占用了已废弃但没清理的编号,新旧版本反序列化错位,库存数字对不上。规矩很快就定下来:字段只增不删,废弃的加 reserved 标记。

第三个坑是 grpcurl 调试需要反射。排查线上问题时发现 grpcurl 连不上,以为网络不通,其实是服务没开 reflection。后来在 main 里统一注册,排查问题方便很多:

import "google.golang.org/grpc/reflection"

reflection.Register(srv)

第四个坑是 keepalive 配置。默认 keepalive 时间较长, CLB 或防火墙空闲连接超时先到的话,连接会被静默断开,表现为偶发的连接错误。配上 keepalive 30 秒 ping 后消失,这个参数在我们 安全组与网络配置 时也验证过一遍。

第五个坑是错误信息塞业务语义。有同事把业务错误码直接塞进 gRPC 状态码的 message 里,客户端解析正则字符串,脆弱且难维护。后来约定:标准状态码表达通用语义,业务细节统一用 google.rpc.ErrorInfo 这类详细错误机制携带,客户端按字段取值,不再解析字符串。

小结

protobuf 把接口变成了契约,编译器替我们盯着调用方;gRPC 把序列化和错误码标准化,省下的心智成本比省下的 CPU 更值钱。互调问题解决后,新问题来了:外部客户端总不能也直连 gRPC,于是我们引出了 API 网关。

延伸阅读