写给后端的Nginx初级入门教程:Nginx原理初探

2,112 阅读16分钟

在上一篇文章写给后端的Nginx初级入门教程:配置高可用集群 中,我们使用keepalived实现了我们Nginx服务器的高可用配置,防止因为Nginx服务器挂掉而导致整个应用挂掉的这种情况的发生。而Nginx作为当下最受欢迎的web服务器软件之一,能做到如今的地位和成就也并不是没有原因的,优秀的性能表现,可伸缩性,可修改性的设计,同时跨平台的特性以及非常低的故障率都是Nginx现今如此受欢迎的重要因素,那Nginx整体架构又是如何设计的呢?本篇文章呢(由于要了解Nginx核心技术需要非常深的技术内力,我没有(大哭)),我们将轻轻的稍微揭开Nginx神秘面纱的一角,去探索一下Nginx内部是如何设计与运作的。

Nginx的特性

Nginx能如此受欢迎,并且企业所广泛采用,一定是有很多技能点满了的,经过查阅相关资料,前辈们一共总结出来六点Nginx服务器相对于其他类型的web服务器软件做的更加优秀的地方,也是Nginx设计之初主要关注的地方。它们分别是:

  • 性能
  • 可伸缩性
  • 简单性
  • 可修改性
  • 可见性
  • 可移植性

性能:

性能我想不必多说,这是Nginx能混到现在的核心资本,即使Nginx在其他方面做的很优秀,如果性能比不上其他的web服务器,在现在这个大家普遍比较看重性能的时代,Nginx有较大概率会受到冷遇。而Nginx和传统的程序不一样的是,其他程序比如游戏可能会需要计算性能,图形渲染,网络渲染性能,而Nginx作为一款web服务器,就只能在网络领域和别人一决雌雄了。

Nginx在网络性能这块做了大量的工作,包括使用事件驱动架构配合请求的多阶段异步处理,以及Master-workers机制的使用,都保证了在高并发场景下Nginx所展现出来的出色性能表现。

可伸缩性:

可伸缩性也可以理解为可扩展性,比如谷歌浏览器的插件,火狐浏览器的插件等等(没想到什么合适的例子),Nginx支持添加相关的模块来增强我们的服务,同时优秀的模块化设计允许我们定制或者采用第三方开发的模块来满足我们额外的业务需求。

简单性:

简单性通常指的是组件的简单程度,每个组件越简单,就会越容易理解和实现,也更容易被验证。当然开发Nginx组件不能随心所欲,同时要遵循Nginx模块开发统一的规范,而Nginx模块接口非常简单,具有很高的灵活性。

可修改性:

Nginx基于BSD开源,这意味当Nginx某些功能不能满足我们其他的额外需求时,我们可以修改它的代码来达到我们的业务要求。同时Nginx也支持在我们不重启,停止服务的前提下,修改我们web服务器的某些配置并使之生效。(平滑重启)

可见性:

可见性呢,就是我们整个应用对使用者的透明程度,开放程度。Nginx 有 http_stub_status_module 来实现基础的可见性,可以让我们了解到Nginx 当前一共建立了多少个链接,处理了多少个请求等等,这些监控参数可以让运维人员更好的了解Nginx服务整体运行的状况,并及时的做出调整。比如当 Reading + Writing 数值比较高的时候,就意味着我们当前的应用并发量还是比较大的。

可移植性:

由于Nginx是基于C语言开发的,这意味着Nginx可以在多个操作系统平台上运行,同时Nginx重新封装了日志,各种数据结构等工具软件,而且核心代码皆采用与操作系统无关代码的实现方式,而涉及到与操作系统的交互,Nginx则为不同操作系统提供了各自独立的实现,这点其实和java虚拟机有着异曲同工之妙。

说完了这些,Nginx又是如何实现这些骚操作的呢?接下来我们浅入Nginx内部,从模块设计,事件驱动,请求处理,进程管理四个方面来简单地了解Nginx内部是如何设计得如此高效的。

优秀的模块化设计:

Nginx和java一样,java呢是除了少数基本类型之外,其他一切皆为对象,Nginx也是如此,除了少部分核心代码之外,其他的皆为模块,Nginx模块遵循着同样的设计规范(ngx_module-t),设计规范中只要求了最核心的几个实现,比如初始化,退出,以及配置等等,这样做的好处和java接口类一样,在给了模块设计者充分自由的同时,又有效地避免了模块设计者乱来导致Nginx本身出现问题

同样的,规范(ngx_module-t)中允许我们自定义服务类型,比如在之前的实战篇 配置详解那部分,我们就主要说了Nginx的 全局块,events块,http块。而这些就属于我们Nginx的模块类型。比如http模块就只负责相关的http请求的处理,而关于事件的处理则全部交给events模块处理。

