0 参考资料

课程资料:https://github.com/russelltao/geektime_distrib_perf

2 系统层网络优化

2.3 优化TCP三次握手性能

2.3.1 客户端的优化

三次握手建立连接的首要目的是同步序列号。只有同步了序列号才有可靠的传输,TCP协议的许多特性都是依赖序列号实现的,比如流量控制、消息丢失后的重发等等,这也是三次握手中的报文被称为SYN的原因,因为SYN的全称就叫做Synchronize Sequence Numbers。

客户端发送SYN开启了三次握手,此时在客户端上用netstat命令(后续查看连接状态都使用该命令)可以看到连接的状态是SYN_SENT(顾名思义,就是刚把SYN发送出去)。

1
tcp    0   1 172.16.20.227:39198     129.28.56.36:81         SYN_SENT

如果客户端一直等不到服务端回复ACK报文,客户端会重发SYN,重试的次数由tcp_syn_retries参数控制,默认是6次。

1
net.ipv4.tcp_syn_retries = 6

每次充实间隔时间倍数增加,6次重试的总时间1+2+4+8+16+32+64=127秒,超过2分钟。
可以通过修改重试次数,尽快把错误暴露给应用程序。

2.3.2 服务器端的优化

当服务器收到SYN报文后,服务器会立刻回复SYN+ACK报文,既确认了客户端的序列号,也把自己的序列号发给了对方。此时,服务器端出现了新连接,状态是SYN_RCV(RCV是received的缩写)。这个状态下,服务器必须建立一个SYN半连接队列来维护未完成的握手信息,当这个队列溢出后,服务器将无法再建立新连接。

netstat -s命令可以查看由于队列已满而发生的失败次数。

1
2
# netstat -s | grep "SYNs to LISTEN"
1192450 SYNs to LISTEN sockets dropped

这里给出的是队列溢出导致SYN被丢弃的个数。注意这是一个累计值,如果数值在持续增加,则应该调大SYN半连接队列。修改队列大小的方法,是设置Linux的tcp_max_syn_backlog 参数

1
net.ipv4.tcp_max_syn_backlog = 1024

如果SYN半连接队列已满,只能丢弃连接吗?并不是这样,开启syncookies功能就可以在不使用SYN队列的情况下成功建立连接。syncookies是这么做的:服务器根据当前状态计算出一个值,放在己方发出的SYN+ACK报文中发出,当客户端返回ACK报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。

1
net.ipv4.tcp_syncookies = 1
  • 0时表示关闭该功能,
  • 2表示无条件开启功能
  • 1表示仅当SYN半连接队列放不下时,再启用它

syncookie仅用于应对SYN泛洪攻击(攻击者恶意构造大量的SYN报文发送给服务器,造成SYN半连接队列溢出,导致正常客户端的连接无法建立),这种方式建立的连接,许多TCP特性都无法使用。所以,应当把tcp_syncookies设置为1,仅在队列满时再启用。

第二次服务端会发送SYN+ACK回复客户端,然后客户端回复ACK到服务端,如果服务器没有收到ACK,就会一直重发SYN+ACK报文。当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。修改重发次数的方法是,调整tcp_synack_retries参数:

1
net.ipv4.tcp_synack_retries = 5

tcp_synack_retries 的默认重试次数是5次,与客户端重发SYN类似,它的重试会经历1、2、4、8、16秒,最后一次重试后等待32秒,若仍然没有收到ACK,才会关闭连接,故共需要等待63秒。
服务器收到ACK后连接建立成功,此时,内核会把连接从SYN半连接队列中移出,再移入accept队列,等待进程调用accept函数时把连接取出来。如果进程不能及时地调用accept函数,就会造成accept队列溢出,最终导致建立好的TCP连接被丢弃。
丢弃连接只是Linux的默认行为,我们还可以选择向客户端发送RST复位报文,告诉客户端连接已经建立失败。打开这一功能需要将tcp_abort_on_overflow参数设置为1。

1
net.ipv4.tcp_abort_on_overflow = 0

通常情况下,应当把tcp_abort_on_overflow设置为0,因为这样更有利于应对突发流量。

举个例子,当accept队列满导致服务器丢掉了ACK,与此同时,客户端的连接状态却是ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成accept队列满,那么当accept队列有空位时,再次接收到的请求报文由于含有ACK,仍然会触发服务器端成功建立连接。所以,tcp_abort_on_overflow设为0可以提高连接建立的成功率,只有你非常肯定accept队列会长期溢出时,才能设置为1以尽快通知客户端。

listen函数的backlog参数就可以设置accept队列的大小。但backlog参数还受限于Linux系统级的队列长度上限,当然这个上限阈值也可以通过somaxconn参数修改。

1
net.core.somaxconn = 128

如果listen中设置的backlog参数大于系统的somaxconn参数,依然会使用系统的somaxconn参数大小。

当下各监听端口上的accept队列长度可以通过ss -ltn命令查看,但accept队列长度是否需要调整该怎么判断呢?还是通过netstat -s命令给出的统计结果,可以看到究竟有多少个连接因为队列溢出而被丢弃。

