0


优化性能到底在优化啥?

概述

性能优化,反复被提起,但其实很不理解,优化性能到底在优化啥?你要先了解这个问题。一直到前些天一个偶然的机会才想明白,高性能的核心就是用数学的计算,合理的运用服务器的资源,主要是Cpu和内存,主要针对网络层、传输层、应用层的优化。

网络层和传输层主要承载Tcp/Ip的网络传输,根据流量配置出合理的系统参数,比如减少一个RTT的时间,针对应用层的Web服务主要的优化点在于Nginx中。

想了解Nginx的,请阅读我之前的Nginx文章,以点击破万。

基础设施优化

提升Cpu缓存命中率

Cpu是电脑中最重要的组成的核心之一,Cpu是计算机的计算大脑,是优化中首先要考虑的地方,Cpu本身内部有缓存,一般所说的一级缓存、二级缓存、三级缓存,我们暂且把Cpu的缓存称为Cache。

三级缓存要比一、二级缓存大许多倍,这是因为当下的 CPU 都是多核心的,每个核心都有自己的一、二级缓存,但三级缓存却是一颗 CPU 上所有核心共享的。

因此,我们的代码优化目标是提升 CPU 缓存的命中率。

array[j][i]和 array[i][j]访问数组元素,哪一种性能更快?

for(i = 0; i < N; i+=1) {
   for(j = 0; j < N; j+=1) {
       array[i][j] = 0;
   }
}

前者 array[j][i]执行的时间是后者 array[i][j]的 8 倍之多!其实这两个问题的答案都与 CPU Cache Line 相关,它定义了缓存一次载入数据的大小,Linux 上你可以通过 coherency_line_size 配置查看它,通常是 64 字节。

因此,遇到这种遍历访问数组的情况时,按照内存布局顺序访问将会带来很大的性能提升。

提升指令缓存的命中率

CPU含有分支预测器,当代码中出现 if、switch 等语句时,意味着此时至少可以选择跳转到两段不同的指令去执行。如果分支预测器可以预测接下来要在哪段代码执行(比如 if 还是 else 中的指令),就可以提前把这些指令放在缓存中,CPU 执行时就会很快。

池化技术

在Mysql或者Redis的应用服务里常能听说到连接池技术,在很多地方都听说过连接池。

连接池就是创建一个容器,并且把资源提前准备好放在里面,比如我们常用的redis连接、mysql连接。在项目启动时把需要的线程数预加载在内存中,省去链接反复链接、断开,和Cpu上下文切换,达到提高性能的目的。

索引和零拷贝

索引和零拷贝技术都是针对磁盘I/O的优化手段。

索引

索引也是常用的优化手段之一,当然我的法力没那么高深,写点我理解的索引,主要是Mysql和ElasticSearch。

零拷贝

协程

系统层网络优化

1.效率高一对多的实现,使用UDP服务,UDP的协议更适合一对多的广播服务,以Swoole代码的为例。

$server = new Swoole\Server('127.0.0.1', 9502, SWOOLE_PROCESS, SWOOLE_SOCK_UDP);

//监听数据接收事件
$server->on('Packet', function ($server, $data, $clientInfo) {
    var_dump($clientInfo);
    $server->sendto($clientInfo['address'], $clientInfo['port'], "Server:{$data}");
});

//启动服务器
$server->start();

2.C10k和C10M:

  • C10K:这是指服务器同时处理 1 万个 TCP 连接。
  • C10M:也就是同时可以处理 1 千万个 TCP 连接。

处理基于 TCP 的应用层协议时,一个请求的处理代码必须被拆分到多个回调函数中,由异步框架在相应的事件生成时调用它们。这就是事件驱动方式,它通过减少上下文切换次数,实现了 C10M 级别的高并发。

提升TCP网络传输的优化

目的在于优化 TCP 的三次握手流程,提升握手速度。

3.1 客户端的优化,修改重试参数

正常情况下,服务器会在几毫秒内返回 ACK,但如果客户端迟迟没有收到 ACK 会怎么样呢?客户端会重发 SYN,重试的次数由 tcp_syn_retries 参数控制,默认是 6 次:

net.ipv4.tcp_syn_retries = 6

第 1 次重试发生在 1 秒钟后,接着会以翻倍的方式在第 2、4、8、16、32 秒共做 6 次重试,最后一次重试会等待 64 秒,如果仍然没有返回 ACK,才会终止三次握手。所以,总耗时是 1+2+4+8+16+32+64=127 秒,超过 2 分钟。

如果这是一台有明确任务的服务器,你可以根据网络的稳定性和目标服务器的繁忙程度修改重试次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

3.2 服务端的优化

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

如果数值在持续增加,则应该调大 SYN 半连接队列。修改队列大小的方法,是设置 Linux 的 tcp_max_syn_backlog 参数:

net.ipv4.tcp_max_syn_backlog = 1024

如果 SYN 半连接队列已满,只能丢弃连接吗?并不是这样,开启 syncookies 功能就可以在不使用 SYN 队列的情况下成功建立连接。

修改 tcp_syncookies 参数即可,其中值为 0 时表示关闭该功能,2 表示无条件开启功能,而 1 则表示仅当 SYN 半连接队列放不下时,再启用它。

net.ipv4.tcp_syncookies = 1

当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 去通知服务器,同时己方连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。服务器端连接成功建立的时间还要再往后,到它收到 ACK 后状态才变为 ESTABLISHED。

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

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。

net.ipv4.tcp_abort_on_overflow = 0

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

tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 accept 队列会长期溢出时,才能设置为 1 以尽快通知客户端。

