NEE's Blog

gRPC 深度解析:从服务定义到二进制格式

February 14, 2026

本文翻译自 gRPC deep dive: from service definition to wire format,原载于 Hacker News。


在之前的文章中(第一部分第二部分),我们揭开了 Protocol Buffers 的神秘面纱,了解了数据如何被编码成紧凑的二进制格式。

但 Protobuf 只负责载荷(payload)的序列化。要在微服务(microservices)之间传输这些数据,我们需要一个传输协议。这就是 gRPC 登场的地方。

虽然很多开发者每天都在使用 gRPC,但很少有人真正深入了解它的工作原理。在这篇文章中,我们将超越基础概念,探索完整的 gRPC 协议栈:从高层的服务架构和流式模型,到底层的 HTTP/2 帧和字节级二进制格式。

契约优先的理念

gRPC 的核心是契约优先(contract-first)方法。与 REST 不同——在 REST 中,API 文档(如 OpenAPI)往往是事后补充——gRPC 使用 Protocol Buffers(.proto 文件)在前期就强制定义好结构。

这个契约不仅定义数据结构(Messages),还定义服务能力(RPCs):

package fruit.v1;

service FruitService {
  // 一元调用:简单请求 -> 响应
  rpc GetFruit(GetFruitRequest) returns (Fruit);

  // 服务端流:一个请求 -> 多个响应
  rpc ListFruits(ListFruitsRequest) returns (stream Fruit);

  // 客户端流:多个请求 -> 一个响应
  rpc Upload(stream Fruit) returns (UploadSummary);

  // 双向流:多个请求 <-> 多个响应
  rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}

这个定义是唯一的真实来源(single source of truth)。

从这个单一文件出发,Protobuf 编译器(protoc)可以生成几乎所有语言的客户端存根(stubs)和服务端样板代码(Go、Java、C#、Python 等),确保客户端和服务端始终对 API 的形态达成一致。

译者注:这种”契约优先”的方式在国内大厂的微服务架构中非常流行。相比 REST API 文档经常与实际代码脱节的问题,gRPC 的 .proto 文件就是代码的一部分,天然保持同步。

四种流式模型

gRPC 最大的差异化优势之一是原生支持流式传输(streaming)。这不仅仅是”分块传输编码”(chunked transfer encoding),而是真正的 API 语义层面的支持。

  • 一元调用(Unary):看起来像标准的函数调用或 REST 请求。客户端发送一条消息,服务端返回一条。
  • 服务端流(Server streaming):非常适合订阅场景或大数据集。客户端发送查询,服务端随时间返回多个结果。
  • 客户端流(Client streaming):适用于发送数据流(如 IoT 设备的遥测数据),服务端可以边接收边处理。
  • 双向流(Bidirectional streaming):真正的实时通信。双方可以独立发送消息。常用于聊天应用或多人游戏。

除了实际数据外,gRPC 还允许发送元数据(metadata)。元数据是键值对列表(类似 HTTP headers),提供关于调用的信息。键是字符串,值通常是字符串,但也可以是二进制数据。键名不区分大小写,且不能以 grpc- 开头(保留给 gRPC 内部使用)。二进制值的键必须以 -bin 结尾。

元数据对于不应该包含在业务逻辑载荷中的横切关注点(cross-cutting concerns)至关重要:

  • 认证(Authentication):使用 Bearer token(如 Authorization: Bearer <token>
  • 链路追踪(Tracing):传递 trace ID(如 transport-id: 12345)以跨微服务跟踪请求
  • 基础设施:为负载均衡器或代理提供提示

元数据可以由客户端(在调用开始时)和服务端(作为 headers 在开始时,或作为 trailers 在结束时)发送。

底层:传输层

那么这个契约如何映射到网络?gRPC 构建在 HTTP/2 之上,利用其高级特性实现流式模型。

最重要的概念是流(streams)。每个 gRPC 调用,无论是简单的一元请求还是长时间的双向流,都映射到单个 HTTP/2 流。这允许多路复用(multiplexing):你可以在单个 TCP 连接上有数千个活动的 gRPC 调用,它们的帧可以交错传输。这避免了 HTTP/1.1 需要开启数千个连接的问题。虽然它解决了 HTTP/1.1 的”队头阻塞”(head-of-line blocking)问题,但如果丢包,TCP 层面的阻塞仍然是一个问题。

构建 URL

在发送任何字节之前,我们需要定位资源。在 gRPC 中,URL 从 .proto 定义自动生成:/{Package}.{Service}/{Method}

对于 GetFruit,路径变为: /fruit.v1.FruitService/GetFruit

这种标准化意味着客户端和服务端永远不需要争论 URL 路径。

HTTP/2 帧

一个 gRPC 调用通常由三个阶段组成,每个阶段映射到 HTTP/2 帧:

  1. 请求头和元数据HEADERS 帧):包含元数据如 :path:methodPOST)和 content-typeapplication/grpc
  2. 数据消息DATA 帧):实际的应用数据
  3. 响应 trailersHEADERS 帧):调用的最终状态

