本系列分为四大部分:
在系列二中,我们一起学习了 gRPC 如何使用 Protobuf 来组织数据,达到高效编解码、高压缩率的目标。本文我们将更进一步,看看这些数据是如何在网络中被传输的,达到以更低的资源实现更高效传输的目标。内容将围绕以下几点展开:
二进制编码
;同时它是如何提高网络资源利用效率,重温多路复用
的思想数据和协议
角度洞悉 gRPC 调用数据的传输,都是被切割成一个个小块,包在层层网络协议头里,通过一个个路由器依次转发,最终到达目的地,被重新组装起来。这是网络传输的基本原理,在这个过程中,有两个亘古不变的目标:
随着行业的发展,对上述两个目标的追求也更加极致,要想传输速度更快,传输的数据体积要小。数据体积拆开来看,有两个部分:
HTTP1.1 以其简单、可读性高、超高普及率、历史悠久,作为经典的存在,为互联网的普及做出了重要贡献。但在当今超高的流量、超高的使用频率背景下,打开一个页面动辄几十个请求,使得速度已经难以满足贪婪人类的需求。这主要表现在两个方面:
一、 冗余文本过多,导致传输体积很大
作为一款经典的无状态协议,它使得 Web 后端可以灵活地转发、横向扩展,但其代价是每个请求都会带上冗余重复的 Header,这些文本内容会消耗很多空间,和更快传输
的目标相左。
二、 并发能力差,网络资源利用率低 HTTP1.1 是基于文本的协议,请求的内容打包在 header/body 中,内容通过\r\n来分割,同一个 TCP 连接中,无法区分 request/response 是属于哪个请求,所以无法通过一个 TCP 连接并发地发送多个请求,只能等上一个请求的 response 回来了,才能发送下一个请求,否则无法区分谁是谁。
于是 H1.1 提出了一个 pipeline 的特性,允许请求方一口气并发多个 request,但对服务方有一个变态的要求,需要对应的 response 按照 request 的顺序严格排列,因为不按顺序排列就分不清楚 response 是属于哪个 request 的。这给 Proxy(Nginx 等) 带来了复杂性,同时如果第一个请求迟迟不返回,那后面的请求都会受影响,所以普及率不高。
但当今的 Web 页面有玲琅满目的图片、js、css,如果让请求一个个串行执行,那页面的渲染会变得极慢。于是只能同时创建多个 TCP 连接,实现并发下载数据,快速渲染出页面。这会给浏览器造成较大的资源消耗,电脑会变卡。很多浏览器为了兼顾下载速度和资源消耗,会对同一个域名限制并发的 TCP 连接数量,如 Chrome 是 6 个左右,剩下的请求则需要排队,Network 下的 Waterfall 就可以观察排队情况 (见下图右边的颜色条)。
H1.1 时,有 6 个并发连接,可以看到最下面三个请求在排队:
HTTP2 中,可以看出请求时同时发出的,没有排队,且只占用一个连接:
狡猾的人类为了避开这个数量限制,将图片、css、js 等资源放在不同域名下 (或二级域名),避开排队导致的渲染延迟。快速下载的目标实现了,但这和更低的资源消耗
目标相违背,背后都是高昂的带宽、CDN 成本。
H1.1 在速度和成本上的权衡让人纠结不已,HTTP2 的出现就是为了优化这些问题,在更快的传输
和更低的成本
两个目标上更进了一步。有以下几个基本点:
核心可分为 头部压缩
和 多路复用
。这两个点都服务于更快的传输
、更低的资源消耗
这两个目标,与上文呼应。
现在的 web 页面,大多比较复杂,新打开一个地址,动辄产生几十个请求,这会发送大量的 header,大部分内容都是一样的内容,以 baidu 为例:
request:
GET HTTP/1.1
Host: www.baidu.com
Cache-Control: no-cache
Postman-Token: a9702bac-94c4-c7da-2041-7c7ac5f85b6e
response:
Access-Control-Allow-Credentials: true
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html; charset=UTF-8
Server: Apache
Transfer-Encoding: chunked
Vary: Accept-Encoding
这些文本内容一次次重复地发送,占用了大量的带宽,如何将这些成本降下去,而又保留 HTTP 无状态的优点呢?
基于这个想法,诞生了 HPACK[2],全称为HTTP2头部压缩
,它以极富创造力的方式,提供了两种方式极大地降低了 header 的传输占用。
一、将高频使用的 Header 编成一个静态表,每个 header 对应一个数组索引,每次只用传这个索引,而不是冗长的文本。表总共有 61 项,下图是前 30 项:
可以预见这种方式,在大量的请求环境下,可以明显降低传输内容。服务端根据内容查表,就可以还原出 header。
二、支持动态地在表中增加 header
Host: www.baidu.com
在上面的实例中,打开 baidu 时,对应域名的所有请求都会带上 Host,这又是重复冗余的数据。但由于各家网站的 host 不相同,无法像上面那样做成一个静态的表。HPACK 支持动态地在表中增加 header,例如:
62 Host: www.baidu.com
在请求发起前,通过协议将上面 Header 添加到表中,则后面的请求都只用发送 62 即可,不用再发送文本,这又节约了大量空间。(请求方/服务方的表成员
会保持同步一致)
上面两个分别被成为静态表和动态表。静态表是协议级别的约定,是不变的内容。动态表则是基于当前 TCP 连接进行协商的结果,发送请求时会相互设置好 header,让请求方和服务方维护同一份动态表,后续的请求可复用。连接销毁时,动态表也会注销。
H1.1 核心的尴尬点在于,在同一个 TCP 连接中,没办法区分 response 是属于哪个请求,一旦多个请求返回的文本内容混在一起,就天下大乱,所以请求只能一个个串行排队发送。这直接导致了 TCP 资源的闲置。
HTTP2 为了解决这个问题,提出了流
的概念,每一次请求对应一个流,有一个唯一 ID,用来区分不同的请求。基于流的概念,进一步提出了帧
,一个请求的数据会被分成多个帧,方便进行数据分割传输,每个帧都唯一属于某一个流 ID,将帧按照流 ID 进行分组,即可分离出不同的请求。这样同一个 TCP 连接中就可以同时并发多个请求,不同请求的帧数据可穿插在一起,根据流 ID 分组即可。这样直接解决了 H1.1 的核心痛点,通过这种复用 TCP 连接的方式,不用再同时建多个连接,提升了 TCP 的利用效率。这也是多路复用
思想的一种落地方式,在很多消息队列协议中也广泛存在,如 AMQP[4],其channel
的概念和流
如出一辙,大道相通。
在 HTTP2 中,流是一个逻辑上的概念,实际上就是一个 int 类型的 ID,可顺序自增,只要不冲突即可,每条帧
数据都会携带一个流 ID,当一串串帧在 TCP 通道中传输时,通过其流 ID,即可区分出不同的请求。
帧则有更多较为复杂的作用,HTTP2 几乎所有数据交互,都是以帧为单位进行的,包括 header、body、约定配置 (除了 Magic 串),这天然地就需要给帧进行分类,于是协议约定了以下帧类型:
一次 HTTP2 的请求有以下过程:
请求A
header 打包发出请求A
request 数据打包发出请求A
response header 打包发出请求A
response 数据打包发出
## 深入 HTTP2
前文简单介绍了,头部压缩和多路复用的具体思路和解决问题的方法,接下来我们深入 HTTP2,看看这两个特性是如何落地的,在数据上形成直观地把握,也借此了解何为二进制编码
。任何一个应用层的传输协议,都需要解决一个问题,那就是如何表示数据结尾,如何分割数据。在 H1.1 中,我们知道,它粗暴地先发 Header,再发 body,每个 header 通过\r\n
文本内容来分割,header 和 body 通过\r\n\r\n
来分割,通过 content-length 的值读取 body,一个请求的内容就成功结束。
// 一次请求的返回
200 OK\r\nHeader1:Value1\r\nHeader2:Value2\r\nHeader3:Value3\r\n\r\nI am body
// 网络中实际传输的是上面文本的ascii编码
HTTP2 为了降低协议占用,不会使用文本分割,也不会使用文本来表示 header。它是如何表示一帧开始、一帧结束、header 传完了、body 传完了呢?
下面是帧格式,所有帧都是一个固定的 9 字节头部 (payload 之前) 跟一个指定长度的数据 (payload):
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+
不同的帧类型,有不同的 Payload 格式,我们分别介绍 DATA 帧和 HEADDERS 帧:
DATA 帧的 Payload:
+---------------+
|Pad Length? (8)|
+---------------+-----------------------------------------------+
| Data (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+
Data 帧的 Flags(8) 目前有两个位有意义:
HEADER 帧 Payload:
+---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|E| Stream Dependency? (31) |
+-+-------------+-----------------------------------------------+
| Weight? (8) |
+-+-------------+-----------------------------------------------+
| Header Block Fragment (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+
HEADERS 帧有以下标识 (flags):
请求的 header 打包在 Header Block Fragment,我们重点关注一下,以便理解 header 是如何被传输的。
由于上面头部压缩的内容,我们知道 header 可以存在于静态表、动态表中。此时只需要传一个 index 即可表达对应的 header,减少传输内容。请求传递的 header 情况有以下几种:
Header Block Fragment 中打包 header 的方式也就是按照上面几种情况展开,具体篇幅较多,本文找一个复杂点的例子: key、value 都不在表中,且需要添加进表中的情况进行举例:(更详细 HPACK 细节可见 [6]、[7])
+---+---+---+---+---+---+---+---+
| 0 | 1 | 0 | // 通过头8个bit表示是哪种case
+---+---+-----------------------+
| H | Key Length (7+) |
+---+---------------------------+
| Key String (Length octets) |
+---+---------------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
01
000000
表达了两点
000000
)、value 也不在 (需要传文本内容)01
开头表示需要追加到表中)上面的场景下,header 的内容包含 key 的 Index,value 的长度、value 的文本内容,其实可分为两种:
对于 custom-key: custom-header
表达示例:(来源 [10])
编码数据的十六进制表示:
400a 6375 7374 6f6d 2d6b 6579 0d63 7573 | @.custom-key.cus
746f 6d2d 6865 6164 6572 | tom-header
解码过程:
40 | == Literal indexed == (01000000表示要追加到表中)
0a | Literal name (len = 10) (得到key长度)
6375 7374 6f6d 2d6b 6579 | custom-key
0d | Literal value (len = 13) (得到value长度)
6375 7374 6f6d 2d68 6561 6465 72 | custom-header (一个key:value 读取完毕)
解码结果可得header: custom-key:custom-header 并将其加入动态表,下次直接只传index
上图中有Key Length (7+)
和 Value Length (7+)
,这是上面提到的数字的表达
,可以看到有7+
这个表示。这里面有一个扩展问题,如果 Value 的长度比较大,7 个 bit 表示不了咋办。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| ? | ? | ? | 1 1 1 1 1 | 第一个字节 N = 5
+---+---+---+-------------------+
| 1 | Value-(2^N-1) LSB |
+---+---------------------------+
...
+---+---------------------------+
| 0 | Value-(2^N-1) MSB |
+---+---------------------------+
当长度 len 比较小,len < 2^N - 1, 则直接用第一个字节即可表达。(N <= 7)。如果 len >= 2^N - 1,则需要用后续的字节继续表达。规则是:
示例: (来源 [10])
1036 二进制串为:010100011010,用多个字节表达
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| X | X | X | 1 | 1 | 1 | 1 | 1 | 第一个字节表达了 2^N - 1 = 31, 下面的字节表达 1337 - 31 = 1306
| 1 | 0 | 0 | 1 | 1 | 0 | 1 | 0 | 后面一截: 0011010 (低位)
| 0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 前面一截: 01010 (高位)
+---+---+---+---+---+---+---+---+
上文已经搞清楚了 HTTP2 的传输原理,接下来通过 wireshark 透视一下 gRPC 调用的过程。
请求内容:
返回:
帧示例:
可见调用语义和 HTTP 并无差别,但通过协议优化,在很大程度上降低了传输的体积,节省资源的同时,也较好地提升了性能。
看了单个请求的抓包样例,我们得再看看 gRPC 的 stream 是什么鬼,代码约定如下:
// proto
service XXX {
rpc StreamTest(stream StreamTestReq) returns (stream StreamTestResp);
}
message StreamTestReq {
int64 i = 1;
}
message StreamTestResp {
int64 j = 1;
}
// server端代码
func (s *XXXService) StreamTest(re v1pb.XXX_StreamTestServer ) (err error) {
for {
data, err := re.Recv()
if err != nil {
break
}
// 将客户端发送来的值乘以10再返回给它
err = re.Send(&v1pb.StreamTestResp{J: data.I * 10 })
}
return
}
// client 端代码
func TestStream(t *testing.T) {
c, _ := service2.daClient.StreamTest(context.TODO())
go func(){
for {
rec, err := c.Recv()
if err != nil {
break
}
fmt.Printf("resp: %v\n", rec.J)
}
}()
for _, x := range []int64{1,2,3,4,5,6,7,8,9}{
_ = c.Send(&dav1.StreamTestReq{I: x})
time.Sleep(100*time.Millisecond)
}
_ = c.CloseSend()
}
// client端输出结果
resp: 10
resp: 20
resp: 30
resp: 40
resp: 50
resp: 60
resp: 70
resp: 80
resp: 90
我们不禁要问,这种流式请求和常规的 gRPC 有没有区别?这从抓包便可知分晓:
上面只提取了 http2 和 grpc 的协议内容,否则会被 tcp 的 ack 打乱视野,可以从图上看到:
stream 模式,其实就是 gRPC 从协议层支持了,在一次长请求中,分批地处理小量数据,达到多次请求的效果,像流水一样可以延绵不绝,直到某一方终止。
试想下,如果 gRPC 内部不支持这种模式,其实也能自己实现流式的服务,只不过在形式上要多调用几次接口而已。从上面抓包来看,这种封装在无论在性能和语义上都更好。
参见 HTTP3,抛弃 TCP 协议,拥抱 QUIC。