了解Nginx架构设计

管理进程、多工作进程

nginx采用一个master管理进程、多个worker进行(cache manager | cache loader进程)的设计。

好处
  • 利用多核系统的并发处理能力
  • 负载均衡
  • 管理进程监控工作进程的状态,负责管理其行为
    • 占用资源少
    • 管理其它进程,提高系统可靠性
    • nginx运行中升级,实现动态扩展、定制、进化
Nginx 在启动时的流程
  • 解析命令行,处理参数。

  • 第2步,这里就是nginx的平滑升级(源码ngx_add_inherited_sockets方法),旧版本的master进程会通过execve系统调用来启动现版本的master进程(先fork出子进程再调用exec运行新的程序)。Nginx通过环境变量,旧版本的master告诉新版本master是在平滑升级,并对Nginx服务监听的句柄做继承处理。

  • 3–8是在ngx_init_cycle方法中执行,Nginx的每个模块都都需要有响应的数据结构在存储配置文件中的各项配置。

img

Worker进程如何工作

master进程要通知worker进程停止服务、更换日志文件等操作,对于这种控制进程运行间的进程间通信方式,Nginx采用信号。每个worker进程都有一个ngx_signal_handler方法。work进程主要关注的几个信号 (源码 ngx_worker_process_cycle )

  • QUIT 优雅的关闭进程
  • TERM或者INT 强制关闭进程
  • USER1 重新打开所有文件
  • WINCH 目前没有实际意义

ngx_exiting 为1,准备开始关闭worker进程,所有正则处理的连接将会调用关闭连接的方法。如果定时器中还存在事件,将继续执行,如果为空表示已经处理完所有事件 ,然后会调用模块的exit_process方法,销毁内存池退出worker进程。

master进程如何工作

master进程不需要处理网络事件,不负责业务的执行,它只会管理worker等子进程,实现服务重启、平滑升级、更换日志文件、配置文件实时生效等功能。需要关注以下几个信号

  • QUIT
  • TERM或INT
  • USER1
  • WINCH 所有的子进程不再接受处理新的连接,相当于对子进程发送了QUIT信号
  • USER2 平滑升级到新版本的Nginx程序
  • HUP 重读配置文件并使服务对新配置生效
  • CHLD 有子进程意外结束,这是需要监控所有的子进程

master管理worker进程的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// master进程使用的一个数组 -- ngx_processes

// 代表最多只能有1024个子进程
#define NGX_MAX_PROCESSES 1024
// 当前操作的进程在ngx_processes中的下标
ngx_int_t ngx_process_slot
// 数组中有意义的ngx_process_t元素最大的下标
ngx_int_t ngx_last_process
ngx_process_t ngx_processes[NGX_MAX_PROCESSES]


typedef struct {
ngx_pid_t pid;
// 系统调用获取到的进程状态
int status;
// 这是进程间通信的socket句柄,这一对socket句柄可以相互通信,master与worker之间的项目通信 (怎么通信?)
ngx_socket_t channel[2];
// 子进程循环调用的方法,
ngx_spawn_proc_pt proc;
void *data;
char *name;

unsigned respawn:1;
unsigned just_spawn:1;
unsigned detached:1;
unsigned exiting:1;
unsigned exited:1;
} ngx_process_t;

流程

img

  • 当ngx_reap为1的时候,就需要监控所有的子进程,通过调用ngx_reap_children这个方法,通过遍历子进程来返回标志位live
  • 当 live 为 1,ngx_termina l ngx_quit为1的时候,通知子进程强制退出,跳回到第1步,并挂起进程,等待信号激活进程。
  • 当ngx_reconfigure为1时,表示需要读取新的配置文件,Nginx不会让原先的worker进程去重新读取文件,而是重新初始化ngx_cycle_t结构体,读取新的配置,再拉起新的worker进程。(源码 ngx_start_worker_processes ngx_worker_process_init ),最后告知原来的worker进程优雅的退出
  • 检查ngx_change_binary标志位,如果为1表示要平滑升级nginx,调用ngx_exec_new_binary用新的子进程启动新版本的Nginx
  • 最后检查ngx_noaccept标志位,为0将继续循环,为1要像所有子进程发送QUIT信号,将ngx_noaccepting置为1,表示停止接受新的连接。

事件驱动

