nginx的reuseport特性分析
1、奇怪的现象
1.1、断崖问题
业务进行性能测试,发现一个奇怪的现象,整个压测过程中总会有断崖的情况。本来TPS在2万8左右,会直接掉到1500左右,然后又马上恢复,但是只能恢复到2万2,损耗了20%。
现场架构:
现场XXX、nginx、服务都部署在一个机器,配置为:
1 |
|
问题在于nginx调大进程为64,或者客户端通道数调大为64,就没有问题。即16-64,64-16没问题,但是16-16有问题,100%复现断崖。
排查步骤:现场没有监控,所以排查过程比较困难,不过还是确认了一些问题
1、整个压测过程中,瓶颈不在nginx,因为最大cpu压力才到45%,主要排查问题是断崖。
2、查看nginx的日志,没有任何报错,但是发现断崖时,客户端给的流量下降了,根据日志绘出曲线后,与压力机的曲线一致
3、所以着重分析为什么此时客户端给的压力会突降低。但是整个链路的节点都有可能有问题,客户端队列阻塞?nginx处理变慢?服务端处理变慢?
重点是,这是私有协议,没有做access.log日志,根本看不到请求的耗时、数量等信息,从压力机看,64个进程和16个进程的平均时延没有差距。2中查看的流量还是打开debug,数的日志条数🙂,苦力活。因此开始tcpdump抓包,一个包抓了21个G,现场内网环境拷贝需要经过2层网络,现场还有其他的测试计划,只能见缝插针压测一把,第一天就这样过去了
第二天包终于拷贝出来了,在看包之前,我想起是信创系统,看了一下nginx的版本,不适配😬,赶快换了对应版本,16-16的模式没有再出现断崖,而且TPS上升了4千,到了3万2,一个数据库的分区直接被打满,断崖问题解决,但是因为操作系统不适配导致的断崖来日待查。紧急的问题是,压测的过程中,nginx的各个进程压力不均匀?进程的压力呈递减状态?
1.2、每个work的连接数不均衡
我们拥有2种私有协议,可以理解为tcp+xxx数据格式、tcp+json。这类协议的客户端和服务端会建立一条长连接,通常成为通道,后续的请求都是依靠这个通道传输。
因此为了更加直观的复现,nginx开启了64个进程,xxx应用与nginx建立64条连接。使用netstat命令统计连接数,得以下结果,nginx共有64条连接(其中39个nginx进程有连接,25个进程处于空闲,没有连接。其中1个进程的连接数为4条;5个进程的连接数分别为3条;14个进程的连接数为2条;19个进程的连接数为1条;25个进程的连接数为0条;
压测过程中,发现共有39个nginx进程有压力,且压力大小与该进程数的连接数成正比,即nginx进程的连接数越多,压力越大,一个进程拿到了4个连接,一个进程拿到了1个连接,压力比是4:1。现场反馈的“递减”现象,其实就是这个现象。那么为什么连接数不一致?
2、HTTP协议的不均衡
既然私有协议的连接数不一致,那么来试试HTTP,直接使用HTTP协议连接nginx,配置做了更新,如下:
1 |
|
架构如下:
发现nginx的压力依旧不均衡,只有十几个进程有压力,维持在60%~90%,此时tps已经达到8万+,依旧是数据库的瓶颈,到这里就需要研究nginx建立连接的机制。
2、基础知识
2.1、epoll
回顾一下4年前写的epoll的例子,https://github.com/ZJfans/EpollET-Server/blob/master/epollET.c
- 初始化监听套接字
- 创建epoll实例
- 监听套接字设置到epoll_ctl中
- 使用epoll_wait,循环等待事件
- 如果触发事件的是监听套接字,那么建立新的连接
- 如果触发事件的是客户端的套接字,那么处理读写事件
2.2、nginx的工作模式 区别于 muduo
nginx为多进程模式,初始化时,master监听端口,而后master fork多个work进程,此时所有的work监听同一端口
muduo为多线程的工作方式,main主线程负责处理监听套接字的事件,在建立连接后,将连接分配给work thread,后续的读写事件都由work线程处理
3、reuseport && SO_REUSEPORT
3.1、惊群效应
惊群效应(Thundering Herd)是多进程或多线程系统在等待同一事件时可能遇到的问题。在网络编程中,尤其是在使用epoll进行I/O多路复用时,惊群效应可能导致性能问题。下面是关于Nginx如何处理惊群效应的详细解释。
原因
nginx的多个进程等待新的网络连接请求。当事件发生时(例如,一个新连接到达),所有等待的进程都会被唤醒。但最终只有一个进程能够处理该事件(例如,通过accept
系统调用接受连接),其他进程在尝试处理事件失败后会重新进入等待状态。这个过程会导致大量的上下文切换和CPU资源的浪费。
Nginx采用了几种策略来避免或减少惊群效应的影响:
accept_mutex
:- Nginx可以使用
accept_mutex
来同步对accept
调用的访问。这意味着在任何给定时间,只有一个工作进程可以处理新的连接请求。这通过在工作进程之间引入互斥锁来实现,从而避免了多个进程同时尝试接受同一个连接的情况。 - 通过在配置文件中设置
accept_mutex
为on
,可以启用此功能。这有助于减少因多个进程竞争同一个accept
操作而产生的惊群效应。
- Nginx可以使用
EPOLLEXCLUSIVE
:- 从Linux内核版本4.5开始,引入了
EPOLLEXCLUSIVE
标志。Nginx从1.11.3版本开始支持这个特性。 - 当使用
EPOLLEXCLUSIVE
标志添加epoll
事件时,内核保证在事件发生时只唤醒一个等待的进程。这减少了因多个进程监听同一个文件描述符而产生的惊群效应。
- 从Linux内核版本4.5开始,引入了
SO_REUSEPORT
:SO_REUSEPORT
是Linux内核3.9版本引入的一个选项,允许多个进程绑定到相同的端口上。Nginx从1.9.1版本开始支持这个特性。- 使用
SO_REUSEPORT
时,内核会在多个监听相同端口的进程之间进行负载均衡。这样,当新的连接请求到达时,内核会根据一定的规则选择一个进程来处理该请求,从而避免了多个进程同时被唤醒的问题。
3.2、SO_REUSEPORT
SO_REUSEPORT
是一个 Linux 内核级别的套接字选项,它允许多个套接字(通常是监听套接字)绑定到相同的网络地址和端口上。这个特性在 Linux 3.9 版本中引入,主要用于解决多进程或多线程环境中的惊群效应问题。以下是 SO_REUSEPORT
的实现原理的详细解释:
传统的端口绑定
在 SO_REUSEPORT
出现之前,根据 POSIX 标准,一个网络端口在同一时间内只能被一个套接字绑定。如果有多个进程想要监听同一个端口,它们必须使用某种同步机制(如互斥锁)来协调对端口的访问,这可能会导致性能问题和复杂的编程模型。
SO_REUSEPORT
的引入
SO_REUSEPORT
选项的引入打破了这个限制,它允许多个套接字监听同一个端口,而不需要特殊的同步机制。当启用 SO_REUSEPORT
时,内核会在内部进行负载均衡,将到达的数据包分发给监听该端口的多个套接字。
实现原理
- 端口复用:
- 当多个进程或线程的套接字启用了
SO_REUSEPORT
并绑定到同一个端口时,内核会为每个套接字创建一个独立的接收队列。 - 所有到达的数据包(例如,新的连接请求)都会根据某种负载均衡算法在这些队列之间进行分配。
- 当多个进程或线程的套接字启用了
- 负载均衡:
- 内核使用一种负载均衡算法(通常是轮询或某种形式的哈希算法)来决定哪个套接字应该接收特定的连接请求。
- 这意味着即使多个进程在监听同一个端口,每个进程也只会接收到一部分的连接请求,而不是全部。
- 并发处理:
- 由于每个进程都有自己的接收队列,它们可以并发地处理连接请求,而不会相互干扰。
- 这种方式显著减少了进程间的上下文切换和竞争,提高了系统的并发处理能力。
- 安全性和隔离:
- 尽管多个套接字绑定到了同一个端口,但它们之间的通信是隔离的。每个套接字只能处理分配给它的数据包。
- 此外,
SO_REUSEPORT
选项通常要求所有绑定到同一端口的套接字必须属于同一个用户,以避免潜在的安全问题。
3.3、负载均衡算法
这是内核从监听的哈希表中查找匹配的套接字,关键函数是compute_score,会给每一个socket算一个权重值,有点类似于nginx的轮询,也是按照算法,得出同一个upstream下每个server的权重,最大的分配请求
但是当开启SO_REUSEPORT后,其实会直接调用inet_lookup_reuseport,这里直接选择socket,选择到就return了。具体分析见第4节。
1 |
|
3.4、均衡吗?引发reuseport奇怪的现象
如果nginx有8个进程监听这个端口,为什么我觉得会很不均匀的分配连接呢?于是用了我们自己的nginx,不开启reuseport的情况下,多个进程都监听了一个socket,但是开启了reuseport后,8个进程每个进程都监听了8个socket??为什么不是8个进程各自监听自己的socket呢?
1、难道是内核版本太低了不支持?或者显示有问题?
我这个虚拟机的内核是3.1,确实低了,于是找了一个4.19的操作系统,也是这样?
那就不是linux内核的版本问题
2、nginx的版本的问题?
我用的是openresty-1.15.8版本,因此nginx的版本也是15.8,因此我下载了最新的openresty-1.25版本,重新编译启动后,结果如下图,work进程完全符合我的预期!!!
因此我去对比了2个版本的代码,发现新版本确实做了优化,会close多余的socket。
而且lsof -i出来的也只是绑定的意思,nginx1.15.8版本的nginx进程虽然绑定了多个socket,但是并没有监听每一个,也就是没有把每一个socket放到epoll里面
nginx-15
1 |
|
nginx-25
1 |
|
问题是master为什么也绑定了4个socket?acceept事件来时,master也会触发?
事实上不用担心这个问题,因为master根本不会把这些socket放到epoll里面,所以永远不会触发。
那能不能删除绑定呢?
nginx的重启依赖于master fork work,我在想是不是master的socket不能丢掉,要不然reload的时候,重新创建socket,那之前的一些状态是不是就丢掉了?
或者停止时,要关掉socket,那么master需要知道当前打开的句柄数,我觉得这个怀疑是最合理的
有兴趣待查
4、linux内核源码分析
现在来看下linux内核是如何实现SO_REUSEPORT
,Linux 内核版本 3.9 中引入了这个特性,所以我下载了2个版本的linux内核代码,目前广泛使用的4.19和最新的6.8
6.8的代码比较清晰直观,直接用ai生成注释😀
1 |
|
那么重点是2个地方
4.1、compute_score
4.1.1、compute_score函数
类似于nginx的轮询算法,算出权重/分数,根据 权重/分数 分发事件
1 |
|
score是有3个地方会变化,连接会分发给哪个socket,那就是看4个socket哪个点不一样,导致分不一样,也就是4个nginx进程
1、检查套接字是否绑定到指定的设备接口,并且设备接口是否匹配差异接口
这个就是网卡,显然对于nginx的4个进程而言,我都是监听所有的网卡,所以这里4个进程的socket得分都是1,也就没有差异
1 |
|
2、 如果套接字是IPv4类型,额外加1分
这里我只考虑ipv4地址的场景,虽然我也监听了ipv6,因此这里4个进程的socket得分也都加1,没有差异
3、如果接收到的数据包的CPU与当前CPU相同,额外加1分
首先先到sock_reuseport.c模块,看下sk_incoming_cpu的定义
- **
sk_incoming_cpu
**: 在Linux内核中,sk_incoming_cpu
是套接字结构中的一个字段,它记录了最近处理该套接字传入数据的CPU核心。当新的数据包到达时,操作系统会尝试将数据包分配给记录在sk_incoming_cpu
中的CPU核心来处理,以此来优化性能。
4.1.2、CPU和套接字的关系
- 数据包到达: 当一个网络数据包到达时,它首先被网络接口卡(NIC)捕获,并通过中断通知CPU。
- 中断处理: CPU接收到中断后,操作系统的中断处理程序会捕获这个事件,并开始处理数据包。
- 套接字绑定: 操作系统的网络栈会根据数据包的目标地址和端口号,决定将数据包发送到哪个套接字。如果一个套接字已经绑定到了特定的端口,那么所有到达该端口的数据包都会被发送到这个套接字。
- CPU亲和性: 在多核CPU系统中,操作系统可能会将特定的套接字或网络流量绑定到特定的CPU核心,这种做法称为CPU亲和性(CPU affinity)。这样做的目的是为了提高效率,因为:
- 缓存利用:如果套接字总是在同一个CPU核心上处理数据,相关的数据结构和状态信息更有可能保留在该核心的CPU缓存中,从而减少内存访问延迟。
- 上下文切换:减少不同CPU核心之间的上下文切换,因为数据包的处理总是在同一个核心上进行。
- 负载均衡:通过将不同的套接字或网络流量分配给不同的CPU核心,可以实现更好的负载均衡。
重要的是第一个网络包达到的时候,那就看看3次握手吧
- 客户端发送 SYN 包: 当客户端想要建立与服务端的 TCP 连接时,它会发送一个 SYN(同步)包给服务端,这个包包含客户端的初始序列号。
- 服务端接收 SYN 包并创建 socket: 服务端收到客户端的 SYN 包后,会分配资源并创建一个用于与客户端通信的 socket,并为该连接分配一个序列号,同时为其分配缓冲区等资源。
- 服务端发送 SYN-ACK 包: 接着,服务端会发送一个 SYN-ACK 包给客户端,该包中包含服务端的序列号以及确认号(即客户端序列号加一),表示服务端已经接收到了客户端的 SYN 包,并愿意建立连接。
- 客户端接收 SYN-ACK 包并发送 ACK 包: 客户端收到服务端的 SYN-ACK 包后,会发送一个 ACK(确认)包给服务端,确认服务端的 SYN 包,并携带服务端的序列号加一的确认号。
- 连接建立完成: 当服务端收到客户端发送的 ACK 包后,连接就建立完成了,服务端和客户端之间可以开始进行数据传输。
可以看到服务端在接收到客户端的 SYN 包后,会创建一个用于与客户端通信的 socket,这时候就会更新cpu了,也就是sk_incoming_cpu,下次这个cpu在分配连接的时候,会优先给这个cpu处理过的socket,也就是加一分
1 |
|
4.1.3、更新sk_incoming_cpu
重要的是reuseport_update_incoming_cpu,如何设置和更新sk_incoming_cpu
1 |
|
理解了sk_incoming_cpu,其实就可以理解得分,但是事实上开启了SO_REUSEPORT后,选择的函数是inet_lookup_reuseport。
4.2、inet_lookup_reuseport
1 |
|
得分完,如果找到了socket,那就直接返回了,让我们看下inet_lookup_reuseport做了什么
4.2.1、inet_lookup_reuseport源码
1 |
|
最后是调用了reuseport_select_sock
1 |
|
实际的选择
1 |
|
reuse->socks[i],是一个指针数组,它存储了一系列 struct sock
指针。每个 struct sock
指针代表一个网络套接字,这些套接字都绑定到了同一个端口上,并且启用了 SO_REUSEPORT
特性。
num_socks;
字段表示 socks
数组中当前有效的套接字(struct sock
指针)的数量。这个字段用于跟踪监听同一个端口并启用了 SO_REUSEPORT
特性的套接字数量。
4.2.2、总结
- 根据
net
、daddr
、hnum
、saddr
和sport
这几个参数计算一个hash值 - 使用哈希值和socket数量计算reuse->socks数组的起始索引
- 判断当前socket是否有连接请求在处理,如果没有,说明这个监听socket目前空闲,所以选择这个
- 如果上面没有返回,再判断sk_incoming_cpu,如果这个socket的上一次数据是当前cpu处理的,那么就选这个socket
- 如果这个socket不满足条件,那么作为保底,将这个socket设置为保底选择
- 循环3-5步骤
- 遍历完reuse->socks数组中的socket后,返回第一个有效的socket,如果没有找到则返回NULL
5、总结
5.1、原理总结
对于内核而言,整个过程处于传输层,它不需要关注应用层,因此对于连接的分配,会最大化的优化处理速度,只考虑传输层的属性。主要点在于优先使用空闲的监听socket,并且使监听socket尽量在一个cpu处理,这样有利于cpu缓存的利用。
因此当开启SO_REUSEPORT
特性后,一个socket是否能拿到连接,取决于3个点
1、根据哈希值和socket数量计算reuse->socks数组的起始值是多少,第一个当然有优先优势
2、取决于当前socket是否处于空闲
3、上一次处理这个socket的数据的cpu,是否是当前cpu
5.2、均匀吗?
5.1.1、不会绝对均匀
当同一个客户端和同一个nginx建立64条长连接时,上面1中的哈希值和socket数量是一样的,所以数组的起始下标是一样的。
那么连接分配给哪个进程就取决于2、3。当64条连接绝对同时来临时,且nginx的socket此时并没有其它连接来时,也就是处于空闲时,那么第2步会保证每个socket拿到1条连接,但是问题是绝对同时?还要保证没有连接到这些socket,这是不可能的。
因为连接总会有先后时间,即数据包会先后到,第一个socket处理完第一个连接后,它就又处于空闲了,所以它还会拿到连接,没办法它有优势,起始值算的。
5.1.2、会发生极限场景吗
nginx开启64个进程,只有几个进程能拿到连接?
可能性几乎为0,因为连接虽然有先后,但是时间差会非常小,所以都会在2中分发。除非客户端隔一段时间发一个请求,事实上客户端如果建立连接会”同时”发的,但是因为有时间差,前面的socket会拿到更多的连接
同时当连接数量级足够大,那么会近似均匀,但是当只有几十个连接时,也会是近似均匀,但是看着差距会比较大,毕竟有的socket拿不到连接,也就是nginx的进程拿不到连接
5.1.3、如果想在应用层保证连接数均匀可以实现吗
这是一个非常意思的想法,我们从2点考虑,可行性与性能。
1、可行性
最直接的想法,应用层怎么判断进程现在拥有多少个长连接,以及长连接就是T2/T3,而不是websocket这种长连接?
2、性能
如果每次建立连接都需要判断是否是长连接,且均匀分发到各个进程,性能会断崖式下降
结论:所以不可能做的到,也没有意义。
5.1.4、目前这种情况,有必要做连接的均匀分发吗
依据上次实际的统计来看,64个进程会有39个进程拿到连接,也就是39个进程会工作。
所以cpu只会利用39个?
根本不是的,因为work进程使用cpu是会切换的,这也是压测到极限,cpu的利用率会超过100%,有些java服务甚至会达到几千。因此cpu的利用率,nginx是最大化的,只不过存在cpu切换的损耗,基本可以忽略不计。
所以,当压力到达nginx极限时,不同的进程的cpu利用率会有不同,但是一定会利用到所有的cpu,也就是可以发挥机器的最大性能。