同时Nginx也引入了核心模块的概念,目前Nginx一共有六个核心模块,用来处理我们常见的

  • 日志(ngx_errlog_module)
  • 事件(ngx_events_module)
  • 安全(ngx_openssl_module)
  • 网络(ngx_http_module)
  • 邮件(ngx_mail_module)
  • 核心代码(ngx_mail_module)

这样做有什么好处呢,这意味着Nginx非模块的代码,比如Nginx的核心代码,只需要关注怎么调用这六个模块进行相应的处理就可以了,完全不需要管它们是具体怎么实现的。同样的,Nginx框架不会约束核心模块的接口和功能。这种简洁,灵活的设计为Nginx实现动态可扩展性,动态可配置性。动态可定制性带来了极大的便利。这段话怎么理解呢,这样理解:

不管黑猫白猫,能抓住耗子的就是好猫。Nginx核心模块不管你是怎么实现的,只要实现就行。所以核心模块的实现才可以充足的发挥。当然,这一切也是需要遵守相关的规范的,但是规范只是极少的一部分,整体留给核心模块的空间是十分大的。

事件驱动架构:

在了解Nginx的事件驱动架构前,我们先看一下传统的web服务器是如何工作的,接下来进入小剧场:

报,报tomcat大王,一个请求过来了!

这样啊,你派一个线程跟着,防止它有什么小动作,记住,等请求结束离开之后,再让那个线程回来。

在传统的web服务器中,一个请求往往会分配一个独立的线程或进程去处理,直到该线程结束,这当然没有什么问题,可是如果该请求请求到一半又想去读一下文件,这个时候就会造成IO阻塞,我们线程就只能在那干等着等它处理完,而请求开始到请求结束的这个过程,线程都始终占用着系统资源,直到请求结束线程被销毁才会释放资源,当然,如果请求刷的一下就处理完了这没有什么问题,但是如果请求一下子处理了几分钟,几十分钟,新的请求到来时只能额外再开新的线程,这谁顶得住,并发量稍微高一点线程数就达到最大值了。

当然,以上只是举例,tomcat在7之后就支持NIO异步IO处理了,tomcat8在linux环境中已经默认开启NIO模式。

而Nginx不一样在哪呢,传统的web服务器往往是事件消费者独自占用一个进程资源,而Nginx的事件消费者只是被事件分发者短期调用而已。比如在传统的web服务器中,当TCP建立链接的时候发生一个事件,然后链接之后交给一个进程去处理消费,这其中比如读写操作什么的都是这一个进程始终如一地去完成的。

而Nginx的独特之处就在于:

比如当tcp连接事件来的时候,会首先被我们事件收集者,分发者收到,然后事件分发者将这个事件交给,记住,交给仅仅处理tcp链接的消费者去处理,而tcp读事件和tcp连接消费者一点关系都没有,当读事件来的时候,就分发给只负责读事件的事件消费者,而每个事件消费者的处理都是刷的一下非常快的就处理完了,所有的事件消费者只是事件分发者进程的短期调用而已,这种设计使得网络性能,用户感知和请求时延都得到了提升,每个用户的请求都会得到及时的响应,整个服务器的网络吞吐量都会由于事件的及时响应而增大。

如果200个请求到达传统的web服务器,将会分配两百个线程去处理,如果传统的web服务器最大只能申请两百个线程的话,后面的用户就只有等待前面的请求完成,而Nginx则是两百个请求发起链接,连接事件消费者只把连接事件处理了,然后剩下的操作交给其他的事件消费者去处理,这样第201个请求来的时候,由于tcp连接事件消费者已经处理完了或者已经处理了大多数请求的连接,所以第201个请求也可以瞬间得到连接成功的响应。

太牛X了。

当然,这样也有弊端,就是我们的事件消费者进程不能阻塞和休眠,比如请求来了,你负责连接的事件消费者阻塞了,那我的事件分发者就得一直等你处理完,要不连接不上我也没法执行读事件。或者负责tcp连接的事件消费者因为太闲进程睡着了,事件分发者每次调用连接事件消费者的时候还得先把它唤醒,这都是不能忍的。所以Nginx的整体实现难度要比传统的web服务器高很多。

Nginx事件处理大致图如下(画的有点丑):

请求的多阶段异步处理:

既然说到了多阶段,在Nginx能够把单个请求分割成多个阶段的也只有事件驱动机制了,所以请求的多阶段异步处理实际上就是基于Nginx本身的事件驱动架构实现的。

比如获取静态文件的HTTP请求就可以划分为以下七个阶段:

阶段 触发事件
建立tcp连接 接收到tcp中的SYN包
开始接收用户请求 接收到TCP中的ACK包表示连接建立成功
接收到用户请求并分析已经接收到的请求是否完整 接收到用户的数据包
接收到完整的用户请求后开始处理用户请求 接受到用户的数据包
由目标静态文件中读取部分内容,并直接发送给用户 接收到用户的数据包,或者接收到TCP中的ACK包表示用户已经接收到上次发送的数据包,TCP滑动窗口向前滑动。
对于非keep-alive请求,再发送完静态文件之后主动关闭连接。 接收到TCP中的ACK包表示用户已经收到之前发送的所有数据包。
由于用户关闭连接而结束请求 接收到TCP中的FIN包。

