为什么服务间通信用 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.1 | HTTP/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。