一句话总结
gRPC 是基于 HTTP/2 + Protocol Buffers 的高性能 RPC 框架.
它用 .proto 文件定义服务契约,自动生成多语言客户端/服务端代码,让微服务间的通信像调用本地函数一样简单--但你必须时刻记住中间隔着网络.
为什么不直接用 HTTP JSON?
上一篇讲了微服务之间需要通信.最直觉的方式是 RESTful HTTP + JSON--为什么还需要 gRPC?
| 维度 | HTTP + JSON | gRPC + Protobuf |
|---|---|---|
| 序列化效率 | 文本格式,体积大,解析慢 | 二进制编码,体积小 3-10 倍,解析快 |
| 传输协议 | HTTP/1.1(一问一答) | HTTP/2(多路复用,头部压缩,流式) |
| 契约定义 | OpenAPI/Swagger(可选,常滞后) | .proto 文件(强制,代码生成) |
| 代码生成 | 手动写 client SDK 或用生成器 | protoc 自动生成多语言代码 |
| 流式通信 | 需要 WebSocket / SSE 等额外方案 | 原生支持双向流 |
| 浏览器支持 | 原生支持 | 需要 gRPC-Web 代理层 |
| 可读性 | curl 直接看 | 二进制,需要工具解码 |
JSON = 写信(人能直接读,但传输慢,体积大).
Protobuf = 电报编码(人读不懂,但传输快,体积小).
内部服务之间通信频率高,延迟敏感--用电报.对外暴露给浏览器的 API--用信件(JSON).
什么时候用 JSON REST,什么时候用 gRPC
对外(南北向):面向浏览器,移动端,第三方开发者的 API → JSON REST(兼容性好,可读性强).
对内(东西向):服务之间的高频 RPC 调用 → gRPC(性能好,契约强,代码生成省心).
两者不冲突--API Gateway 对外提供 REST,内部转发为 gRPC 是常见模式.
Protocol Buffers 基础
Protocol Buffers(简称 Protobuf)是 Google 开源的接口描述语言(IDL)+ 序列化格式.它是 gRPC 的默认编码方式,但也可以独立使用.
proto 文件结构
一个典型的 .proto 文件:
|
关键概念
| 概念 | 说明 |
|---|---|
syntax = "proto3" |
使用 proto3 语法(当前主流,字段默认 optional) |
package |
命名空间,防止消息名冲突 |
service |
定义 RPC 服务接口(一组方法) |
message |
定义数据结构(类似 struct) |
enum |
枚举类型,第一个值必须为 0(UNSPECIFIED) |
| 字段编号(= 1, 2, 3) | 二进制编码的标识符,一旦发布不可更改 |
repeated |
数组/列表字段 |
optional |
可选字段(proto3 中可显式标记以区分"未设置"和"零值") |
字段编号是永久契约
字段编号在二进制编码中用于标识字段.一旦 .proto 发布使用,编号不能修改,不能复用.
删除字段后,用 reserved 保留编号防止误用:reserved 6, 7;.这是 Protobuf 向前/向后兼容的基础.
标量类型映射
| Proto 类型 | Go 类型 | 说明 |
|---|---|---|
string |
string |
UTF-8 文本 |
int32 / int64 |
int32 / int64 |
变长编码,负数效率低 |
sint32 / sint64 |
int32 / int64 |
ZigZag 编码,负数友好 |
uint32 / uint64 |
uint32 / uint64 |
无符号整数 |
bool |
bool |
布尔 |
bytes |
[]byte |
任意二进制数据 |
double / float |
float64 / float32 |
浮点数 |
金额用 int64 表示分,不要用 float
浮点数有精度问题(0.1 + 0.2 ≠ 0.3).
金额字段用 int64 price_cents 表示分或用 string 配合 decimal 库,绝不用 double.
常用 Well-Known Types
Google 提供了一组标准消息类型,避免重复造轮子:
| 类型 | 用途 | Go 对应 |
|---|---|---|
google.protobuf.Timestamp |
时间戳 | timestamppb.Timestamp |
google.protobuf.Duration |
时间段 | durationpb.Duration |
google.protobuf.Empty |
空消息(无参数/无返回) | emptypb.Empty |
google.protobuf.FieldMask |
部分更新(指定更新哪些字段) | fieldmaskpb.FieldMask |
google.protobuf.Struct |
动态 JSON 结构 | structpb.Struct |
Protobuf 编码原理
理解编码原理能帮你做出更好的 schema 设计决策.
Wire Format
Protobuf 不存储字段名--只存储字段编号 + 类型标记 + 值.这就是为什么改字段名不会破坏兼容性,但改字段编号会.
|
编码规则:
- Varint:整数用变长编码,小数字只占 1 字节(0-127),大数字才占更多
- Length-delimited:string / bytes / 嵌套 message 前面加长度前缀
- 零值不编码:proto3 中,字段为默认零值(0 / "" / false)时不写入,节省空间
字段编号 1-15 只占 1 字节
编号 1-15 的 tag 只需要 1 字节,16-2047 需要 2 字节.
把最高频的字段放在 1-15 号位可以节省带宽.对于高吞吐场景(如每秒百万级消息),这个优化是值得的.
gRPC 核心概念
整体架构
|
开发者只需要:
- 写 .proto 定义契约
- 用 protoc 生成代码
- 服务端实现接口
- 客户端像调本地函数一样调用
序列化,网络传输,连接管理全部由框架处理.
四种通信模式
| 模式 | proto 定义 | 适用场景 |
|---|---|---|
| Unary(一元) | rpc Get(Req) returns (Resp) |
最常见的请求-响应,类似 HTTP |
| Server Streaming | rpc List(Req) returns (stream Resp) |
服务端持续推送(日志流,大列表分批返回) |
| Client Streaming | rpc Upload(stream Req) returns (Resp) |
客户端持续发送(文件上传,批量数据) |
| Bidirectional Streaming | rpc Chat(stream Req) returns (stream Resp) |
双向实时通信(聊天,协同编辑) |
90% 的场景用 Unary 就够了
不要为了用流而用流.只有当数据量大到不适合一次性返回,或者需要实时推送时才考虑流式.
过度使用流式会增加错误处理的复杂度(流中断,背压,重连等).
Go 实现示例
以上面的 OrderService 为例,展示服务端和客户端的核心代码:
服务端实现:
|
客户端调用:
|
从开发者视角看,gRPC 调用和本地函数调用几乎一样--传入 request struct,拿到 response struct.
但底层经过了:Protobuf 序列化 → HTTP/2 帧封装 → 网络传输 → 反序列化 → 执行 → 原路返回.
"看起来像本地调用"是它的优点,也是它的陷阱--你必须始终记住中间有网络延迟和故障可能.
gRPC 错误模型
状态码体系
gRPC 定义了一组标准状态码(类似 HTTP 状态码,但语义更精确):
| 状态码 | 含义 | 对应 HTTP | 典型场景 |
|---|---|---|---|
OK |
成功 | 200 | 正常返回 |
InvalidArgument |
参数无效 | 400 | 字段格式错误,缺少必填字段 |
NotFound |
资源不存在 | 404 | 查询的订单不存在 |
AlreadyExists |
资源已存在 | 409 | 重复创建 |
PermissionDenied |
无权限 | 403 | 操作权限不足 |
Unauthenticated |
未认证 | 401 | 缺少或无效 token |
ResourceExhausted |
资源耗尽 | 429 | 限流,配额不足 |
Unavailable |
服务不可用 | 503 | 服务过载,维护中(客户端应重试) |
DeadlineExceeded |
超时 | 504 | 请求超过 deadline |
Internal |
内部错误 | 500 | 未预期的服务端异常 |
Unimplemented |
未实现 | 501 | 方法未实现 |
富错误详情(Error Details)
状态码 + message 有时不够用.gRPC 支持通过 status.WithDetails() 附加结构化错误信息:
|
不要把所有错误都用 Internal
常见错误做法:服务端 catch 所有异常然后返回 codes.Internal.
这让客户端无法区分"该重试"(Unavailable)还是"参数有误"(InvalidArgument)还是"确实挂了"(Internal).正确使用状态码是服务间有效协作的基础.
拦截器(Interceptor)
拦截器是 gRPC 的中间件机制,用于在 RPC 调用前后注入通用逻辑(日志,认证,限流,链路追踪等).
两种拦截器
| 类型 | 作用域 | 接口签名 |
|---|---|---|
| Unary Interceptor | 一元 RPC | func(ctx, req, info, handler) (resp, error) |
| Stream Interceptor | 流式 RPC | func(srv, ss, info, handler) error |
典型拦截器:日志 + 耗时统计
|
拦截器链
实际项目中通常需要多个拦截器.使用 grpc.ChainUnaryInterceptor 或第三方库 go-grpc-middleware:
|
|
拦截器的执行顺序
ChainUnaryInterceptor 的顺序是从左到右进入,从右到左返回(类似洋葱模型).
panic recovery 放最外层确保任何内层 panic 都能被捕获;logging 放在 auth 前面才能记录到认证失败的请求.
生产环境常用拦截器
| 拦截器 | 功能 | 推荐库 |
|---|---|---|
| Recovery | panic → gRPC Internal error | go-grpc-middleware/recovery |
| Logging | 请求/响应日志 + 耗时 | go-grpc-middleware/logging |
| Auth | Token 校验,权限检查 | go-grpc-middleware/auth |
| Tracing | 分布式链路追踪 | otelgrpc(OpenTelemetry) |
| Metrics | Prometheus 指标采集 | go-grpc-prometheus |
| Validator | 请求参数自动校验 | go-grpc-middleware/validator |
| Rate Limit | 限流 | go-grpc-middleware/ratelimit |
Metadata(元数据)
gRPC 的 Metadata 类似 HTTP Header,用于传递请求级别的附加信息(认证 token,trace ID,请求 ID 等),不污染业务消息体.
|
Deadline 与超时传播
gRPC 最强大的机制之一:Deadline 自动沿调用链传播.
|
如果 A → B → C,A 设置了 3s deadline:
- A 调 B 时 deadline 还剩 3s
- B 处理了 1s,调 C 时 deadline 自动传播为还剩 2s
- C 处理超过 2s → DeadlineExceeded 自动向上冒泡
永远设置 Deadline
不设置 deadline 的 gRPC 调用会无限等待--如果下游卡住,调用方的 goroutine 永远不会释放,最终耗尽资源.
生产环境的每一个 RPC 调用都必须有 deadline.推荐在客户端拦截器中强制注入默认 deadline.
版本兼容与演进
微服务独立部署意味着客户端和服务端版本可能不一致.Protobuf 的编码设计天然支持向前/向后兼容--但前提是你遵守规则.
兼容性规则
| 操作 | 向后兼容? | 说明 |
|---|---|---|
| 新增字段(新编号) | 安全 | 旧客户端忽略未知字段,新客户端对旧响应得到零值 |
| 删除字段 | 安全 | 但必须 reserved 编号,防止未来误用 |
| 重命名字段 | 安全 | 二进制编码只认编号,不认名字 |
| 修改字段编号 | 破坏 | 编码依赖编号,改了就不认了 |
| 修改字段类型 | 通常破坏 | int32 → int64 兼容,但 string → int32 不兼容 |
| 新增 enum 值 | 安全 | 旧客户端遇到未知值会得到默认值 0 |
| 新增 RPC 方法 | 安全 | 旧客户端不调用新方法即可 |
| 删除 RPC 方法 | 破坏 | 旧客户端调用会得到 Unimplemented |
版本管理策略
当需要做破坏性变更时,使用 package 版本号:
|
两个版本可以同时运行在同一个服务端,通过不同的路径 /order.v1.OrderService/ 和 /order.v2.OrderService/ 区分.客户端按需迁移到 v2,迁移完毕后下线 v1.
API 演进的最佳实践
- 只做加法(新增字段/方法),不做减法
- 新功能通过新字段实现,旧字段标记 deprecated 但不删除
- 真的需要破坏性变更时才升大版本号
- 用
buf lint/buf breaking自动化检测兼容性
HTTP/2 带来的优势
gRPC 选择 HTTP/2 作为传输层不是偶然--HTTP/2 的特性直接解决了微服务通信的核心问题:
| HTTP/2 特性 | 对 gRPC 的意义 |
|---|---|
| 多路复用 | 一个 TCP 连接上并发多个 RPC,不需要连接池 |
| 头部压缩(HPACK) | 重复的 header(metadata)只发一次 |
| 二进制帧 | 比 HTTP/1.1 文本解析更高效 |
| 流控制 | 支持背压(backpressure),防止快生产者压垮慢消费者 |
| 服务端推送 | 支持 Server Streaming |
回顾:gRPC 长连接与负载均衡
在 Kubernetes 04 篇中我们讨论过--gRPC 使用 HTTP/2 长连接,所有请求复用同一个连接.
这导致 K8s Service(kube-proxy L4 负载均衡)只在连接建立时选择一次后端 Pod,后续所有 RPC 都打到同一个 Pod.
解决方案:使用 Headless Service + 客户端负载均衡(gRPC 内置 round_robin 策略),或使用 L7 代理(Envoy / Istio).
gRPC 生态工具
buf - 现代化 Proto 管理
buf 是 Protobuf 的现代化工具链,替代原生 protoc:
| 功能 | protoc | buf |
|---|---|---|
| 代码生成 | 需要手动安装插件,写复杂命令 | buf generate 一条命令 |
| Lint | 无内置 | buf lint 检查命名规范 |
| Breaking 检测 | 无内置 | buf breaking 自动检测兼容性破坏 |
| 依赖管理 | 手动拷贝 .proto 文件 | buf.yaml 声明依赖,自动拉取 |
grpcurl - gRPC 的 curl
gRPC 是二进制协议,不能直接用 curl 调试.grpcurl 是命令行调试工具:
|
前提:开启 Server Reflection
grpcurl 需要服务端开启反射(reflection)才能发现服务和方法.Go 中一行代码开启:reflection.Register(srv).
生产环境建议只在内网开启,不要对外暴露.
gRPC-Gateway
自动将 gRPC 接口暴露为 RESTful HTTP API--一套 .proto 同时服务内部 gRPC 和外部 REST:
|
编译后生成一个 HTTP 反向代理,自动将 GET /v1/orders/123 转换为 gRPC 调用 GetOrder({order_id: "123"}).
生产环境最佳实践
Proto 设计规范
- 请求/响应独立:每个 RPC 方法有自己的 Request 和 Response 消息,不复用.即使结构相同,独立定义可以各自演进
- Enum 零值 = UNSPECIFIED:永远用
FOO_UNSPECIFIED = 0表示未设置,而非有效值(详解) - 字段命名 snake_case:Protobuf 规范,生成的 Go 代码自动转为 CamelCase
- 用 FieldMask 做部分更新:不要用"传哪个字段就更新哪个"的隐式约定(详解)
- 分页用 page_token:而非 offset,避免数据变动导致的漏读/重读(详解)
上面三条里的前两条,都在解决同一个底层问题--下面先讲清这个共同病根,再逐个展开.
第三条(分页)是另一类问题,放在最后一并说明.
共同病根:没传 vs 传了零值
proto3 有一条必须先理解的铁律:"字段没传"和"字段传了零值",在 wire format(序列化字节流)上无法区分.proto3 默认不传输零值字段,所以下面两种情况发出的字节完全一样:
|
接收方无法判断这个 0 是"默认填充"还是"用户真的填了 0".这个二义性,就是 Enum 和 FieldMask 两条实践要消灭的对象.
Enum 零值用 UNSPECIFIED
proto3 强制 enum 的第一个值必须是 0,且 0 是字段未设置时的默认值.如果把一个有效业务状态放在 0,灾难就来了:
|
假设某天新增的上游服务漏传了 status 字段,接收方拿到的 status 是 0,也就是 PENDING.系统会把一条"字段缺失的脏数据"静默地当成合法的"待支付订单"处理,不报错,你永远查不出问题.
正确做法是把 0 这个"天然缺省槽位"留给"未指定"语义:
|
这跟数据库里用 NULL 表示"未知",而不是用 0 或空字符串硬塞是同一种思维:
0 是一个值,NULL 是"没有值",别让它们混淆.
枚举值要带前缀
注意是 ORDER_STATUS_UNSPECIFIED 而非 UNSPECIFIED.
Protobuf 的 enum 值在 C++ 等语言里是包级作用域,不加前缀,两个 enum 各自的 UNSPECIFIED 会撞名.这是 Google 官方风格规范的硬性要求.
FieldMask 部分更新
设想一个"更新用户资料"接口,用户只想改昵称,不动其他字段.客户端发来 User{nickname: "新名字"},bio 和 age 没传.服务端立刻陷入无解的难题:
- 用户是想只改昵称,保留 bio 和 age?
- 还是想把 bio 清空,把 age 改成 0?
回到铁律:没传的字段和零值字段在 wire 上一样,服务端根本分不清.常见的补丁是"传了非零值才更新,零值就忽略"--但只要用户真的想把 age 改成 0或清空 bio,这套隐式约定就会把合法操作吞掉.
google.protobuf.FieldMask 的解法:用一份独立的字段路径清单,显式声明这次要动哪些字段.
|
语义瞬间清晰:bio 虽是空字符串,但因为在 mask 里,所以是"明确要清空";age 不在 mask 里,服务端一行都不碰,自然保留原值.
FieldMask = 装修施工范围声明:把"改哪些字段"(意图)和"改成什么值"(数据)拆开传输.
就像给装修师傅留便条写明"只动主卧的墙,其他房间别碰"--精确圈定施工范围,而不是把整套房子推倒重建.
分页用 page_token
这一条与零值无关,解决的是另一类问题:数据一致性和深翻页性能.先看传统 offset 分页的硬伤.
设想一个按时间倒序的列表,基于 LIMIT/OFFSET 翻页.你看第 1 页时,有人发了一条新帖子,整个列表整体后移一位.于是翻第 2 页时:
|
根因:OFFSET 是"跳过前 N 行",而"前 N 行是什么"取决于查询那一刻的数据快照.数据一变,含义就漂移.此外 OFFSET 1000000 必须先扫描丢弃前 100 万行,深翻页性能是 O(N).
游标分页(cursor)的思路:不再用"第几页/跳过多少行"这种物理位置定位,而是用"上一页最后一条记录的标记"续读.这个标记就是 page_token.
|
底层 SQL 不用 OFFSET,而用条件过滤精确续上:
|
此时即使插入了 id=101 的新帖子,它不满足 id < 91,根本不会进入第 2 页.游标牢牢钉在"id=91"这个逻辑位置上,与列表整体是否移动无关;走索引定位,深翻页依然 O(log N).
代价是无法跳页(只能逐页"下一页"),实现略复杂.选型取舍:
| offset 分页 | page_token 游标分页 | |
|---|---|---|
| 漏读/重读 | 数据变动时会发生 | 不会 |
| 深翻页性能 | 越深越慢 O(N) | 稳定 O(log N) |
| 跳到第 N 页 | 支持 | 不支持,只能逐页 |
| 适用场景 | 后台管理,数据量小,需跳页 | 信息流,时间线,大数据量,高频变动 |
offset = 翻到第 100 页--中途有人撕了或加了几页,第 100 页就不是你预期的内容.
page_token = 在书里夹书签,无论别人怎么增删页面,下次都从书签那页精确续读.
补充问答
Q:既然问题是零值二义性,那 Go 里用指针类型(*int32)不就天然区分 nil / 0 / 有效值了吗?为什么还要 UNSPECIFIED 和 FieldMask?
方向是对的,但要区分两个层级:指针是语言层(Go)的表现,proto 最佳实践要解决的是协议层(wire format)的事.proto3 的 optional 关键字生成的 Go 代码正是指针,其底层机制是给字段加上"存在性(presence)"标记--这才是恢复二义性区分的根本,指针只是它在 Go 里的形态.
但 optional/指针只解决"单个字段设没设",而三点里只有部分场景与它重叠:
- Enum:理论上能用
optional,但 enum 本就强制白送一个 0 槽位,用UNSPECIFIED占住是顺势而为,且枚举值跨语言形态一致,比指针的 nil 更自解释. - FieldMask:
optional反而不够用--它无法表达嵌套路径(只更新address.city不动address.zipcode),也无法明确表达"把一个 message 字段显式置空"(nil 到底是不改还是清空?又回到二义性).FieldMask 是optional的超集,且是 gRPC/AIP 生态的行业契约. - 分页:与零值二义性完全无关,指针在这里没有用武之地.
一句话:optional/指针是"字段级"的存在性开关,FieldMask 是"请求级"的意图描述,page_token 是"接口设计模式"--三者是不同抽象层级的工具,不是竞争关系.
连接管理
- 复用连接:gRPC Channel(Go 中是
*grpc.ClientConn)线程安全,全局复用一个即可,不要每次 RPC 都 Dial - Keepalive:配置心跳检测,避免空闲连接被中间网关(如 ALB)静默断开
- 重试策略:通过 service config 配置自动重试(仅限幂等操作)
|
健康检查
gRPC 定义了标准健康检查协议 grpc.health.v1.Health,K8s 可以直接用它做探针:
|
快速回顾
- gRPC = HTTP/2 + Protobuf + 代码生成:高性能,强类型,多语言
- Protobuf:二进制编码(体积小 3-10x),字段编号是永久契约,零值不编码
- 四种通信模式:Unary 覆盖 90% 场景,流式用于大数据/实时推送
- 错误模型:标准状态码 + 富错误详情,正确使用状态码让客户端能区分处理策略
- 拦截器:洋葱模型中间件,生产必备(recovery / logging / auth / tracing / metrics)
- Deadline 传播:沿调用链自动递减,永远设置 deadline
- 版本兼容:只做加法;用 buf breaking 自动检测破坏性变更
- 长连接 + LB:需要客户端负载均衡或 L7 代理(Envoy/Istio)
动手练习
- 创建 Proto 项目:用
buf init创建一个 Proto 项目,定义一个简单的 UserService(CreateUser / GetUser),运行buf generate生成 Go 代码 - 实现服务端:实现 UserService 的服务端,注册 loggingInterceptor 和 recoveryInterceptor,用 grpcurl 调用验证
- 测试 Deadline:在客户端设置 1s 的 deadline,服务端 sleep 2s 模拟慢接口,观察 DeadlineExceeded 错误
- 验证兼容性变更:给 UserService 新增一个
email字段,用buf breaking验证这是否是兼容性变更 - 验证破坏性变更:尝试修改一个已有字段的编号,用
buf breaking观察它是否报错