HTTP 2.0
HTTP/2 可以说是google早些年推出的SPDY方案的升级版,HTTP2.0 跟 SPDY 不同的地方主要是以下两点:
- HTTP2.0 支持HTTPS但也支持明文 HTTP 传输,SPDY 则强制使用 HTTPS
- HTTP2.0 消息头的压缩算法采用 HPACK,而非 SPDY 采用的 DEFLATE
关于HTTP/2的讨论仍在持续,所以不能排除会发生重大改变的可能性——《图解HTTP》。
HTTP/2 相较与HTTP/1.1 基本语义是不变的,主要的变化在于以下几点:
1、多路复用
对于HTTP/1.0,一次请求-响应 建立一个连接,每一个请求都要建立一个连接。想要并发请求的话只能通过同时建立多个连接实现,而且浏览器对于同一域名下的连接数有限制,一般是 4-8 个,超过限制的数目会被阻塞等待,这也是有些站点会有多个静态资源 CDN 域名的原因之一。
HTTP/1.1中默认采用长连接 ,通过头部Connection: keep-alive(无需显示声明,http/1.0使用长连接的话需显示声明),长连接即客户端和服务器之间的TCP连接保持打开状态:如果客户端一直没有发送请求的话,服务端会根据配置的空闲超时时间(tomcat默认为20秒,nginx默认为75秒)来关闭连接,客户端也可以在请求的头部加上Connection:close——tomcat在处理完请求后会直接关闭连接,服务端也可以在响应头里添加Connection:close——浏览器处理完响应后自动关闭连接。因为连接不是一直保持的,所以客户端必须实现连接失效机制或请求重试机制,当客户端企图复用已被服务端关闭的连接发送请求的时候会报错Broken Pipe 或 Connection Reset。
HTTP/1.1中还提供 管道化pipeline技术,管道化指的是可以在一个连接上连续发送多个请求,服务器会按照请求的顺序依次处理请求,所以虽然请求可以一次发送多个,但是回应还是按照请求的顺序一个一个的发送给客户端。一旦有请求处理的时间过长的话,后面的请求只能排队等待,这就是http/1.1中的"队头阻塞"问题,而且一些浏览器会将管道化作为一个高级配置选项,默认并不开启,所以要实现并行处理的话,要通过建立多个连接来实现。
对于HTTP/1.1来说,不管是否使用管道化技术,都会有“队头阻塞”问题:在一个连接中某个请求处理时间过长就会导致阻塞后面请求的处理。针对队头阻塞问题可以进行相应的改进措施:如浏览器会采用多TCP连接(浏览器对于同一域名下的连接数有限制,一般是 4-8 个)来分散请求;将资源分散到多个子域名以绕过浏览器对单域名的连接数限制(增加了DNS解析开销);合并 CSS/JS 文件和启用 Gzip 压缩以减少请求数量和数据量。
http/1.1必须严格遵循请求-响应模型——客户端在发送一个请求后同样必须等待服务器返回该请求的响应后才能发送下一个请求,这就导致了“队头阻塞”问题。 HTTP/2.0通过多路复用解决了队头阻塞问题:在一个连接上客户端可以发送多个请求,而服务端可以同时处理多个请求,先处理完的请求可以直接发送给客户端,如下所示。一个请求-响应属于一个“流”, 每个请求-响应的“流ID”不同,通过这个ID客户端可以匹配响应到对应的请求。



2、二进制帧
HTTP/1.1 中,数据是以“报文”(Message)形式传输的,报文消息基于文本格式。http/2协议则采用二进制“帧”(Frame)格式,一条帧数据由9字节的帧头+数据构成, 帧头包括3字节的数据大小、1字节的帧类型、1字节的标志位、4字节的所属流ID:

“帧负载数据(不包含9字节的枕头)大小”是3个字节,所以http/2中帧数据最大为16M,这个值其实不是固定的,最大为16兆,最小为16K,:如下所示,连接建立后,会通过SETTINGS类型的帧(流ID为0)来协商帧负载数据的最大值,然后使用双方协商后的较小值(一般使用默认值16K,大文件传输的话可适当增大该值),帧负载超过这个值后会被分成两个或多个帧。