1
2
# netstat -s | grep "listen queue"
14 times the listen queue of a socket overflowed

如果持续不断地有连接因为accept队列溢出被丢弃,就应该调大backlog以及somaxconn参数。

2.3.3 TFO技术绕过三次握手

TFO把通讯分为两个阶段,第一阶段为首次建立连接,这时走正常的三次握手,但在客户端的SYN报文会明确地告诉服务器它想使用TFO功能,这样服务器会把客户端IP地址用只有自己知道的密钥加密(比如AES加密算法),作为Cookie携带在返回的SYN+ACK报文中,客户端收到后会将Cookie缓存在本地。
之后,如果客户端再次向服务器建立连接,就可以在第一个SYN报文中携带请求数据,同时还要附带缓存的Cookie。很显然,这种通讯方式下不能再采用经典的“先connect再write请求”这种编程方法,而要改用sendto或者sendmsg函数才能实现。
服务器收到后,会用自己的密钥验证Cookie是否合法,验证通过后连接才算建立成功,再把请求交给进程处理,同时给客户端返回SYN+ACK。虽然客户端收到后还会返回ACK,但服务器不等收到ACK就可以发送HTTP响应了,这就减少了握手带来的1个RTT的时间消耗。


为了防止SYN泛洪攻击,服务器的TFO实现必须能够自动化地定时更新密钥。

TFO技术通过tcp_fastopen参数控制,只有客户端和服务器同时支持时,TFO功能才能使用,所以tcp_fastopen参数是按比特位控制的。其中,第1个比特位为1时,表示作为客户端时支持TFO;第2个比特位为1时,表示作为服务器时支持TFO,所以当tcp_fastopen的值为3时(比特为0x11)就表示完全支持TFO功能。

1
net.ipv4.tcp_fastopen = 3

2.3.4 总结

  1. tcp_syn_retries 控制请求发起syn包重试次数
  2. tcp_syncookies 设置为1,请求队列满时,使用syncookies功能
  3. tcp_max_syn_backlog 控制半连接队列长度
  4. tcp_abort_on_overflow 全连接满,丢弃,并且发送RST通知客户端,一般不设置。
  5. net.core.somaxconn 系统参数控制全连接队列长度
  6. tcp_fastopen 开启TFO技术

2.4 优化TCP四次挥手的性能

因为TCP是双向通道相互独立,所以需要四次挥手分别关闭双向通道。
我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。互联网中往往服务器才是主动关闭连接的一方。
这是因为,HTTP消息是单向传输协议,服务器接收完请求才能生成响应,发送完响应后就会立刻关闭TCP连接,这样及时释放了资源,能够为更多的用户服务。


四次挥手只涉及两种报文:FIN和ACK,FIN就是Finish结束连接的意思,谁发出FIN报文,就表示它将不再发送任何数据,关闭这一方向的传输通道。ACK是Acknowledge确认的意思,它用来通知对方:你方的发送通道已经关闭。

  1. 主动方关闭连接时,会发送FIN报文,此时主动方的连接状态由ESTABLISHED变为FIN_WAIT1。当被动方收到FIN报文后,内核自动回复ACK报文,连接状态由ESTABLISHED变为CLOSE_WAIT,顾名思义,它在等待进程调用close函数关闭连接。当主动方接收到这个ACK报文后,连接状态由FIN_WAIT1变为FIN_WAIT2,主动方的发送通道就关闭了。
  2. 被动方的发送通道是如何关闭的。当被动方进入CLOSE_WAIT状态时,进程的read函数会返回0,这样开发人员就会有针对性地调用close函数,进而触发内核发送FIN报文,此时被动方连接的状态变为LAST_ACK。当主动方收到这个FIN报文时,内核会自动回复ACK,同时连接的状态由FIN_WAIT2变为TIME_WAIT,Linux系统下大约1分钟后TIME_WAIT状态的连接才会彻底关闭。而被动方收到ACK报文后,连接就会关闭。

2.4.1 主动方优化

异常退出,内核可以直接发送RST报文来关闭。它可以不走四次挥手强制关闭连接,但当报文延迟或者重复传输时,这种方式会导致数据错乱,所以这是不得已而为之的关闭连接方案。
安全关闭连接的方式必须通过四次挥手,它由进程调用close或者shutdown函数发起,这二者都会向对方发送FIN报文(shutdown参数须传入SHUT_WR或者SHUT_RDWR才会发送FIN),区别在于close调用后,哪怕对方在半关闭状态下发送的数据到达主动方,进程也无法接收。
此时,这个连接叫做孤儿连接,如果你用netstat -p命令,会发现连接对应的进程名为空。而shutdown函数调用后,即使连接进入了FIN_WAIT1或者FIN_WAIT2状态,它也不是孤儿连接,进程仍然可以继续接收数据。
主动方发送FIN报文后,连接就处于FIN_WAIT1状态下,该状态通常应在数十毫秒内转为FIN_WAIT2。如果未收到ACK,则会重试,其中重发次数由tcp_orphan_retries参数控制,默认值是0,特指8次:

1
net.ipv4.tcp_orphan_retries = 0

如果FIN_WAIT1状态连接有很多,你就需要考虑降低tcp_orphan_retries的值。当重试次数达到tcp_orphan_retries时,连接就会直接关闭掉。

对于正常情况来说,调低tcp_orphan_retries已经够用,但如果遇到恶意攻击,FIN报文根本无法发送出去。这是由TCP的2个特性导致的。

  • 首先,TCP必须保证报文是有序发送的,FIN报文也不例外,当发送缓冲区还有数据没发送时,FIN报文也不能提前发送。
  • 其次,TCP有流控功能,当接收方将接收窗口设为0时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过将接收窗口设为0,导致FIN报文无法发送,进而导致连接一直处于FIN_WAIT1状态。

通过tcp_max_orphans 控制孤儿连接的最大数量,当进程调用close函数关闭连接后,无论该连接是在FIN_WAIT1状态,还是确实关闭了,这个连接都与该进程无关了,它变成了孤儿连接。Linux系统为防止孤儿连接过多,导致系统资源长期被占用,就提供了tcp_max_orphans参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送RST复位报文强制关闭。

1
net.ipv4.tcp_max_orphans = 16384

当连接收到ACK进入FIN_WAIT2状态后,就表示主动方的发送通道已经关闭,接下来将等待对方发送FIN报文,关闭对方的发送通道。这时,如果连接是用shutdown函数关闭的,连接可以一直处于FIN_WAIT2状态。但对于close函数关闭的孤儿连接,这个状态不可以持续太久,而tcp_fin_timeout控制了这个状态下连接的持续时长。

1
net.ipv4.tcp_fin_timeout = 60

它的默认值是60秒。这意味着对于孤儿连接,如果60秒后还没有收到FIN报文,连接就会直接关闭。这个60秒并不是拍脑袋决定的,它与接下来介绍的TIME_WAIT状态的持续时间是相同的,我们稍后再来回答60秒的由来。

如果主动方不保留TIME_WAIT状态,会发生什么呢?此时连接的端口恢复了自由身,可以复用于新连接了。然而,被动方的FIN报文可能再次到达,这既可能是网络中的路由器重复发送,也有可能是被动方没收到ACK时基于tcp_orphan_retries参数重发。这样,正常通讯的新连接就可能被重复发送的FIN报文误关闭。保留TIME_WAIT状态,就可以应付重发的FIN报文,当然,其他数据报文也有可能重发,所以TIME_WAIT状态还能避免数据错乱。

TIME_WAIT是主动方四次挥手的最后一个状态。TIME_WAIT和FIN_WAIT2状态的最大时长都是2 MSL,由于在Linux系统中,MSL的值固定为30秒,所以它们都是60秒。
Linux提供了tcp_max_tw_buckets 参数,当TIME_WAIT的连接数量超过该参数时,新关闭的连接就不再经历TIME_WAIT而直接关闭。

1
net.ipv4.tcp_max_tw_buckets = 5000

当服务器的并发连接增多时,相应地,同时处于TIME_WAIT状态的连接数量也会变多,此时就应当调大tcp_max_tw_buckets参数,减少不同连接间数据错乱的概率。
也可以设置tcp_tw_reuse参数设为1,允许作为客户端建立新连接时使用TIME_WAIT下的端口。

1
net.ipv4.tcp_tw_reuse = 1

要想使tcp_tw_reuse生效,还得把timestamps参数设置为1,它满足安全复用的先决条件(对方也要打开tcp_timestamps ):

1
net.ipv4.tcp_timestamps = 1

2.4.2 被动方优化

大多数应用程序并不使用shutdown函数关闭连接,当你用netstat命令发现大量CLOSE_WAIT状态时,要么是程序出现了Bug,read函数返回0时忘记调用close函数关闭连接,要么就是程序负载太高,close函数所在的回调函数被延迟执行了。此时,我们应当在应用代码层面解决问题。
调用close函数后,内核会发送FIN关闭发送通道,同时连接进入LAST_ACK状态,等待主动方返回ACK来确认连接关闭。
如果迟迟等不到ACK,内核就会重发FIN报文,重发次数仍然由tcp_orphan_retries参数控制,这与主动方重发FIN报文的优化策略一致。
两方发送FIN报文时,都认为自己是主动方,所以都进入了FIN_WAIT1状态,FIN报文的重发次数仍由tcp_orphan_retries参数控制。

双方在等待ACK报文的过程中,都等来了FIN报文。这是一种新情况,所以连接会进入一种叫做CLOSING的新状态,它替代了FIN_WAIT2状态。此时,内核回复ACK确认对方发送通道的关闭,仅己方的FIN报文对应的ACK还没有收到。所以,CLOSING状态与LAST_ACK状态下的连接很相似,它会在适时重发FIN报文的情况下最终关闭。

2.4.3 从客户端服务端角度分析

  • 如果客户端是主动方

如果客户端存在过多的TIME_WAIT状态,可能导致端口耗尽(每次新建一个连接就需要一个随机端口,端口就65536个),导致无法与服务端新建连接。

  • 如果服务器是主动方