网络上的元数据

由于 gRPC 构建在 HTTP/2 之上,元数据简单地映射到 HTTP/2 headers。字符串值原样发送(如 user-agent: grpc-kreya/1.18.0)。

二进制值进行 base64 编码,键必须以 -bin 结尾。库通常会透明地处理这种编码/解码。

长度前缀消息

在 HTTP/2 DATA 帧内部,gRPC 使用一种称为长度前缀帧(length-prefixed framing)的机制包装你的 protobuf 消息。即使在流式调用中,每条消息都是独立的,并以 5 字节头部为前缀:

字节 用途 描述
0 压缩标志 0 = 未压缩,1 = 已压缩
1-4 消息长度 4 字节大端整数,表示载荷大小

可视化字节

让我们重用之前文章中的水果消息,它编码为 10 字节的 protobuf 数据:08 96 01 12 05 41 70 70 6c 65

当通过 gRPC 发送时,我们添加头部:

  • 压缩标志0(无压缩)
  • 长度100x0A

最终的 15 字节 gRPC 消息如下:

00 00 00 00 0a 08 96 01 12 05 41 70 70 6c 65
│              └─ Protobuf 载荷(10 字节)
│  └───────────── 载荷消息长度(0xA = 10 字节)
└──────────────── 压缩标志(0 = false)

这种简单的帧机制允许接收方准确读取下一条消息所需的字节数,解码,然后重复,实现流畅的流式传输。

译者注:这个 5 字节的头部设计非常精妙。第一个字节用于标识是否压缩,后四个字节用大端序表示消息长度。这种设计让接收方可以先读取 5 字节,知道接下来要读多少数据,然后精确读取,非常适合流式场景。

状态码和 Trailers

在 REST 中,你检查 HTTP 状态码(200、404、500)。在 gRPC 中,HTTP 状态几乎总是 200 OK,即使逻辑失败了!

实际的应用状态在 trailers(最后的 HTTP/2 header 帧)中发送。这种分离至关重要:它允许服务端成功流式传输 100 个项目,然后在第 101 个处理步骤报告错误。

典型的 trailer 块如下:

grpc-status: 0
grpc-message: OK

(状态 0 表示成功。非零值表示错误,如 NOT_FOUNDUNAVAILABLE 等)

富错误信息

有时,简单的状态码和字符串消息是不够的。你可能想返回特定字段的验证错误或其他错误详情。富错误模型(Rich error model)(具体是 google.rpc.Status)解决了这个问题。

