Grpc(一):HTTP/2、Protobuf 编码原理与四种通信模式
Grpc(一):HTTP/2、Protobuf 编码原理与四种通信模式
导语:gRPC 面试的必答题几乎绕不开三件事——为什么偏偏选了 HTTP/2、Protobuf 到底快在哪、四种通信模式各用在什么场景。本篇先用图把 HTTP/2 的二进制分帧与多路复用(以及它没能解决的 TCP 队头阻塞)讲清楚,再逐字节拆开 Protobuf 的 Tag-Length-Value / Varint / ZigZag 编码与字段演进规则,接着画出 gRPC 四种通信模式的时序,补齐 Metadata / Deadline / Interceptor / 状态码这套"工程必备件",最后给出 Dubbo 与 gRPC 的完整对比与选型建议,共 6 题。
一、gRPC 概览
1. 什么是 gRPC?它的核心特性和整体调用流程是什么?
答: gRPC 是 Google 开源(现属 CNCF)的高性能、跨语言 RPC 框架。一句话概括它的技术选型:「用 Protobuf 定义服务契约,用 HTTP/2 传输,代码由编译器生成」。
核心特性:
| 特性 | 说明 |
|---|---|
| IDL 优先 | 先在 .proto 文件里定义 service 与 message,严格约束接口契约(强类型,不是靠文档) |
| 代码生成 | 用 protoc 插件自动生成各语言(Go/Java/Python/C++/Node/Rust…)的客户端 Stub 与服务端骨架 |
| 基于 HTTP/2 | 多路复用、头部压缩、二进制分帧、流控——这是它高性能的根因 |
| 默认 Protobuf 序列化 | 体积小、解析快、支持字段级前后兼容 |
| 四种通信模式 | 一元调用、服务端流、客户端流、双向流——这是它比 Dubbo 2.x 多出来的能力 |
| 跨语言友好 | 官方支持十几种语言,是"多语言混战"场景的事实标准 |
| 云原生友好 | K8s / Istio(Envoy) 天然支持 HTTP/2 与 gRPC,Service Mesh 里是一等公民 |
整体调用流程:
关键理解:客户端拿到的 Stub 只是一个看起来像本地对象的代理,内部持有一个 HTTP/2 Channel(连接池)。所有"像本地调用"的体验,都是这个 Stub + HTTP/2 Channel 提供的。
gRPC vs 传统 HTTP API 的本质区别:
| 维度 | gRPC | HTTP + JSON REST |
|---|---|---|
| 接口定义 | .proto 强契约(编译期就能发现类型错误) | 文档约定(运行期才发现参数写错) |
| 传输协议 | HTTP/2(多路复用,单连接并发) | HTTP/1.1(串行 + Keep-Alive,常伴连接池打满) |
| payload | 二进制 Protobuf | 文本 JSON |
| 语义 | 面向方法(GetUser) | 面向资源(GET /users/{id}) |
| 流式 | ✅ 原生支持四种模式 | ❌ 需 SSE / WebSocket 另行处理 |
| 可读性/调试 | ❌ 二进制不可读,需 grpcurl / 反射 | ✅ curl 直连肉眼可读 |
| 浏览器支持 | ❌ 浏览器无法直接调用(需 grpc-web + Envoy 代理) | ✅ 天然支持 |
二、HTTP/2:gRPC 性能的根因
2. 为什么 gRPC 要基于 HTTP/2?HTTP/2 的关键特性是什么?
答: gRPC 需要一个能承载多路并发、低延迟、双向流的传输层。HTTP/1.1 做不到(一条连接同一时刻只能处理一个请求-响应),而自建私有协议又会失去网关/代理/Mesh 的生态支持。HTTP/2 恰好同时满足这两点,且天然被网关、负载均衡器、Envoy、防火墙等设备识别。
HTTP/2 的五大关键特性:
| 特性 | 原理 | 带来了什么 |
|---|---|---|
| ① 二进制分帧 | 把报文拆成 Frame(HEADERS 帧 + DATA 帧),用二进制编码替代文本 | 解析更快、更省空间,也为多路复用打下基础 |
| ② 多路复用(核心) | 一条 TCP 连接上同时存在多个 Stream,每个 Stream 有唯一 ID,不同 Stream 的帧可以交错发送,接收端按 ID 重组 | 一个连接承载所有并发请求——连接数骤减、握手开销消失 |
| ③ 头部压缩 HPACK | 用静态/动态表 + 哈夫曼编码压缩 Header,重复的 :method、:path、content-type 只传索引 | 头部体积大幅下降(gRPC 每次调用 Header 高度重复,收益极大) |
| ④ 流量控制 | 基于 WINDOW_UPDATE 帧做连接级 + Stream 级滑动窗口流控 | 防止快发送方打爆慢接收方;也是"客户端/服务端流"不会撑爆内存的原因 |
| ⑤ 优先级 / 依赖 | Stream 可设置权重与依赖树 | 重要请求优先被调度 |
⚠️ 一个常见错误:很多人说"gRPC 用了 HTTP/2 的服务端推送 Server Push"。这是错的。gRPC 没有使用 Server Push——HTTP/2 的双向能力在 gRPC 里是通过"客户端在本已建立的 Stream 上继续发 DATA 帧"实现的,而不是服务端主动推新流。
⚠️ 高分追问:「HTTP/2 解决了队头阻塞吗?」——只解决了一半,务必说清:
面试标准口径:HTTP/2 解决了 HTTP/1.1 的「应用层队头阻塞」(多请求串行),但没有解决「TCP 层的队头阻塞」——因为 HTTP/2 仍然在单条 TCP 上运行,一个包丢失会让所有 Stream 停下来等重传。这正是 HTTP/3 改用 QUIC(UDP)的原因。
gRPC 在 HTTP/2 上的报文约定(加分项):
请求 HEADERS 帧:
:method = POST
:scheme = http | https
:path = /{包名}.{服务名}/{方法名} ← 这就是 gRPC 的"方法寻址"方式
:authority = example.com
content-type = application/grpc
te = trailers ← 声明响应还有一个 trailers
请求/响应 DATA 帧里的每条消息(Length-Prefixed Message):
┌──────────┬──────────────┬────────────────────┐
│ 1 字节 │ 4 字节 │ N 字节 │
│ 压缩标志 │ 消息长度(大端) │ Protobuf 消息体 │
└──────────┴──────────────┴────────────────────┘
响应 TRAILERS 帧(这里才是真正的状态):
grpc-status = 0 (OK) / 14 (UNAVAILABLE) / ...
grpc-message = 错误描述注意:gRPC 几乎总是返回 HTTP 200,真正的业务错误在 trailers 的
grpc-status里。这是 HTTP/1 时代过来的工程师最容易踩的坑——用 HTTP 状态码判断 gRPC 成功与否是错的。
三、Protobuf 编码原理
3. Protobuf 为什么快?它的编码原理是什么?(源码级高频)
答: Protobuf(Protocol Buffers)是 Google 的语言中立、平台无关的结构化数据序列化格式。它比 JSON 快、比 JSON 小的原因可以归结为一句话:「不传字段名、不传类型,只传带编号的紧凑二进制;小整数用 1 字节,字符串按需带长度」。
① 核心编码结构:TLV(Tag-Length-Value)
┌──────────────┬────────────────┬─────────────────────────┐
│ Tag(1~N 字节)│ Length(N 字节) │ Value(N 字节) │
│ 字段号 + 类型 │ 仅变长类型有 │ 实际数据 │
└──────────────┴────────────────┴─────────────────────────┘
↑ 只有当 wire_type = 2(string/bytes/嵌套message/ packed 数组)时才存在Tag 是怎么算的?
Tag = (field_number << 3) | wire_type
wire_type 取值:
0 → Varint (int32/int64/uint32/uint64/sint32/sint64/bool/enum)
1 → 64-bit (fixed64 / sfixed64 / double)
2 → Length-delimited (string / bytes / 嵌套 message / packed repeated)
3 → Start group (已废弃)
4 → End group (已废弃)
5 → 32-bit (fixed32 / sfixed32 / float)
举例:字段 id 定义为 int32 id = 1; → Tag = (1 << 3) | 0 = 0x08(1 字节)对比同样的数据,JSON 需要 {"id":150,"name":"Tom"} = 22 字节;Protobuf 只要 8 字节——省掉了字段名、花括号、引号、冒号,这就是它体积小的直接原因。
② Varint:为什么 150 要编成两个字节 0x96 0x01?
Varint 规则:每个字节只用低 7 位存数据,最高位(MSB)是"是否还有后续字节"的标志位
且低位在前(little-endian)
150 的二进制 : 1001 0110
取低 7 位 001 0110 → 补 MSB=1(还有后续)→ 1001 0110 = 0x96
剩下的 000 0001 → 补 MSB=0(结束) → 0000 0001 = 0x01
→ 最终:0x96 0x01
【收益】0~127 只用 1 字节,int64 的最大值需要 10 字节
—— 而 JSON/XML 无论数字多小,都按字符逐个编码("1" 也要 1 字节,127 要 3 字节)③ ZigZag:为什么负数要有 sint32 这种类型?
问题:负数在计算机里按补码存,-1 的 int32 是 0xFFFFFFFF
→ 直接用 Varint 编码要用满 10 个字节(因为它是个"很大的正数")
解决:sint32 / sint64 使用 ZigZag 映射,把有符号整数"之字形"折叠成无符号数
zigzag(n) = (n << 1) ^ (n >> 31) (32位版本)
n = 0 → 0 n = -1 → 1
n = 1 → 2 n = -2 → 3
n = 2 → 4 n = -3 → 5
...
→ -1 只需要 1 个字节(0x01)
【记忆】绝对值越小的数,ZigZag 编码越短 —— 而业务里小数值占绝大多数⚠️ 面试常见坑:
int32和sint32的区别?
int32:负数会用 10 字节(补码太大)——适合非负的值sint32:走 ZigZag,负数也只要很少字节——可能取负数的字段就用它
④ 三个"必须知道"的性能/兼容细节
| 细节 | 说明 |
|---|---|
| 字段号 1~15 最省 | 字段号 ≤ 15 时 Tag 只需 1 字节(因为 15<<3=120,仍在 7 位内);16~2047 需要 2 字节。高频出现的字段要用小号 |
| 19000~19999 保留 | 这是 Protobuf 内部保留区间(含描述符),自定义字段不能用,编译会报错 |
| proto3 的"默认值不序列化" | proto3 中如果一个字段等于零值(0 / "" / false),它根本不会被写进报文,省空间。⚠️ 副作用:接收方无法区分「没设置」和「设为 0」——需要区分时加 optional 关键字(proto3 3.15+ 重新支持 presence) |
⑤ 字段演进(前后兼容)规则——这是它被选中的决定性理由:
面试加分句:Protobuf 的兼容性来自「用字段号而不是字段名标识数据」+「未知字段直接跳过」。这带来的工程价值是:服务端升级可以不强制所有客户端同时升级(灰度发布的基础)。JSON 靠字段名,改字段名就破坏兼容;Protobuf 改字段名没事,只要不动号码。
四、通信模式
4. gRPC 有哪几种通信模式?各自适用什么场景?
答: gRPC 基于 HTTP/2 的 Stream 能力,提供 4 种通信模式:
逐一展开:
① 一元 RPC(Unary)
rpc SayHello (HelloRequest) returns (HelloReply);最常见的模式,语义等价于普通函数调用。绝大多数业务接口都是它。
② 服务端流式 RPC(Server Streaming)
rpc ListFeatures (Rectangle) returns (stream Feature); // 注意 returns 后的 stream| 要点 | 说明 |
|---|---|
| 别把它当成"多次调用" | 客户端只发一次请求,服务端在同一个 Stream 上持续写回多个消息 |
| 适用场景 | 实时行情推送、日志订阅、服务端分批查询、✅ 大模型逐个 token 流式输出 |
| 注意 | 客户端可随时取消(发送 RST_STREAM 帧),服务端应当相应停止计算 |
③ 客户端流式 RPC(Client Streaming)
rpc RecordRoute (stream Point) returns (RouteSummary); // 注意入参的 stream| 要点 | 说明 |
|---|---|
| 必须先流完,才有结果 | 客户端 N 条消息发完后,服务端才返回一次聚合结果 |
| 适用场景 | 大文件/视频分片上传、批量数据上报后返回汇总、语音数据持续上传 |
| 收益 | 不必在客户端先把整个大对象拼出来,避免内存峰值 |
④ 双向流式 RPC(Bidirectional Streaming)
rpc RouteChat (stream RouteNote) returns (stream RouteNote); // 双方都是 stream| 要点 | 说明 |
|---|---|
| 请求流与响应流相互独立 | 不必"一问一答";可以发一堆再收一堆,也可以边发边收 |
| 适用场景 | 即时聊天、在线语音/视频流转写、多人协同、长连接双向通知 |
| 与 WebSocket 对比 | gRPC 双向流自带 IDL 强类型 + 流控 + 取消 + 状态码 + 拦截器,工程化更完备;WebSocket 更轻量、浏览器原生 |
四种模式速查表:
| 模式 | proto 语法 | 请求次数 | 响应次数 | 典型场景 |
|---|---|---|---|---|
| 一元 | Request) returns (Reply) | 1 | 1 | 常规业务接口 |
| 服务端流 | Request) returns (stream Reply) | 1 | N | 推送、订阅、LLM 流式输出 |
| 客户端流 | stream Request) returns (Reply) | N | 1 | 分片上传、批量上报 |
| 双向流 | stream Request) returns (stream Reply) | N | M | IM、实时音视频、双向通知 |
⚠️ 面试常见追问:"gRPC 的流式是 WebSocket 吗?"
不是。gRPC 双向流跑在 HTTP/2 的一条 Stream 上(多个逻辑流复用一条 TCP),而 WebSocket 是一条独立的全双工通道且浏览器原生支持。另外,浏览器无法直接发起 gRPC 双向流,需要 grpc-web + Envoy 代理转译。
五、工程必备:Metadata / Deadline / Interceptor / 状态码
5. gRPC 的 Metadata、超时(Deadline)、拦截器和状态码分别是什么?
答: 这四件事是 gRPC 从"能跑起来"到"能在生产用"的分水岭,也是面试官判断有没有真实项目经验的常用切入点。
① Metadata —— 传输鉴权、链路追踪等"旁路信息"
| 要点 | 说明 |
|---|---|
| 是什么 | key-value 形式的请求头/响应头,底层就是 HTTP/2 Header 帧 |
| 为什么要用 | 不要把 token、traceId 塞进 protobuf message——它们属于通信元数据,不属于业务字段 |
| 键的规则 | 必须小写;二进制值的键必须以 -bin 结尾,值用 base64 编码(如 my-binary-key-bin) |
| 限制 | 默认 8 KB(--max_metadata_size),超限会 RESOURCE_EXHAUSTED |
| 典型用法 | authorization: Bearer xxx、trace-id、client-version、灰度标签 |
// 客户端:附加 Metadata
Metadata md = new Metadata();
md.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer " + token);
stub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(md)).sayHello(request);
// 服务端:读取 Metadata
Metadata md = ContextKeysInterceptor.CONTEXT_KEY.get(Context.current());
String token = md.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER));② Deadline / Timeout —— 超时是"绝对时间",这一点很关键
| 要点 | 说明 |
|---|---|
| 本质是截止时间(deadline)而非时长 | 客户端指定的是一个绝对时间点,通过 HTTP/2 header grpc-timeout 传输(格式:1H / 100M / 1S / 100m / 100u / 100n) |
| ⏳ 为什么用绝对时间? | 跨服务调用时会自然递减——B 收到剩余 2.5s 的 deadline,转发给 C 时自动带上 2.5s 而不是重新计 3s,避免"整条链路每个环节都等满 3 秒"导致雪崩 |
| 超时后的行为 | gRPC 发送 RST_STREAM 结束该 Stream,客户端收到 DEADLINE_EXCEEDED;服务端正在执行的逻辑不会被强制杀掉——和 Dubbo 一样,超时≠回滚,所以同样需要幂等 |
| 不设 deadline 的后果 | 默认无限等待 —— ⚠️ 生产环境强烈要求每个调用都设置 deadline |
③ Interceptor(拦截器)—— 等价于 Dubbo Filter / Spring AOP
| 用途 | 客户端 | 服务端 |
|---|---|---|
| 鉴权 | 附加 token | 校验 token |
| 链路追踪 | 注入 traceId | 读取并传递 traceId |
| 日志 | 记录请求/响应/耗时 | 记录入参与异常 |
| 限流熔断 | — | 基于 QPS 拒绝请求(返回 RESOURCE_EXHAUSTED) |
| 重试 | 内置/自定义重试 | — |
| 错误转换 | 把状态码映射成业务异常 | 统一封装异常 |
对比记忆:gRPC Interceptor ≈ Dubbo Filter ≈ Spring 的 HandlerInterceptor。面试说 "gRPC 里没有内置的服务治理能力,这些都要靠 Interceptor 自己实现或交给 Istio",会很加分。
④ 状态码(Status Codes)—— ⚠️ 不要看 HTTP 状态码
【核心事实】gRPC 几乎所有响应都返回 HTTP 200,
真正的成功与否写在 TRAILERS 帧的 grpc-status 里。
→ 用 HTTP 状态码判断 gRPC 结果肯定是错的。| Code | 名称 | 含义 / 处理建议 |
|---|---|---|
| 0 | OK | 成功 |
| 1 | CANCELLED | 客户端主动取消——通常不需要告警 |
| 2 | UNKNOWN | 未知错误(如服务端抛未捕获异常) |
| 3 | INVALID_ARGUMENT | 参数非法(类比 HTTP 400) |
| 4 | DEADLINE_EXCEEDED | 超时(类比 504)→ 关注性能或调大 deadline |
| 5 | NOT_FOUND | 资源不存在(类比 404) |
| 6 | ALREADY_EXISTS | 资源已存在(创建冲突) |
| 7 | PERMISSION_DENIED | 无权限(类比 403) |
| 8 | RESOURCE_EXHAUSTED | 限流/配额不足(类比 429)→ 客户端应退避重试 |
| 9 | FAILED_PRECONDITION | 系统状态不满足操作前提(如并发版本冲突) |
| 10 | ABORTED | 并发冲突导致中止(如 CAS 失败) |
| 11 | OUT_OF_RANGE | 越界(类比 400,如分页越界) |
| 12 | UNIMPLEMENTED | 方法未实现(服务端没注册这个 service) |
| 13 | INTERNAL | 服务端内部错误(类比 500) |
| 14 | UNAVAILABLE | 服务不可用/连接失败 → 最典型的可重试错误 |
| 15 | DATA_LOSS | 不可恢复的数据丢失/损坏 |
| 16 | UNAUTHENTICATED | 未认证(类比 401) |
⚠️ 重试必须识别可重试状态码:通常只对
UNAVAILABLE、RESOURCE_EXHAUSTED(配合指数退避) 重试;对INVALID_ARGUMENT、NOT_FOUND、PERMISSION_DENIED等重试毫无意义,只会放大无效流量。
六、选型
6. Dubbo 和 gRPC 怎么选?(把两个框架串起来的压轴题)
答:
| 维度 | Apache Dubbo | Google gRPC |
|---|---|---|
| 出身 | 阿里开源 → Apache 顶级项目,Java 生态基因 | Google 开源 → CNCF,跨语言基因 |
| 契约定义 | Java 接口(也可配 Protobuf) | .proto IDL(必须先写契约,强类型) |
| 传输协议 | 默认私有 dubbo 协议(TCP);Dubbo 3 起主推 Triple(HTTP/2) | HTTP/2(单一选择) |
| 序列化 | Hessian2 / Kryo / JSON / Protobuf 可插拔 | Protobuf(事实标准,也可换 JSON) |
| 服务治理 | ✅ 内置且丰富:注册发现、路由、负载均衡、容错、限流、降级、Mock、动态配置、控制台 | ⚠️ 薄弱:只有基础 LB/重试,限流熔断等要自己写 Interceptor 或交给 Service Mesh |
| 流式通信 | Dubbo 2 ❌ / Dubbo 3 Triple ✅ | ✅ 原生四种模式,这是最大优势之一 |
| 跨语言 | 有(Java/Go/Python/Rust/Node/Erlang 的 Dubbo3 SDK),但生态成熟度不如 gRPC | ✅ 十几种官方语言,多语言混战首选 |
| 云原生 | Dubbo 3 支持 K8s 原生服务发现、Proxyless Mesh | ✅ 天然——K8s / Istio / Envoy 一等公民,xDS 原生支持 |
| 调试友好 | dubbo 协议不直观,需要 Telnet/Admin/控制台 | HTTP/2 + protobuf 同样不直观,但有 grpcurl + Server Reflection 可"像 curl 一样调试" |
| 浏览器直连 | ❌ | ❌(需 grpc-web + 代理) |
| 代表场景 | 国内互联网、Java 技术栈、需要强治理的中台/交易系统 | 多语言、云原生、需要流式(LLM/IM/推送)、Service Mesh 架构 |
一句话选型结论:
1)国内 Java 中台/交易链路、需要开箱即用的服务治理 → Dubbo
2)多语言并存、云原生 Service Mesh、需要流式通信 → gRPC
3)折中路线,也是近年趋势 → Dubbo 3 + Triple 协议
(Triple 基于 HTTP/2、兼容 gRPC wire format,同时保留 Dubbo 的服务治理能力,
等于"两边的好处都要"——这是面试里很出彩的一句话)⚠️ 常见错误认知纠正:
- "gRPC 一定比 Dubbo 快" —— 错。两者同为 TCP/HTTP2 + 二进制序列化,性能同量级;真实差距更多来自序列化选择、连接模型、线程池配置,而不是框架本身。
- "Dubbo 只能 Java 用" —— 错。Dubbo 3 已经提供了 Go / Python / Node / Rust / Erlang 的多语言 SDK,并能与 gRPC 互通。
- "gRPC 自带服务治理" —— 错。gRPC 只提供基础的 LB/重试/拦截器,限流、熔断、降级、路由都要自行实现或依赖 Istio。
本篇小结
| 题 | 一句话 |
|---|---|
| 1 | gRPC = Protobuf 契约 + protoc 代码生成 + HTTP/2 传输;Stub 代理 + Channel 连接池 |
| 2 | HTTP/2 带来二进制分帧 / 多路复用 / HPACK / 流控;解决了应用层队头阻塞,但没解决 TCP 层队头阻塞(HTTP/3 QUIC 才解决) |
| 3 | Protobuf 用 TLV + Varint + ZigZag,去掉字段名与类型,因而又快又小;兼容性靠"字段号 + 跳过未知字段" |
| 4 | 四种模式:一元 / 服务端流 / 客户端流 / 双向流,流式是 gRPC 相对 Dubbo 2.x 的最大差异化能力 |
| 5 | Metadata 传旁路信息;Deadline 用绝对时间(跨服务调用自动递减防雪崩);Interceptor ≈ Dubbo Filter;看 grpc-status 不看 HTTP 200 |
| 6 | Java 中台要治理 → Dubbo;多语言/云原生/流式 → gRPC;Dubbo 3 + Triple 是兼顾路线 |
下一篇:回到 Dubbo 的实战场景篇,结合真实业务讲清楚 灰度发布与标签路由、压测时如何隔离流量、泛化调用/直连/rest 调用的适用边界,以及一次 Dubbo 调用超时该如何从 Consumer → Provider → 网络 → GC 逐层排查。