理论上服务端可以建立很多连接,因为只需要监听一个对外服务的端口,类似Nginx,通过epoll多路复用,把建立好的连接交给woker进程处理。但是如果服务器是主动断开连接,导致TIME_WAIT太多,会造成系统资源被占满,导致处理不过来新的连接。

  • 优化的方式
  1. 短连接改为使用长连接,然后配置keepalive_timeout、keepalive_requests指令,对长连接做限制。如果Nginx作为反向代理,可以在upstream中设置keepalive NUMS,指定保持长连接的数量。
  2. 内核参数:
    • 作为服务端设置tcp_max_tw_buckets 参数,当TIME_WAIT的连接数量超过该参数,新关闭的连接就不再经历TIME_WAIT,而直接关闭。
      1
      2
      [root@kvm-10-115-88-47 ~]# cat /proc/sys/net/ipv4/tcp_max_tw_buckets
      32768
  • 作为客户端:设置tcp_tw_reuse参数,允许将TIME_WAIT sockets重新用于新的连接,复用连接(主动发起连接的一方)
    1
    2
    net.ipv4.tcp_timestamps = 1
    net.ipv4.tcp_tw_reuse = 1

2.4.4 总结

  1. tcp_orphan_retries 定义FIN报文重发次数
  2. tcp_fin_timeout 主动方如果tcp_fin_timeout秒内孤儿连接未收到对方FIN包,则直接关闭
  3. tcp_max_orphans 定义了最大孤儿连接的数量,超过时连接就会直接释放。
  4. tcp_max_tw_buckets 定义了TIME_WAIT最大数量
  5. tcp_tw_reuse和tcp_timestamps为1 讲TIME_WAIT状态的端口复用于作为客户端新连接
  6. setsockopt 设置TCP的选项SO_LINGER

    SO_LINGER选项用来设置延迟关闭的时间,等待套接字发送缓冲区中的数据发送完成。
    以调用close()主动关闭为例,在发送完FIN包后,会进入FIN_WAIT_1状态。如果没有延迟关闭(即设置SO_LINGER选项),在调用tcp_send_fin()发送FIN后会立即调用sock_orphan()将sock结构从进程上下文中分离。分离后,用户层进程不会再接收到套接字的读写事件,也不知道套接字发送缓冲区中的数据是否被对端接收。如果设置了SO_LINGER选项,并且等待时间为大于0的值,会等待套接字的状态从FIN_WAIT_1迁移到FIN_WAIT_2状态

2.5 修改TCP缓冲区兼顾并发数量与传输速度

在Linux系统中用free命令查看内存占用情况,会发现一栏叫做buff/cache,它是系统内存,似乎与应用进程无关。但每当进程新建一个TCP连接,buff/cache中的内存都会上升4K左右。而且,当连接传输数据时,就远不止增加4K内存了。这样,几十万并发连接,就在进程内存外又增加了GB级别的系统内存消耗。

这是因为TCP连接是由内核维护的,内核为每个连接建立的内存缓冲区,既要为网络传输服务,也要充当进程与网络间的缓冲桥梁。如果连接的内存配置过小,就无法充分使用网络带宽,TCP传输速度就会很慢;如果连接的内存配置过大,那么服务器内存会很快用尽,新连接就无法建立成功。因此,只有深入理解Linux下TCP内存的用途,才能正确地配置内存大小。

2.5.1 滑动窗口对传输速度的影响

TCP必须保证每一个报文都能够到达对方,它采用的机制就是:报文发出后,必须收到接收方返回的ACK确认报文(Acknowledge确认的意思)。如果在一段时间内(称为RTO,retransmission timeout)没有收到,这个报文还得重新发送,直到收到ACK为止。

可见,TCP报文发出去后,并不能立刻从内存中删除,因为重发时还需要用到它。由于TCP是由内核实现的,所以报文存放在内核缓冲区中,这也是高并发下buff/cache内存增加很多的原因。
数据传输的过程如下:

为了限制发送方批量发送数据的速度,引入了滑动窗口,依赖于接收方的处理能力,实时的调整滑动窗口的大小。

窗口字段只有2个字节,因此它最多能表达2^16 即65535字节大小,在当今的高速网络中显然不够用,可以设置tcp_window_scaling配置设为1,此时窗口的最大值可以达到1GB(2^30)。

1
net.ipv4.tcp_window_scaling = 1

只要进程能及时地调用read函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。很显然,这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。

2.5.2 带宽时延积对最大传输速度的限制

带宽是单位时间内的流量 ,它表达的是速度,比如你家里的宽带100MB/s,而窗口和缓冲区的单位是字节。当网络速度乘以时间才能得到字节数,差的这个时间,这就是网络时延。
当最大带宽是100MB/s、网络时延是10ms时,这意味着客户端到服务器间的网络一共可以存放100MB/s * 0.01s = 1MB的字节。这个1MB是带宽与时延的乘积,所以它就叫做带宽时延积(缩写为BDP,Bandwidth Delay Product)。这1MB字节存在于飞行中的TCP报文,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了1MB,就一定会让网络过载,最终导致丢包。
由于发送缓冲区决定了发送窗口的上限,而发送窗口又决定了已发送但未确认的飞行报文的上限,因此,发送缓冲区不能超过带宽时延积,因为超出的部分没有办法用于有效的网络传输,且飞行字节大于带宽时延积还会导致丢包;而且,缓冲区也不能小于带宽时延积,否则无法发挥出高速网络的价值。

