深入剖析gRPC:传输方式、报文解析与流模式,掌握HTTP/2、Protobuf和抓包实践

4,130 阅读7分钟

我正在参加「掘金·启航计划」

gRPC

gRPC 支持 QUIC、HTTP/1 等多种协议,但鉴于 HTTP/2 协议性能好,应用场景又广泛,因此 HTTP/2 是 gRPC 的默认传输协议。

gRPC 可以简单地分为三层,包括底层的数据传输层,中间的框架层(框架层又包括 C 语言实现的核心功能,以及上层的编程语言框架),以及最上层由框架层自动生成的 Stub 和 Service 类。

image2022-1-27_15-19-4

传输方式

请求报文

image2022-1-27_15-20-18

HTTP/2 头部

请求中有 2 个关键的 HTTP 头部,path 和 content-type,它们决定了 RPC 方法和具体的消息编码格式。path 的值为“/demo.GRPCDemo/SimpleMethod”,通过“/ 包名. 服务名 / 方法名”的形式确定了 RPC 方法。content-type 的值为“application/grpc”,确定消息编码使用 Protobuf 格式。

HTTP/2 包体

包体并不会直接存放 Protobuf 消息,而是先要添加 5 个字节的 Length-Prefixed Message 头部,其中用 4 个字节明确 Protobuf 消息的长度(1 个字节表示消息是否做过压缩),即上图中的桔色方框。为什么要多此一举呢?这是因为,gRPC 支持流式消息,即在 HTTP/2 的 1 条 Stream 中,通过 DATA 帧发送多个 gRPC 消息,而 Length-Prefixed Message 就可以将不同的消息分离开。

响应报文

image2022-1-27_15-24-18

响应头部被拆成了 2 个部分,其中 grpc-status 和 grpc-message 是在 DATA 帧后发送的,这样就允许服务器在发送完消息后再给出错误码。

gRPC 中的 HTTP 头部与普通的 HTTP 请求完全一致,因此,它兼容当下互联网中各种七层负载均衡,这使得 gRPC 可以轻松地跨越公网使用。

流模式

流模式共有 3 种类型,包括客户端流模式、服务器端流模式,以及两端双向流模式。

HTTP/2 协议中每个 Stream 就是天然的 1 次 RPC 请求,每个 RPC 消息又已经通过 Length-Prefixed Message 头部确立了边界,这样,在 Stream 中连续地发送多个 DATA 帧,就可以实现流模式 RPC。

image2022-1-27_16-1-48

基础知识

HTTP/2

连接 Connection: 1 个 TCP 连接,包含一个或者多个 Stream。 数据流 Stream:一个双向通讯数据流,包含 1 条或者多条 Message。 消息 Message:对应 HTTP/1 中的请求或者响应,包含一条或者多条 Frame。 数据帧 Frame:最小单位,以二进制压缩格式存放 HTTP/1 中的内容。

image2022-1-27_15-26-39

HTTP 请求和响应都被称为 Message 消息,它由 HTTP 头部和包体构成,承载这二者的叫做 Frame 帧,它是 HTTP/2 中的最小实体。Frame 的长度是受限的,比如 Nginx 中默认限制为 8K(http2_chunk_size 配置),因此我们可以得出 2 个结论:HTTP 消息可以由多个 Frame 构成,以及 1 个 Frame 可以由多个 TCP 报文构成(TCP MSS 通常小于 1.5K)。

再来看 Stream 流,它与 HTTP/1.1 中的 TCP 连接非常相似,当 Stream 作为短连接时,传输完一个请求和响应后就会关闭;当它作为长连接存在时,多个请求之间必须串行传输。在 HTTP/2 连接上,理论上可以同时运行无数个 Stream,这就是 HTTP/2 的多路复用能力,它通过 Stream 实现了请求的并发传输。

虽然 RFC 规范并没有限制并发 Stream 的数量,但服务器通常都会作出限制,比如 Nginx 就默认限制并发 Stream 为 128 个(http2_max_concurrent_streams 配置),以防止并发 Stream 消耗过多的内存,影响了服务器处理其他连接的能力。

