【高性能服务器】Nginx剖析

时间:2023-08-23 20:31:32

引言

Nginx是一个流行的高性能服务器,官方宣称在压力测试下可以支持5万个并发连接,而且占用内存极低。相比于其他昂贵的硬件负载均衡解决方案,Nginx是开源免费的,可以大大降低成本。本文将从一下几个方面来剖析其内部结构。

  1. 特点
  2. 进程模型
    1. 惊群效应
    2. 负载均衡
  3. 核心模块
    1. 模块分类
    2. 事件驱动模块机制
    3. 反向代理模块
  4. 配置文件

Nginx的特点

Nginx是俄罗斯工程师开发的高性能Web服务器,为了实现高效Nginx全部采用C语言编写,因为底层对不同的操作系统进行了封装,所以Nginx实现了平台无关性。 众所周知,Nginx性能高,而高性能与其架构是分不开的。Nginx采用多工作进程的方式处理连接请求,并且利用Cpu和进程的亲缘性将进程和特定Cpu绑定,在实现并发的同时避免了进程上下文切换的开销。同时,所有的Io操作,不管是网络Io还是磁盘Io,Nginx统一采用Linux内核的Epoll机制配合回调函数实现异步。这使得Nginx可以轻轻松松实现万级别的并发,相比其他主流Web服务器,如Apache的千级别的并发有很大的进步。除了上述两大特点外,Nginx内部处处可见大量的细节优化也是Nginx高效的基石。

 

进程模型

Nginx在启动后,在Unix系统中会以Daemon的方式在后台运行,后台进程包含一个Master进程和多个Worker进程。我们也可以手动地关掉后台模式,让Nginx在前台运行,并且通过配置让Nginx取消Master进程,从而可以使Nginx以单进程方式运行。

Nginx在启动后,会有一个Master进程和多个Worker进程。Master进程主要用来管理Worker进程,包含:接收来自外界的信号,向各Worker进程发送信号,监控Worker进程的运行状态,当Worker进程退出后(异常情况下),会自动重新启动新的Worker进程。而基本的网络事件,则是放在Worker进程中来处理了。多个Worker进程之间是对等的,他们同等竞争来自客户端的请求,各进程互相之间是独立的。一个请求,只可能在一个Worker进程中处理,一个Worker进程,不可能处理其它进程的请求。Worker进程的个数是可以设置的,一般我们会设置与机器Cpu核数一致,我们之前说过,推荐设置Worker的个数为Cpu的核数,在这里就很容易理解了,更多的Worker数,只会导致进程来竞争Cpu资源了,从而带来不必要的上下文切换。而且,Nginx为了更好的利用多核特性,提供了Cpu亲缘性的绑定选项,我们可以将某一个进程绑定在某一个核上,这样就不会因为进程的切换带来Cache的失效。像这种小的优化在Nginx中非常常见,比如,Nginx在做4个字节的字符串比较时,会将4个字符转换成一个Int型,再作比较,以减少Cpu的指令数等等。Nginx的进程模型,可以由下图来表示:

【高性能服务器】Nginx剖析

图 3-1

在Nginx启动后,Master进程会接收来自外界发来的信号,再根据信号做不同的事情。所以我们要控制Nginx,只需要通过Kill向Master进程发送信号就行了。比如Kill -HUP Pid,则是告诉Nginx,需要重启Nginx。Master进程在接收到Kill信号后是怎么做的呢?首先Master进程在接到信号后,会先重新加载配置文件,然后再启动新的Worker进程,并向所有老的Worker进程发送信号,告诉他们可以光荣退休了。新的Worker在启动后,就开始接收新的请求,而老的Worker在收到来自Master的信号后,就不再接收新的请求,并且在当前进程中的所有未处理完的请求处理完成后,再退出。

Worker进程又是如何处理请求的呢?我们前面有提到,Worker进程之间是平等的,每个进程,处理请求的机会也是一样的。当我们提供80端口的Http服务时,一个连接请求过来,每个进程都有可能处理这个连接,怎么做到的呢?首先,每个Worker进程都是从Master进程Fork过来,在Master进程里面,先建立好需要Listen的Socket(Listenfd)之后,然后再Fork出多个Worker进程。所有Worker进程的Listenfd会在新连接到来时变得可读,为保证只有一个进程处理该连接,所有Worker进程在注册Listenfd读事件前抢Accept_Mutex,抢到互斥锁的那个进程注册Listenfd读事件,在读事件里调用Accept接受该连接。当一个Worker进程在Accept这个连接之后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接,这样一个完整的请求就是这样的了。我们可以看到,一个请求,完全由Worker进程来处理,而且只在一个Worker进程中处理。