2.5.3 调整缓冲功能区适配滑动窗口

Linux的缓冲区支持动态调节功能,先来看发送缓冲区,它的范围通过tcp_wmem配置:

1
net.ipv4.tcp_wmem = 4096        16384   4194304

第1个数值是动态范围的下限,第3个数值是动态范围的上限。而中间第2个数值,则是初始默认值。
发送缓冲区完全根据需求自行调整。比如,一旦发送出的数据被确认,而且没有新的数据要发送,就可以把发送缓冲区的内存释放掉。而接收缓冲区的调整就要复杂一些,先来看设置接收缓冲区范围的tcp_rmem:

1
net.ipv4.tcp_rmem = 4096        87380   6291456

它的数值与tcp_wmem类似,第1、3个值是范围的下限和上限,第2个值是初始默认值。

接收缓存区依据空闲系统内存的数量来调节接收窗口。如果系统的空闲内存很多,就可以把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而对方的发送速度就会通过增加飞行报文来提升。反之,内存紧张时就会缩小缓冲区,这虽然会减慢速度,但可以保证更多的并发连接正常工作。
发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置tcp_moderate_rcvbuf为1来开启调节功能:

1
net.ipv4.tcp_moderate_rcvbuf = 1

接收缓冲区调节时,怎么判断空闲内存的多少呢?这是通过tcp_mem配置完成的:

1
net.ipv4.tcp_mem = 88560        118080  177120

tcp_mem的3个值,是Linux判断系统内存是否紧张的依据。当TCP内存小于第1个值时,不需要进行自动调节;在第1和第2个值之间时,内核开始调节接收缓冲区的大小;大于第3个值时,内核不再为TCP分配新内存,此时新连接是无法建立的。

我们应当保证缓冲区的动态调整上限达到带宽时延积,而下限保持默认的4K不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。
同时,如果这是网络IO型服务器,那么,调大tcp_mem的上限可以让TCP连接使用更多的系统内存,这有利于提升并发能力。需要注意的是,tcp_wmem和tcp_rmem的单位是字节,而tcp_mem的单位的页面。而且,千万不要在socket上直接设置SO_SNDBUF或者SO_RCVBUF,这样会关闭缓冲区的动态调整功能。

2.5.4 总结

  1. tcp_window_scaling 设为1,提升滑动窗口的上限
  2. tcp_moderate_rcvbuf 设为1,设置自动调整接收缓冲区大小,调节的依据根据tcp_mem而定

2.6 调整TCP拥塞控制的性能

2.6.1 调整TCP拥塞窗口

在tcp传输数据时,考虑到网络拥塞,发送窗口应当是拥塞窗口与对方接收窗口的最小值。

1
swnd = min(cwnd, rwnd)

这样,发送速度就综合考虑了接收方和网络的处理能力。
可以根据网络状况和传输对象的大小,调整初始拥塞窗口的大小。调整前,先要清楚你的服务器现在的初始拥塞窗口是多大。你可以通过ss命令查看当前拥塞窗口:

1
2
ss -nli|fgrep cwnd
cubic rto:1000 mss:536 cwnd:10 segs_in:10621866 lastsnd:1716864402 lastrcv:1716864402 lastack:1716864402

再通过ip route change命令修改初始拥塞窗口:

1
2
3
# ip route | while read r; do
ip route change $r initcwnd 10;
done

更大的初始拥塞窗口以及指数级的提速,连接很快就会遭遇网络拥塞,从而导致慢启动阶段的结束。

2.6.2 出现网络拥塞怎么办?

以下3种场景都会导致慢启动阶段结束:

  • 通过定时器明确探测到了丢包;
  • 拥塞窗口的增长到达了慢启动阈值ssthresh(全称为slow start threshold),也就是之前发现网络拥塞时的窗口大小;
  • 接收到重复的ACK报文,可能存在丢包。

第一种场景:在规定时间内没有收到ACK报文,这说明报文丢失了,网络出现了严重的拥塞,必须先降低发送速度,再进入拥塞避免阶段。不同的拥塞控制算法降低速度的幅度并不相同,比如CUBIC算法会把拥塞窗口降为原先的0.8倍(也就是发送速度降到0.8倍)。此时,我们知道了多大的窗口会导致拥塞,因此可以把慢启动阈值设为发生拥塞前的窗口大小。

第二种场景:虽然还没有发生丢包,但发送方已经达到了曾经发生网络拥塞的速度(拥塞窗口达到了慢启动阈值),接下来发生拥塞的概率很高,所以进入拥塞避免阶段,此时拥塞窗口不能再以指数方式增长,而是要以线性方式增长。

