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 网关。