“帧类型”有HEADERS、DATA、SETTINGS、PUSH_PROMISE、RST_STREAM等,header帧的帧数据仅包含"请求行/状态行+请求首部/响应首部",data帧的帧数据仅包含请求或响应的数据(也就是说http/2将http/1.1的一条帧数据拆分成了两条帧数据——如下图所示,headers帧和data帧,这么做的原因见下面的“首部压缩”),push帧是服务器推送资源,settings帧用来配置连接参数。

“标志位”是与帧类型相关的标志。
“所属的流ID”用来匹配到对应的请求,客户端发起的流ID是奇数,服务端发起的流(比如推送)是偶数,如下所示,客户端依次发起了四个请求:GET /index.html、GET /style.css、GET /app.js、GET /logo.png,这四个请求分配的流ID分别是1、3、5、7,收到的响应顺序可能不会按照发送时候的顺序,所以通过流ID来匹配对应的请求。

如果DATA没有被分帧的话,这个DATA帧就会直接携带END_STREAM表示流结束。如上所示,当HTML过大的时候,会分成多个DATA帧来发送(即帧分片或分帧),这些帧的流ID相同,但只有最后一个帧的标志位为END_STREAM(0x1),表示流的结束。Nginx配置也可能导致帧分片,如下所示,即使文件小于16K,也可能被分成多个4KB的块发送:
http{ # 缓冲区设置 proxy_buffers 8 4k; # 每个连接8个缓冲区,每个4KB proxy_buffer_size 4k; }
3、流状态
- IDLE(初始态):流被创建但未使用(在该流上还未发送/接收帧)
- OPEN(激活态):发送或接收HEADERS帧后进入,可双向传输帧数据
- HALF-CLOSED(半关闭):
- LOCAL:本地发送带END_STREAM标志的帧 → 禁止发送新帧,可接收响应
- REMOTE:对端发送带END_STREAM标志的帧 → 禁止接收新帧,可发送数据
- CLOSED(终止态):双向均发送带END_STREAM标志的帧后流正常关闭,或接收RST_STREAM类型帧强制关闭流,在该流上不再发送和接收帧
当发送GET这种无请求体请求的时候,会在请求帧(HEADERS类型帧)的标志位设置END_STREAM,如果响应的帧数据没有进行分帧的话,响应数据帧里会携带END_STREAM标志,该流结束,即为CLOSED状态。如果响应帧进行了分帧或者响应数据是流数据(SSE通知),仅在最后一个数据帧里设置END_STREAM标志,该流进入CLOSED状态;当发送POST这种附带请求体请求的时候,HEADERS帧不会设置END_STREAM,最后一个DATA帧里会设置END_STREAM。
流的状态机:

4、首部压缩
HTTP2.0在客户端和服务器端使用“首部表”来跟踪和存储之前发送的首部键-值对,对于每次请求-响应中相同的首部数据,不再重复发送,自动使用之前请求发送的首部。
如果首部发生变化了,那么只需要发送变化了头部在Headers帧里面,新增或修改的首部帧会被追加到“首部表”,首部表在 HTTP2.0的连接存续期内始终存在,由客户端和服务器共同渐进地更新。

5、服务端推送
http/2.0增加了服务端推送功能——比如客户请求了html后,服务端除了html响应外,还允许将后续需要的css、js等资源直接推送给客户端,减少了客户端请求,如下所示:

服务器推送是 HTTP/2 协议里面唯一一个需要开发者自己配置才能开启的功能,如下所示,在nginx配置文件的location指令里添加http2_push命令后,当用户请求www.test.com/user/user.html,除了响应user.html,还会自动推送style.css和example.png:
location / { root /usr/share/nginx/html; # 静态文件的根目录 index index.html index.htm; # 如果请求的是一个目录,默认查找的文件,比如请求www.test.com/的话,返回 index.html,请求www.test.com/about的话,如果/usr/share/nginx/html/about不存在则返回 about.html http2_push /style.css; http2_push /example.png; }
如果是spring服务提供响应的话, 可以在响应头中加入一个名为 Link 的字段,用来告诉浏览器,除了这个响应外,app.css、example.png这些资源也会一并推送给你:
@GetMapping("/")
public ResponseEntity<String> index(HttpServletResponse response) {
// 添加 Link 头,rel="preload" 表示预加载,as="style" 指明资源类型
response.addHeader("Link", "</css/app.css>; rel=preload; as=style, </png/example.png>; rel=preload; as=image");
return ResponseEntity.ok("<html>...</html>");
}
如果要推送的资源文件,浏览器已经有缓存,推送就是浪费带宽,最佳实践是使用 Link 头配合 nopush 指令,这样就由服务器推送变成了服务器通知——浏览器收到响应后会先检查有没有这些资源的缓存,如果没有的话再发起请求去下载,如下所示。
如果是nginx提供下载的话,可以不使用http2_push,在location配置中添加link + nopush响应头来实现服务器通知,如下所示。
@GetMapping("/")
public ResponseEntity<String> index(HttpServletResponse response) {
// 添加 Link 头
response.addHeader("Link", "</css/app.css>; rel=preload; as=style, </png/example.png>; rel=preload; as=image; nopush");
return ResponseEntity.ok("<html>...</html>");
}
location = / index.html{ # 基于Link头自动推送 http2_push_preload on; # 添加Link头 add_header Link "</style.css>; rel=preload; as=style; nopush"; add_header Link "</app.js>; rel=preload; as=script; nopush"; }
6、SSE
Http/2.0使用二进制帧格式,其天然支持流式传输,所以使用http/2.0实现SSE的话,不再需要chunked编码,实际上chunked编码在http/2中已被废弃,而且Content-Length头也变得不再重要,因为帧里面有数据大小。在HTTP/2中,SSE通过DATA帧序列来实现——客户端照常发起SSE请求(Accept头需要设置为text/event-stream表示请求的不是一次性的数据包而是一个数据流),服务端发回header类型的响应帧(其content-type头需要被设置成text/event-stream),服务端以后要推送数据的话,使用data类型的帧(帧的流ID与客户端发起请求的header帧中流ID一致),标志位为END_STREAM的帧表示最后一个DATA帧,该流结束:

7、RST_STREAM帧
发送RST_STREAM类型的帧用来表示关闭这个流,RST_STREAM类型的帧需要附加错误码(如NO_ERROR为0x0,表示正常流终止)。RST_STREAM帧应用如下:
①、通过http2_push配置或link头来开启服务端推送后,服务端在推送数据之前实际上还会发送PUSH_PROMISE类型的帧,以通知客户端我会推送这个数据,如下所示。客户端在收到PUSH_PROMISE帧后,可以向服务端发送RST_STREAM类型的帧,表示我不需要这个资源,不用给我推了,比如浏览器已缓存了这个资源的情况。

②、客户端通过SSE订阅了服务端通知后,可以通过向服务端发送RST_STREAM类型的帧表示取消订阅。
③、浏览器正在加载资源的时候客户点击了取消按钮,浏览器会发送RST_STREAM帧通知服务器停止处理该请求。
④、用户快速滚动网页,浏览器会通过发送RST_STREAM帧来取消那些已不再需要的图片或资源请求。
⑤、服务器在处理请求过程中遇到错误(如资源不存在、权限不足等)时,会通过RST_STREAM帧终止流并返回错误码。
8、开启http/2支持
现代浏览器都支持http/2,浏览器在建立TLS连接(HTTPS)的过程中,会通过 ALPN扩展 主动告知服务器它支持的协议列表(通常包含 h2代表HTTP/2),服务器根据自身配置来选择使用http/2还是http/1,然后告知浏览器,咱俩通信使用Http/2或http/1.1。
浏览器不管是使用http/1.1还是http/2.0,前端代码都不用修改,因为http/2主要优化的是底层传输机制,http语义没有变化。不过使用http/2的话,可以针对以下前端 http/1.1的做法进行相应的调整:
①、资源合并,如将多个JS合并为1个bundle.js以避免队头阻塞或减少连接请求,缺点是小改动的话也必须更新整个大文件。HTTP/2的多路复用允许同时传输多个小文件。
②、域名分片,为了突破浏览器对同一域名连接数的限制(通常6个),使用static1.example.com, static2.example.com等多个域名,增加了连接数和DNS耗时。HTTP/2中可以同时发起多个请求并且无队头阻塞问题,无需创建过多连接。
③、内联资源,如将CSS样式、JS脚本直接写在HTML中以减少请求,内联的缺点是会失去独立的缓存性(css、js无法单独进行缓存,耦合度高)和复用性(内部css样式无法给其它html文件使用)。在HTTP/2下可考虑恢复为外部样式表和外部JS文件。
④、精灵图/雪碧图,为了减少HTTP请求,将多张小图合并为一张大图(前端通过 CSS 中的 background-image、background-position 等属性,控制显示该大图中的特定区域)。HTTP/2下单独请求多个小图的成本已很低。
后端服务升级为使用http/2.0的话,代码也不用修改,只需要修改配置文件即可,核心就是启用 HTTPS 并开启 HTTP/2 支持。虽然HTTP/2.0规范中并没有明确必须使用HTTPS,但很多应用是开启了HTTP/2.0的话,必须使用HTTPS。HTTP/2.0会对整个二进制帧进行加密。
9、grpc与http/2
grpc底层使用http/2,其支持四种通信模式,如下所示:

grpc订阅底层实现:如下所示,客户端向GRPC服务发送带END_STREAM标志的POST请求,如果附加请求内容的话,使用携带END_STREAM标志的DATA帧来传输,无附加请求内容的话,仅使用携带END_STREAM标致的HEADER帧。服务端收到请求后回应HEADER帧(不携带END_STREAM标志),然后持续向客户端推送订阅数据(不带END_STREAM标志的DATA帧)。推送结束的话,服务端向客户端发送携带END_STREAM标志的DATA帧以关闭流,客户端主动取消订阅的话,向GRPC服务发送RST_STREAM类型的帧来关闭流,客户端再次订阅的话,再次向服务发送携带END_STREAM标志的POST请求。客户端关闭的话,关闭与GRPC服务的连接。


可以看到,发送订阅请求的时候不用像SSE那样设置Accept头为text/event-stream,服务响应也不用设置content-type为ext/event-stream,因为SSE是应用层协议,而grpc为RPC框架,框架内部已经约定好了行为模式,无需再进行客户端和服务端的协商。如下为grpc与sse的对比:
①、sse是单向通信,主要用于向客户端推送通知,grpc是双向通信,支持四种通信模式。
②、sse消息是文本格式,grpc则使用二进制(Protobuf),效率更高。
③、sse底层使用http / 1.1(chunked编码传输)或http2(DATA帧序列),grpc底层使用http / 2。
④、sse订阅请求是GET(兼容浏览器模式),grpc订阅请求是POST。
⑤、sse浏览器原生支持,grpc浏览器原生支持差。
⑥、对于订阅功能,如果是频次不高的推送,可以选择sse,否则应该选择性能更高的grpc。
grpc浏览器原生支持差的原因:gRPC 强依赖 HTTP/2 的完整特性,浏览器为了安全,并没有完全开放 HTTP/2 的底层控制权给 JavaScript。所以要在浏览上实现grpc客户端的话,一般是使用gRPC-Web——浏览器发出的 gRPC 请求实际上是 gRPC-Web 协议的,中间必须通过一个代理(如 Envoy、Nginx)来将请求转换成标准的 gRPC 格式才能被后端服务识别,这增加了架构的复杂性,而且由于无法突破浏览器的限制,gRPC-Web 对 gRPC 的一些高级特性支持有限。
浙公网安备 33010602011771号