RFC5681 建议最初的慢启动阈值尽可能的大,这样才能在第1、3种场景里快速发现网络瓶颈。

第三种场景:TCP传输的是字节流,而“流”是天然有序的。因此,当接收方收到不连续的报文时,就可能发生报文丢失或者延迟,等待发送方超时重发太花时间了,为了缩短重发时间,快速重传算法便应运而生。当连续收到3个重复ACK时,发送方便得到了网络发生拥塞的明确信号,通过重复ACK报文的序号,我们知道丢失了哪个报文,这样,不等待定时器的触发,立刻重发丢失的报文,可以让发送速度下降得慢一些,这就是快速重传算法。

  • 快速恢复

出现拥塞后,发送方会缩小拥塞窗口,再进入前面提到的拥塞避免阶段,用线性速度慢慢增加拥塞窗口。然而,为了平滑地降低速度,发送方应当先进入快速恢复阶段,在失序报文到达接收方后,再进入拥塞避免阶段。

第6个报文在慢启动阶段丢失,接收方收到失序的第7个报文会触发快速重传算法,它必须立刻返回ACK6。而发送方接收到第1个重复ACK6报文时,就从慢启动进入了快速重传阶段,此刻的重复ACK不会扩大拥塞窗口。当连续收到3个ACK6时,发送方会重发报文6,并把慢启动阈值和拥塞窗口都降到之前的一半:3个MSS,再进入快速恢复阶段。按照规则,由于收到3个重复ACK,所以拥塞窗口会增加3个MSS。之后收到的2个ACK,让拥塞窗口增加到了8个MSS,直到收到期待的ACK12,发送方才会进入拥塞避免阶段。

上边出现重复ACK6三次,就会快速重传,并且拥塞窗口降到一半,但是为了平滑降速,发送方进入快速恢复。

慢启动、拥塞避免、快速重传、快速恢复,共同构成了拥塞控制算法。Linux上提供了更改拥塞控制算法的配置,你可以通过tcp_available_congestion_control配置查看内核支持的算法列表:

1
net.ipv4.tcp_available_congestion_control = cubic reno

再通过tcp_congestion_control配置选择一个具体的拥塞控制算法:

1
net.ipv4.tcp_congestion_control = cubic

2.6.3 基于测量的拥塞控制算法


出现丢包其实已经算是出现了严重的网络拥塞,进行拥塞控制的最佳时间点,是缓冲队列刚出现积压的时刻,此时,网络时延会增高,但带宽维持不变,这两个数值的变化可以给出明确的拥塞信号。
这种以测量带宽、时延来确定拥塞的方法,在丢包率较高的网络中应用效果尤其好。2016年Google推出的BBR算法(全称Bottleneck Bandwidth and Round-trip propagation time),就是测量驱动的拥塞控制算法,它在YouTube站点上应用后使得网络时延下降了20%以上,传输带宽也有5%左右的提升。
Linux 4.9版本之后都支持BBR算法,开启BBR算法仍然使用tcp_congestion_control配置:

1
net.ipv4.tcp_congestion_control=bbr

2.7 实现管理百万主机的心跳服务

13-实战:单机如何实现管理百万主机的心跳服务?

3 应用层编解码优化

3.1 TLS/SSL性能优化

优化TLS/SSL性能主要从两个方向下手:

  1. 对称加密算法性能优化
  2. 协商密钥过程优化

3.1.1 提升对称加密算法的性能

目前主流的对称加密算法叫做AES(Advanced Encryption Standard),它在性能和安全上表现都很优秀。而且,它不只在访问网站时最为常用,甚至你日常使用的WINRAR等压缩软件也在使用AES算法(见官方FAQ)。因此,AES是我们的首选对称加密算法,下面来看看AES算法该如何优化。

AES只支持3种不同的密钥长度,分别是128位、192位和256位,它们的安全性依次升高,运算时间也更长。比如,当密钥为128比特位时,需要经过十轮操作,其中每轮要用移位法、替换法、异或操作等对明文做4次变换。而当密钥是192位时,则要经过12轮操作,密钥为256比特位时,则要经过14轮操作,如下图所示。

主流对称算法会将原始明文分成等长的多组明文,再分别用密钥生成密文,最后把它们拼接在一起形成最终密文。而AES算法是按照128比特(16字节)对明文进行分组的(最后一组不足128位时会填充0或者随机数)。为了防止分组后密文出现明显的规律,造成攻击者容易根据概率破解出原文,我们就需要对每组的密钥做一些变换,这种分组后变换密钥的算法就叫做分组密码工作模式(下文简称为分组模式)
CBC分组模式中,只有第1组明文加密完成后,才能对第2组加密,因为第2组加密时会用到第1组生成的密文。因此,CBC必然无法并行计算。因此CBC分组模式无法使用多核并行计算能力,性能受到影响。通常我们应选择可以并行计算的GCM分组模式,这也是当下互联网最常见的AES分组算法。

