HTTP 123

HTTP1.0

根据谷歌的调查, 现在请求一个网页,平均涉及到 80 个资源,30 多个域名。考虑最原始的情况,每请求一个资源都需要建立一次 TCP 请求,显然不可接受。HTTP 协议规定了一个字段 Connection,不过默认的值是 close,也就是不开启。

HTTP1.1

Pipeline 是为了减少不必要的 TCP 连接,但依然存在队头阻塞(HOC)的缺点,一种解决思路是利用并发连接减少某一个 HOC 的影响,另一个是共享(注意与复用的区别) TCP 连接,直接避免 HOC 问题的发生。

HTTP1.1 的缺陷

  1. 高延迟 — 队头阻塞(Head-Of-Line Blocking)

    当有多个串行请求执行时,如果第一个请求不执行完,后续的请求也无法执行。

    支持并发请求是解决解决 HOC 问题的一种方案,并发请求并非是直接解决了 HOC 的问题,而是尽可能减少 HOC 造成的影响。

    1. 将同一页面的资源分散到不同域名下,提升连接上限。

    2. 减少请求数量

    3. 内联一些资源:css、base64 图片等

    4. 合并小文件减少资源数

  2. 无状态特性 — 阻碍交互

  3. 明文传输 — 不安全性

    HTTP 1.x 也可以配合 TLS 进行安全传输,只是不是强制的。

  4. 不支持服务端推送

SPDY

SPDY 是由 Google 推行的改进版本的 HTTP1.1。

SPDY

针对 HTTP1.1 的缺陷,SPDY 提供了如下特性:

  1. 多路复用 — 解决队头阻塞

    SPDY 允许在一个连接上无限制并发流。因为请求在一个通道上,TCP 效率更高。

    在 HTTP 1.1 中只有前面一个资源的所有数据包传输完毕后后面资源的包才能开始传递(HOC 问题),而 SPDY 并不这么要求,大家可以一起传输。

  2. 头部压缩 — 解决巨大的 HTTP 头部,使用 DEFLATE 算法。

  3. 请求优先级 — 先获取重要数据

  4. 服务端推送 — 填补空缺

    可以让服务端主动把资源文件推送给客户端。当然客户端也有权利选择是否接收。

  5. 提高安全性

HTTP2

SPDY 的成功,让网络工作组心动了。所以,他们基于 SPDY 制定了 HTTP2 协议。HTTP2 基于 SPDY,专注于性能,最大的一个目标是在用户和网站间只用一个连接。

重要特性:

  1. 重新设计的头部压缩算法:HPACK 算法。

  2. 二进制分帧 - HTTP2 性能增强的核心

    在应用层使用二进制分帧方式传输。因此,引入了新的通信单位:帧、消息、流。 分帧的好处:服务器单位时间接收到的请求数变多,可以提高并发数。最重要的是,为多路复用提供了底层支持。

    HTTP2 frame
  3. 多路复用 - 解决串行的文件传输和连接数过多

    一个域名对应一个连接,一个流代表了一个完整的请求-响应过程。帧是最小的数据单位,每个帧会标识出该帧属于哪个流,流也就是多个帧组成的数据流。多路复用,就是在一个 TCP 连接中可以存在多个流。

HTTP 中的压缩,是否压缩首部?还是只压缩 Body?

Go + HTTP/2 Go 语言对 HTTP2 支持的示例。

Akamai 提供的一个 HTTP2 演示示例: HTTP/2: the Future of the Internet | Akamai

由于众所周知的原因,D瓜哥最近一次查看时,HTTP2 的示例加载的很慢。所以,HTTP2 看来也不够完美…

HTTP2 的缺陷

  1. TCP 以及 TCP+TLS 建立连接的延时

    1. TCP 连接需要和服务器进行三次握手,即消耗完 1.5 个 RTT 之后才能进行数据传输。

    2. TLS 连接有两个版本—— TLS1.2 和 TLS1.3,每个版本建立连接所花的时间不同,大致需要 1~2 个 RTT。

  2. TCP 的队头阻塞并没有彻底解决

  3. 多路复用导致服务器压力上升

    有许多请求的短暂爆发,导致瞬时 QPS 暴增。

  4. 多路复用容易 Timeout

    大批量的请求同时发送,而网络带宽和服务器资源有限,每个流的资源会被稀释,虽然它们开始时间相差更短,但却都可能超时。似乎这是个问题,而且并没有好的解决方案。

