大家好,我是编程小6,很高兴遇见你,有问题可以及时留言哦。
本文理论比较多,主要目的是
- 理解 Nginx 在集群中的定位
- 理解异步、非阻塞IO
- 了解 Nginx 常用的负载均衡算法
- 了解 浏览器缓存和 Nginx 缓存的区别 建议前端了解后端的起点是从 Node开始学习尤其关于事件驱动的思路无论是前后端都会被频繁提起。而且 Node 和 Nginx 的共同点很多。
首先要声明一点:Nginx 本身来说更擅长底层服务器端资源的处理比如静态资源处理转发、反向代理,负载均衡等,而 Node 更擅长上层具体业务逻辑的处理,两者可以完美组合。
Nginx是由异步框架开发的网页服务器,也可以用作 反向代理、负载均衡 和 缓存(Made in Russia)。目前 Nginx 已经在 2019年被 F5 收购。 目前来讲经过测试能够支持五万个并行连接,而在实际的运作中,可以支持二万至四万个并行连接。 Nginx 本身也是由大量模块扩展组成而来,模块化方面十分类似 webpack 或者 ESlint;
目前来讲 Nginx 作为后端集群中的一员,无论是从可用还是并发的角度都有他能够发挥的地方,算是一个顶梁柱的存在了,现代互联网架构软件设计没有能离开他的。
前端要了解 Nginx ,还要说说现代服务器集群演进,这里不想从远古说起。笼统来讲网站开发只需要三个步骤,Web 客户端 - 应用 /文件服务器 - 数据库服务器,我们基于这三个方面不断做变革。
首先,最开始的服务器大多都是放在同一台机器上的,但是这会导致并发、存储能力受到限制,并且一旦这台机器挂掉,意味着整个应用程序都无法访问,所以又出现了 服务器分离 部署的方式。
解决了 服务器分离 的问题,下一个问题是解决我们的数据库压力,当业务量上升,请求数据量变大之后,数据库的压力也会随之增大,接口请求延迟就会增加,当然用户体验也上不去,这时候就出现了缓存。并且我们发现用户操作大部分都是读取操作而不是写入操作,通过采用 数据库读写分离 模式,主库负责写请求,从库负责读请求将流量分配到不同节点来改善数据库的负载能力。
再后来由于单个服务器不足以支撑我们应用的并发量,并且单个服务器不具备容灾功能,所以我们又把应用服务器和数据库都集群化,为了协调集群之间各种服务器的数据传输,我们又引入了 负载均衡器(Nginx/Tomcat) 来通过各种算法分配给应用服务集群(Tomcat)。
再后来我们发现,虽然负载均衡器的可用性足够高,但是一旦发生了宕机,我们仍然会面临应用不可用的情况,这里我们又引入了 LVS主备 做容灾,这里我们可以稍稍引申一些,因为 LVS 和我们本文要说的 Nginx 十分接近,LVS 既可以做负载均衡的最前端接入点,也可以为高可用集群做 Keeplive。
从高可用上来讲,如果有一台 Web 服务器宕机,或工作出现故障,Keepalived 将检测到并将有故障的服务器从系统中剔除,同时使用其他服务器代替该服务器的工作,当服务器工作正常后 Keepalived 自动将服务器加入到服务器群中,这些工作全部自动完成,不需要人工干涉,需要人工做的只是修复故障的服务器。LVS 的主备容灾就是其中一个例子,通过其中一台机器做备用机来防止机器宕机。当然这种双机热备只是高可用集群中的一种,它也是随用户需求不断变化的。
从负载均衡上来说,Nginx 基本会配备4万左右的并行连接,那么如果实际场景超过了这个数字就需要多个 Nginx 联合工作,我们一般就是使用 LVS 或者 DNS 作为 Nginx 的上层节点来分发给 Nginx 数据。
其实除了 LVS 四层来转发请求,我们也可以根据用户 IP 的不同用 DNS 域名解析来做负载均衡,这里留在后面说。
当大型网站随着业务越来越多,数据量提升,我们又对数据库进行了 分库分表,即把同一张表的数据,根据一定的规则算法,散列到不同库上。然后我们又想继续减轻数据库压力,我们又在应用服务集群和缓存数据库服务集群中间引入了搜索引擎,通过搜索引擎能让海量的用户搜索请求和我们的数据库中间做一层保护。
最后我们业务压力更高的时候,我们又会将具体的业务做隔离,变成一个个独立的子系统,这就是微服务的概念。另外在这里就涉及到横向扩展的问题比如 Docker、K8s,还有不同服务集群中间通信涉及到的 mq、zookeeper。不过这些就不在我们本文的内容内了。
Nginx 是一款轻量级的 HTTP 服务器,采用事件驱动的异步非阻塞处理方式框架,这让其具有极好的 IO 性能,时常用于服务端的反向代理和负载均衡。具有高并发、高可靠、高性能等优点并且支持热部署。
Nginx 与 Node 的事件驱动、异步I/O设计理念非常相似。不过 Nginx 采用纯C编写, Node 采用C++编写, 性能表现都很优异。它们的区别在于,Nginx 具备面向客户端管理连接的强大能力,而 Node 却是全方位的。
假设业务场景中有一组互不相关的任务需要完成,现行的主流方法有以下两种。
首先如果我们要创建多线程的开销小于并行执行,那么多线程的方式是首选的。多线程的代价在于创建线程和执行期线程上下文切换的开销较大。另外,在复杂的业务中,多线程编程经常面临锁、状态同步等问题,这是多线程被诟病的主要原因。但是多线程在多核CPU上能够有效提升CPU的利用率,这个优势是毋庸置疑的。
单线程顺序执行任务的方式比较符合编程人员按顺序思考的思维方式。但是串行执行的缺点在于性能,任意一个略慢的任务都会导致后续执行代码被阻塞。
在计算机资源中,通常 I/O 与 CPU 计算之间是可以并行进行的。但是同步的编程模型导致的问题是,I/O的进行会让后续任务等待,这造成资源不能被更好地利用。 操作系统会将CPU的时间片分配给其余进程,以公平而有效地利用资源。
基于这一点,有的服务器为了提升响应能力,会通过启动多个工作进程来为更多的用户服务(横向扩展)。
不过对于一组任务而言,如果它无法分发任务到多个进程上,所以依然无法高效利用资源,结束所有任务所需的时间将会较长。这种模式类似于加三倍服务器,达到占用更多资源来提升服务速度,它并没能真正改善问题。 添加硬件资源是一种提升服务质量的方式,但它不是唯一的方式。
单线程同步编程模型会因阻塞I/O导致硬件资源得不到更优的使用。多线程编程模型也因为编程中的死锁、状态同步等问题让开发人员头疼。
Node在两者之间给出了它的方案:
异步I/O的提出是期望 I/O 的调用不再阻塞后续运算,将原有等待 I/O 完成的这段时间分配给其余需要的业务去执行。
从计算机内核I/O而言,异步/同步和阻塞/非阻塞实际上是两回事。 操作系统内核对于 I/O 只有两种方式:阻塞与非阻塞。
在调用阻塞I/O时,应用程序需要等待 I/O完成才返回结果。 阻塞I/O的一个特点是调用之后一定要等到系统内核层面完成所有操作后,调用才结束。
阻塞I/O造成CPU等待I/O,浪费等待时间,CPU的处理能力不能得到充分利用。为了提高性能,内核提供了非阻塞I/O。非阻塞I/O跟阻塞I/O的差别为调用之后会立即返回。
非阻塞I/O返回之后,CPU的时间片可以用来处理其他事务,此时的性能提升是明显的。
但非阻塞I/O也存在一些问题。由于完整的I/O并没有完成,立即返回的并不是业务层期望的数据,而仅仅是当前调用的状态。而为了获取完整的数据,应用程序需要重复调用I/O操作来确认是否完成。这种重复调用判断操作是否完成的技术叫做轮询,关于轮询,我们也有很多种实现方式,接下来就会具体介绍这些轮询方式:
它是最原始、性能最低的一种,通过重复调用来检查I/O的状态来完成完整数据的 读取。在得到最终数据前,CPU一直耗用在等待上。说白了就是真正的轮询。
它是在read的基础上改进的一种方案,说白了就是通过对文件描述符上的事件完成状态来进行判断。
该方案是Linux下效率最高的I/O事件通知机制,在进入轮询的时候如果没有检查到 I/O事件,将会进行休眠,直到事件发生将它唤醒。它是真实利用了事件通知、执行回调的方式,而不是遍历查询,所以不会浪费CPU,执行效率较高。这也是 Nginx 采取的非阻塞I/O 轮询方式。
目前为止无论 Node 还是 Nginx 都采取的 epoll 方式做非阻塞I/O,只不过 Node 中的 libuv 将 epoll 封装了起来。
这里稍稍扩展以下服务架构模型的演进:
最早的服务器其执行模型是同步的,它的服务模式是一次只为一个请求服务,所有请求都得按次序等待服务。这意味除了当前的请求被处理外,其余请求都处于耽误的状态。它的处理能力相当低下,假设每次响应服务耗用的时间稳定为N秒,这类服务的QPS为1/N。(QPS:意思是“每秒查询率”,是一台服务器每秒能够相应的查询次数,是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。)
为了解决同步架构的并发问题,我们可以通过进程的复制同时服务更多的请求和用户。
这样每个连接都需要一个进程来服务,即100个连接需要启动100个进程来进行服务,其实这个也是不可接受的。因为这个过程要复制较多的数据, 启动是较为缓慢的。
后来为了解决启动缓慢的问题,我们又引用了预复制,即预先复制一定数量的进程。同时将进程复用来避免进程创建、销毁带来的开销。但是这个模型并不具备伸缩性,一旦并发请求过高,内存使用随着进程数的增长将会被耗尽。 假设通过进行复制和预复制的方式搭建的服务器有资源的限制,且进程数上限为M,那这类服务的QPS为M/N。
为了解决进程复制中的浪费问题,我们又引入了多线程服务模型。
让一个线程服务一个请求。线程相对进程的开销要小,并且线程之间可以共享数据,我们也可以利用线程池减少创建和销毁线程的开销。我们只能说多线程面临并发问题时处理能力比多进程好一些,但是线程堆栈都需要占用一定的内存空间。
另外由于一个 CPU 核心在一个时刻只能做一件事情,操作系统只能通过将 CPU 切分为时间片的方法,让线程可以较为均匀地使用 CPU 资源,但是操作系统内核在切换线程的同时也要切换线程的上下文,当线程数量过多时, 时间将会被耗用在上下文切换中。
所以在大并发量时多线程结构还是无法做到强大的伸缩性。 如果忽略掉多线程上下文切换的开销,假设线程所占用的资源为进程的1/L,受资源上限的影响,它的QPS则为M * L/N。
这种每线程/每请求的方式就是 Apache 使用的服务器模型。
当并发增长到上万时,多线程内存耗用的问题将会暴露出来,为了解决高并发问题,基于事件驱动的服务模型出现了,Node 与 Nginx 均是基于事件驱动的方式实现的,采用多进程/单线程避免了不必要的内存开销和上下文切换开销。
事件驱动架构是建立在软件开发中一种通用模式上的,这种模式被称为发布-订阅或观察者模式。事件驱动架构是建立在软件开发中一种通用模式上的,这种模式被称为发布-订阅或观察者模式。主题就像调频收音机一样,向有兴趣收听该主题所说内容的观察者进行广播。观察者可能只有一个,也可能有一百个,这都没有关系,只要主题有一些要广播的消息就够了。
综上,Nginx 这种架构尤其在高并发情况下比传统的多线程架构优秀,Apache 是同步多线程模型,一个连接对应一个线程。而 Nginx 是异步的,多个连接(万级别)可以对应一个进程。
Nginx 是多进程而不是多线程结构,因为线程之间共享同一个地址空间,是做不到维持高可用性的,因为如果某一个第三方模块错误极容易导致整个服务宕机。
Nginx 中主要维持一个父进程,这个父进程没有具体的功能,只是负责监控管理其他的子进程。子进程有两种,一种是缓存管理进程(cache process),一种是工作进程(work process)。
负载均衡也是高可用架构的一个关键组件,主要用来提高性能和可用性,通过负载均衡将流量分发到多个服务器,同时多服务器能够消除这部分的单点故障。Nginx 既可以做 http 层面的负载均衡,对于传输层 TCP、UDP的流量也可以做转发,对应用层的转发就是大家常说的七层负载,对传输层的转发就是四层负载均衡。
一般是lvs做4层负载;nginx做7层负载。
上文一直提到的4、7层负载其实就是基于 OSI 参考模型中的层级关系作区分,其实负载均衡的如果按照层级分类的话大致分为如下几种:
DNS 负载均衡:
因为 DNS 查询时需要解析域名返回 IP 地址,所以在 DNS 层面我们也可以做一层负载均衡,通过地理信息分配不同的服务器 IP 给 DNS 解析,现在的内容分发网络 CDN 都是需要 DNS 解析服务器来配合处理的,通过将域名解析成离用户地理位置最近的服务器能提升一定的响应速度。
软件负载均衡就是上文提到的 LVS、Nginx,四层负载均衡就是在网络层利用IP地址端口进行请求转发。七层负载均衡可以根据访问用户的HTTP请求头、URL 信息将请求转发到特定的主机。 LVS 为四层负载均衡。七层负载均衡交给 Nginx。
其中 LVS 一般用作最前端服务器负责顶住压力,后续再通过 Nginx 转发到各个服务器集群。优点就是便宜且扩充方便,缺点就是没有下文中的硬件负载均衡性能强并且安全性不足。
硬件负载均衡就是用基础网络设备比如交换机来实现负载均衡(F5、A10)
硬件负载的特点就是可以提供一定的防火墙安全功能,并且其性能也是相当强悍,唯一的问题就是贵,非常贵。另外其基本没有扩展能力,访问量如果变化频繁的话他基本没法做动态扩容。
接下来要具体聊聊具体的负载均衡算法,负载均衡算法性能主要围绕两个方面考虑,第一是高效,第二是可用性。
(hash % n)
,其中n为服务器节点数。所以当节点数量没变时,每个服务器的请求都是固定落地到同一个上游服务器的,但是如果当服务器扩容或者宕机,所有的客户端请求都会发生计算变化,大概率会发生大范围的缓存失效。 首先轮询(Round-Robin)是计算机算法里最基础最常见的方式,不管是分配 CPU 还是分配服务器都是一样的。它将请求按顺序轮流地分配到后端服务器上,均衡地对待后端的每一台服务器,而不关心服务器实际的连接数和当前的系统负载。 而加权轮询的原因就是因为不同的上游服务器处理业务的能力都不一样,所以承受的压力也不能相同。所以加权轮询就是给不同的上游服务器设置不同的权重,负载能力差的就分配较低的权重,能力强的就多分配一点。
这里主要涉及以下几个参数(upstream就是 nginx http负载均衡的配置位置)
upstream http_nginx {
server 127.0.0.1:3001 weight=10;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
在之前的加群轮询中其实忽略了一个问题,当权重按照 {2, 1, 1} 设计时,后端 nginx 接收到的请求其实也是按照 2次服务器1、1次服务器2、1次服务器3发送,这样会造成连续的请求不断落在服务器1中,分布相对不够均匀也可能导致短时间服务器1的负载压力上升。 这时就出现了平滑加权算法,将2次服务器1的请求分散在这四次请求之间,属于加权轮询的一点小优化。
原理其实也比较简单,其实就是在转发过程中动态修改当前服务器的权重,比如击中一次服务器1就把服务器1的 weight - 1
。
有关平滑加权轮询,我们其实也有一定程度的优化方案,当 swrr 算法调整权重时会导致被调高权重的服务器流量突然集中,QPS上升。并且 swrr 的线性 O(n) 算法时间复杂度会在大规模后端场景下处理能力线性下降。 所以后续又推出了 VNSWRR ,既能达到 SWRR 平滑、分散的特点,又能拥有稳定的线性复杂度。其实就是批量设置服务器节点降低短时计算量。
upstream backend {
vnswrr; # enable VNSWRR load balancing algorithm.
127.0.0.1 port=81;
127.0.0.1 port=82 weight=2;
127.0.0.1 port=83;
127.0.0.1 port=84 backup;
127.0.0.1 port=85 down;
}`
上文的加权轮询算法基本无法使用缓存,因为轮询的含义就是不管客户端传什么,我这边依次按顺序发给上游服务器就完事了,下游客户端的请求不存在任何标记使其能使用同一台上游服务器的缓存。
在哈希算法中,主要解决的就是让 Nginx 可以充分利用缓存,这里可以采用用户 ID 或者 IP 作为用户标记,哈希算法的原理上文已经叙述过,这里说一下具体的参数:
hash $request_uri;
upstream http_nginx {
# ip_hash;
# hash $request_uri;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
}
Nginx 也可以将请求发送给当前处理请求数量最少的服务器上。可以分担各个服务器之间的压力。但是如果各个服务器本身能力就不匹配,Nginx 一味把请求打到最小连接数量的服务器中的话,也可能会造成服务器压力过大
虽然哈希算法实现了缓存的利用,但是当服务器宕机或者扩容的时候,hash 算法会引发大量的路由定位变化,这就很大概率会导致缓存大范围失效,而一致性哈希就是为了解决这个问题。
一致哈希算法也用了取模运算,但与哈希算法不同的是哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值,这样其实对 2^32 进行取模运算的结果值就会变成一个圆环。
如果在特定时刻我们需要做扩容,那么只需要在这个环中确定一个范围将新加入的服务器放入其中,这样首先保证了缓存的稳定性,如果有新增服务器就会在这个环中增加节点即可,受到缓存重算影响的用户节点会被限定在一个很小的范围内。
worker_processes 1;
http {
upstream test {
consistent_hash $request_uri;
server 127.0.0.1:9001 id=1001 weight=3;
server 127.0.0.1:9002 id=1002 weight=10;
server 127.0.0.1:9003 id=1003 weight=20;
}
}
我们一般也会叫它浏览器缓存,也就是不断提起的强弱缓存,对于浏览器缓存,它的主要优点就是没有网络消耗,哪怕在缓存失效的情况下也会走协商缓存去让上游判断有无更新,如果没有更新也不需要上游服务器全量返回数据而是通过304告诉下游客户端可以使用缓存。
浏览器缓存一定程度达到了减少流量消耗的目的,不过缺点就是每个客户端的缓存都是不同的,浏览器缓存只针对一个用户设备。
如果用户真的走过了强弱缓存,仍然没有找到可用的结果,Nginx 就会向上游服务器发送请求,不过在发送请求前,Nginx 也会看看本地服务器有没有上游服务器未过期的返回值。 与浏览器缓存相比, Nginx 的缓存可以提升所有经过这台 Nginx 设备的用户体验,并且 Nginx 也能通过缓存拦住一部分发往上级服务器的流量。 不过既然已经到了 Nginx,那么用户也不可避免的产生网络消耗。
# expires [modified] time | expires epoch | max | off
server {
# 缓存设置到最大
# 其中 Expires: Thu, 07 Mar 2042 22:01:05 GMT
# 另外 Cache-Control: max-age=315360000
expires max;
# 不添加缓存
expires off;
# 缓存必定过期
expires epoch;
# 设置具体缓存时间
expires time 100
}
Nginx 的缓存内容都是放在磁盘上,但是文件信息都是放在内存中,Nginx 中的缓存为了防止长时间占用服务器磁盘和内存,会用到 LRU缓存淘汰算法 淘汰长时间不访问的缓存。其中 Nginx 有两个缓存相关进程会在这个步骤发挥作用。
# proxy_cache zone | off; 缓存的命名空间
# proxy_cache_path path keys_zone=name:size 缓存的具体文件路径和参数设置
# proxy_cache_valid [code ...] time; 什么样的缓存需要响应
http {
proxy_cache_path /nginx-demo/tempcache levels=2:2 keys_zone=cache_test:2m loader_threshold=300 loader_files=200 max_size=200m inactive=1m;
server {
proxy_cache cache_test;
proxy_cache_valid valid 200 1m;
proxy_cache_methods GET;
location / {
proxy_pass http://localhost: 8091; # 上游服务器的位置;
}
}
}
具体说一下涉及到缓存的 Ngnix 配置信息,这里涉及的参数比较多,建议直接在 ngx_http_proxy_module 模块中具体对照参数。
参数 | 适用范围 | 解释 | 默认值 | |
---|---|---|---|---|
proxy_cache zone off | http、server、location | 缓存命名空间 | off | |
proxy_cache_path path [levels=levels] [use_temp_path=on off]keys_zone=name:size [inactive=time] [max_size=size] [manager_files=number] [manager_sleep=time] [manager_threshold=time] [loader_files=number][loader_sleep=time] [loader_threshold=time] [purger=on off] [purger_files=number] [purger_sleep=time] [purger_threshold=time]; | http | 缓存配置路径、进程控制缓存的一些参数 | - | |
proxy_cache_key string | http、server、location | 缓存关键字,如何去查找缓存 | proxy_cache_key
s c h e m e scheme proxy_host$request_uri |
|
proxy_cache_valid valid[code] time; | http、server、location | 什么样的数据需要去缓存 | ||
proxy_no_cache string | http、server、location | 不存入缓存内容 | ||
proxy_cache_bypass string | http、server、location | 不使用缓存内容 | ||
proxy_cache_methods GET | POST | http、server、location | 那些方法需要缓存 | proxy_cache_methods GET HEAD; |
其中比较关键的参数是 proxy_cache_path,它的一些参数这里会列出来
proxy_cache_path | |
---|---|
path | 磁盘具体位置存放缓存文件 |
keys_zone | 共享内存名字 |
size | 共享内存大小 |
levels | 缓存文件目录层级,不建议过深 |
use_temp_path | 临时文件存放目录 |
inactive | LRU 缓存淘汰时间 |
max_size | LRU 缓存文件大小,超过了也会进行 LRU |
manager_files | 缓存管理进程一次淘汰的最大文件数,防止占用过多资源 |
manager_sleep | 缓存管理进程执行淘汰后休眠的时间。避免高频淘汰 |
manager_threshold | 缓存管理进程执行耗时 |
loader_files | 缓存加载进程载入磁盘缓存到共享缓存的每次最大数量,也是为了防止占用过多资源 |
loader_sleep | 缓存加载进程执行加载后的休眠时间 |
loader_threshold | 缓存加载进程载入文件的最大耗时 |
以下是缓存的具体流程,其实相对比较好理解,缓存是互联网领域不可或缺的一环,正是通过不断地增加缓存,我们的服务才支撑的住高并发。