由于AES算法中的替换法、行移位等流程对CPU指令并不友好,所以Intel在2008年推出了支持AES-NI指令集的CPU,能够将AES算法的执行速度从每字节消耗28个时钟周期(参见这里),降低至3.5个时钟周期(参见这里)。在Linux上你可以用下面这行命令查看CPU是否支持AES-NI指令集:

1
2
# sort -u /proc/crypto | grep module |grep aes
module : aesni_intel

如果CPU支持AES-NI特性,那么应选择AES算法,否则可以选择CHACHA20 对称加密算法,它主要使用ARX操作(add-rotate-xor),CPU执行起来更快。

3.1.2 更快地协商密钥

早期使用最多的是RSA密钥协商算法,但是RSA密钥协商算法不支持前向保密(Forward Secrecy)一旦服务器的私钥泄露,过去被攻击者截获的所有TLS通讯密文都会被破解。解决前向保密的是DH(Diffie–Hellman)密钥协商算法。

DH算法的工作流程:
通讯双方各自独立生成随机的数字作为私钥,而后依据公开的算法计算出各自的公钥,并通过未加密的TLS握手发给对方。接着,根据对方的公钥和自己的私钥,双方各自独立运算后能够获得相同的数字,这就可以作为后续对称加密时使用的密钥。即使攻击者截获到明文传递的公钥,查询到公开的DH计算公式后,在不知道私钥的情况下,也是无法计算出密钥的。这样,DH算法就可以在握手阶段生成随机的新密钥,实现前向保密。

DH算法的计算速度很慢,如上图所示,计算公钥以及最终的密钥时,需要做大量的乘法运算,而且为了保障安全性,这些数字的位数都很长。为了提升DH密钥交换算法的性能,诞生了当下广为使用的ECDH密钥交换算法,ECDH在DH算法的基础上利用ECC椭圆曲线特性,可以用更少的计算量计算出公钥以及最终的密钥。
在Nginx上,你可以使用ssl_ecdh_curve指令配置想使用的曲线:

1
ssl_ecdh_curve X25519:secp384r1;

选择密钥协商算法是通过ssl_ciphers指令完成的:

1
ssl_ciphers 'EECDH+ECDSA+AES128+SHA:RSA+AES128+SHA';

当ssl_prefer_server_ciphers设置为on时,ssl_ciphers指定的多个算法是有优先顺序的,我们应当把性能最快且最安全的算法放在最前面。
提升密钥协商速度的另一个思路,是减少密钥协商的次数,主要包括以下3种方式。

  1. 请求头中加入Connection: keep-alive头部,使用长连接
  2. 使用session
  3. 使用session ticket

3.1.3 使用TLS1.3

TLS1.3中把Hello消息和公钥交换合并为一步,这就减少了一半的握手时间,如下图所示:

那TLS1.3握手为什么只需要1个RTT就可以完成呢?因为TLS1.3支持的密钥协商算法大幅度减少了,这样,客户端尽可以把常用DH算法的公钥计算出来,并与协商加密算法的HELLO消息一起发送给服务器,服务器也作同样处理,这样仅用1个RTT就可以协商出密钥。

而且,TLS1.3仅支持目前最安全的几个算法,比如openssl中仅支持下面5种安全套件:

  • TLS_AES_256_GCM_SHA384
  • TLS_CHACHA20_POLY1305_SHA256
  • TLS_AES_128_GCM_SHA256
  • TLS_AES_128_CCM_8_SHA256
  • TLS_AES_128_CCM_SHA256

3.2 优化HTTP/1

HTTP/1.1协议的优化策略:

  1. 客户端缓存响应,可以在有效期内避免发起HTTP请求。即使缓存过期后,如果服务器端资源未改变,仍然可以通过304响应避免发送包体资源。浏览器上的私有缓存、服务器上的共享缓存,都对HTTP协议的性能提升有很大意义。
  2. 降低请求的数量,如将原本由客户端处理的重定向请求,移至代理服务器处理可以减少重定向请求的数量。或者从体验角度,使用懒加载技术延迟加载部分资源,也可以减少请求数量。再比如,将多个文件合并后再传输,能够少传许多HTTP头部,而且减少TCP连接数量后也省去握手和慢启动的消耗。当然合并文件的副作用是小文件的更新,会导致整个合并后的大文件重传。
  3. 通过压缩响应来降低传输的字节数,选择更优秀的压缩算法能够有效降低传输量,比如用Brotli无损压缩算法替换gzip,或者用WebP格式替换png等格式图片等。

3.3 HTTP2是怎样提升性能的

3.3.1 静态表编码节约带宽

HTTP/2将61个高频出现的头部,比如描述浏览器的User-Agent、GET或POST方法、返回的200 SUCCESS响应等,分别对应1个数字再构造出1张表,并写入HTTP/2客户端与服务器的代码中。由于它不会变化,所以也称为静态表。

3.3.2 动态表编码节约带宽