TCP Retransmission TimeOut

RTO:英文全称是 Retransmission TimeOut,即重传超时时间;RTO 是一个动态值,会根据网络的改变而改变。RTO 是根据给定连接的往返时间 RTT 计算出来的。接收方返回的 ack 是希望收到的下一组包的序列号。

QUIC

在推 SPDY 的时候就已经意识到了这些问题,于是就另起炉灶搞了一个基于 UDP 协议的 QUIC 协议。而这个就是 HTTP3。它真正“完美”地解决了“队头阻塞”问题。

QUIC

主要特点

  1. 改进的拥塞控制、可靠传输

  2. 快速握手

  3. 集成了 TLS 1.3 加密

  4. 多路复用

  5. 连接迁移

改进的拥塞控制、可靠传输

从拥塞算法和可靠传输本身来看,QUIC 只是按照 TCP 协议重新实现了一遍,QUIC 协议做了如下改进:

1. 可插拔 — 应用程序层面就能实现不同的拥塞控制算法。

一个应用程序的不同连接也能支持配置不同的拥塞控制。应用程序不需要停机和升级就能实现拥塞控制的变更,可以针对不同业务,不同网络制式,甚至不同的 RTT,使用不同的拥塞控制算法。

2. 单调递增的 Packet Number — 使用 Packet Number 代替了 TCP 的 seq。

每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。而 TCP 重传策略存在二义性,比如客户端发送了一个请求,一个 RTO 后发起重传,而实际上服务器收到了第一次请求,并且响应已经在路上了,当客户端收到响应后,得出的 RTT 将会比真实 RTT 要小。当 Packet N 唯一之后,就可以计算出正确的 RTT。

3. 不允许 Reneging — 一个 Packet 只要被 Ack,就认为它一定被正确接收。

Reneging 的意思是,接收方有权把已经报给发送端 SACK(Selective Acknowledgment) 里的数据给丢了(如接收窗口不够而丢弃乱序的包)。

QUIC 中的 ACK 包含了与 TCP 中 SACK 等价的信息,但 QUIC 不允许任何(包括被确认接受的)数据包被丢弃。这样不仅可以简化发送端与接收端的实现难度,还可以减少发送端的内存压力。

4. 前向纠错(FEC)

操作系统中有一种存储方式叫 RAID 5,采用的是异或运算加上数据冗余的方式来保证前向纠错(FEC: Forward Error Correcting)。

我们知道异或运算的规则是,0 ^ 1 = 1、1 ^ 1 = 0,也就是相同数字异或成 1,不同数字异或成 0。对两个数字做异或运算,其实就是将他们转成二进制后按位做异或,因此对于任何数字 a,都有:

a ^ a = 0
a ^ 0 = a

同时很容易证明异或运算满足交换律和结合律,我们假设有下面这个等式:

A1 ^ A2 ^ A3 ^ ... ^ An = T

如果想让等式的左边只留下一个一个元素,只要在等号两边做 n-1 次异或就可以了:

(A1 ^ A1) ^ A2 ^ A3 ^ ... ^ An = T ^ A1
// 所以
A2 ^ A3 ^ ... ^ An = T ^ A1
// 所以
A3 ^ ... ^ An = T ^ A1 ^ A2
// 所以 ......
Ai = T ^ A1 ^ A2 ^ ... Ai-1 ^ Ai+1 ^ Ai+2 ^ ... ^ An

换句话说,A1 到 An 和 T 这总共 n+1 个元素中,不管是任何一个元素缺失,都可以从另外 n 个元素推导出来。如果把 A1、A2 一直到 An 想象成要发送的数据,T 想象成冗余数据,那么除了丢包重传,我们还可以采用冗余数据包的形式来保证数据准确性。

举个例子,假设有 5 个数据包要发送,我可以额外发送一个包(上面例子中的 T),它的值是前五个包的异或结果。这样不管是前五个包中丢失了任何一个,或者某个包数据有错(可以当成丢包来处理),都可以用另外四个包和这个冗余的包 T 进行异或运算,从而恢复出来。