就是由一些事件发生源来产生事件,由一个或者多个时间收集器来收集、分发事件,然后很多的事件处理器会注册自己感兴趣的事件,同时“消费”这些事件。

传统服务器与Nginx的差别

传统web服务器

传统web服务器采用的事件驱动往往会局限在TCP建立连接、关闭事件上。一个连接建立之后,在它关闭前所有的操作都不再是事件驱动,它会退化成按照顺序执行每个操作的批处理模式,这样会导致一个请求在建立连接之后,会一直占用系统资源,直到连接断开。如果一个请求持续几秒或者更长,整个事件消费过程只是在等待某个条件而已,浪费了资源,影响了系统可处理的并发数。

img

把一个进程或者线程作为事件消费者,当一个请求产生的事件被改进程处理时,直到整个请求处理结束时,进程资源都将被这一个请求所占用。

Nginx

nginx不会使用进程或者线程来作为事件消费者,他们只能是某个模块(没有进程的概念)。只有事件收集、分发器才有资格去占用进程资源,它们在分发某个事件时调用事件消费模块使用当前占用的进程资源。下图显示的5个事件,按照顺序被收集之后,将使用当前进程进行分发,调用相应的事件消费模块来处理事件。

img

事件消费者只被事件分发者进程短期调用,使得网络性能、请求时延都得到了提升,每个用户所产生的请求都会及时响应,服务器的吞吐量也随之提升。

NOTE

nginx这种必须保证所有事件消费者不能有阻塞的行为, 否则将会导致占用分发进程,导致其它事件不能及时响应。

请求的多阶段异步处理

把一个请求的处理过程按照事件的出发方式分为多个阶段,每个阶段都可以由事件收集、分发器来触发。

如下请求一个静态资源的请求:

img