虽然静态表已经将24字节的Host头部压缩到13字节,但动态表可以将它压缩到仅1字节,这就能节省96%的带宽!那动态表是怎么做到的呢?
如果HTTP/2能在一个连接上传输所有对象,那么只要客户端与服务器按照同样的规则,对首次出现的HTTP头部用一个数字标识,随后再传输它时只传递数字即可,这就可以实现几十倍的压缩率。所有被缓存的头部及其标识数字会构成一张表,它与已经传输过的请求有关,是动态变化的,因此被称为动态表。
静态表有61项,所以动态表的索引会从62起步。比如下图中的报文中,访问test.taohui.tech的第1个请求有13个头部需要加入动态表。其中,Host: test.taohui.tech被分配到的动态表索引是74(索引号是倒着分配的)。


这样,后续请求使用到Host头部时,只需传输1个字节11001010即可。其中,首位1表示它在动态表中,而后7位1001010值为64+8+2=74,指向服务器缓存的动态表第74项:

静态表、Huffman编码、动态表共同完成了HTTP/2头部的编码,其中,前两者可以将体积压缩近一半,而后者可以将反复传输的头部压缩95%以上的体积!

是否要让一条连接传输尽量多的请求呢?并不是这样。动态表会占用很多内存,影响进程的并发能力,所以服务器都会提供类似http2_max_requests这样的配置,限制一个连接上能够传输的请求数量,通过关闭HTTP/2连接来释放内存。因此,http2_max_requests并不是越大越好,通常我们应当根据用户浏览页面时访问的对象数量来设定这个值。

3.3.3 并发传输请求

HTTP/1.1中的KeepAlive长连接虽然可以传输很多请求,但它的吞吐量很低,因为在发出请求等待响应的那段时间里,这个长连接不能做任何事!而HTTP/2通过Stream这一设计,允许请求并发传输。
HTTP/2中有Stream、Message、Frame这3个概念。
HTTP请求和响应都被称为Message消息,它由HTTP头部和包体构成,承载这二者的叫做Frame帧,它是HTTP/2中的最小实体。Frame的长度是受限的,比如Nginx中默认限制为8K(http2_chunk_size配置),因此我们可以得出2个结论:HTTP消息可以由多个Frame构成,以及1个Frame可以由多个TCP报文构成(TCP MSS通常小于1.5K)。
再来看Stream流,它与HTTP/1.1中的TCP连接非常相似,当Stream作为短连接时,传输完一个请求和响应后就会关闭;当它作为长连接存在时,多个请求之间必须串行传输。在HTTP/2连接上,理论上可以同时运行无数个Stream,这就是HTTP/2的多路复用能力,它通过Stream实现了请求的并发传输。

虽然RFC规范并没有限制并发Stream的数量,但服务器通常都会作出限制,比如Nginx就默认限制并发Stream为128个(http2_max_concurrent_streams配置),以防止并发Stream消耗过多的内存,影响了服务器处理其他连接的能力。

HTTP/2的并发性能比HTTP/1.1通过TCP连接实现并发要高。这是因为,当HTTP/2实现100个并发Stream时,只经历1次TCP握手、1次TCP慢启动以及1次TLS握手,但100个TCP连接会把上述3个过程都放大100倍!

HTTP/2还可以为每个Stream配置1到256的权重,权重越高服务器就会为Stream分配更多的内存、流量,这样按照资源渲染的优先级为并发Stream设置权重后,就可以让用户获得更好的体验。而且,Stream间还可以有依赖关系,比如若资源A、B依赖资源C,那么设置传输A、B的Stream依赖传输C的Stream即可,如下图所示:

3.3.4 服务器主动推送资源

主动推送资源可以省掉客户端发送定时拉取消息。


HTTP/2的推送是这么实现的。首先,所有客户端发起的请求,必须使用单号Stream承载;其次,所有服务器进行的推送,必须使用双号Stream承载;最后,服务器推送消息时,会通过PUSH_PROMISE帧传输HTTP头部,并通过Promised Stream ID告知客户端,接下来会在哪个双号Stream中发送包体。

在nginx中的配置示例:

1
2
3
location /a.js { 
http2_push /b.js;
}

服务器同样也会控制并发推送的Stream数量(如http2_max_concurrent_pushes配置),以减少动态表对内存的占用。

3.3.5 总结

HTTP/2使用静态表和Huffman编码压缩头部,在后续请求中使用动态表压缩请求头部,但动态表也会导致内存过大,所以会限制HTTP/2连接的使用时长。

HTTP/2使用Stream实现并发,节约了TCP和TLS协议的握手时间,并减少了TCP慢启动的影响。Stream之间还支持使用权重调节优先级,还可以设置Stream之间的依赖关系,为接收端提供更优秀的体验。

HTTP/2支持消息推送,从HTTP/1.1的拉模式到推模式,信息传输效率有了巨大的提升。HTTP/2推消息时,会使用PUSH_PROMISE帧传输头部,并用双号的Stream来传递包体,了解这一点对定位复杂的网络问题很有帮助。

HTTP/2下层使用的是TCP协议,由于TCP是字符流协议,在前1字符未到达时,后接收到的字符只能存放在内核的缓冲区里,即使它们是并发的Stream,应用层的HTTP/2协议也无法收到失序的报文,这就叫做队头阻塞问题。

3.4 Protobuf进一步提高编码效率