当然要注意的是,这种方案仅仅在只发生一个错包或丢包时有效,如果丢失两个包就无能为力了(这也就是为什么只发一个冗余包就够的原因)。

FEC 中,QUIC 数据帧的数据混合原始数据和冗余数据,来确保无论到达接收端的 n 次传输内容是什么,接收端都能够恢复所有 n 个原始数据包。FEC 的实质就是异或。

QUIC FEC

5. 更多的 Ack 块和增加 Ack Delay 时间。

QUIC 可以同时提供 256 个 Ack Block,因此在重排序时,QUIC 相对于 TCP(使用 SACK)更有弹性,这也使得在重排序或丢失出现时,QUIC 可以在网络上保留更多的在途字节。在丢包率比较高的网络下,可以提升网络的恢复速度,减少重传量。

TCP 的 Timestamp 选项存在一个问题:发送方在发送报文时设置发送时间戳,接收方在确认该报文段时把时间戳字段值复制到确认报文时间戳,但是没有计算接收端接收到包到发送 Ack 的时间。这个时间可以简称为 Ack Delay,会导致 RTT 计算误差。现在就是把这个东西加进去计算 RTT 了。

6. 基于 stream 和 connection 级别的流量控制。

为什么需要两类流量控制呢?主要是因为 QUIC 支持多路复用。Stream 可以认为就是一条 HTTP 请求。Connection 可以类比一条 TCP 连接。多路复用意味着在一条 Connetion 上会同时存在多条 Stream。

QUIC 接收者会通告每个流中最多想要接收到的数据的绝对字节偏移。随着数据在特定流中的发送,接收和传送,接收者发送 WINDOW_UPDATE 帧,该帧增加该流的通告偏移量限制,允许对端在该流上发送更多的数据。

除了每个流的流控制外,QUIC 还实现连接级的流控制,以限制 QUIC 接收者愿意为连接分配的总缓冲区。连接的流控制工作方式与流的流控制一样,但传送的字节和最大的接收偏移是所有流的总和。

最重要的是,我们可以在内存不足或者上游处理性能出现问题时,通过流量控制来限制传输速率,保障服务可用性。

QUIC Stream

集成了 TLS 1.3 加密

TLS 1.3 支持 3 种基本密钥交换模式:

  1. (EC)DHE (基于有限域或椭圆曲线的 Diffie-Hellman)

  2. PSK - only

  3. PSK with (EC)DHE

TCP 快速打开

客户端可以在发送第一个 SYN 握手包时携带数据,但是 TCP 协议的实现者绝对不允许(原文: MUST NOT) 把这个数据包上传给应用层。这主要是为了防止 TCP 泛洪攻击。

TCP 泛洪攻击是指攻击者利用多台机器发送 SYN 请求从而耗尽服务器的 backlog 队列,backlog 队列维护的是那些接受了 SYN 请求但还没有正式开始会话的连接。这样做的好处是服务器不会过早的分配端口、建立连接。RFC 4987 详细的描述了各种防止 TCP 泛洪攻击的方法,包括尽早释放 SYN,增加队列长度等等。

如果 SYN 握手的包能被传输到应用层,那么现有的防护措施都无法防御泛洪攻击,而且服务端也会因为这些攻击而耗尽内存和 CPU。所以人们设计了 TFO (TCP Fast Open),这是对 TCP 的拓展,不仅可以在发送 SYN 时携带数据,还可以保证安全性。

TFO 设计了一个 cookie,它在第一次握手时由 server 生成,cookie 主要是用来标识客户端的身份,以及保存上次会话的配置信息。因此在后续重新建立 TCP 连接时,客户端会携带 SYN + Cookie + 请求数据,然后不等 ACK 返回就直接开始发送数据。

TCP Fast Open

服务端收到 SYN 后会验证 cookie 是否有效,如果无效则会退回到三次握手的步骤,如下图所示:

TCP Fast Open

同时,为了安全起见,服务端为每个端口记录了一个值 PendingFastOpenRequests,用来表示有多少请求利用了 TFO,如果超过预设上限就不再接受。