Nginx采用这种进程模型有什么好处呢?当然,好处肯定会很多了。首先,对于每个Worker进程来说,独立的进程,不需要加锁,所以省掉了锁带来的开销,同时在编程以及问题查找时,也会方便很多。其次,采用独立的进程,可以让互相之间不会影响,一个进程退出后,其它进程还在工作,服务不会中断。

惊群效应

什么是惊群效应,这个Nginx的进程模型有关。Nginx采用的是多Worker进程处理连接的方式,是一个请求只能由一个进程独占,所以除了那个先拿到请求的和Tomcat等一般的服务器不同,没有一个专门用于处理连接的工作单元。每个进程都是独立的工作单元。假如说现在没有连接请求到服务器,那么所有Worker进程都是处于休眠监听状态的,但是一旦有请求到来,那么所有休眠的Worker进程都会被唤醒,但除了建立连接的进程外其他进程又会恢复到休眠状态。这样的开销是可以避免的,在某些情况下,进程被频繁的大面积唤醒,休眠会消耗大量的资源。Nginx的做法是某一时刻只有一个Worker进程监听端口,具体的做法是设置一把锁。当进程空闲下来试图去监听端口时先尝试获取锁,如果没获取到锁那么进程继续当前工作。如果获取到了锁,则监听端口,负责处理新的连接请求。这里有一个问题,什么时候释放锁呢?是等到进程处理完所有事件后么?这样可能会导致事件过长,那么如何减少时间呢?Nginx的做法是设置两个队列,一个用于存放获取锁后建立的新的连接事件,另一个存放普通事件。当进程处理完新的连接事件后就释放锁,减少锁的占用事件。

负载均衡

Nginx如何实现多个Worker进程之间的负载均衡?Nginx采用了一个简单的算法,这个算法大致描述如下:每个Worker进程在初始化的时候会有一个Ngx_Accept_Disabled整形变量。这个变量初始值是每个进程自身连接池大小的7/8,符号为负。每次进程建立了一个连接就把这个值加1。直到这个值为正,当前进程不在处理新的连接,此时如果有锁,释放锁。这时其他进程获得锁的几率变大。每次完成连接后再把这个值减1,直到他恢复到初始值,进程开始尝试获取锁。这个策略比较简单,在一定程度上实现的负责均衡。

Nginx 的核心模块

Nginx将各功能模块组织成一条链,当有请求到达的时候,请求依次经过这条链上的部分或者全部模块,进行处理。每个模块实现特定的功能。例如,实现对请求解压缩的模块,实现SSI的模块,实现与上游服务器进行通讯的模块,实现与Fastcgi服务进行通讯的模块。

有两个模块比较特殊,他们居于Nginx Core和各功能模块的中间。这两个模块就是Http模块和Mail模块。这2个模块在Nginx Core之上实现了另外一层抽象,处理与HTTP协议和Email相关协议(SMTP/POP3/IMAP)有关的事件,并且确保这些事件能被以正确的顺序调用其他的一些功能模块。目前HTTP协议是被实现在Http模块中的,但是有可能将来被剥离到一个单独的模块中,以扩展Nginx支持SPDY协议。

模块的分类

Nginx的模块根据其功能基本上可以分为以下几种类型:

  1. Event Module:搭建了独立于操作系统的事件处理机制的框架,及提供了各具体事件的处理。包括Ngx_Events_Module, Ngx_Event_Core_Module和Ngx_Epoll_Module等。Nginx具体使用何种事件处理模块,这依赖于具体的操作系统和编译选项。
  2. Phase Handler:此类型的模块也被直接称为Handler模块。主要负责处理客户端请求并产生待响应内容,比如Ngx_Http_Static_Module模块,负责客户端的静态页面请求处理并将对应的磁盘文件准备为响应内容输出。
  3. Output Filter:也称为Filter模块,主要是负责对输出的内容进行处理,可以对输出进行修改。例如,可以实现对输出的所有Html页面增加预定义的Footbar一类的工作,或者对输出的图片的URL进行替换之类的工作。
  4. Upstream:Upstream模块实现反向代理的功能,将真正的请求转发到后端服务器上,并从后端服务器上读取响应,发回客户端。Upstream模块是一种特殊的Handler,只不过响应内容不是真正由自己产生的,而是从后端服务器上读取的。

