网络性能优化策略
2021-06-25
Linux网络性能优化实际优化的是Linux内核或者系统调用的几个参数,我们可以从应用程序、套接字、传输层、网络层以及链路层等几个角度,分别来看网络性能优化的基本思路。
网络性能指标
- 带宽:带宽,表示链路的最大传输速率,单位通常为 b/s (比特/秒)。
- 吞吐量:表示单位时间内成功传输的数据量,单位通常为 b/s(比特/秒)或者 B/s(字节/秒)。吞吐量受带宽限制,而吞吐量/带宽,也就是该网络的使用率。
- 延时:表示从网络请求发出后,一直到收到远端响应,所需要的时间延迟。在不同场景中,这一指标可能会有不同含义。比如,它可以表示,建立连接需要的时间(比如 TCP 握手延时),或一个数据包往返所需的时间(比如 RTT)。
- PPS:是 Packet Per Second(包/秒)的缩写,表示以网络包为单位的传输速率。PPS 通常用来评估网络的转发能力,比如硬件交换机,通常可以达到线性转发(即PPS可以达到或者接近理论最大值)。而基于 Linux 服务器的转发,则容易受网络包大小的影响。
除了这些指标,网络的可用性(网络能否正常通信)、并发连接数(TCP连接数量)、丢包率(丢包百分比)、重传率(重新传输的网络包比例)等也是常用的性能指标。
网络收发包流程
这里介绍进程收到网络数据的整个流程:
- 一个网络帧到达网卡,网卡会通过DMA方式,把这个包放在接收队列里(Tx Ring buff),然后通过硬中断,告诉网卡中断程序收到了网络包;
- 网卡中断程序会为该网络帧分配内核数据结构sk_buff(socket buffer),再通过软中断告知内核收到了网络帧;
- 内核协议栈从sk_buff缓冲区将网络帧取出处理,由下至上解包处理该网络帧;
- 在链路层检查报文的合法性,找出上层协议的类型(比如 IPv4 还是 IPv6),再去掉帧头、帧尾,然后交给网络层;
- 网络层取出 IP 头,判断网络包下一步的走向,比如是交给上层处理还是转发。当网络层确认这个包是要发送到本机后,就会取出上层协议的类型(比如 TCP 还是 UDP),去掉 IP 头,再交给传输层处理。
- 传输层取出 TCP 头或者 UDP 头后,根据 < 源 IP、源端口、目的 IP、目的端口 > 四元组作为标识,找出对应的 Socket,并把数据拷贝到 Socket的接收缓存中,也就是TCP接收窗口。
- 最后,应用程序就可以使用 Socket read接口,程序会切换到内核区,并且会把 socket 接收缓冲区中的数据拷贝到用户区,拷贝后的数据会从 socket 缓冲区中移除。
上面的收包流程中深色标记了数据的存储位置,收到的数据一开始存储于内核专用的缓冲区,接收队列(ring buff)存的是指向这片区域的指针,后面数据被拷贝到sk_buff缓冲区(这个sk_ buff 形成的链表,就是常说的 socket 发送缓冲区),最后被拷贝到进程使用。
只在两种情况下创建 sk_ buff:
- 应用程序给 socket 写入数据时。
- 当数据包到达 NIC 时。
数据只会拷贝两次:
- 用户空间与内核空间之间的拷贝(socket 的 read、write)。
- sk_buff 与 NIC 之间的拷贝。
MTU大小(影响时延)
物理链路中并不能传输任意大小的数据包。网络接口配置的最大传输单元(MTU),就规定了最大的 IP 包大小。在我们最常用的以太网中,MTU 默认值是 1500(这也是 Linux 的默认值)。一旦网络包超过 MTU 的大小,就会在网络层分片,以保证分片后的 IP 包不大于 MTU 值。显然,MTU 越大,需要的分包也就越少,自然,网络吞吐能力就越好。因此我们可以根据 MTU 大小,调整发送的 数据包的大小,减少或者避免分片的发生。下图就表示了应用层的要发送的数据超过MTU后,会被分为多个包发送。
TCP优化
网络性能优化很大一部分就是在优化TCP的系统参数,因为操作系统一般设定的一些网络参数都较为保守,因为这些参数设为保守值那系统能稳定运行在各个环境上的能力就更强。但线上服务器都有自己的特殊处境,一个万金油的值并不能满足服务器的性能要求。一个典型的例子就是,局域网内网络环境都很好,因此没有必要严格遵循TCP那套拥塞控制策略,而是应想方法尽可能地提升传输性能。
先来一张TCP三次握手四次挥手的神图镇场。
1.TIME_WAIT过多(影响并发量,吞吐量)
在请求数比较大的场景下,你可能会看到大量处于 TIME_WAIT 状态的连接,它们会占用大量内存和端口资源。这时,我们可以优化与 TIME_WAIT 状态相关的内核选项,比如采取下面几种措施。
- 增大处于 TIME_WAIT 状态的连接数量 net.ipv4.tcp_max_tw_buckets ,并增大连接跟踪表的大小 net.netfilter.nf_conntrack_max。
- 减小 net.ipv4.tcp_fin_timeout 和 net.netfilter.nf_conntrack_tcp_timeout_time_wait ,让系统尽快释放它们所占用的资源。
- 开启端口复用 net.ipv4.tcp_tw_reuse和设置net.ipv4.tcp_timestamps=1(默认即为1)。这样,被 TIME_WAIT 状态占用的端口,还能用到新建的连接中。
- 增大本地端口的范围 net.ipv4.ip_local_port_range 。这样就可以支持更多连接,提高整体的并发能力。
- 增加最大文件描述符的数量。你可以使用 fs.nr_open 和 fs.file-max ,分别增大进程和系统的最大文件描述符数;或在应用程序的 systemd 配置文件中,配置 LimitNOFILE ,设置应用程序的最大文件描述符数。
值得注意的是,内核参数tcp_tw_recycle不建议打开,因为它的副作用是会拒绝所有比这个客户端时间戳更靠前的网络包,所以我方的就把带了“倒退”的时间戳的包当作是“recycle的tw连接的重传数据,不是新的请求”,于是丢掉不回包,造成大量丢包。但按包的时间戳来判定包是否是重传包并不靠谱,比如机器时钟并不一定相同。因此高版本的Linux已经把该参数废弃了。
除了上述方法可以移除系统中过多TIME_WAIT外,其实还可以使用SO_LIGNER参数快速关闭socket连接,不走TCP的四次挥手,而是使用RST快速关闭连接,这就避免了TIME_WAIT状态的产生。
回顾一下TCP关闭连接的两种方式:
- FIN:正常关闭,走4次挥手,优雅关闭,发送 FIN 包表示自己这端所有的数据都已经发送出去了,后面不会再发送数据
- RST:处理异常情况,强制连接重置关闭,无法做出什么保证。
scoket编程中通过linger结构体的l_onoff和l_linger来控制整个TCP关闭连接的行为。
struct linger {
int l_onoff; /* 0 = off, nozero = on */
int l_linger; /* linger time */
};
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 30;
z = setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
- 第一个字段 l_onoff 用来表示是否启用 linger 特性,非 0 为启用,0 为禁用 ,linux 内核默认为禁用。
禁用情况下, close函数立即返回,操作系统负责把缓冲队列中的数据全部发送至对端 - 第二个参数 l_linger 在 l_onoff 为非 0 (即启用特性)时才会生效。
- 如果 l_linger 的值为 0,那么调用 close,close 函数会立即返回,同时丢弃缓冲区内所有数据并立即发送 RST 包重置连接
- 如果 l_linger 的值为非 0,那么此时 close 函数在阻塞直到 l_linger 时间超时或者数据发送完毕,发送队列在超时时间段内继续尝试发送,如果发送完成则皆大欢喜,超时则直接丢弃缓冲区内容 并 RST 掉连接。
2.CLOSE_WAIT过多(影响并发量,吞吐量)
一般而言CLOSE_WAIT不会很多,但当程序发生异常时,该状态会大量出现,逐渐耗尽系统fd,影响网络并发连接数和吞吐量。
半关闭的状态下的服务器连接会处于 CLOSE_WAIT 状态,直到服务器发送了FIN。那么在应用层则是调用socket.close()会执行FIN的发送,如果服务器出现大量CLOSE_WAIT状态的连接,那么有可能的原因:
- 服务器压力过大,根本来不及调用close存在连接泄露问题(Bug),比如事务回滚耗费大量时间;
- 服务器未及时关闭连接。(更为常见,比如逻辑不严谨,跳过了close)。
CLOSE_WAIT过多的唯一调优方法是:检查程序逻辑,确保socket.close不会异常跳过。当系统CLOSE_WAIT过多,但同时也不能杀死进程时,可以利用tcpkill等工具回收这些CLOSE_WAIT状态的僵死连接。
当系统出现大量量CLOSE_WAIT后该如何处理,方法有2:
- 杀死进程,就是释放进程内使用到的socket连接,因此CLOSE_WAIT的连接就会清理掉。
- 利用tcpkill、killcx相关工具或自己编写脚本,原理就是构造假的RST包发给对方,让对方主动断掉这条连接。那RST包的seq怎么获取呢,这是个难题,因为只有落在SEQ号落在滑动窗口内的包才会处理,否则这些包都会被丢弃。思路就是主动给模拟发一个SYN包,Linux 内核对于收到的乱序 SYN 报文,会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK,此时我们就获得了正确的SEQ和ACK了。最后再按照该SEQ和ACK构造RST包发给B,B收到后就会关闭连接了。此方法适合关闭所有非僵死的TCP连接。
知识点补充:系统收到RST包后会就会KILL掉这条连接,回收socket资源。阻塞模型下,内核无法主动通知应用层出错,只有应用层主动调用read()或者write()这样的IO系统调用时,内核才会利用出错来通知应用层对端RST。非阻塞模型下,select或者epoll会返回sockfd可读,应用层对其进行读取时,read()会报错RST(-1)。第一次read是返回-1,后续继续读返回将会是0。
CLOSE_WAIT意味着操作系统知道远程应用程序已关闭连接并等待本地应用程序也这样做,操作系统并没有提供 相应的TCP参数来解决此问题,因为操作系统并不知道你的程序是否还在处理重要数据,系统主动关闭CLOSE_WAIT连接并不安全。所以最好的 办法是检查拥有本地主机上的连接的应用程序。由于没有CLOSE_WAIT超时,连接可以永远保持这种状态(或者至少在程序最终关闭连接或进程存在或被杀死之前)。当确认了直接KILL socket连接不会对业务逻辑有影响时,才可以考虑使用KILLCX等工具。
3.SYN FLOOD(影响吞吐和并发数)
为了缓解 SYN FLOOD 等,利用 TCP 协议特点进行攻击而引发的性能问题,你可以考虑优化与 SYN 状态相关的内核选项,比如采取下面几种措施。
- 增大 TCP 半连接的最大数量 net.ipv4.tcp_max_syn_backlog ,或者开启 TCP SYN Cookies net.ipv4.tcp_syncookies ,来绕开半连接数量限制的问题(注意,这两个选项不可同时使用)。
- 减少 SYN_RECV 状态的连接重传 SYN+ACK 包的次数 net.ipv4.tcp_synack_retries。
4.Keepalive(影响吞吐和并发量)
如果一个给定的连接在两小时内(默认时长)没有任何的动作,则服务器就向客户发一个探测报文段,客户主机必须处于以下4个状态之一:
- 客户主机依然正常运行,并从服务器可达。客户的TCP响应正常,而服务器也知道对方是正常的,服务器在两小时后将保活定时器复位。
- 客户主机已经崩溃,并且关闭或者正在重新启动。在任何一种情况下,客户的TCP都没有响应。服务端将不能收到对探测的响应,并在75秒后超时。服务器总共发送10个这样的探测 ,每个间隔75秒。如果服务器没有收到一个响应,它就认为客户主机已经关闭并终止连接。
- 客户主机崩溃并已经重新启动。服务器将收到一个对其保活探测的响应,这个响应是一个复位(RST),使得服务器终止这个连接。
- 客户机正常运行,但是服务器不可达,这种情况与2类似,TCP能发现的就是没有收到探测的响应。
对于linux内核来说,应用程序若想使用TCP Keepalive,需要设置SO_KEEPALIVE套接字选项才能生效。
在长连接的场景中,通常使用 Keepalive 来检测 TCP 连接的状态,以便对端连接断开后,可以自动回收。但是,系统默认的 Keepalive 探测间隔和重试次数,一般都无法满足应用程序的性能要求,一般而言,打开了keepalive功能但没做参数优化,那建议不要打开了,因为tcp 默认的keepalive参数效率太低了。看看这些默认的参数:
# cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
9
2小时才发一次心跳包,心跳包没收到回复后继续探测间隔是75秒,一共重试9次才认为当前连接已经关闭。这样一算下来,一旦对方已经挂了,自己还继续等待2个多小时才会释放该socket,那socket的利用率实在太低了。
所以,这时候你需要优化与 Keepalive 相关的内核选项,比如:
- 缩短最后一次数据包到 Keepalive 探测包的间隔时间 net.ipv4.tcp_keepalive_time;
- 缩短发送 Keepalive 探测包的间隔时间 net.ipv4.tcp_keepalive_intvl;
- 减少 Keepalive 探测失败后,一直到通知应用程序前的重试次数 net.ipv4.tcp_keepalive_probes。
5.Nagle算法和延迟ACK导致的网络延时(影响时延)
Nagle算法和delay ack机制是减少发送端和接收端包量的两个机制,可以有效减少网络包量,避免拥塞。但是,在特定场景下,Nagle算法要求网络中只有一个未确认的包, 而delay ack机制需要等待更多的数据包, 再发送ACK回包, 导致发送和接收端等待对方发送数据, 造成死锁, 只有当delay ack超时或者发送方等待超时后才能解开死锁,进而导致应用侧对外的延时高。
Nagle算法主要是避免发送小的数据包,要求TCP连接上最多只能有一个未被确认的小分组,在该分组的确认到达之前不能发送其他的小分组。相反,TCP收集这些少量的小分组,并在接收方的确认到来时以一个分组的方式发出去。
考虑发送一个字节的的情景,每次发送一个字节的有用数据,就会产生41个字节长的分组,20个字节的IP Header 和 20个字节的TCP Header,这就导致了1个字节的有用信息要浪费掉40个字节的头部信息,这是一笔巨大的字节开销,而且这种Small packet在广域网上会增加拥塞的出现。因此Nagle算法是用于缓解网络拥塞的优化手段。该算法的优越之处在于它是自适应的,确认到达的越快,数据也就发送的越快。
Nagle核心算法思想:一个TCP连接上最多只能有一个未被确认的小数据包,在该分组的确认到达之前,不能发送其他的小数据包。数据发送时是否选择立即发送判定步骤如下:
1. 如果发送内容>=1个MSS, 立即发送;
2. 如果之前没有包未被确认, 立即发送;
3. 如果之前有包未被确认, 缓存发送内容;
4. 如果收到ack, 立即发送缓存的内容。
5. 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。
这里介绍一下MSS和MTU。
网络 MTU (Maximum Transmission Unit,最大传输单元) 表示网络一次传输的最大数据字节数 (不包括网络封装占用字节数),通常 MTU 是网络硬件规定的。 对于最常用的以太网,MTU 是 1500 字节。
TCP MSS (Maximum Segment Size,最大分节大小),用于告诉 TCP 对端在每个分节中能够发送的最大 TCP 数据量。 MSS 的目的是告诉对端其重组缓冲区大小的实际值,从而试图避免分片。 MSS 经常设置成为 MTU (1500) - IP 固定长度 (20) - TCP 固定长度 (20) = 1460 字节,IPv6 是 1440 字节,因为 IPv6 长度为 40 字节。
延迟ACK的核心思想与Nagle思想是一致的,只是一个针对发送方,一个针对接收方。延迟ack:如果tcp对每个数据包都发送一个ack确认,那么只是一个单独的数据包为了发送一个ack代价比较高,所以tcp会延迟一段时间,如果这段时间内有数据发送到对端,则捎带发送ack,如果在延迟ack定时器触发时候(超时了),发现ack尚未发送,则立即单独发送,因此延迟ACK同样是用于缓解网络拥塞的优化手段。
Nagle算法关联的socket 参数是TCP_NODELAY,与延迟ACK关联的参数是TCP_QUICKACK,TCP_NODELAY针对的是数据发送方,TCP_QUICKACK针对的是数据接收方。
TCP_NODELAY:该参数设置后就是关闭了Nagle算法,即发送数据时不管包的大小,一律立即发送。
TCP_QUAICLACK: 该参数设置后表示关闭延迟ack, 表示接收到数据之后立即回复ACK。
观察下面这个案例,左侧是客户端,右侧是服务端,从下图可以看出,前面三次握手,以及第一次 HTTP 请求和响应还是挺快的,但第二次 HTTP 请求就比较慢了,特别是客户端在收到服务器第一个分组后,40ms 后才发出了 ACK 响应(图中蓝色行)。实际上,该延时是TCP 延迟确认(Delayed ACK)导致的,延时的时间就是TCP 延迟确认的最小超时时间40ms,这是个神奇的40ms,如果以后一看到40ms这个值时,需条件反射想到是否是delay ack或者是Nagle算法的40ms。
再看下面的案例:客户端需要发送2062字节数据,然后从服务器读取响应。
通过wireshark抓包,数据分成了1460字节和602字节两段发送。
如图所示:发送第一段1460字节后,服务器等待40ms后才发送ACK;客户端也是收到ACK后才发送第二段的602字节。
现象看起来跟Nalge算法和ACK延迟确认机制相符。首先一次发送的数据量已经超过了MTU(2062 > 1460),因此被分片了。客户端发送的第一段数据大小满足MSS,立即发送。服务器收到后因为要等待接收剩下的602个字节,所以没有发送响应数据,也就不能携带ACK,导致ACK延迟。客户端第二段602字节数据因为第一段数据没有确认而被延迟发送,直到40ms后收到ACK。因此条件反射想到是TCP_QUICKACK或者TCP_NODELAY没有开启,检查代码后发现确实TCP_QUICKACK和TCP_NODELAY都没开启,因此主动打开即可修复。
recv(fd, rcvBuf, 132, 0);
setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, (int[]){1}, sizeof(int));
注意,TCP_QUICKACK需要在每次调用recv后重新设置,因为map tcp中明确提到该设置并非设置后就永久不变的:
针对数据发送方的禁用Nagle算法可以这个操作
setsockopt(client_fd, SOL_TCP, TCP_NODELAY,(int[]){1}, sizeof(int));
TCP的延时确认以及Nagle算法,从思想看都是一致的,都是通过延迟发送来减轻网络传输负担。不用每次请求都发送一个网络包,而是先等一会儿(比如 40ms),看看有没有“顺风车”或者是否有还要发送的数据。如果这段时间内,正好有其他包需要发送,那就捎带着 ACK 或本次数据 一起发送过去。当然,如果一直等不到其他包,那就超时后单独发送。但是我们再网路负载不严重的情况下(比如在局域网内),ack delay和nagle算法对于我们来说并无太大意义,而且还增加了我们的网络延时。
在默认的情况下,Nagle算法和延迟ACK是默认开启,也就是说TCP_NODELAY和TCP_QUICKACK默认关闭。如果服务器开启延迟ACK、客户端开启Nagle算法 ,就很容易导致网络延迟增大。所以为了减少网络时延,可以开启TCP_NODELAY 和TCP_QUICKACK。
UDP
UDP 提供了面向数据报的网络协议,它不需要网络连接,也不提供可靠性保障。所以,UDP 优化,相对于 TCP 来说,要简单得多。这里我也总结了常见的几种优化方案。
- 跟上篇套接字部分提到的一样,增大套接字缓冲区大小以及 UDP 缓冲区范围;
- 跟前面 TCP 部分提到的一样,增大本地端口号的范围;
- 根据 MTU 大小,调整 UDP 数据包的大小,减少或者避免分片的发生。
应用层
应用层的网络协议优化,也是至关重要的一点。我总结了常见的几种优化方法。
- 使用长连接取代短连接,可以显著降低 TCP
- 建立连接的成本。在每秒请求次数较多时,这样做的效果非常明显。
- 使用内存等方式,来缓存不常变化的数据,可以降低网络 I/O 次数,同时加快应用程序的响应速度。
- 使用 Protocol Buffer 等序列化的方式,压缩网络 I/O 的数据量,可以提高应用程序的吞吐。
- 使用 DNS 缓存、预取、HTTPDNS 等方式,减少 DNS 解析的延迟,也可以提升网络 I/O 的整体速度。
- I/O 多路复用技术 epoll
- 使用异步 I/O(Asynchronous I/O,AIO)
- 主进程 + 多个 worker 子进程。
- 听到相同端口的多进程模型。在这种模型下,所有进程都会监听相同接口,并且开启 SO_REUSEPORT 选项,由内核负责,把请求负载均衡到这些监听进程中去。
socket
为了提高网络的吞吐量,你通常需要调整这些缓冲区的大小。比如:
- 增大每个套接字的缓冲区大小 net.core.optmem_max;
- 增大套接字接收缓冲区大小 net.core.rmem_max 和发送缓冲区大小 net.core.wmem_max;
- 增大 TCP 接收缓冲区大小 net.ipv4.tcp_rmem 和发送缓冲区大小 net.ipv4.tcp_wmem。
- 使用 SO_SNDBUF 和 SO_RCVBUF ,可以分别调整套接字发送缓冲区和接收缓冲区的大小。
内核选项的范围是全局的,套接字接口里面设置的是单个,如SO_SNDBUF设置得是当前socket发送缓冲区的大小。
总结
- 在应用程序中,主要是优化 I/O 模型、工作模型以及应用层的网络协议;
- 在套接字层中,主要是优化套接字的缓冲区大小;
- 在传输层中,主要是优化 TCP 和 UDP 协议;
- 在网络层中,主要是优化路由、转发、分片以及 ICMP 协议;
- 最后,在链路层中,主要是优化网络包的收发、网络功能卸载以及网卡选项。