技术栈:为什么NGINX能扛住李佳琦直播间?底层原理揭秘 !
你有没有想过,单个 NGINX 实例是如何处理数百万个并发连接的?
NGINX 可扩展性和高性能背后的秘诀在于事件驱动的非阻塞 I/O 架构。这种架构能够充分利用 CPU 和内存资源,高效地处理不断增长的流量。
早在 21 世纪初,NGINX 就是首批专注于为可扩展性、高性能而设计的 Web 服务器之一,其架构设计理念也启发了许多其它 Web 服务器、反向代理(如 Pingora)、CDN 和云负载均衡器的设计。
本文我们将了解 NGINX 是如何扩展以便高效地处理海量并发连接的。我们将探讨不同的方案,并了解它们的局限性。接着,我们将深入了解 NGINX 的架构是如何解决可扩展性和性能挑战的。
首先,我们从 Web 服务的基本概念开始,了解为什么我们的系统中需要像 NGINX 这样的组件。
NGINX 是做什么的?
NGINX 作为客户端与 Web 服务之间的中介,接收来自客户端的请求,并将其转发至后端 Web 服务。它也被称为反向代理,能够在多个后端服务器之间进行负载均衡。
除了请求转发和负载均衡之外,NGINX 还负责处理一些通用的任务,如 SSL 终止、连接管理、限流等:
- SSL终止:NGINX负责解密HTTPS请求(处理SSL/TLS加密),然后将明文的HTTP请求转发给后端服务。这样后端服务无需各自配置SSL证书或消耗CPU资源解密请求。
- 连接管理:NGINX管理客户端连接(如长连接复用、超时控制、缓冲等),这样后端服务无需处理大量客户端连接的细节(如慢客户端、连接池维护),专注于业务。
- 限流:NGINX可以按IP、URL等维度限制请求速率,防止突发流量打垮后端,后端服务无需自行实现限流逻辑,避免重复开发。
下图展示了客户端与服务器通过 NGINX 进行交互的方式:
尽管 NGINX 有效解决了传统 Web 架构中的许多问题,但仍然需要应对以下几个挑战:
- 并发连接:需要应对来自大量客户端的高并发访问;
- 性能问题:在用户数量增长的情况下,性能不能出现明显下降;
- 资源利用率:在提升性能的同时,要尽量降低内存占用并充分利用 CPU 资源。
在深入了解 NGINX 的解决方案之前,我们先回顾一下连接管理的基本概念。
如何处理连接?
当 Web 服务器启动时,它会向操作系统发出调用,并告知其监听的端口号。例如,Web 服务器通常会监听 80 端口(HTTP)或 443 端口(HTTPS)。
当客户端发起连接请求时,操作系统内核会执行 TCP 三次握手,建立起与客户端之间的连接。每建立一个连接,操作系统就会为其分配一个文件描述符(也称为 socket)。
下图展示了客户端与服务器之间连接建立的过程:
默认情况下,网络中的数据收发(即网络 I/O)是阻塞式的。当线程或进程通过网络读取或写入数据时,会进入等待状态,直到操作完成。
此外,网络 I/O 的效率还受到客户端带宽的影响。对于网络较慢的客户端,数据传输可能会花费较长时间。
下图展示了一个进程如何等待数据传输完成:
因此,当服务器正在处理某个客户端的请求时,就无法同时接收新的连接请求,这不仅限制了系统的可扩展性,也影响了整体性能。
为了解决这个问题,业界提出了多种方案来提升连接处理能力。接下来我们将依次了解这些不同的方案及其各自的局限性。
每个请求对应一个进程(Process-Per-Request)
为了解决网络 I/O 的瓶颈问题,一种常见的方法是为每个客户端连接创建一个子进程,由子进程来处理这个连接的请求。
也就是说,每当有一个新的连接到来时,主进程就会 fork 出一个子进程来负责处理该连接的请求。一旦请求-响应的过程完成,子进程便会被销毁。
下图展示了这一处理流程:
但是你觉得这种方式能支撑上百万用户的连接吗?先花一点时间思考一下,然后再继续往下看。
我们来做个简单的估算:假设服务器的内存是 32GB,而每个子进程大约占用 100MB 内存。这样一来,在最理想的情况下,服务器最多也只能同时处理 320 个连接(32GB / 100MB)。
由此可见,这种方式在面对大规模并发请求的时候,显然是无法满足需求的。
那我们有没有更好的解决方案呢?如果不再为每个连接创建新的进程,而是改为创建线程,会不会更高效?
每个请求对应一个线程(Thread-Per-Request)
在这种方式中,每当有一个客户端连接建立时,服务器就会启动一个新的线程来处理该连接。每个请求由独立的线程负责处理,彼此之间互不影响。
下图展示了这一处理流程的工作原理:
线程相比进程更加轻量,内存占用大约只有进程的十分之一。因此,相较于“每个请求对应一个进程”的方式,这种方案在性能和资源利用方面有了显著提升。
但是!它依然存在前面提到的一些问题,难以应对海量并发场景。
一个进程不可能无限制地创建线程。随着线程数量的增加,频繁的 CPU 上下文切换会导致多线程的优势逐渐减弱,甚至带来性能下降。
为了进一步优化,我们可以采用线程池的方式:预先创建一个固定数量的线程,例如在一个进程中预建 500 个线程。
这种方式确实能更高效地利用内存资源。然而,如果所有线程都处于忙碌状态,新的连接就只能在请求队列中等待,导致响应延迟,影响整体性能体验。
因此,这种方式依然无法从根本上解决可扩展性和性能问题。由于网络 I/O 操作本身耗时较长,它仍然是整个系统的主要瓶颈,限制了整体的扩展能力。
那有没有办法在网络 I/O 阻塞时,让进程或线程“腾出手来”处理其他任务呢?答案是:有的。NGINX 就采用了一种非常巧妙的方案:基于事件驱动的非阻塞 I/O 模型。
接下来我们将深入了解 NGINX 的架构,看看它是如何有效的应对这些挑战。
NGINX 架构
NGINX 采用模块化架构,整体由多个关键组件组成,包括:
- 主进程(Master Process):它充当中央控制器,负责启动、关闭以及管理工作进程的创建和维护。
- 工作进程(Worker Processes):工作进程是真正执行核心逻辑的部分,负责处理客户端连接、转发请求、负载均衡等功能。
- 缓存加载进程(Cache Loader):该进程在 NGINX 启动时运行,用于将之前缓存的元数据加载到内存中,以加快后续请求的响应速度。
- 缓存管理进程(Cache Manager):该进程会定期检查缓存目录,并清理过期的缓存内容,以释放磁盘空间,确保缓存系统高效运作。
- 共享内存(Shared Memory):NGINX 的多个进程之间通过共享内存进行通信。共享内存不仅用于进程间的信息交换,还用于缓存数据和管理共享状态(如负载均衡的运行状态等)。
下图展示了 NGINX 架构中各个组件之间的关系和整体结构。
事件驱动的非阻塞 I/O
在非阻塞 I/O 模式下,Web 服务器或应用程序不会傻等客户端的数据到来。而是由操作系统在数据准备好时主动通知应用程序。
这使得整个处理流程转变为一种“事件驱动”机制。也就是说,只要客户端有数据到达,应用程序就会被“打断”去处理该数据;如果暂时没有数据,它就可以去处理其他任务,而不会被阻塞在那里空等。
在底层实现上,应用程序会通过如 epoll(Linux)或 kqueue(BSD/macOS)等系统调用,将感兴趣的套接字注册到操作系统中。内核会维护一个特殊的数据结构(如 Epoll 实例)来追踪这些套接字的状态变化。
一旦某些套接字中有数据可读,这些套接字就会被操作系统移动到一个“就绪列表”中。随后,操作系统会通知应用程序这些 套接字已经准备好了,应用程序就可以开始处理对应的数据了。
下面这张图展示了整个流程:
如图所示,当 fd3 和 fd4 上有数据到达时,操作系统会立即通知应用程序进程,告知它们已经可以读取数据了。
接下来,我们将结合 NGINX 的工作进程,进一步理解这个机制在实际中的应用。
NGINX 工作进程(Worker)
每个 NGINX 的工作进程是单线程的,并运行一个事件循环(event loop)。这个事件循环类似于一个无限的 while 循环,持续监听套接字上是否有事件发生,比如是否有新的连接请求或已连接上的套接字是否有数据到达。
此外,为了避免线程在多个 CPU 之间频繁切换,NGINX 通常会将每个工作进程绑定到特定的 CPU 核心(也称为 CPU 亲和性)。这样可以有效减少上下文切换的开销,进一步提升整体性能。
下面是一段伪代码,展示了 NGINX 工作进程的工作逻辑:
while True:
# 等待监听的 socket 上有事件发生
events = epoll_wait(epoll_fd, MAX_EVENTS, timeout=-1) # Block indefinitely
if events == -1:
raise Exception("epoll_wait failed")
# 遍历所有就绪的事件
for event in events:
fd = event.data.fd
# Case 1: 监听 socket 上有新连接请求
if fd == server_socket:
# Accept the new connection
# Case 2: 已连接 socket 上有可读数据
elif event.events & EPOLLIN:
# 通过已连接 socket 读取数据
data = client_socket.recv(BUFFER_SIZE)
ifnot data: # 关闭连接
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_socket, None)
client_socket.close()
del client_sockets[client_socket]
else:
# 追加数据到该客户端的缓冲区
client_sockets[client_socket]["buffer"] += data
# 处理客户端请求(处理过程大约耗时几百微秒)
request = client_sockets[client_socket]["buffer"]
response = process_request(request) # Assume this function exists
# 发送响应数据给客户端
client_socket.sendall(response)
# Case 3: 已连接 socket 可写据
elif event.events & EPOLLOUT:
# 处理向客户端写入数据的逻辑
pass
使用非阻塞套接字的情况下,NGINX 的工作进程不需要等待数据完全发送到客户端后再继续处理下一个连接,而是可以迅速切换去处理其他连接(对应 Case-1 的情况)。
如果同一时刻有多个套接字上都有数据可读,工作进程会遍历这些套接字,逐个处理客户端请求(对应 Case-2 的情况)。(对应 Case-2 的情况)。
由于网络 I/O 是非阻塞的,进程在数据传输期间无需等待,从而释放了 CPU 的空转时间。而 NGINX 的工作进程只有在解析请求、过滤内容或执行计算逻辑时才会真正使用 CPU。
这些计算操作通常非常快速,耗时仅为微秒级,因此一个工作进程每秒最多可以并发处理多达 10 万个请求。
假设一个工作进程可以同时处理 10 万个连接,那么在一台拥有 10 核心 CPU 的服务器上,就理论上可以支撑 100 万个并发连接(这里只是一个示意性举例,真实环境下会受到许多因素影响,表现可能有所不同)。
需要注意的是,如果一台服务器要同时支持 100 万个连接,它必须具备足够的内存资源,因为每个连接大约需要 100KB 到 1MB 的内存。虽然可以通过内核参数调优来减少每个连接的内存占用,但这可能会影响连接的稳定性,因此需要在性能和可靠性之间做出权衡。
总而言之,相比于“每个请求对应一个进程”或“每个请求对应一个线程”的模型,事件驱动的非阻塞 I/O 模式能更高效地利用 CPU,同时内存消耗也要小得多,能够更好地支撑高并发场景。