请求一个静态资源大致分成了7个阶段,这7个阶段是可以重复执行的(一个大文件可能会被分成上百个这样的阶段)。异步和多阶段是相辅相成的,当一个事件被分发到事件消费者中处理时,消费者处理完这个事件只想当于处理完了1个请求的某个阶段,等待内核的通知去处理下一个阶段。(epoll事件驱动


HTTP框架

http配置文件例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
http {
server {
server_name name1;
listen 80;
location /v1 {}
location /v2 {}
}
server {
server_name name2;
listen 8080;
location = /test0/ {
}

location /test0/ {
location /test0/hello {
}
}

location = /test1/ {
}
}
}
HTTP请求流程

以一个HTTP Request为例,Nginx内部一个request请求需要涉及以下几个阶段

  • 始化HTTP Request(读取来自客户端的数据,生成HTTP Request对象,该对象含有该请求所有的信息)
  • 处理请求头
  • 处理请求体
  • 调用与此请求(URL或者Location)关联的handler
  • 依次调用各phase handler进行处理

img

phase字面的意思,就是阶段。所以phase handlers也就好理解了,就是包含若干个处理阶段的一些handler。

在每一个阶段,包含有若干个handler,再处理到某个阶段的时候,依次调用该阶段的handler对HTTP Request进行处理,通常情况下,一个phase handle 对Request进行处理,然后产生一些输出。通常phase handler是与定义在配置文件中的某个location相关联的。

一个phase handler要完成的任务

  • 获取location配置
  • 产生适当的响应
  • 发送response header
  • 发送response body

当Nginx读取到一个HTTP Request的header的时候,Nginx首先查找与这个请求关联的虚拟主机的配置。如果找到了这个虚拟主机的配置,那么通常情况下,这个HTTP Request将会经过以下11个阶段的处理(phase handlers):

NGX_HTTP_POST_READ_PHASE

读取请求内容阶段 , 源码 ngx_http_core_generic_phase (这阶段的checker方法)

NGX_HTTP_SERVER_REWRITE_PHASE

Server请求地址重写阶段 源码 ngx_http_core_rewrite_phase,如果这阶段不存在返回值,是可以让请求直接跳到下一个阶段执行的。

NGX_HTTP_FIND_CONFIG_PHASE

配置查找阶段。这个阶段非常重要,location快速检测(每一个server模块都可以配置多个location,每一个location还可以继续嵌套location。HTTP模块是通过静态的二叉查找树来保存location的, 源码)

1
2
3
4
5
6
7
8
9
typedef struct {
ngx_queue_t queue;
ngx_http_core_loc_conf_t *exact;
ngx_http_core_loc_conf_t *inclusive;
ngx_str_t *name;
u_char *file_name;
ngx_uint_t line;
ngx_queue_t list;
} ngx_http_location_queue_t;

如何创建location查找树

nginx在加载location配置时,首先会生成一个location list,也是一个前缀list。生成步骤:从第一个location节点开始,找到一个与第一个location节点前缀不相同的节点,然后把这个节点之前的list到location,全部作为第一个location的location list,然后递归这个location list,同时继续递归后面剩下的location

  1. 原始的location分布

image-20200522172515405

  1. 生成location list

    从a1开始寻找和a1前缀相同的location,表示没有,所以a1就没有前缀list,继续aa节点,从aa节点到aad节点都是以aa为前缀的,所以location变为了下图

    image-20200522173101616

  2. 继续执行,递归分离的aa节点的list ,aac和aad节点,看aac的节点的后继节点有没有是aac前缀的。然后主location继续递归ab节点的后继节点。最后形成如下的location list(list指针的链表后面的元素都是拥有相同前缀的)

    image-20200522173342569

  3. Nginx中有个函数 ngx_queue_split(locations, q, &tail),作用是把location切割成两个双向循环队列,location队列和tail队列,location队列从原始的头元素到q元素之前的元素,tail队列从q元素开始到原location队列的最后一个元素。

    img

  4. location list的结构中,原始的那个location 的队列中只剩下了a1,aa,ab,ac,ad,ae这几个location节点,tree的构建是个递归的过程,首先从location队列中取中间节点,就认为是tree的root节点,它的list指针认为是tree节点,中间节点之前的那段list ,a1 ,aa认为是ab节点的左节点,ac,ad,ae认为是ab节点的右节点

    img

  5. 然后递归每个container再进行刚才的操作

    image-20200525083344288

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    >    static void 
    > ngx_http_create_locations_list(ngx_queue_t *locations, ngx_queue_t *q)
    > {
    > u_char *name;
    > size_t len;
    > ngx_queue_t *x, tail;
    > ngx_http_location_queue_t *lq, *lx;
    > //如果location为空就没有必要继续走下面的流程了,尤其是递归到嵌套location
    > if (q == ngx_queue_last(locations)) {
    > return;
    > }
    >
    > lq = (ngx_http_location_queue_t *) q;
    >
    > if (lq->inclusive == NULL) {
    > //如果这个节点是精准匹配那么这个节点,就不会作为某些节点的前缀,不用拥有tree节点
    > ngx_http_create_locations_list(locations, ngx_queue_next(q));
    > return;
    > }
    >
    > len = lq->name->len;
    > name = lq->name->data;
    >
    > for (x = ngx_queue_next(q);
    > x != ngx_queue_sentinel(locations);
    > x = ngx_queue_next(x))
    > {
    > lx = (ngx_http_location_queue_t *) x;
    > //由于所有location已经按照顺序排列好,递归q节点的后继节点,如果后继节点的长度小于后缀节点的长度,
    > //那么可以断定,这个后继节点肯定和后缀节点不一样,并且不可能有共同的后缀;如果后继节点和q节点的交集做比较,
    > //如果不同,就表示不是同一个前缀,所以可以看出,从q节点的location list应该是从q.next到x.prev节点
    > if (len > lx->name->len
    > || (ngx_strncmp(name, lx->name->data, len) != 0))
    > {
    > break;
    > }
    > }
    >
    > q = ngx_queue_next(q);
    >
    > if (q == x) {
    > //如果q和x节点直接没有节点,那么就没有必要递归后面了产生q节点的location list,直接递归q的后继节点x,产生x节点location list
    > ngx_http_create_locations_list(locations, x);
    > return;
    > }
    > //location从q节点开始分割,那么现在location就是q节点之前的一段list
    > ngx_queue_split(locations, q, &tail);
    > //q节点的list初始为从q节点开始到最后的一段list
    > ngx_queue_add(&lq->list, &tail);
    >
    > //原则上因为需要递归两段list,一个为p的location list(从p.next到x.prev),另一段为x.next到location的最后一个元素,
    > //这里如果x已经是location的最后一个了,那么就没有必要递归x.next到location的这一段了,因为这一段都是空的。
    > if (x == ngx_queue_sentinel(locations)) {
    > ngx_http_create_locations_list(&lq->list, ngx_queue_head(&lq->list));
    > return;
    > }
    > //到了这里可以知道需要递归两段location list了
    > ngx_queue_split(&lq->list, x, &tail);//再次分割,lq->list剩下p.next到x.prev的一段了
    > ngx_queue_add(locations, &tail); // 放到location 中去
    >
    > ngx_http_create_locations_list(&lq->list, ngx_queue_head(&lq->list)); //递归p.next到x.prev
    >
    > ngx_http_create_locations_list(locations, x); //递归x.next到location 最后了
    > }
    >
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ngx_http_location_tree_node_s {
// 左子树
ngx_http_location_tree_node_t *left;

// 右子树
ngx_http_location_tree_node_t *right;

// 无法完全匹配的location组成的树
ngx_http_location_tree_node_t *tree;

// 代表location中的URI字符串能够完全匹配, exact指向其对应的ngx_http_core_loc_conf_t,否则为NULL
ngx_http_core_loc_conf_t *exact;

// 代表location中的URI字符串无法完全匹配, inclusive指向其对应的ngx_http_core_loc_conf_t,否则为NULL
ngx_http_core_loc_conf_t *inclusive;

u_char auto_redirect;
u_char len;
// name 指向location对应的URI匹配表达式
u_char name[1];
};

NGX_HTTP_REWRITE_PHASE

Location请求地址重写阶段, 在上个阶段检索到location后 有机会再次利用rewrite URL

NGX_HTTP_POST_REWRITE_PHASE

请求地址重写提交阶段,用于检查rewrite重写URL的次数不可以超过10次,防止由于rewrite死循环导致Nginx服务不可用。

NGX_HTTP_PREACCESS_PHASE

访问权限检查准备阶段,

NGX_HTTP_ACCESS_PHASE

访问权限检查阶段,与nginx指令satisfy有关

NGX_HTTP_POST_ACCESS_PHASE

访问权限检查提交阶段

NGX_HTTP_TRY_FILES_PHASE

配置项try_files处理阶段

NGX_HTTP_CONTENT_PHASE

内容产生阶段

NGX_HTTP_LOG_PHASE

日志模块处理阶段


UPSTREAM

Nginx的upstream机制是事件驱动和HTTP框架的综合,它属于HTTP框架的一部分,也可以处理所有基于TCP的应用层协议(不限HTTP)。它没有任何阻塞地实现了Nginx与上游服务器的交互,同时又很好的解决了一个请求、多个TCP连接、多个读/写事件间的复杂关系。

定义、设计目的

img

  • 上游和下游
    • Nginx 的客户端可以是一个浏览器、服务器、应用程序,它们都属于下游服务,Nginx为了实现下游所需要的功能,是需要在上游获取原材料的。所谓的upstream机制就是用来使用HTTP模块在处理客户端请求时可以访问上游的后端服务器
  • 上游服务器提供的协议
    • 使用upstream机制时客户端必须为HTTP协议
    • Nginx可以访问所有支持TCP的上游服务器
  • 每个客户端实际上可以向多个上游服务器发起请求
  • 反向代理与转发上游服务器的响
    • 下游是HTTP协议,上游可以是基于TCP的任何协议,upstream为了做适配,会将上游响应划分成包头、包体,包头会由HTTP模块的方法(process_header)处理,包体直接转发
    • 上、下游网速差别。
      • 当两者网速差别不大时,或者下游网速更快,为了能够并发更多请求,(希望内存占用小一些)会开辟一个固定大小的内存,既用来接收上游的响应,又将保存的内容转发给下游。
      • 上游网速更快时,必须开辟足够多的内存缓冲区来缓存上游的响应,当达到内存使用上限,还会将上游响应缓存到磁盘中
负载均衡

负载均衡就是将请求“均衡”地分配到多台业务节点服务器上。这里的“均衡”是依据实际场景和业务需要而定的。

对于Nginx来说,请求到达Nginx,Nginx作为反向代理服务器,有绝对的决策权,可以按照规则将请求分配给它知道的节点中的一个,通过这种分配,使得所有节点需要处理的请求量处于相对平均的状态,从而实现负载均衡

实现功能

  • 将多个服务器节点绑定在一起提供统一的服务入口。
  • 故障转移,在意外发生的时候,可以增加一层保险,减少损失。
  • 降低上线运维复杂度,实现平滑上线。运维和开发同学都喜欢

种类

  • round robin(轮询)

    最佳实践,其实就是最常见、最普通的默认配置,当然也是在一定程度上最好用的配置。

    1
    2
    3
    4
    5
    #默认配置就是轮询策略
    upstream server_group {
    server backend1.example.com;
    server backend2.example.com;
    }
  • random(随机)

    1
    2
    3
    4
    5
    6
    7
    upstream server_group { 
    random;
    server backend1.example.com;
    server backend2.example.com;
    server backend3.example.com;
    server backend4.example.com;
    }
  • weight(权重)

    让业务节点中性能更强的机器得到更多请求

    1
    2
    3
    4
    5
    upstream server_group {
    server backend1.example.com weight=5;
    #默认为不配置权重为1
    server backend2.example.com;
    }
  • fair(按响应时长,三方插件)

    1
    2
    3
    4
    5
    6
    7
    upstream server_group{
    fair;
    server backend1.example.com;
    server backend2.example.com;
    server backend3.example.com;
    server backend4.example.com;
    }
  • least_conn(最少连接数)

    1
    2
    3
    4
    5
    upstream server_group {
    least_conn;
    server backend1.example.com;
    server backend2.example.com;
    }
  • url_hash(url的hash值)

    很多请求都是有状态的,上一次请求到哪个业务节点,这次还要请求到哪台机器。比如常见的session就是这样一种有状态的业务。

    这里Nginx提供了按照客户端ip的hash来作为用户的标示分配、url的hash作为分配标示的规则。本质上还是要找到用户的请求中不变的要素,抽离出来,这样就可以进行分配了。

    1
    2
    3
    4
    5
    6
    7
    upstream server_group{
    hash $request_uri consistent;
    server backend1.example.com;
    server backend2.example.com;
    server backend3.example.com;
    server backend4.example.com;
    }
  • ip_hash(ip的hash值)

    1
    2
    3
    4
    5
    upstream server_group {
    ip_hash;
    server backend1.example.com;
    server backend2.example.com;
    }
一致性hash

Nginx支持一致性hash进行分配,也就是配置中consistent。

什么是一致性hash?为什么要引入这个机制?在生产环境下,业务节点经常会出现增加或减少的情况,就算这种增加或减少都是被动的,也可能会对hash分配产生影响。如何能够做到尽量减少影响呢?这时一致性hash被发明出来。

解决的问题

  1. 请求分配特别不均匀

  2. 节点变动除了对分配到这个节点上的请求有影响,还会导致其他节点上的请求重新分配。

    原理

  3. 一致性哈希将哈希值空间组织成一个虚拟的圆环,如假设某哈希函数H的值空间为0 ~ 232-1(即哈希值是一个32位无符号整形),整个空间按顺时针方向组织,0和2^32^-1在零点中方向重合,整个哈希空间环如下

    img

  4. 将各个节点通过hash算法哈希,具体可以选择服务器的ip或主机名作为关键字进行哈希,这样每台机器就能确定其在哈希环上的位置,如下四个节点

    img

  5. 定位数据访问到相应的服务器:将数据key使用相同的函数hash计算出哈希值,确定在环上的位置,沿顺时针方向,遇见的第一个节点就是该访问的节点。例如有Object A、Object B、Object C、Object D四个数据对象,经过哈希计算后,在环空间上的位置如下:

    img

  6. 宕机分析:如果Node C宕机了,对象A、B、D不会受到影响,对象C会被重新定位到Node D上。(一般的,在一致性哈希算法中,如果一台服务器不可用,则受影响的数据仅仅是此服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它不会受到影响。)

  7. 添加节点:添加Node X,此时对象Object A、B、D不受影响,只有对象C需要重定位到新的Node X 。(一般的,在一致性哈希算法中,如果增加一台服务器,则受影响的数据仅仅是新服务器到其环空间中前一台服务器(即沿着逆时针方向行走遇到的第一台服务器)之间数据,其它数据也不会受到影响。)

    img

    综上看到, 一致性哈希算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。

    问题

    一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。

    img

    如上图只有两个节点的分布,很容易造成Node A集中大量的数据。出现这种问题也会产生雪崩的状态,为了解决数据倾斜的问题,一致性哈希引入了虚拟节点机制。

    办法

    每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。具体做法可以在服务器ip或主机名的后面增加编号来实现,比如可以分别计算 “Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,形成6个虚拟节点

    img