服务端不只是返回 grpc-statusgrpc-message,而是将详细的 protobuf 消息序列化为 base64 放入 grpc-status-details-bin trailer。这个标准消息包含:

  1. Code:gRPC 状态码
  2. Message:面向开发者的错误消息
  3. Detailsgoogle.protobuf.Any 消息列表,包含任意错误详情(如 BadRequestPreconditionFailureDebugInfo
message Status {
  // gRPC 状态码(3=INVALID_ARGUMENT, 5=NOT_FOUND 等)
  int32 code = 1;

  // 错误消息
  string message = 2;

  // 额外错误详情列表(任意自定义 protobuf 消息,如验证错误详情)
  repeated google.protobuf.Any details = 3;
}

客户端可以解码这个 trailer 获取结构化、可操作的错误信息。

译者注:这个特性在处理复杂的业务错误时特别有用。比如用户注册时,可以一次性返回所有字段的验证错误,而不是只返回第一个错误。Google 的 API 设计指南中有详细的使用说明。

压缩

根据环境不同,带宽可能非常宝贵,尤其是在移动网络上。gRPC 内置支持压缩以减少载荷大小。

工作原理

  1. 协商:客户端发送 grpc-accept-encoding header(如 br, gzip, identity)告诉服务端它支持哪些算法
  2. 编码:如果服务端决定压缩响应,它设置 grpc-encoding header(如 br
  3. 标志:对于每条消息,压缩标志(5 字节头部的第 0 字节)设置为 1
  4. 载荷:消息载荷使用选定的算法压缩

让我们看看启用压缩时二进制格式如何变化。注意用 brotli 压缩我们微小的”Apple”消息由于开销会变大,但结构保持不变:

01 00 00 00 0e 8f 04 80 08 96 01 12 05 41 70 70 6c 65 03
│              └─ 压缩后的载荷
│  └───────────── 压缩消息长度(0xE = 14 字节)
└──────────────── 压缩标志(1 = true)

这是按消息进行的。甚至可以给请求和响应设置不同的压缩设置(非对称压缩)。

替代传输方式

虽然 gRPC 通常通过 TCP/IP 使用 HTTP/2 运行,但协议足够通用,可以在其他地方运行。

  • Unix Domain Sockets:非常适合本地 IPC(进程间通信)。它绕过 TCP 网络栈以获得最大效率
  • Named Pipes:Windows 上的等效方案

这种灵活性使 gRPC 成为组件之间的通用粘合剂,无论它们位于不同的大陆还是同一块芯片上。

浏览器缺口(gRPC-Web)

gRPC 在一个地方力不从心:浏览器。Web 浏览器不公开 gRPC 所需的低级 HTTP/2 帧控制(具体来说,读取 trailers 和精细的流控制)。

这个挑战由 gRPC-Web 解决,这是一个协议适配:

  1. 将 trailers 编码到数据流主体内(这样浏览器不需要读取 HTTP trailers)
  2. 支持基于文本的应用层编码(base64)以绕过二进制限制

我们将在未来的文章中详细介绍 gRPC-Web 的具体工作原理。

总结

gRPC 不仅仅是一个序列化格式,它是一个完整的生态系统,标准化了我们定义、生成和使用 API 的方式。通过理解各个层次——从 .proto 契约到网络上的 5 字节头部——你可以更有效地调试问题并设计更好的系统。

像 Kreya 这样的工具为日常测试抽象了这种复杂性,但了解底层发生的事情让你在事情变得棘手时能够掌控局面。


核心要点

  1. 契约优先.proto 文件是 API 的唯一真实来源,自动生成多语言代码
  2. 四种流式模型:Unary、Server streaming、Client streaming、Bidirectional streaming
  3. HTTP/2 基础:利用 HTTP/2 的多路复用和流特性
  4. 5 字节消息头:1 字节压缩标志 + 4 字节长度,实现高效的流式传输
  5. Trailers 传递状态:真正的错误状态在 trailers 中,HTTP 状态总是 200
  6. 富错误信息:通过 grpc-status-details-bin 传递结构化的错误详情
  7. 灵活的传输层:除 TCP 外还支持 Unix Domain Sockets 和 Named Pipes

延伸阅读

comments powered by Disqus