为什么服务间通信用 gRPC:HTTP/2 与 protobuf

IM 后端拆成 account(账号)、message(消息投递)、relation(关系链)三个服务之后,最先冒出来的问题不是分库分表,而是「登录接口怎么拿到用户资料」:长连接网关在校验令牌时要向 account 要用户资料、向 relation 拉会话列表,这些调用跑在机房内网、由你自己的代码发起、单机每秒上千次。这类调用叫服务间通信(service-to-service communication),它和「给 App 提供对外接口」是两件不同的事:对外要通用、好调试、能缓存;对内要快、强类型、能流式。

gRPC 就是为后者设计的:用 HTTP/2 做传输、用 protobuf 做契约与序列化、用代码生成把契约变成两侧的强类型骨架。

内网调用真正在意的三件事

关注点内网 RPC 的要求REST + JSON 的代价
开销单机每秒上千次调用,头部与序列化开销要摊薄JSON 是文本、体积大、解析要分配对象;HTTP/1.1 每请求一条连接
契约字段类型与可选性必须在编译期暴露靠文档和口头约定,字段改名要全链路对齐
流式消息投递、在线状态推送天然是「一次请求、持续多条响应」要用 SSE 或 WebSocket 另起一套协议

一句话:对外接口拼的是生态兼容,对内接口拼的是契约强度与单位成本。

HTTP/2:多路复用与头部压缩

gRPC 把每次调用映射成一个 HTTP/2 流(stream)。一元(unary)调用的帧序列是:HEADERS(:path 为 /im.account.v1.AccountService/Login)→ DATA(protobuf 请求体)→ 响应 HEADERS + DATA + 尾部 trailer(grpc-status、grpc-message)。注意业务状态走的是 trailer 而非 HTTP 状态码,这正是「用 curl 调 gRPC 端口拿不到错误信息」的根因。

特性HTTP/1.1HTTP/2
连接模型一条连接一个请求,靠多条连接并发一条 TCP 连接上并发多个流,互不阻塞
头部纯文本,每次全量发送二进制帧 + HPACK 头部压缩,重复头部只发索引
消息边界靠 Content-Length 或 chunked帧自带长度,天生适合二进制消息
流量控制无连接级与流级窗口,避免大消息打爆接收端
服务端推送不支持支持(gRPC 不用它,但流式能力被用作四种调用模式)

多路复用还带来一个实际好处:客户端与某个服务之间只需一条连接(gRPC 里叫 channel),省掉连接池管理与 HTTP/1.1 的队头阻塞(head-of-line blocking);但 TCP 层仍可能因丢包整体停顿,这也是第 11 章要谈 keepalive 与连接管理的原因。

protobuf:契约写进文件

gRPC 默认用 protobuf(Protocol Buffers)做 IDL(接口描述语言)与序列化格式,契约放在 .proto 文件里(语法细节见第 2 章):

syntax = "proto3";

package im.account.v1;

option go_package = "example.com/im/gen/account/v1;accountv1";

message LoginReq {
  int64 user_id = 1;   // 等号右边是字段编号,不是赋值
  string token = 2;
  string device_id = 3;
}

字段编号(field number)在编码时替代字段名进入二进制,所以改字段名不影响兼容,改编号直接破坏兼容(第 15 章展开)。编码走 TLV(Tag-Length-Value)思路:编号与线类型合成一个 tag 字节,值按 varint 或长度前缀编码。

同一份登录请求,两种格式的体积对照(示意值,随字段内容浮动):

内容JSON 文本protobuf 二进制
user_id = 10086"user_id":10086 约 15 字节tag 1 字节 + varint 2 字节 = 3 字节
token 32 个字符键名加引号约 42 字节tag 1 + 长度 1 + 内容 32 = 34 字节
整数一律按文本存小整数只占 1~2 字节

想自己验证不用起服务,protoc 就能编解码:

# JSON 转二进制:stdin 读 JSON,stdout 出二进制
protoc --encode=im.account.v1.LoginReq --proto_path=proto proto/account/v1/account.proto < login.json > login.bin

# 二进制转回 JSON,确认字段编号解析正确
protoc --decode=im.account.v1.LoginReq --proto_path=proto proto/account/v1/account.proto < login.bin

客户端长什么样

生成代码之后(生成流程见第 3、4 章),一次调用在 Go 里是这样:

// 片段:需放在函数体内,import 省略
conn, err := grpc.NewClient("127.0.0.1:9000", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
	return fmt.Errorf("new client: %w", err)
}
defer conn.Close()

client := accountv1.NewAccountServiceClient(conn)

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

resp, err := client.Login(ctx, &accountv1.LoginReq{UserId: 10086, Token: token})
if err != nil {
	return fmt.Errorf("login: %w", err)
}
log.Printf("login ok: nickname=%s", resp.GetNickname())

对比 REST 的 json.Marshal 加 http.Post:请求类型由编译器检查、超时通过 context 传递、错误自带 gRPC 状态码(第 6 章)。

什么时候不该用 gRPC

场景建议原因
浏览器直连后端REST/JSON,或用 gRPC-Web、Connect 中转浏览器不原生支持 gRPC 的帧与 trailer
对外开放、给第三方调REST + OpenAPI生态、调试、缓存、网关兼容性优先
极低频、人工触发的运维接口简单 HTTP 接口curl 就能查,不值得上工具链
内网服务之间、调用频繁gRPC强契约、低开销、原生流式
需要双向实时推送gRPC 双向流,或独立的长连接层属于架构选择,本章不展开

常见坑

坑后果改法
用 curl 调 gRPC 端口乱码或直接失败,看不到业务错误用 grpcurl、grpcui,或翻 trailer 里的 grpc-status
明文端口当成普通 HTTP 用握手失败、协议混用内网明文用 insecure.NewCredentials(),对外一律 TLS
以为换了 gRPC 就一定快小消息低频调用差距有限收益主要来自高频、小载荷、流式与连接复用
把大文件塞进单个 message超默认 4MiB 上限直接报错分片走流式,或调整接收上限(第 12 章)
把生成代码当手写代码改下次生成即被覆盖生成物放独立目录,只读不改
一元调用传超大 ctx无法取消,超时也不生效超时一律用 context.WithTimeout 派生子 ctx

小结:服务间通信用 gRPC,是把契约交给 protobuf 文件、把传输交给 HTTP/2 多路复用、把类型安全交给代码生成;内网高频调用选它,浏览器与对外开放场景仍留给 REST。

笔记加载中…