Load-Balancer:负载均衡模块,实现特定的算法,在众多的后端服务器中,选择一个服务器出来作为某个请求的转发服务器。

事件模块核心 Epoll

Nginx如何做到几十万的并发连接,答案就是Epoll事件驱动机制。假设有100万用户与一个进程保持Tcp连接,虽然连接数巨大,但是在某个时刻只有小部分连接是活跃的。所以,进程只需要处理这100万连接中的小部分足矣。要达到这种目的,怎么做才是最高效的。进程要在某个时刻在100万个连接中找出活跃的小部分,是把所有的连接都交给操作系统,让操作系统负责查找么?在Linux内核2.4版本之前,Select或者Poll事件驱动方式就是这样做的。这样做的缺点很明显,如果每次收集事件都把所有的连接都传递给操作系统会造成用户态到内核态的内存大量复制,而查找过程也是巨大的资源浪费,Select和Poll采取这样的方式,导致他们都只能处理几千个并发连接。

【高性能服务器】Nginx剖析

图 4-1

让我们来看看Epoll的做法,Epoll在内存中申请一棵红黑树,用于存放所有的事件。同时会申请一个双向链表,用于存放活跃事件。所有添加到红黑树中事件都会与设备(主要是网卡)驱动程序建立回调关系,当有事件发生的时候回调函数会将事件放入到双向链表中。相比Select和Poll只是通知用户态有事件发生了,但是究竟是哪几个事件发生了他不管,必须由用户自己遍历判断的做法,Epoll因为有一个存放活跃事件的双向链表,可以大大的提升效率。所以有事件发生的时候,只要复制这个链表到用户内存中即可。而且采用红黑树存放事件有利于事件的查找和删除。在Nginx中Epoll不仅用于监听网络Io设备,同样用于监听文件Io,使Nginx的文件读写效率提升。下图是采用Select Io模型的Apache服务器和采用Epoll模型的Nginx服务器的性能对比。可以看出在并发数超过2500左右Apache服务器的效率开始显著下降,而Nginx服务器的效率下降平滑。

【高性能服务器】Nginx剖析

图 4-2

处理过期事件

在Epoll模型中存在一个事件过期问题,假设调用Epoll_Wait一次返回3个事件,当进程处理第一个事件的时候,第三个事件可能过期了,比如说网络连接被客户端主动关闭了,原本第三个事件占有的连接被归还给了连接池。那么如何标志这个事件已经过期了呢,一种做法是将连接描述符置为-1来表示这个连接对应的事件已经过期了。但是在某种极端的情况下这种做法会发生错误,比如说:进程处理完第一个事件后,开始处理第二个事件,而这个事件恰巧又是和客户端建立连接,那么这时候从连接池里获取的连接很可能就是刚刚第三个事件归还的连接。这时候第二个事件建立连接,描述符不再是-1,而当进程处理第三个事件的时候无法识别出已经过期的事件。Nginx的做法是用一个单独的标志位处理,每次连接从连接池中取出来的时候将这个标志位取反。这样每次处理前先看看现在事件的标志位和之前的是不是一致,不一致就是过期事件。

反向代理模块

数据转发功能,为Nginx提供了跨越单机的横向处理能力,使Nginx摆脱只能为终端节点提供单一功能的限制,而使它具备了网路应用级别的拆分、封装和整合的功能。同时,Nginx的配置系统提供的层次化和松耦合使得系统的扩展性也达到比较高的程度。一个典型的反向代理示意图:

【高性能服务器】Nginx剖析

图 4-3

