14.2 nginx 架构总览
传统的网络编程模型是, 每个进程/线程处理一个请求, 在网络读写会阻塞住. 这种编程模型, 在某些应用场景, 不能有效利用起内存及CPU资源. 因为创建一个进程/线程的内存开销很大, 包括栈以及堆内存的分配, 执行上下文的建立等. 进程/线程创建/销毁, 也带来了额外的CPU的开销. 此外, 当进程/线程数过多时, 频繁的上下文切换的开销, 会导致争用的问题, 从而导致性能不佳. 这些复杂性在Apache之类的Web服务上充分体现了出来. 我们需要在所提供功能的丰富性, 和资源的有效利用之间, 找到平衡.
nginx从项目伊始, 目标就是, 在满足站点流量不断增长的情况下, 达到更好的性能, 更有效地利用服务器资源. 所以, nginx采用了截然不同的编程模型. nginx的开发受到了最近系统内核开发中, 基于事件的机制的启发. 其结果就是, 模块化, 事件驱动的, 异步的, 单线程, 非阻塞, 奠定起了nginx代码架构的基调.
nginx充分利用了多路复用, 以及事件通知机制, 并将特定的任务分发给不同的进程去执行.
请求被固定数目的worker进程处理.
每个worker进程, 每秒可以同时处理数以千计的请求.
代码结构
worker囊括了最核心最基础的各个模块.
nginx的核心, 是维护一个简短有效的执行循环, 将处于不同阶段的请求, 交由各个模块代码去处理.
表现层以及应用层的功能逻辑绝大部分由各个模块提供.
模块的功能包括:
从网络/磁盘读写数据;
对内容做处理;
输出过滤;
执行服务器端引用操作;
以及在代理的情形下, 将请求转发给上游服务(upstream).
nginx的模块化架构, 允许开发者, 不改动核心代码, 就可以扩展Web服务器的功能. nginx模块分成了几类: 核心模块, 事件模块, 阶段处理模块, 协议模块, 变量处理模块, 过滤模块, 上游模块, 负载均衡模块等. 不同类型的模块的细节, 在后文会详细介绍. 目前nginx不支持模块动态加载, 也就是说, 模块必须在编译的时候引入. 将来的版本计划会对模块动态加载提供支持.
在各种事件处理(接受请求, 处理请求, 管理连接, 内容获取等)的实现上, nginx尽可能利用了系统内核提供的各种事件通知机制,
及各种磁盘读写优化手段, 如kqueue, epoll, event ports等.
目的是在网络异步读写, 磁盘操作, socket读写, 超时机制等方面, 尽可能的利用系统内核所提供的优化机制.
我们针对每个基于Unix的系统内核, 尽可能地优化了多路复用, 异步IO的处理方式.
下图是一个简略的nginx架构图:

Worker 模型
如前所述, nginx不会为每一个请求创建进程/线程.
worker进程, 从一个共享的监听socekt, 接受新的请求, 并在
进程内一个非常高效的执行循环中, 去负责上千个请求的处理.
nginx本身没有特别的请求仲裁/分发机制, 这一环节是交由系统内核去处理的.
nginx一启动就创建了多个监听socket.
worker进程不断地从这些socket接受请求, 负责读写, 处理HTTP请求和响应.
worker进程最复杂的代码块, 是上述的这个执行循环.
它囊括了各种内部调用, 并以异步事件的方式去执行.
异步操作是通过模块化, 事件通知机制, 回调函数, 以及精细控制的定时器实现的.
总的来说, 最重要的想法就是, 尽可能的非阻塞操作.
事实上, 只有当磁盘性能不足是, 才会发生阻塞的情形.
因为不会为每个请求创建进程/线程, 所以, 在绝大部分情况下, nginx对内存的使用是非常节省有效的.
由于省去了频繁创建销毁的开销, nginx对CPU的使用同样节省.
nginx仅仅做了这样一件事: 检查网络及存储状态, 将新的请求初始化, 丢入到执行循环中去, 异步执行直到完成,
请求一旦结束, 就会被销毁, 并从执行循环中移除.
此外, 通过对syscall的谨慎使用, 对象池及内存分配器的精确实现, 即便在很高的流量情形下, nginx也实现了对CPU的低负载.
nginx用多个worker处理请求, 所以在多核系统上有很好的扩展性.
一般来说, 每个计算核心分配一个worker, 可以充分利用多核架构的优势, 并且避免了争用及锁的问题.
资源控制, 分拆到了每个单线程worker的进程中去做, 因此避免了资源干等的情形.
这种模式也能在多磁盘的情形下, 避免IO阻塞, 达到更好的磁盘利用率.
因此, 当流量被分摊到多个worker时, 服务器的资源能够得到更有效的利用.
但是对于某些磁盘及CPU使用场景, worker的数目需要调整一下.
这里我们给的建议比较简单, 系统管理员应该针对自己的业务场景, 尝试多种配置方案.
比较通用的建议:
如果是计算密集型的业务 (如, 非常多的TCP/IP协议栈相关处理, SSL, 或者压缩), 那么worker数应该和核心数相等;
如果是IO密集型的业务 (如, 非常多的静态资源请求, 代理请求), 那么worker数应该在核心数的1.5到2倍之间.
有些工程师, 根据独立的存储单元数目, 决定worker数, 但是这种策略的有效性依赖于磁盘存储的类型及配置.
在下一个版本里, nginx的开发人员要去解决的一个主要问题就是, 如何避免绝大部分的阻塞磁盘IO操作.
目前, 如果磁盘性能不能满足一个worker的存储请求时, 这个worker任然有可能在磁盘读写时阻塞.
我们有一系列机制及配置项, 去缓解这个导致磁盘IO阻塞的点.
其中最值得一提的是, AIO以及sendfile等选项, 给磁盘性能提供了不少优化空间.
一个nginx的实例配置, 应该根据数据量, 可用内存大小, 以及底层的存储架构, 来做规划.
另一个和worker相关的问题是, 对于嵌入式脚本的有限支持.
举例来说, 标准nginx发行版只支持Perl脚本嵌入.
原因很简单: 一个嵌入脚本有可能在任何一个操作上阻塞, 或者异常退出, 其中任何一种情况, 都会导致worker挂起,
从而一下子影响了该worker所正在处理的上千个链接.
我们计划让nginx脚本嵌入, 更简单, 更健壮, 从而适用到更加广泛的应用场景中去.
nginx 进程角色
nginx由多个进程构成: 一个master进程以及多个worker进程; 此外, 还有多个特定用途的进程, 如加载进程, 缓存管理进程.
在1.x版本系列里, 每个进程都是单线程的.
所有进程主要通过共享内存的机制实现夸进程通讯.
master进程以root用户执行. 其它进程以普通用户权限执行.
master进程负责如下几件事情:
- 读取并验证配置
- 创建/绑定/销毁 sockets
- 根据配置, 启动/停止/监管
worker进程 - 在不造成服务中断的前提下, 更新配置
- 不停服务的二进制更新操作
- 重新打开日志文件
- 编译潜入的Perl脚本
worker进程接受/处理来自客户端的连接, 提供反向代理及过滤功能等, 几乎其它nginx所提供的功能.
就nginx实例监控而言, 系统管理员需要关注worker进程, 因为它们才反映了Web服务每日的运作情况.
缓存加载进程负责检查落地到磁盘的缓存对象, 并负责将缓存元数据加载到nginx的内存数据库中. 本质上来说, 缓存加载进程让nginx能够对一个特别分配的目录结构下的文件进行处理. 缓存加载进程遍历这个目录, 检查缓存元数据, 在内存数据库里更新相关记录, 当所有信息有效可用时, 这个进程就结束了使命.
缓存管理进程主要负责缓存的过期和标记失效.
该进程在nginx运行时长期驻留, 在运行失败的时候会被mater进程重启.
14.4 nginx 内部实现
如前所述, nginx代码由了内核以及多个模块组成. 内核提供Web服务器, Web及邮件反向代理的基础功能; 内核提供了底层网络协议支持, 构建了必要的运行时环境, 保证了不同模块间的无缝交互. 但是, 绝大部分协议及应用相关的特性, 都是通过模块, 而不是内核实现的.
内部实现上, nginx以模块流水线的方式处理连接. 换言之, 对于每个操作, 都有对应的模块去负责; 例如, 压缩, 内容替换, 执行服务端引用, 通过FastCGI/uwsgi等协议, 和上游应用服务器交互, 以及和memcached交互等.
有些模块, 如http和mail, 处于内核以及真正完成工作的模块之间.
这两个模块提供了对内核以及底层组件的封装.
在这两个模块中, 实现了和特定协议相关的一系列事件的处理, 如HTTP, SMTP, IMAP等协议.
和nginx内核一起, 这些上层模块负责维护具体功能模块的调用顺序.
虽然HTTP模块目前是在http模块实现的, 考虑到未来对SPDY等协议的支持, 我们计划将这部分隔离到一个具体功能模块去实现.
功能模块可以被划分为事件模块, 阶段处理模块, 协议模块, 变量处理模块, 过滤模块, 上游模块, 负载均衡模块等.
绝大部分功能模块补充了nginx的HTTTP功能, 不过事件模块以及协议模块在mail模块也被用到.
事件模块提供了系统相关的时间通知机制, 如kqueue, epoll, 依赖具体系统所提供的功能以及构建配置.
协议模块允许nginx使用HTTPS, TLS/SSL, SMTP, POP3及IMAP等协议通讯.
一个典型的HTTP请求处理循环:
- 客户端发送HTTP请求
- nginx内核根据配置的location匹配请求, 选择适当的阶段处理模块
- 如果有的话, 负载均衡器选择一个上游服务器, 作请求代理
- 阶段处理模块完成它的工作后, 将输出传送给其第一个过滤模块
- 第一个过滤模块将其输出传送给第二个过滤模块
- 第二个过滤模块将其输出传送给下一个过滤模块, 如此反复
- 最终的响应结果被发送到客户端
nginx模块的调用是高度可配置的. 整个过程是通过一系列函数指针的回调实现的. 当然, 这个模式也对自己开发模块的程序员带来了很大的负担, 因为需要严格定义其模块何时, 以及如何被调用. 为了缓解这个麻烦, nginx的API以及开发者文档都在不断的改进.
模块可以挂载的地方举例:
- 在配置文件被读取及处理之前
- 配置中每个location及server项出现的地方
- 主配置项初始化的时候
- 单个server配置初始化的时候
- server配置和主配置合并的时候
- lcation配置初始化, 或者和其所在的server配置合并的时候
master进程启动/停止的时候- 一个新的
worker进程启动/停止的时候 - 处理一个请求的时候
- 过滤请求的头部及内容信息的时候
- 选择/初始化/重新初始化上游服务的时候
- 处理来自上游服务返回数据的时候
- 和上游服务结束交互的时候
再一个worker的执行循环内, 负责处理一个请求的一系列事件处理, 大致如下:
- 开始调用
ngx_worker_process_cycle() - 处理系统相关的事件, 如
epoll,kqueue - 接受事件并派发相关事件
- 处理或者代理请求头部和内容
- 生成相应内容(头部, 内容), 以数据流的方式发送给客户端
- 结束请求
- 重新初始化各种计时器及事件
执行循环(5 -6 步)保证了请求的增量生成, 并以流的方式及时发送给客户端.
更细一点, 处理一个HTTP请求的步骤大概是这样的:
- 初始化请求处理
- 处理请求头部
- 处理请求内容
- 调用相关的处理器
- 按照处理阶段的顺序, 一步步执行下来
这里引出了处理阶段的概念. 当nginx处理一个HTTP请求时, 会把请求交到一些列处理阶段中去. 在每个处理阶段, 都有处理器需要执行. 总的来说, 阶段处理器处理请求, 产生相关的输出. 阶段处理器是和配置里面的location定义关联在一起的.
绝大多数情况下, 阶段处理器做四件事情: 拿到location配置, 生成响应, 发送头部, 以及发送内容. 每个处理器有一个描述请求的结构体参数. 请求结构体中有非常多的关于客户端的信息, 如请求URI, 以及头部等.
当读完HTTP请求的头部时, nginx查找对应的虚拟server配置. 如果找到, 请求会经历如下六个阶段:
- server重写阶段
- locaton匹配阶段
- loactoin重写阶段 (有可能会跳转回上一步)
- 访问权限决议阶段
- 尝试文件返回阶段
- 打日志阶段
对于进来的请求, 为了生成相应所需的内容, nginx将请求交给合适的内容处理器去处理.
基于具体的location配置, nginx可能会先尝试无调件处理器, 如perl, proxy_pass, fly, mp4 等.
如果上述内容处理器无一符合, nginx会按照如下顺序, 逐一选择: ramdon index, index, autoindex,
gzip_static, static.
索引模块的细节在nginx文档中有详述, 概括一下就是, 索引模块处理路径带/后缀的请求.
如果一个特定的模块 (如 mp4 或 autoindex) 不匹配, 请求的内容视作磁盘上的一个普通文件或者目录, 并由static 处理器处理.
对于一个目录路径, 会自动重写URI, 追加/, 并返回一个HTTP重定向.
内容处理器的内容会被过滤器过滤. 过滤器和location绑定, 一个location可能会有多个过滤器.
过滤器会修改处理器输出的内容. 过滤器的执行顺序在编译的时候就已经固定下来了.
对于自带的过滤器, 顺序是固定的; 而第三方的过滤器, 可以在编译的时候指定顺序.
就目前的nginx实现来说, 过滤器只能对输出内容进行修改, 不支持对输入内容进行写入和修改.
输入过滤器在将来的nginx版本中会支持.
过滤器使下面所说的这种”流水线”的设计模式成为可能: 过滤器被调用执行, 并调用了下一个过滤器, 直到整个过滤链最后一环执行完成. 之后, nginx结束了请求的处理. 过滤器不需要等前一个过滤器完成. 一旦上游过滤器开始有内容, 下游过滤器就可以开始工作 (很像UNIX的管道机制). 所以, 上游服务的输出尚未完全被接收的时候, 就可以向客户端的返回内容.
过滤器分为头部过滤器和内容过滤器, nginx将响应的头部及内容分别发给不同的过滤器.
头部过滤器有以下三大步:
- 决定是否处理这个响应
- 处理这个响应
- 调用下一个过滤器
内容过滤器修改返回的内容. 例如:
- 服务器端的引入
- XSLT过滤
- 图片过滤, 如重新切图片大小
- 字符集修改
gzip压缩- 不确定长度内容的分段编码
过滤链结束后, 响应被交给写者处理. 和写者相关联的, 还有其他特定用途的过滤器, 如copy过滤器, postpone过滤器.
copy过滤器负责将可能被放入代理临时目录的内容写入内存缓存.
postpone过滤器和子请求相关.
子请求对于请求/响应处理非常重要, 也是nginx最强大的地方.
通过子请求, nginx可以返回和URL原始路径不同的内容.
有些Web框架称之为内部重定向. nginx不限于此, 处理过滤多个子请求并合并为一个响应,
子请求是可以嵌套并有层级关系的.
子请求可以有自己的子子请求, 如此下去.
子请求可以映射到磁盘上的文件, 其它处理器, 或者上游服务.
举例来说, 服务器端引入(SSI)模块用过滤器解析返回的内容, 并将include字段替换为其所指定的URL内容.
或者, 可以将获取的文件内容当作作URL, 并将获取的新文件的内容追加到这个URL中.
上游服务模块以及负载均衡器模块也值得介绍一下.
上游服务可以被理解为反向代理的内容处理器 ((proxy_pass 处理器).
上游服务模块主要是讲请求发送给上游服务, 获取响应内容. 没有输出过滤器的调用.
上游服务模块具体所做的事情就是, 在上游服务有数据可读时触发回调.
我们有实现了如下功能的各种回调:
- 创建一个(或者是一系列)请求缓冲, 用于发送数据给上游服务
- 在创建请求之前, 重新初始化/重置和上游的连接
- 读取来自上游服务的数据
- 在客户端关闭链接的时候, 终止请求
- 消费完上游服务的响应后, 结束请求
- 清理返回的内容
负载均衡器模块和proxy_pass处理器相关, 在多个上游服务可用时, 决定选在哪一个.
负载均衡器注册了一个开关配置, 提供了额外的上游服务初始化函数 (解析上有服务域名, 等),
初始化连接的结构, 决定路由请求地址, 以及跟新状态信息.
当前nginx支持两种负载均衡策略: 轮流服务, 以及基于IP的hash.
负载均衡机制包括了一系列检测失效上游服务, 重新路由到余下上游服务的算法. 当然, 我们也计划对此做更多的改进. 总的来说, 我们计划对负载均衡这块做更多的开发, 在后续的版本中, 请求分发及上游服务健康检查将得到极大地优化.
另外一些有趣的模块, 为我们提供了额外的配置变量.
尽管变量会在不同的模块间被创建并更新, 有两个模块专门处理变量: geo以及map.
geo模块目的是根据客户端的IP做跟踪. 这个模块可以基于客户IP, 创建任意的变量.
map模块, 允许通过其他变量来创建变量, 从而提供了域名以及其他运行时变量的灵活映射能力.
这些模块可以被称之为变量模块.
在过去, nginx的内存分配机制受到Apache的影响:
每个链接所需的内存是动态分配引用, 用于储存修改请求和响应的头部及内容, 并在连接解除的时候释放.
要注意一点, nginx尽可能的避免内存拷贝, 绝大部分数据通过指针传递, 而不是memcpy.
深入一点说, 当一个模块返回响应的时候, 获取的内容被放在一个内存缓冲区里面, 并添加到缓冲链中. 之后的处理和这个缓冲链打交道. 由于会根据模块类型有不同的处理场景, 缓冲链会非常复杂. 例如, 当执行一个内容过滤模块的时候, 很难去精确控制缓冲区. 这种模块一次只能处理一个缓冲(链), 并且需要决定是否重写, 替换, 还是追加一个缓冲区. 更复杂的是, 有时, 一个模块可能会同时收到多个缓冲区, 导致当前工作的缓冲链内容不完整. 在这种情况下, nginx仅提供了一个操作缓冲链的底层API, 所以开发者在实现第三方模块的时候, 需要对这块儿特别熟悉.
上述的内存缓冲区, 在一个连接的整个周期内都存在. 因此, 对于长连接来说, 会导致一些额外的内存使用. 另外, 对于一个空闲的keepalive连接, nginx仅仅分配了550字节内存. 在将来的nginx版本里, 一个可能的优化是, 共享并重用长连接所占用的内存.
nginx内存池分配器负责内存的分配.
共享内存用来保存锁, 缓存元数据, SSl会话缓存, 以及关于带宽的管理策略信息.
共享内存由一个对象缓存分配器(slab allocator)负责管理.
为了保证共享内存使用的线程安全性, nginx提供了一系列锁机制, 如互斥锁, 信号量.
为了管理复杂的数据结构, nginx也提供了红黑树的实现. 红黑树被用在了缓存元数据跟踪, 非正则规则的location定义记录, 以及其他一些地方.
不幸的是, 上面这些从没有被很好地记录下来, 从而导致第三方模块的开发非常复杂. 虽然已经有一些非常好的关于nginx内部的文档 (比如说, Evan Miller编写的), 但是写这些文档需要浩大的反向工程工作, 导致ngxin模块开发对很多人来说, 仍然是很陌生.
尽管有开发的种种困难, 我们仍然看到许多优秀的第三方扩展模块. 如嵌入Lua脚本的模块, 额外的负载均衡模块, 完整的WebDAV支持, 高级缓存控制, 以及其他各种模块, 本作者非常鼓励这些模块的开发, 并在将来会做到很好的支持.