当然,对于很多计算机网络基础较差的同学不是特别明白也没有关系,我们这篇文章并不是去分析Nginx这些操作是如何具体去实现的,而是去宏观的了解Nginx具体用了一种什么样的思路去设计和实现的。

大家这样去理解,每个响应的事件都会有对应的专门的事件消费者去处理,由于是单一的任务(比如只处理连接或者关闭),这对于每一个事件消费者来说都是相对容易且处理迅速的,负责tcp连接的事件消费者处理过之后可以马上投入到下一个tcp连接事件的处理中,这样可以使得我们每个事件消费者进程都一直在马不停蹄的全速工作,在高并发的情况下就很少有进程休眠这种情况的发生,因为在高并发的场景下,每个进程要处理的事件是非常多的,哪有功夫去睡觉。而传统的web服务器,一旦出现进程休眠,对于用户的感知就是请求的响应变慢了,而在高并发的场景下,由于一个请求对应一个进程(或线程),这个时候,如果进程不够了,系统就会去创建更多的进程,进程间的切换都会占用相当多的操作系统的资源,从而导致我们网络性能的下降。

可是如何把一个请求划分成多个阶段的呢?一般是找到请求处理流程中的阻塞方法。

比如在使用send调用发送数据给用户时,如果使用阻塞socket句柄,当send在向操作系统内核发出数据包之后就必须把当前进程休眠,直到数据成功发送之后才能醒来。而Nginx根据不同的触发事件把send这个过程分成两个阶段:

  1. 向操作系统内核发出数据包,不等待结果
  2. send结果返回。

因此就可以使用非阻塞的socket句柄,然后把socket句柄加入到事件中,也就是你发吧,我先干别的事儿,发完了通过事件告诉我,我再来处理数据包的事儿。

而在大文件中,也可以把阻塞的方法按照时间分解成多个阶段的方法调用,比如在没有开启异步IO的情况下,把1000M 的文件处理成1000份,每份1M,处理完这1M,马上处理其他的事情,然后再回来接着依次处理剩下的999M,这样的好处是,每次处理1m,我们能先腾出手来去处理一下其他的事情,而不是一下子处理1000M,干等着发送完。

如果实在没有办法把阻塞的操作拆分成多个阶段处理,Nginx便会派一个新的进程去单独处理这个阻塞方法,完成之后再发送完成事件通知。这样虽然方法是阻塞的,但是由于是额外的进程在处理,对其他的请求处理的影响是相对来说较小的。

管理进程+多工作进程的设计:

Nginx采用master - worker 机制,这样对于每个worker进程来说,由于是独立的进程,所以也避免了锁带来的额外开销,如果有多个CPU的情况下,多个worker进程占用不同的CPU核心来工作,提高了网络性能,降低了请求的平均时延,毕竟再怎么说,十个进程也要比一个进程处理起来快一点。

而我们master进程并不针对请求做处理,主要是用来管理和监控我们其他的worker进程,所以master并不会占用特别多的系统资源,同时还能通过进程通信做到worker之间的负载均衡,比如请求来的时候,优先分配给压力较小的worker进程去处理。同样的,比如我们单个worker进程挂了,由于进程之间是独立的,所以并不会影响到其他worker进程的处理。提高了整个系统的可靠性,降低了由于单个进程挂掉导致整个应用挂掉的风险。

如图所示:

下面开始技术总结:

今天呢,作为写给后端的Nginx初级入门教程最后一篇,原理篇,我们通过对Nginx架构的设计的简单探索非常浅显地了解了一下Nginx内部是如何设计和工作的,总的来说,本篇文章内容较为基础,对代码层面上的分析几乎没有提到,主要原因第一呢,考虑到这是一篇初级入门教程,所以并没有在代码设计上做很深的分析,更多的是架构设计,实现思路上面的宏观解释,至少让我们在不了解代码实现之前可以粗略地知道Nginx是如何运作的,第二个则是Nginx源码太过复杂,不是我这样的菜鸟可以分析透彻的(这个是主要原因)。

最后,非常感谢阅读本篇文章的小伙伴们,能够帮助到你们对于我来说是一件非常开心的事儿,如果有什么疑问或者批评欢迎留言到本篇文章下方,有时间的话我会一一回复。

韩数的学习笔记目前已经悉数开源至github,一定要点个star啊啊啊啊啊啊啊

万水千山总是情,给个star行不行

韩数的开发笔记

欢迎点赞,关注我,有你好果子吃(滑稽)

附:写给后端的Nginx初级入门教程所有文章链接:

写给后端的Nginx初级入门教程:基础篇

写给后端的Nginx初级入门教程:实战篇

写给后端的Nginx初级入门教程:配置高可用集群