HTTP/2 的最大问题来自于它下层的 TCP 协议。由于 TCP 是字符流协议,在前 1 字符未到达时,后接收到的字符只能存放在内核的缓冲区里,即使它们是并发的 Stream,应用层的 HTTP/2 协议也无法收到失序的报文,这就叫做队头阻塞问题。解决方案是放弃 TCP 协议,转而使用 UDP 协议作为传输层协议,这就是 HTTP/3 协议的由来。

image2022-1-27_15-31-19

HPACK

image2022-1-27_15-29-35

HTTP/2 使用了静态表、动态表两种编码技术(合称为 HPACK),极大地降低了 HTTP 头部的体积,搞清楚编码流程,你自然就会清楚服务器提供的 http2_max_requests 等配置参数的意义。

静态表、Huffman 编码、动态表共同完成了 HTTP/2 头部的编码,其中,前两者可以将体积压缩近一半,而后者可以将反复传输的头部压缩 95% 以上的体积!

262d580d1da67afa613eb44e905d4495.png转存失败,建议直接上传图片文件

是否要让一条连接传输尽量多的请求呢?并不是这样。动态表会占用很多内存,影响进程的并发能力,所以服务器都会提供类似 http2_max_requests 这样的配置,限制一个连接上能够传输的请求数量,通过关闭 HTTP/2 连接来释放内存。因此,http2_max_requests 并不是越大越好,通常我们应当根据用户浏览页面时访问的对象数量来设定这个值。

Protobuf

Protobuf 对不同类型的值,采用 6 种不同的编码方式。

image2022-1-27_15-32-24

类型是 PB 的类型标记 含义是 PB 的编码类型 原始数据类型是消息定义时的字段类型

PB 如何提高编码效率

首先发布消息声明,然后在数据传输过程中省略字段名。Protobuf 是按照字段名、值类型、字段值的顺序来编码的,由于编码极为紧凑,所以分析时必须基于二进制比特位进行。

image2022-1-27_15-35-36

比如红色的 00001、00010、00011 等前 5 个比特位,就分别代表着 name、id、sex 字段。

Length-delimited

字符串用 Length-delimited 方式编码,顾名思义,在值长度后顺序添加 ASCII 字节码即可。"John"需要 5 个字节进行编码,绿色表示长度,紫色表示 ASCII 码。

image2022-1-27_15-35-56

字符串长度的编码逻辑与字段名相同,当长度小于 128 时,1 个字节就可以表示长度。若长度从 128 到 16384,则需要 2 个字节,以此类推。

由于字符串编码时未做压缩,所以并不会节约空间,但胜在速度快。如果你的消息中含有大量字符串,那么使用 Huffman 等算法压缩后再编码效果更好。

Varint

对负数支持的不好,因为符号位是最左一位。

int 类型举例

Protobuf 中所有数字的编码规则是一致的,字节中第 1 个比特位仅用于指示由哪些字节编码 1 个数字。由于消息中的大量数字都很小,这种编码方式可以带来很高的空间利用率!图中二进制后半部分,用来表达 1234,将由 14 个比特位 00010011010010 表示(1024+128+64+16+2,正好是 1234)。

image2022-1-27_15-36-16

enum 类型举例

Protobuf 还可以通过 enum 枚举类型压缩空间。回到第 1 幅图,sex: FEMALE 仅用 2 个字节就编码完成,正是枚举值 FEMALE 使用数字 1 表示所达到的效果。

image2022-1-27_15-36-31

repeated 列表

当使用 repeated 语法将多个数字组成列表时,还可以通过打包功能提升编码效率。

image2022-1-27_15-36-49

64-bit

如果你确定数字很大,这种编码方式不但不能节约空间,而且会导致原先 4 个字节的大整数需要用 5 个字节来表示时,你也可以使用 fixed32、fixed64 等类型定义数字。

抓包实践

gRPC protobuf 编码

request

image2022-1-27_16-3-40

response

image2022-1-27_16-4-1

HTTP/2 header 压缩

image2022-1-27_15-47-57

抓包 gRpc 请求,同样的请求重复发送三次。第一次 header 长度 129 字节。

image2022-1-27_15-48-19

第二次请求和第三次请求的 header 长度相同,都是 13 字节。

image2022-1-27_15-48-37