当客户端向某个Ip地址发起请求服务的时候,Nginx作为高负载的入口服务器负责建立连接,因为服务器的存储大小有限,所以具体的请求内容不会存储在本地,而是把内容存放在上游的Web服务器中。Nginx通过两种全异步的方式来和第三方服务器通信,Upstream与Subrequest。两种方式都保证在通信的时候不会产生阻塞,使得服务器的效率达到最大。Surequest从本职上和Upstream没有区别,他的底层实现就是基于Upstream的。两者的区别在于设计的目的不同,Upstream的功能主要是转发请求给上游服务器,也就是所谓的透传。Nginx获取到内容后不会经过过多的加工就会把内容传递给客户端。而Subrequst指的是在建立连接后,Nginx需要和上游服务器进行多次Upstream通信,获得的内容多半不是完整的内容,而是一些数据。Nginx将这些数据进行拼接后再将其传回客户端。

负载均衡算法

Nginx 的 Upstream 模块所支持负载均衡的算法:

  1. 默认的方式是轮询,每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器宕机,能自动剔除。
  2. 权重轮询均衡:可以指定轮询几率,权重(Weight)和访问比率成正比,用于后端服务器性能不均的情况。
  3. 每个请求按访问 Ip 的 Hash 结果分配,这样每个访客固定访问一个后端服务器,在有些应用情况下,需要将来自同一客户端的所有请求都分配给同一台服务器去负担,例如服务器将客户端注册、购物等服务请求信息保存的本地数据库的情况下,把客户端的子请求分配给同一台服务器来处理就显的至关重要了。
  4. 按后端服务器的响应时间来分配请求,响应时间短的优先分配。
  5. 按访问 Url 的 Hash 结果来分配请求,使每个 Url 定向到同一个后端服务器,后端服务器为缓存时比较有效。在 Nginx.Conf 配置文件中,用 Upstream 指令定义一组

(以 4 台服务器为例)负载均衡后端服务器池:

Upstream Servername {

Server 192.168.1.10:80 Weight=1 Max_Fails=3 Fail_Timeout=60s;

Server 192.168.1.11:80 Weight=1 Max_Fails=3 Fail_Timeout=60s;

Server 192.168.1.12:80 Weight=1 Max_Fails=3 Fail_Timeout=60s;

Server 192.168.1.13:80 Weight=1 Max_Fails=3 Fail_Timeout=60s;

}

其中,Servername 是服务器组名;Weight:设置服务器的权重,默认值是 1,权重值越大那么该服务器被访问到的几率就越大;Max_Fails 和 Fail_Timeout :这两个是关联的,如果某台服务器在 Fail_Timeout 时间内出现了 Max_Fails 次连接失败,那么 Nginx 就会认为那个服务器已经宕机,从而在 Fail_Timeout 时间内不再去查询它。

 

配置文件

Nginx的配置系统由一个主配置文件和其他一些辅助的配置文件构成。这些配置文件均是纯文本文件,全部位于Nginx安装目录下的Conf目录下。Nginx.Conf中的配置信息,根据其逻辑上的意义,对它们进行了分类,也就是分成了多个作用域,或者称之为配置指令上下文。不同的作用域含有一个或者多个配置项。当前Nginx支持的几个指令上下文:

  1. Main: Nginx在运行时与具体业务功能(比如Http服务或者Email服务代理)无关的一些参数,比如工作进程数,运行的身份等。
  2. Http:与提供Http服务相关的一些配置参数。例如:是否使用Keepalive啊,是否使用Gzip进行压缩等。
  3. Server: Http服务上支持若干虚拟主机。每个虚拟主机一个对应的Server配置项,配置项里面包含该虚拟主机相关的配置。在提供Mail服务的代理时,也可以建立若干Server.每个Server通过监听的地址来区分。
  4. Location:    Http服务中,某些特定的URL对应的一系列配置项。
  5. Mail:实现Email相关的SMTP/IMAP/POP3代理时,共享的一些配置项(因为可能实现多个代理,工作在多个监听地址上)。

指令上下文,可能有包含的情况出现。例如:通常Http上下文和Mail上下文一定是出现在Main上下文里的。在一个上下文里,可能包含另外一种类型的上下文多次。例如:如果Http服务,支持了多个虚拟主机,那么在Http上下文里,就会出现多个Server上下文。

总结

Nginx是一个较为复杂的服务器,本文只是简单的介绍了其中一些重要的组件及其运行原理,对于内部其他组件,比如说日志模块,邮件代理模块等没有涉及。本文主要针对Nginx的典型功能和其实现机制进行了剖析,对于大型系统的建设时服务器的选择有参考意义。

版权声明:本文为博主原创文章,未经博主允许不得转载。