关于 TFO 的优化,可以总结出三点内容:

  1. TFO 设计的 cookie 思想和 SSL 恢复握手时的 Session Ticket 很像,都是由服务端生成一段 cookie 交给客户端保存,从而避免后续的握手,有利于快速恢复。

  2. 第一次请求绝对不会触发 TFO,因为服务器会在接收到 SYN 请求后把 cookie 和 ACK 一起返回。后续客户端如果要重新连接,才有可能使用这个 cookie 进行 TFO

  3. TFO 并不考虑在 TCP 层过滤重复请求,以前也有类似的提案想要做过滤,但因为无法保证安全性而被拒绝。所以 TFO 仅仅是避免了泛洪攻击(类似于 backlog),但客户端接收到的,和 SYN 包一起发来的数据,依然有可能重复。不过也只有可能是 SYN 数据重复,所以 TFO 并不处理这种情况,要求服务端程序自行解决。这也就是说,不仅仅要操作系统的支持,更要求应用程序(比如 MySQL) 也支持 TFO。

0-RTT

TFO 使得 TCP 协议有可能变成 0-RTT,核心思想和 Session Ticket 的概念类似: 将当前会话的上下文缓存在客户端。如果以后需要恢复对话,只需要将缓存发给服务器校验,而不必花费一个 RTT 去等待。

结合 TFO 和 Session Ticket 技术,一个本来需要花费 3 个 RTT 才能完成的请求可以被优化到一个 RTT。如果使用 QUIC 协议,我们甚至可以更进一步,将 Session Ticket 也放到 TFO 中一起发送,这样就实现了 0-RTT 的对话恢复。感兴趣的读者可以阅读: Facebook App对TLS的魔改造:实现0-RTT

TLS 1.3 0-RTT

但是 TLS1.3 也并不完美。TLS 1.3 的 0-RTT 无法保证前向安全性(Forward secrecy)。简单讲就是,如果当攻击者通过某种手段获取到了 Session Ticket Key,那么该攻击者可以解密以前的加密数据。

要缓解该问题可以通过设置使得与 Session Ticket Key 相关的 DH 静态参数在短时间内过期(一般几个小时)。

多路复用

QUIC 是为多路复用从头设计的,携带个别流的的数据的包丢失时,通常只影响该流。QUIC 连接上的多个 stream 之间并没有依赖,也不会有底层协议限制。

HTTP2 上的多路复用有什么缺陷?

连接迁移

TCP 是按照 4 要素(客户端 IP、端口, 服务器 IP、端口)确定一个连接的。而 QUIC 则是让客户端生成一个 Connection ID (64 位)来区别不同连接。只要 Connection ID 不变,连接就不需要重新建立,即便是客户端的网络发生变化。由于迁移客户端继续使用相同的会话密钥来加密和解密数据包,QUIC 还提供了迁移客户端的自动加密验证。

NAT 问题

为了解决 IP 地址不足的问题,NAT 给一个局域网络只分配一个 IP 地址,这个网络内的主机,则分配私有地址,这些私有地址对外是不可见的,他们对外的通信都要借助那个唯一分配的 IP 地址。所有离开本地网络去往 Internet 的数据报的源 IP 地址需替换为相同的 NAT,区别仅在于端口号不同。

TLS 1.3 0-RTT

对于基于 TCP 的 HTTP、HTTPS 传输,NAT 设备可以根据 TCP 报文头的 SYN/FIN 状态位,知道通信什么时候开始,什么时候结束,对应记忆 NAT 映射的开始和结束。

一个可行的方案是,让 QUIC 周期性地发送 Keepalive 消息,刷新 NAT 设备的记忆,避免 NAT 设备自动释放。

NAT 设备禁用 UDP,这时客户端会直接降级,选择 HTTPS 等备选通道,保证正常业务请求。

NGINX 负载均衡问题概念

QUIC 客户端存在网络制式切换,就算是同一个移动机房,可能第一次业务请求时会落到 A 这台服务器,后续再次连接,就会落到 B 实例上,重复走 1-RTT 的完整握手流程。

一个解决方案是:为所有 QUIC 服务器实例建立一个全局握手缓存。当用户网络发生切换时,下一次的业务请求无论是落到哪一个机房或哪一台实例上,握手建连都会是 0-RTT。但是,这样不确定会不会产生单点问题?

TLS 1.3 0-RTT