那么,怎样调整 accept 队列的长度呢?listen 函数的 backlog 参数就可以设置 accept 队列的大小。事实上,backlog 参数还受限于 Linux 系统级的队列长度上限,当然这个上限阈值也可以通过 somaxconn 参数修改。

net.core.somaxconn = 128

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

[push@cp-pre ~]$ netstat -s | grep "listen queue"
    19876935 times the listen queue of a socket overflowed

3.3 TFO绕过三次握手

HTTP 请求必须在一次 RTT(Round Trip Time,从客户端到服务器一个往返的时间)后才能发送,Google 对此做的统计显示,三次握手消耗的时间,在 HTTP 请求完成的时间占比在 10% 到 30% 之间。

TFO 到底怎样达成这一目的呢?它把通讯分为两个阶段,第一阶段为首次建立连接,这时走正常的三次握手,但在客户端的 SYN 报文会明确地告诉服务器它想使用 TFO 功能,这样服务器会把客户端 IP 地址用只有自己知道的密钥加密(比如 AES 加密算法),作为 Cookie 携带在返回的 SYN+ACK 报文中,客户端收到后会将 Cookie 缓存在本地。

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

net.ipv4.tcp_fastopen = 3

3.4 提升TCP四次挥手的性能

Tcp协议的关闭:close 和 shutdown 函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的 Linux 参数也不相同。close 函数会让连接变为孤儿连接,shutdown 函数则允许在半关闭的连接上长时间传输数据。

为什么建立连接是三次握手,而关闭连接需要四次挥手呢?

这是因为 TCP 不允许连接处于半打开状态时就单向传输数据,所以在三次握手建立连接时,服务器会把 ACK 和 SYN 放在一起发给客户端,其中,ACK 用来打开客户端的发送通道,SYN 用来打开服务器的发送通道。这样,原本的四次握手就降为三次握手了。

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

3.4.1 主动方的优化

内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0,特指 8 次:

net.ipv4.tcp_orphan_retries = 0

对于正常情况来说,调低 tcp_orphan_retries 已经够用,但如果遇到恶意攻击,FIN 报文根本无法发送出去。

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

解决这种问题的方案是调整 tcp_max_orphans 参数:

net.ipv4.tcp_max_orphans = 16384

如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态。但对于 close 函数关闭的孤儿连接,这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长。

我们再回过头来看看,为什么 TIME_WAIT 状态要保持 60 秒呢?这与孤儿连接 FIN_WAIT2 状态默认保留 60 秒的原理是一样的,因为这两个状态都需要保持 2MSL 时长。MSL 全称是 Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间(报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃,这就限制了报文的最长存活时间)。

Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭。

net.ipv4.tcp_max_tw_buckets = 5000
3.4.2 被动方的优化

如果被动方迅速调用 close 函数,那么被动方的 ACK 和 FIN 有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。

此时,上面介绍过的优化策略仍然适用。两方发送 FIN 报文时,都认为自己是主动方,所以都进入了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。

3.5 TCP缓冲区兼顾并发数量与传输速度

但每当进程新建一个 TCP 连接,buff/cache 中的内存都会上升 4K 左右。

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

滑动窗口是怎样影响传输速度的?

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

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

事实上,确认报文被收到的机制非常复杂,它受制于很多因素。速度和接收方的处理能力。

接收方把它的处理能力告诉发送方,使其限制发送速度即可,这就是滑动窗口的由来。接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用 read 函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。

net.ipv4.tcp_window_scaling = 1

RFC1323 定义了扩充窗口的方法,但 Linux 中打开这一功能,需要把 tcp_window_scaling 配置设为 1,此时窗口的最大值可以达到 1GB(230)。

net.ipv4.tcp_window_scaling = 1

需要注意的是,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。如果缓冲区大小与网络传输能力匹配,那么缓冲区的利用率就达到了最大值。

我们可以使用 Linux 的缓冲区动态调节功能解决上述问题。其中,缓冲区的调节范围是可以设置的。先来看发送缓冲区,它的范围通过 tcp_wmem 配置:

net.ipv4.tcp_wmem = 4096  16384  4194304

发送缓冲区的调节功能是自动开启的,而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能:

net.ipv4.tcp_moderate_rcvbuf = 1

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

应用层编解码优化

优化TLS/SSL性能

目前主流的对称加密算法叫做 AES(Advanced Encryption Standard),它在性能和安全上表现都很优秀。

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

在 Linux 上你可以用下面这行命令查看 CPU 是否支持 AES-NI 指令集:

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

当通讯双方协商使用 X25519 曲线用于 ECDH 算法时,只需要传递 X25519 这个字符串即可。在 Nginx 上,你可以使用 ssl_ecdh_curve 指令配置想使用的曲线:

ssl_ecdh_curve X25519:secp384r1;

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

ssl_ciphers 'EECDH+ECDSA+AES128+SHA:RSA+AES128+SHA';
升级到 TLS1.3

TLS1.3(参见RFC8446)对性能的最大提升,在于它把 TLS 握手时间从 2 个 RTT 降为 1 个 RTT。

部署教程 每日一课《TLS1.3 原理及在 Nginx 上的应用》

优化Http1.1

有 3 种优化思路:首先是通过缓存避免发送 HTTP 请求;其次,如果不得不发起请求,那么就得思考如何才能减少请求的个数;最后则是减少服务器响应的体积。

标签: 缓存 服务器 网络

本文转载自: https://blog.csdn.net/xuezhiwu001/article/details/122539309
版权归原作者 stark张宇 所有, 如有侵权,请联系我们删除。

“优化性能到底在优化啥?”的评论:

还没有评论