GRPC与HTTP2
前言
grpc算是相当有名,也是云原生指定RPC实现,但是目前的情况下如果整体技术栈比较单一的话可能更多的会选择与语言相关的、针对性更强的RPC方案,用不到它也是很常见的情况,或者也许依赖的三方库已经在内部用到了它只是你没有注意。我这恰好有场景用了,恰好又出现了问题,所以琢磨了一番,就是这么一篇东西。
grpc因为选择http2作为传输协议以及自身的特点,拥有很高的兼容性,在多技术栈的情况下往往是不二之选,通过grpc-web也可以在浏览器中使用grpc(听说的),直接做到前后端大一统。另外比较有特色的是grpc支持streaming形式的调用,包括单向和双向,这是很多其他rpc不具备的能力。
grpc官网的doc也比较粗略吧我觉得,只是因为支持的语言多所以内容不算少。基本上就是个入门示范,并没有什么注意事项和实现细节的说明,可查的中文资料相对也比较少,特别是针对语言内部运行机制的解析方面的,所以要用好还是免不了看源码。grpc是相当一大块内容,不可能一篇文章大包大揽,由于出发点是排查问题且双方的语言技术栈不同,本文更注重基础内容和通信的细节。
HTTP2基础
http/2协议发布于2015年,由以下两个RFC组成:
- RFC 7540:Hypertext Transfer Protocol Version 2 (HTTP/2)
- RFC 7541:HPACK: Header Compression for HTTP/2
具体细节可以看上面的RFC协议详情,http/2的核心特性主要有以下几点:
- 多路复用:http1.1解决了http1.0 tcp短连接的问题,但仍受限于队头阻塞问题,一个连接只能同时跑一个请求,而http2允许在同一个连接上同时发起多个请求、返回多个响应。
- 服务端推送:允许服务器主动推送数据。
- 流量控制:由于允许多路复用,那么连接就有可能存在竞争和拥塞,导致stream阻塞。http2引入了客户端和服务端交换发送窗口信息的能力,但是并没有规定流控的细节,允许自定义流控算法而无需更改协议。
- 头部压缩:即第二个RFC所示的HPACK,http1.1不会对头部进行压缩,那么对于同属一次交互的大量请求,重复的头部信息就会造成浪费带宽、影响延迟。基本做法是将高频使用的Header和Value编成固定的一个静态表,每组Header/Value对应一个数值索引,每次只传输索引值而不是冗长的文本;其次支持动态在表中增加数据,动态表基于TCP连接进行协商,双方共同维护,连接销毁时动态表也会注销。
为了实现这些能力,http2设计了新的二进制协议,加入了额外的抽象。这里目标是为了建立初步的概念,只叙述三个重点。
抽象层级
首先http2作为应用层协议,其上是传输层协议通常也就是Tcp,那么Tcp Connection就是第一层。然后是新加入的抽象概念Stream,stream可以理解为一个在两端之间独立的双向传输数据流,传统的request/response也可以归为一个从request开始到response结束的stream,而stream的范围更大。最后是Frame,frame是http2数据传输的基本单位,也翻译为帧,它有规定的协议格式,frame归属于某个stream,一个请求可以由多个frame组成。
所以总的来看,一个connection上可以同时打开多个stream,允许不同stream的数据以frame的形式交错地传输发送,以此实现多路复用;frame有规定的协议格式可以表明自己的类型、携带额外的信息,用于实现流控等其他辅助功能。
Frame结构
frame帧括9byte固定大小的头部和变长的数据载荷
1 | +-----------------------------------------------+ |
- Length:载荷的长度,24 bit长的unsigned int值
- Type:类型,主要包括DATA(数据)、HEADERS(头信息)、PRIORITY(优先级)、RST_STREAM(错误终止)、SETTINGS(连接参数同步)、PUSH_PROMISE(服务端推送)、PING(心跳)、GOAWAY(停止)、WINDOW_UPDATE(流控)、CONTINUATION(扩展header数据块)
- Flags:标记/状态,用于指定的Type表达某些语义,如果frame type没有对应的flag,那么必须被忽略以及置空
- R:保留位
- Stream Identifier:stream id,31 bit长的unsigned int值。其中0值是保留值,值为0的stream指代整个connection,客户端发起的stream id必须为奇数,服务端必须为偶数,id必须递增且不可重用
Stream状态机
stream可以打开、关闭,有不同的状态,可以用有限状态机表示它的生命周期。
1 | +--------+ |
客户端和服务端各自维持stream的状态,收发特定的frame会触发状态变更,在这些frame传输的过程中客户端和服务端对同一个stream的状态可能不同。状态只有5种,只是区分了local和remote后会显得多一些。
- idle:所有stream的初始状态
- reserved:server push相关
- open:正常打开状态,两端都可以发送frame
- half-closed:半开状态,有一方正常关闭了stream
- closed:终止状态,stream关闭结束
GRPC基础
grpc主要由三个部分组成:
- http2作为底层传输协议
- protobuf作为数据序列化协议和接口定义语言(IDL)
- 通过protoc生成各个语言易用的sdk
grpc提供了以下几种RPC类型:
- Unary:客户端发送一个请求,服务端回复单个响应,典型的普通方法调用
- Server streaming:客户端发送请求后,服务端可以返回多个响应
- Client streaming:客户端在一次交互中可以发送多个请求,服务端处理完成后返回单个响应
- Bidirectional Streaming:客户端可以发送多个请求,服务端也可以返回多个响应
grpc允许双端发送单个或多个数据对象,因此组合成了四种RPC类型,这种交互中单测发送多次数据的能力由http2协议天生支持,grpc将这种多次返回数据的形式称为stream(包括api定义和方法接口),这里的stream与http2的stream也是紧密相关的。
API与数据包
总结一下grpc的API调用与http2 frame数据包的对应关系,以java api为例。
所有请求开始前,客户端与服务端建立tcp连接、http2握手时,会相互发送SETTING帧和WINDOW_UPDATE帧,用来同步连接相关的设置和流控初始化,比如连接上允许的最大stream数量(MAX_CONCURRENT_STREAMS)、初始窗口大小(INITIAL_WINDOW_SIZE)等。由于流控的存在,在实际数据的发送过程中会伴随着一些PING帧,下面的论述中将忽略这些PING帧的存在。
Unary
客户端发起请求:
API:
stub.unaryMethodName(ReqObj, respObserver)
数据包:
CLIENT:HEADER帧,包含了请求接口和方法等元数据
CLIENT:DATA帧,带有flag:endStream=true,包含了编码的请求数据
服务端发送响应:
API:
respObserver.onNext(respObj)
数据包:
SERVER:HEADER帧,status: 200
SERVER:DATA帧,带有flag:endStream=false,包含了编码的响应数据
服务端错误地再次发送响应:
API:
respObserver.onNext(respObjInstance)
数据包:
SERVER:RST_STREAM帧,errorCode:8
此时服务端会产生warn日志Too many responses,客户端会触发onError(只有一次),异常类型为io.grpc.StatusRuntimeException
,错误消息内容为Received Rst Stream
服务端结束响应:
API:
respObserver.onComplete()
数据包:
如果是发送完响应后正常结束
SERVER:HEADER帧,带有flag:endStream=true
如果没有发送响应直接结束
SERVER:RST_STREAM帧,errorCode:8
如果没有发送响应直接结束,客户端会触发onError,错误消息内容为Received Rst Stream
服务端异常处理:
API:
respObserver.onError(errorInstance)
数据包:
SERVER:HEADER帧,带有flag:endStream=true,status: 200,grpc-status: 2
客户端会触发onError,异常类型为io.grpc.StatusRuntimeException
,错误消息内容为UNKNOWN
Client streaming
客户端开始请求:
API:
stub.clientStreamMethodName(respObserver)
数据包:
SERVER:HEADER帧,包含了请求接口和方法等元数据
客户端发送数据
API:
reqObserver.onNext(reqObj)
数据包:
CLIENT:DATA帧,带有flag:endStream=false,包含了编码的请求数据
客户端在收到服务结束后发送数据:
API:
reqObserver.onNext(reqObj)
数据包:
无,此时调用api数据不会被发送到server端
客户端结束请求:
API:
reqObserver.onComplete()
数据包:
CLIENT:DATA帧,带有flag:endStream=false,没有具体数据
客户端异常处理:
API:
reqObserver.onError(errorInstance)
数据包:
CLIENT:RST_STREAM帧,errorCode:8
在客户端,这个抛出的异常会触发传入respObserver.onError
。而在服务端也会触发onError,错误消息内容为client cancelled
服务端发送响应:
API:
respObserver.onNext(respObj)
数据包:
SERVER:HEADER帧,status: 200
SERVER:DATA帧,带有flag:endStream=false,包含了编码的响应数据
服务端错误地再次发送响应:
API:
respObserver.onNext(respObj)
数据包:
SERVER:RST_STREAM帧,errorCode:8
此时server和client端都会触发onError
服务端结束响应:
API:
respObserver.onComplete()
数据包:
如果没有发送响应直接结束
SERVER:RST_STREAM帧,errorCode:8
此时server和client端都会触发onError
如果发送完响应且客户端已经结束请求
SERVER:HEADER帧,带有flag:endStream=true,grpc-status: 0
如果发送完响应且客户端还没有结束请求
SERVER:HEADER帧,带有flag:endStream=true,grpc-status: 0
CLIENT:RST_STREAM帧,errorCode:8
服务端异常处理:
API:
respObserver.onError(errorInstance)
数据包:
SERVER:HEADER帧,带有flag:endStream=false,status: 200,grpc-status: 2
CLIENT:RST_STREAM帧,errorCode:8
客户端会触发onError,异常类型为io.grpc.StatusRuntimeException
,错误消息内容为UNKNOWN
Server streaming
客户端发起请求:
API:
stub.serverStreamMethodName(ReqObj, respObserver)
数据包:
CLIENT:HEADER帧,包含了请求接口和方法等元数据
CLIENT:DATA帧,带有flag:endStream=true,包含了编码的请求数据
服务端发送响应:
API:
respObserver.onNext(respObj)
数据包:
SERVER:HEADER帧,status: 200,注意只有第一个回复操作会发送这个帧
SERVER:DATA帧,带有flag:endStream=false,包含了编码的响应数据
服务端结束响应:
API:
respObserver.onComplete()
数据包:
SERVER:HEADER帧,带有flag:endStream=true,grpc-status: 0
在这种调用形式下,服务端可以不返回任何响应直接结束而不会触发任何错误处理
服务端异常处理:
API:
respObserver.onError(errorInstance)
数据包:
SERVER:HEADER帧,带有flag:endStream=false,status: 200,grpc-status: 2
客户端会触发onError,异常类型为io.grpc.StatusRuntimeException
,错误消息内容为UNKNOWN
Bidirectional streaming
客户端开始请求:
API:
stub.bidiMethodName(respObserver)
数据包:
CLIENT:HEADER帧,包含了请求接口和方法等元数据
客户端发送数据:
API:
reqObserver.onNext(reqObj)
数据包:
CLIENT:DATA帧,带有flag:endStream=false,包含了编码的请求数据
客户端在收到服务结束后发送数据:
API:
reqObserver.onNext(reqObj)
数据包:
无,此时调用api数据不会被发送到server端
客户端结束请求:
API:
reqObserver.onComplete()
数据包:
CLIENT:DATA帧,带有flag:endStream=false,没有具体数据
客户端异常处理:
API:
reqObserver.onError(errorInstance)
数据包:
CLIENT:RST_STREAM帧,errorCode=8
在客户端,这个抛出的异常会触发传入respObserver.onError
。而在服务端也会触发onError,错误消息内容为client cancelled
服务端发送响应:
API:
respObserver.onNext(respObj)
数据包:
SERVER:HEADER帧,status: 200,注意只有第一个回复操作会发送这个帧
SERVER:DATA帧,带有flag:endStream=false,包含了编码的响应数据
服务端结束响应:
API:
respObserver.onComplete()
数据包:
如果客户端已经结束请求
SERVER:HEADER帧,带有flag:endStream=true,grpc-status: 0
如果客户端还没有结束请求
SERVER:HEADER帧,带有flag:endStream=true,grpc-status: 0
CLIENT:RST_STREAM帧,errorCode=8
在这种调用形式下,服务端可以不返回任何响应直接结束而不会触发任何错误处理,发帧情况只取决于客户端有没有结束请求
服务端异常处理:
API:
respObserver.onError(errorInstance)
数据包:
SERVER:HEADER帧,带有flag:endStream=false,status: 200,grpc-status: 2
客户端会触发onError,异常类型为io.grpc.StatusRuntimeException
,错误消息内容为UNKNOWN
问题和注意点
有了API对应的数据包信息,结合http2的状态变更逻辑,就可以推断客户端和服务端在交互过程中底层http2 stream的状态。为了对http2 stream的状态进行处理,客户端和服务端势必需要在内存中维护状态信息,以java应用下默认的netty4实现为例,状态维护在io.netty.handler.codec.http2.DefaultHttp2Connection
中的streamMap和activeStreams成员中。
只有stream的状态变更为closed时,状态信息才可以清除,因此如果stream一直增加并且不关闭,可能会产生内存泄露。产生这种情况的原因可能有:
- client应用逻辑异常,不结束streaming请求并且server端没有做超时处理,导致server端泄露
- server应用逻辑异常,不结束响应并且client端没有做超时处理,导致双端泄露
- client底层逻辑异常,比如使用了非标准的库。如上所述,在grpc的api下server端一般是无法主动发送RST_STREAM的(除非是在单返回值情况下返回了多个响应的情况),那么如果server端结束了响应(onComplete)而client没有回复RST_STREAM或者带有endStream flag的HEADER,server端就无法结束这个stream,导致server端泄露
在创建server时,可以设置连接上允许的最大stream数量(NettyServerBuilder.maxConcurrentCallsPerConnection
),但是要注意,java api下如果一个连接上超过了最大stream数量,此时client再次发起请求并不会产生异常,而是被存入等待队列,直到stream数量减少后才会发送请求到server。
client端可以在stub层面设置响应超时,同时超时信息会通过发起请求时的HEADER帧传递到server端。但是server端的原生api并没有对client端streaming形式下的超时处理,如有需求需要额外处理。