再谈 gRPC 的 Trailers 设计

2022-08-09 ⏳4.6分钟(1.8千字)

我多次「批判」过 gRPC。在它众多复杂的设计当中,最神秘的就是依赖 trailers 头。网上基本没有资料讲 gRPC 为什么要使用 trailers 传递状态码。最近读到 Carl 一篇文章 Why Does gRPC Insist on Trailers?。Carl 曾经是 gRPC 研发团队的成员,他在文章中详细说明设计 gRPC 的愿景以及后面逐渐走向失控的过程。今天结合自己的理解再谈一下 gRPC 的设计。

很多人可能根本就不知道这里说的 trailers。HTTP 协议在返回数据的时候通常是先发送 Header 信息,再发送 Body 数据。但 Trailers 是一类特殊的 Header,它们是在 Body 传输结束后才发送给客户端的。因为发送顺序不同,所以,在 HTTP/1.1 中 Trailers 只能跟 chunked 传输编码配合使用。而 chunked 传输编码主要用于无法在传输开始之前确定数据长度的场景(比如压缩)。举个例子:

HTTP/1.1 200 OK
Content-Type: text/plain
Transfer-Encoding: chunked
Trailer: MD5

7\r\n
Mozilla\r\n
9\r\n
Developer\r\n
7\r\n
Network\r\n
0\r\n
MD5: 68b329da9893e34099c7d8ad5cb9c940\r\n
\r\n

这里的传输编码指定为 chunked 所以 body 部分需要分段传输。每段以数字+换行开始,数字表示该分段的长度(十六进制),然后就是对应长度的字节流,数据结束后还需要追加换行。所以分段传输完成之后,需要再发送一段长度为零的数据表示结束。

Trailer 头里面表示数据传输结束后还有额外的 Header,Header 的名字为 MD5。可以指定多个,以逗号分隔。所以在分段数据之后又额外发送了所有数据的 MD5 值用作校验。因为 Body 的内容是动态生成的,我们不可能事先得到它的 MD5 值。只能是一边传输一边计算,等传完了也就计算好了,然后使用 Trailer 头「补发」给客户端。

以上是 Trailers 的基本概念。到了 HTTP/2 时代,因为有了帧的概念,所以 Header 和 Body 可以并发传输,不再有先发 Header 再传 Body 的限制。因此,在 HTTP/2 中,Trailers 不需要依赖 chunked 传输编码,所以响应都能发送 Trailers 信息。

那问题来了,为什么 gRPC 要依赖 Trailers 呢?核心原因还是为了支持流式接口。因为是流式接口,我们不可能事先确定数据的长度,也就不可能使用 HTTP 的 Content-Length 头。对应的 HTTP 请求大约是这个样子:

GET /data HTTP/1.1
Host: example.com

HTTP/1.1 200 OK
Server: example.com

abc123

什么?长度不确定?那不正好使用 chunked 传输吗?Carl 指出使用 chunked 会产生歧义。他给了如下的例子:

GET /data HTTP/1.1
Host: example.com

HTTP/1.1 200 OK
Server: example.com
Transfer-Encoding: chunked

6\r\n
abc123\r\n
0\r\n

假如客户端跟服务器之前有一个代理。代理收到响应之后开始将数据转发给客户端。首先就是把 Header 部分发送给客户端,于是调用方确定本次的状态码为 200,成功了。然后逐段转发数据部分。如果代理在转发完第一段的abc123后服务端异常退出了,那代理需要给客户端发送什么信号呢?

因为状态码已经发出去,所以没办法把 200 改成 5xx 了。也不能直接发送0\r\n来结束 chunked 传输,这样客户端得知服务器已经异常退出的信息。唯一能做的就是直接关闭对应的底层连接,但这样会因为客户端创建新的连接而消耗额外的资源。所以需要找一种尽量复用底层连接的条件下通知客户端服务器出错的办法。最终 gRPC 团队决定使用 Trailers 来传输。

大家可能觉得出错后关闭连接也没什么大不了的。但 Carl 表示,早在 2015 年,谷歌的 Stubby RPC 系统每秒处理的请求数已经超过了 1010,所以关闭连接的影响不不容小觑。

HTTP/1.1 本身可以通过 pipeline 功能实现连接并发请求。但 pipeline 功能太弱,一个请求出错会导致整个连接被关闭,同样也不能满足谷歌内部的要求。最终 gRPC 团队决定使用 HTTP/2 作为底层传输协议。所以典型的 gRPC 调用如下:

HEADERS (flags = END_HEADERS)
:method = POST
:scheme = http
:path = /foo.HelloService/Hi
:authority = taoshu.in
content-type = application/grpc+proto

DATA (flags = END_STREAM)
<Length-Prefixed Message>

HEADERS (flags = END_HEADERS)
:status = 200
content-type = application/grpc+proto

DATA
<Length-Prefixed Message>

HEADERS (flags = END_STREAM, END_HEADERS)
grpc-status = 0 # OK

这里的 HEADERS 和 DATA 表示 HTTP/2 的数据帧。gRPC 在数据传输结束后才会发送 grpc-status 表示最终 RPC 调用状态。

但这里埋下了一个隐患——不支持浏览器!按 Carl 的说法,gRPC 一个很重要的目标就是支持浏览器、手机、服务器和代理的互联互通。但是在 HTTP/1.1 时代,浏览器对 Trailers 的支持并不好。到了 HTTP/2 时代,各浏览器正好都在实现 Fetch API。而且 Fetch 接口最初也支持 Trailers,参考这里。然而,Chrome 团队最终决定不支持通过 Fetch 接口获取 Trailers 信息,说是出于安全考虑。具体的论战可以到这里围观。所以说 gRPC 没办法直接用于浏览器通信,于是便有了 grpc-web 等项目。

以上便是 Trailers 的故事。除此之外,gRPC 还有一个让人难受的设计:Length-Prefixed Message。这也是为了支持流式接口而引入的。无论是不是流式接口,所有消息前面都要加上长度为五个字节的前缀。其中第一个字节表示后续内容是否加密,后面四个字节以大端的方式保存消息内容的长度。这种设计导致无法直接用 curl + json 的方式来调试 gRPC 接口,必须使用专门的工具。可以说很不方便。

另外,既然有了消息前缀,那完全可以把 Trailers 的职能转移到消息前缀里。比如可以设置一个特殊的前缀来传输 grpc-status 等字段。如果当初这样做的话,那么现在也就可以直接在浏览器调用 gRPC 接口了。可以惜木已成舟。

虽然现在 gRPC 用的越来越多,但依然不认为它是一种很好的设计。原因在于它所有的机制都是为了支持流式请求。而流式接口在实际的业务中占比极低。我今天统计B站内部微服务的接口类型,一共有 8542 个接口,流式接口只有 59 个,占比不到 0.7%。绝大多数接口都是请求响应式的,完全不需要 gRPC 这么复杂的机制。对于这些少数的使用场景,我们可以基于 WebSocket/HTTP2/HTTP3 甚至是 TCP 特殊实现一下就好。这也是我一直在推广 Twirp 原因。

最后,以 Carl 总结的三条教训结束本文: