Web 性能之 TCP

1,392 阅读14分钟

TCP 简介

因特网有两个核心协议: IP 和 TCP。 IP,即 Internet Protocol(因特网协议),负责联网主机之间的路由选择和寻址; TCP,即 Transmission Control Protocol(传输控制协议),负责在不可靠的传输信道之上提供可靠的抽象层。 TCP/IP 也常被称为“因特网协议套件”。

我们都知道有 IPv4 和 IPv6,那 IPv1~3 和 IPv5 呢?IPv4 中的 4 表示 TCP/IP 协议的第 4个版本,发布于 1981 年 9 月。最初的 TCP/IP 建议中同时包含两个协议,但标准草案第 4 版将这两个协议分开,使之各自成为独立的 RFC。实际上, IPv4 中的 v4 只是表明了它与 TCP 前 3 个版本的承继关系,之前并没有单独的 IPv1、 IPv2 或 IPv3 协议。1994 年,当工作组着手制定 Internet Protocol next generation(IPng)需要一个新版本号时, v5 已经被分配给了另一个试验性协议 Internet Stream Protocol(ST)。但ST 一直没有什么进展,这也是我们为什么很少听说它的原因。结果 TCP/IP 的下一版本就成了 IPv6。

三次握手

所有 TCP 连接一开始都要经过三次握手,客户端与服务器在交换应用数据之前,必须就起始分组序列号,以及其他一些连接相关的细节达成一致。出于安全考虑,序列号由两端随机生成。

三次握手

三次握手的步骤:

  1. SYN。客户端选择一个随机序列号 x,并发送一个 SYN 分组,其中可能还包括其他 TCP 标志和选项。
  2. SYN ACK。服务器给 x 加 1,并选择自己的一个随机序列号 y,追加自己的标志和选项,然后返回响应。
  3. ACK。客户端给 x 和 y 加 1 并发送握手期间的最后一个 ACK 分组

三次握手带来的延迟使得每创建一个新 TCP 连接都要付出很大代价,而这也决定了提高 TCP 应用性能的关键,在于想办法重用连接

拥塞预防及控制

参考《TCP/IP 详解》,可知 TCP 保证可靠传输的机制有:

  1. 分割数据块。应用数据被分割成 TCP 认为最适合发送的数据块
  2. 自适应的超时及重传策略。TCP 在发送时会设置一个定时器,如果时间到了还没有收到确认,它就重传数据
  3. 停止等待。TCP 每发送完一个分组,就会停止发送,等待对方的确认,只有对方确认之后,才发送下一个分组
  4. 检验和。如果首部的检验和出错,那么 TCP 会重新丢弃该报文段,并要求发送端重新发送
  5. 重新排序。IP 数据报的到达顺序可能会乱,因此 TCP 需要对收到的数据进行重新排序
  6. 丢弃重复的数据。IP 数据包可能会重复,因此 TCP 的接收端需要对重复的数据进行丢弃处理
  7. 流量控制。
  8. 拥塞控制。

下面详细介绍流量控制及拥塞控制。

流量控制

流量控制是一种预防发送端过多向接收端发送数据的机制。否则,接收端可能因为忙碌、负载重或缓冲区既定而无法处理。为实现流量控制, TCP 连接的每一方都要通告自己的接收窗口(rwnd),其中包含能够保存数据的缓冲区空间大小信息。

接收窗口

如果其中一端跟不上数据传输,那它可以向发送端通告一个较小的窗口。假如窗口为零,则意味着必须由应用层先清空缓冲区,才能再接收剩余数据。这个过程贯穿于每个 TCP 连接的整个生命周期:每个 ACK 分组都会携带相应的最新 rwnd 值,以便两端动态调整数据流速,使之适应发送端和接收端的容量及处理能力。

最初的 TCP 规范分配给通告窗口大小的字段是 16 位的,这相当于设定了发送端和接收端窗口的最大值(2^16 即 65 535 字节)。结果,在这个限制内经常无法获得最优性能,特别是在那些“带宽延迟积”(下面会介绍)很高的网络中。为解决这个问题, RFC 1323 提供了 **TCP 窗口缩放(TCP Window Scaling) ** 选项,可以把接收窗口大小由 65 535 字节提高到 1G 字节!缩放 TCP 窗口是在三次握手期间完成的,其中有一个值表示在将来的 ACK 中左移 16 位窗口字段的位数。今天, TCP 窗口缩放机制在所有主要平台上都是默认启用的。不过,中间节点和路由器可以重写,甚至完全去掉这个选项。如果你的服务器或客户端的连接不能完全利用现有带宽,那往往该先查一查窗口大小。在 Linux 中,可以通过如下命令检查和启用窗口缩放选项:

$> sysctl net.ipv4.tcp_window_scaling
$> sysctl -w net.ipv4.tcp_window_scaling=1

慢启动

尽管流量控制确实可以防止发送端向接收端过多发送数据,但却没有机制预防任何一端向潜在网络过多发送数据。换句话说,发送端和接收端在连接建立之初,谁也不知道可用带宽是多少,因此需要一个估算机制,然后还要根据网络中不断变化的条件而动态改变速度。

解决这个问题的算法有:慢启动、拥塞预防、快速重传和快速恢复

慢启动算法的设计思路是这样的:服务器通过 TCP 连接初始化一个新的**拥塞窗口(cwnd)**变量,将其值设置为一个系统设定的保守值(在 Linux 中就是 initcwnd)。客户端与服务器之间最大可以传输(未经 ACK 确认的)数据量取 rwnd 和 cwnd 变量中的最小值。然后在分组被确认后增大窗口大小,慢慢地启动(下图前半部分)——慢启动中的“慢”指的不是窗口增长的速度慢,而是因为要增长到适合当前带宽的窗口大小,需要多次 TCP 通信往返,这个过程带来了较大的时间消耗。

拥塞控制和拥塞预防

为了说明这个过程,这里先介绍两个概念:

  1. MTU,Maximum Transmit Unit,最大传输单元,即物理接口(数据链路层、IP 层)提供给其上层最大一次传输数据的大小,默认为 1500 Byte
  2. MSS,Maximum Segment Size ,最大TCP分段大小,默认为 1500 - 20(TCP 首部)- 20(IP 首部) = 1460 字节

并假设有如下条件:

  1. 客户端和服务器的接收窗口为 65 535 字节(64 KB)
  2. 初始的拥塞窗口: 4 段(MSS)
  3. 往返时间是 56 ms(伦敦到纽约)

计算可知,要达到 64 KB 的限制,需要把拥塞窗口大小增加到 45 段,而这需要 224 ms:

也就是说,要达到客户端与服务器之间 64 KB 的吞吐量,需要 4 次往返,几百 ms 的延迟!至于客户端与服务器之间实际的连接速率是不是在 Mbit/s 级别,丝毫不影响这个结果。这就是慢启动。

慢启动导致客户端与服务器之间经过几百 ms 才能达到接近最大速度的问题,对于大型流式下载服务的影响倒不显著,因为慢启动的时间可以分摊到整个传输周期内消化掉。可是,对于很多 HTTP 连接,特别是一些短暂、突发的连接而言,常常会出现还没有达到最大窗口请求就被终止的情况。换句话说,很多 Web 应用的性能经常受到服务器与客户端之间往返时间的制约。因为慢启动限制了可用的吞吐量,而这对于小文件传输非常不利。

因此,把服务器的初始 cwnd 值增大到 RFC 6928 新规定的 10 段(IW10),是提升用户体验以及所有 TCP 应用性能的最简单方式。好消息是,很多操作系统已经更新了内核,采用了增大后的值。

另外,除了调节新连接的传输速度, TCP 还实现了 SSR(Slow-Start Restart,慢启动重启)机制。这种机制会在连接空闲一定时间后重置连接的拥塞窗口。道理很简单,在连接空闲的同时,网络状况也可能发生了变化,为了避免拥塞,理应将拥塞窗口重置回“安全的”默认值。毫无疑问, SSR 对于那些会出现突发空闲的长周期 TCP 连接(比如 HTTP 的 keep-alive 连接)有很大的影响。因此,建议在服务器上禁用 SSR。

拥塞预防

拥塞预防算法,其实就是上图(图2-3)的后半部分。

慢启动以保守的窗口初始化连接,随后的每次往返都会成倍提高传输的数据量,直到超过接收端的流量控制窗口,或者有分组丢失为止,此时拥塞预防算法介入。拥塞预防算法把丢包作为网络拥塞的标志,即路径中某个连接或路由器已经拥堵了,以至于必须采取删包措施。因此,必须调整窗口小,以避免造成更多的包丢失,从而保证网络畅通。重置拥塞窗口后,拥塞预防机制按照自己的算法来增大窗口以尽量避免丢包。某个时刻,可能又会有包丢失,于是这个过程再从头开始。

确定丢包恢复的最优方式并不容易。如果太激进,那么间歇性的丢包就会对整个连接的吞吐量造成很大影响。而如果不够快,那么还会继续造成更多分组丢失。

最初, TCP 使用 AIMD( Multiplicative Decrease and Additive Increase,倍减加增)算法,即发生丢包时,先将拥塞窗口减半,然后每次往返再缓慢地给窗口增加一个固定的值。不过,很多时候 AIMD 算法太过保守,因此又有了新的算法。

PRR( Proportional Rate Reduction,比例降速)就是 RFC 6937 规定的一个新算法,其目标就是改进丢包后的恢复速度。改进效果如何呢?根据谷歌的测量,实现新算法后,因丢包造成的平均连接延迟减少了 3%~10%。

快速重传和快速恢复

简单介绍一下这两个算法:如果一连串收到3个或3个以上的重复ACK,就非常可能是一个报文段丢失了。于是我们就重传丢失的数据报文段,而无需等待超时定时器溢出。这就是快速重传算法。接下来执行的不是慢启动算法而是拥塞避免算法。这就是快速恢复算法。

带宽延迟积

BDP(Bandwidth-delay product,带宽延迟积):数据链路的容量与其端到端延迟的乘积。

在 TCP 通信中,发送端或接收端无论谁被迫频繁地停止等待之前分组的 ACK,都会造成数据缺口,从而必然限制连接的最大吞吐量。为解决这个问题,应该让窗口足够大,以保证任何一端都能在 ACK 返回前持续发送数据。只有传输不中断,才能保证最大吞吐量。而最优窗口大小取决于往返时间!无论实际或通告的带宽是多大,窗口过小都会限制连接的吞吐量。

那么,流量控制窗口( rwnd)和拥塞控制窗口( cwnd)的值多大合适呢?实际上,计算过程很简单。首先,假设 cwnd 和 rwnd 的最小值为 16 KB,往返时间为 100 ms,那么:

因此,不管发送端和接收端的实际带宽多大,这个 TCP 连接的数据传输速率不会超过 1.31Mbit/s !想提高吞吐量,要么增大最小窗口值,要么减少往返时间。

类似地,知道往返时间和两端的实际带宽也可以计算最优窗口大小。这一次我们假设往返时间不变(还是 100 ms),发送端的可用带宽为 10 Mbit/s,接收端则为100 Mbit/s+。还假设两端之间没有网络拥塞,我们的目标就是充分利用客户端的 10Mbit/s 带宽:

因此,窗口至少需要 122.1 KB(这个值就是带宽延迟积) 才能充分利用 10 Mbit/s 带宽!

队首阻塞

每个 TCP 分组都会带着一个唯一的序列号被发出,而所有分组必须按顺序传送到接收端。如果中途有一个分组没能到达接收端,那么后续分组必须保存在接收端的 TCP 缓冲区,等待丢失的分组重发并到达接收端。这一切都发生在 TCP 层,应用程序对 TCP 重发和缓冲区中排队的分组一无所知,必须等待分组全部到达才能访问数据。在此之前,应用程序只能在通过套接字读数据时感觉到延迟交付。这种效应称为** TCP 的队首阻塞**。

队首阻塞造成的延迟可以让我们的应用程序不用关心分组重排和重组,从而让代码保持简洁。然而,代码简洁也要付出代价,那就是分组到达时间会存在无法预知的延迟变化。这个时间变化通常被称为抖动,也是影响应用程序性能的一个主要因素

有些应用程序可能并不需要可靠的交付或者不需要按顺序交付。比如,每个分组都是独立的消息,那么按顺序交付就没有任何必要。而且,如果每个消息都会覆盖之前的消息,那么可靠交付同样也没有必要了。无需按序交付数据或能够处理分组丢失的应用程序,以及对延迟或抖动要求很高的应用程序,最好选择 UDP 等协议

针对 TCP 的优化建议

由上文可知,TCP 的核心原理及其影响有:

  1. TCP 三次握手增加了整整一次往返时间
  2. TCP 慢启动将被应用到每个新连接
  3. TCP 流量及拥塞控制会影响所有连接的吞吐量
  4. TCP 的吞吐量由当前拥塞窗口大小控制

现代高速网络中 TCP 连接的数据传输速度,往往会受到接收端和发送端之间往返时间的限制。尽管带宽不断增长,但延迟依旧受限于光速,而且已经限定在了其最大值的一个很小的常数因子之内。因此,大多数情况下,TCP 的瓶颈都是延迟,而非带宽

服务器配置调优

TCP 的最佳实践以及影响其性能的底层算法一直在与时俱进,而且大多数变化都只在最新内核中才有实现。因此,让你的服务器跟上时代是优化发送端和接收端 TCP 栈的首要措施。此外,可以采取下列措施配置服务器:

  1. 增大TCP的初始拥塞窗口
  2. 禁用慢启动重启
  3. 启用窗口缩放
  4. TCP 快速打开(TFO)

应用程序行为调优

  1. 消除不必要的数据传输。比如,减少下载不必要的资源,或者通过压缩算法把要发送的比特数降到最低。
  2. 部署 CDN。通过在不同的地区部署服务器(比如,使用 CDN),把数据放到接近客户端的地方,可以减少网络往返的延迟,从而显著提升 TCP 性能。
  3. 尽可能重用已经建立的 TCP 连接,把慢启动和其他拥塞控制机制的影响降到最低

性能检查清单

  1. 把服务器内核升级到最新版本
  2. 增大TCP的初始拥塞窗口(cwnd.大小
  3. 禁用慢启动重启
  4. 启用窗口缩放
  5. 减少传输冗余数据
  6. 压缩要传输的数据
  7. 把服务器放到离用户近的地方以减少往返时间
  8. 尽最大可能重用已经建立的 TCP 连接

参考:《Web 性能权威指南》