Nginx 高性能背后的原因,如何使用 Nginx 做数据缓存提升服务性能
在上一篇文章【Java性能优化--Nginx篇(一)】中,我们介绍了 Nginx 的基本概念和基础应用,并使用 Nginx 的反向代理对服务水平扩展,提升系统吞吐量,接下来让我们一起解析下 Nginx 高性能原因,以及使用 Nginx 进一步提升系统性能。
一、Nginx 高性能原因分析
在目前软件开发的技术选型中,通常会选择 Nginx 作为 Web 应用的容器以及做负载均衡的软性反向代理,主要原因就是因为 Nginx 的高性能,而 Nginx 高性能的背后主要是因为以下几个因素:
-
使用 epoll 多路复用模型解决 IO 阻塞的回调通知问题;
-
使用 master worker 进程模型实现了平滑的过渡、平滑的重启,结合 worker 单线程模型,epoll 多路复用机制完成高效操作;
-
使用协程机制将用户的请求对应到线程中的某一个协程中,然后在协程中通过 epoll 多路复用机制来完成同步调用开发。
1. epoll 多路复用模型为啥是高性能的王者?
俗话说,有对比有差距,如果要弄清楚 epoll 为什么高效,需要对比其他几种模型
| 模型 | 工作机制 | 缺点 | 优点 |
|---|---|---|---|
| Java BIO | 一连接一线程 | 阻塞式的线程模型,线程资源浪费,扩展性差 | 编程简单 |
| Linux Select | 单线程轮询文件描述符(FD) | 每次轮询都要遍历所有FD,效率低 | 支持多路复用 |
| Linux epoll | 内核事件驱动通知回调机制 | 编程复杂 | 支持大并发,性能高 |
(1)Java BIO 模型
✅工作原理:
服务端 ServerSocket.accept()接受链接,每当有一个客户端连接,就新建一个线程处理它,每个线程主要阻塞在 InputStream.read() 或 OutputStream.write()上,如图:
🧠 核心流程:
- 建立连接:客户端通过网卡与Linux服务器建立 socket 连接;
- 客户端发送请求:客户端程序通过网卡将数据发送出去,Linux 主机通过调用 read 系统调用,将这份请求数据从接收网卡中读取到服主机内的内核缓冲区;
- 服务端获取请求:Java服务端程序通过 read 系统调用,从Linux 内核缓冲区读取数据到用户缓冲区;
- 服务端业务处理:Java服务端在自己的用户空间中完成客户端的请求所对应的业务处理。
- 服务端返回数据:Java服务端完成数据处理后,构建好响应数据,从用户缓冲区通过 write 系统调用,将数据写入 Linux 内核缓冲区;
- 发送响应数据给客户端:Linux 系统将内核缓冲区中的数据通过 write 系统调用,将内核缓冲区的数据写入网卡,网卡通过底层。
总结:以上就是Java BIO 一次完整请求的过程,在此过程中线程会一直在阻塞中等待系统完成 read 和 write 操作,而 read 和 write 调用是由操作系统自动完成,用户程序无感知。
❌缺点:
假设有10个客户端连接进来,服务端就得创建10个线程,每个线程都阻塞等待客户端发消息,线程资源消耗大,每个连接都要一个线程,并发能力差,线程切换成本高,CPU开销大。
(2)Linux Select 模型(IO 多路复用)
与 Java BIO 相比,Select 模型 不用一个连接就使用一个线程处理了,而是使用一个线程监听多个 socket 连接,大大减少了CPU 上下文频繁切换带来的性能消耗,其工作原理如下所示。
✅工作原理
-
用户态构建监听集合并调用
select()。 -
内核将用户态的集合拷贝到内核态,并轮询检测每个 fd(文件描述符) 的就绪状态。
-
如果某个 fd 没有就绪,内核将当前线程挂到该 fd 的 等待队列 中,等待事件发生;
-
当 fd 事件发生时,内核会唤醒等待队列中的线程,遍历就绪的 fd 进行相应的 I/O 操作。
❌缺点
- 监听的数量太少,最多只能同时监听1024个套接字,不适宜于广域网开发;
-
- 随着select的持续使用,会产生大量的拷贝开销和挂载开销;
- 通过轮询遍历的方式,遍历所有文件描述符,筛选出哪些可读/可写,非常消耗CPU,会导致服务器吞吐能力会非常差。
如果要详细了解 select 模型可以参考此文章:select详解&如何使用select模型实现简易的一对多服务器
(3)Linux Epoll 模型(事件驱动 IO,多路复用升级版)
✅ 工作原理:
- 使用
epoll_create()创建 epoll 实例。 - 使用
epoll_ctl()注册 socket 和你关心的事件(比如可读)。 - 使用
epoll_wait()等待事件。 - 在epoll 模型中,还是通过监听 socket 连接是否发生变化,并对每个连接设置回调函数,如果发生变化则直接唤醒并执行回调函数,而不需要遍历 fd 进行相应的 I/O 操作。
🧠 核心机制:
- 内核和用户空间之间建立了一种 回调通知机制(event ready) 。
- 支持 边缘触发(Edge Triggered)和 水平触发(Level Triggered)。
- 支持超大连接数(上万、十万级别),适合高并发服务端。
🔥 优点
- 性能比 select 高很多。
- 没有 FD 数量限制。
- IO 事件是 回调通知式 的,不用每次全遍历。
2. master worker 进程模型的到底做了啥?
Nginx 中 使用的是 master worker 模型来做高性能的网络交互的
🧠 核心层
-
创建 master 进程:在服务器中,我们使用的 OpenResty 来操作 Nginx 为例(如有疑问,请看我的 Nginx篇(一)),首先使用 sbin/nginx -c conf/nginx.conf 命令发送信号启动 Nginx ,这时 Nginx 会启动一个 master 进程,如下图所示;
-
创建 worker 子进程:Nginx在创建好master 进程后,还会根据 nginx.conf 配置文件中的
worker_processes配置来创建指定个数的 worker 进程,比如我这边使用的是默认的 worker_processes 1 配置,因此只创建了一个 worker 子进程; -
master 进程不处理任何 socket 连接:master 进程管理所有 worker 进程的内存空间,客户端发起连接时,由 worker 进程处理客户端连接,连接成功后,Nginx 则使用 epoll 多路复用机制来处理数据的读写;当 Nginx 配置文件有更新时,master 进程负责加载新配置,然后创建一个新的 worker 进程,将原有 worker 进程的socket 连接交给新的 worker 进程,达到无感刷新的效果。
3. 协程机制为啥高效?
-
依附于线程的内存模型,切换开销小:协程是线程的一个内存模型,它的切换不需要消耗CPU的损耗,只需要消耗内存的切换开销;
-
遇到阻塞及时归还执行权限,代码同步:若协程程序发生了阻塞,Nginx 对应的协程机制会自动剥夺协程的执行权限,交由另一个非阻塞的协程处理。
-
无需加锁:由于协程是基于线程的执行方式,所有协程都是串行执行的过程无需加锁。
二、使用 Nginx 缓存,解决服务的查询压力,提升服务整体性能
要说到缓存,大家应该都会想到使用 Redis、Guava 以及 Memcached 等等,但是无论哪种缓存技术都是先通过Nginx的反向代理请求到Java服务端后在业务方法中去读取缓存的,这难免会有网络消耗,而根据金字塔原则,距离请求客户越近,消耗就越小,因此如果能在 Nginx 层对热点数据做缓存不失为一种提升性能的捷径,而在 Nginx 中就提供了这种技术,下面就来看下实际的应用。
1. Nginx Proxy Cache 缓存
💡 Nginx Proxy Cache 是啥?
Nginx Proxy Cache 是基于 Nginx 的反向代理缓存机制,可以把后端服务器的响应内容临时存到本地磁盘,下次同样的请求就直接从缓存读取,不用再请求后端。
🧠 Nginx Proxy Cache 核心原理
-
以 Nginx 反向代理为基础:通过反向代理将请求发送到后台服务器,并将请求内容缓存到Nginx 本地,下次请求时直接从缓存读取;
-
依靠内存缓存文件地址(缓存 Key):在 Nginx Proxy Cache 缓存Key存储在内存中,缓存 Key 在内存中的内容就是缓存内容在文件中的地址;
-
依靠文件系统存储索引级的文件 (缓存内容):Nginx Proxy Cache 缓存的内容以文件的方式存储在本地,下次请求进来,先看本地是否有这个文件来决定是否去 Nginx Proxy Cache 读取响应内容。
🔧 Nginx Proxy Cache 实际使用
- 配置 Nginx 缓存文件信息
proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;
| 参数名 | 作用 | 举例说明 |
|---|---|---|
/usr/local/openresty/nginx/tmp_cache | 缓存文件存储路径 | 缓存会落地在这个目录 |
levels=1:2 | 子目录层级结构 | 避免单目录文件太多,例如生成 a/bc/ 结构的子目录 |
keys_zone=tmp_cache:100m | 定义一个叫 tmp_cache 的缓存区域,使用 100MB 的共享内存保存缓存的 key 索引 | 不存内容,只存 metadata(key、header 等) |
inactive=7d | 如果 7 天没有访问该缓存,就清理它 | 类似于 LRU 逻辑 |
max_size=10g | 缓存最大磁盘空间 | 超过后会自动清理旧缓存(先淘汰最久未用的) |
- 配置指定路径的反向代理请求才使用 Nginx Proxy Cache
# 配置使用 Nginx Proxy Cache 的反向代理动态请求
location ^~ /item/getFromNginxProxyCache {
proxy_pass http://backend_server;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ============ 配置 proxy cache ============
# 启用 `tmp_cache` 的缓存区
proxy_cache tmp_cache;
#
proxy_cache_key $uri$is_args$args;
proxy_cache_valid 200 206 304 302 7d;
}
完整的 nginx.conf 配置文件如下:
user nobody;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
upstream backend_server {
server 自己的服务器IP:8090 weight=1;
}
proxy_cache_path /usr/local/openresty/nginx/tmp_cache levels=1:2 keys_zone=tmp_cache:100m inactive=7d max_size=10g;
server {
listen 80;
server_name localhost;
location /resources/ {
alias /usr/local/openresty/nginx/html/resources/;
index index.html index.htm;
}
location ^~ /item/getFromNginxProxyCache {
proxy_pass http://backend_server;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache tmp_cache;
proxy_cache_key $uri$is_args$args;
proxy_cache_valid 200 206 304 302 7d;
}
location / {
proxy_pass http://backend_server;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
}
重新加载 nginx.conf 后,请求对应的接口,如图:
可以看到目录下多了一个 tmp_cache 目录,进入后可以看到有两个文件目录(levels=1:2配置的),进入目录后可以通过 cat 命令查看缓存内容,如图:
后续在请求这个接口,Nginx就直接从 proxy cache 缓存中获取数据直接返回,不会再请求到后端服务器。
2. Nginx Lua 实现请求缓存
上面我们使用 Nginx Proxy Cache 缓存来提高请求性能,但是 Nginx Proxy Cache 是基于文件系统,每次访问都要扫描磁盘,而扫描磁盘是极其耗时的行为,所以将缓存数据存放在内存中是非常有必要的,恰巧 OpenResty 可以通过 Lua 脚本的方式来操作内存空间。
🧠 在 OpenResty 操作 Nginx 时,在 Nginx 各阶段都有提供 lua 脚本的插载点,如下图:
🔵 Initialization Phase(初始化阶段)
1. init_by_lua*
- 阶段:Nginx 主进程启动时(只执行一次)
- 适合做的事:
- 加载全局配置
- 加载共享数据(例如从 Redis/MySQL)
- 初始化第三方库(如数据库连接池)
2. init_worker_by_lua*
- 阶段:每个 Nginx worker 启动时执行(每个 worker 执行一次)
- 适合做的事:
- 设置定时任务(
ngx.timer.at) - 初始化 worker 独有的数据结构
- 与外部服务建立长连接(Redis、MySQL 等)
- 设置定时任务(
🔵 Rewrite/Access Phase(请求预处理阶段)
请求进来之后,Secure Request 判断是否是 HTTPS 请求,如果是 HTTPS 请求,会执行 ssl_certificate_by_lua* 用来自定义证书获取逻辑,然后进入请求预处理阶段,此阶段在每个客户端请求到达时触发,主要用于处理 URL 重写、权限控制、路由分发等,这个阶段的lua挂载点有以下几个:
1. ssl_certificate_by_lua*
- 阶段:TLS 握手阶段
- 适合做的事:
- 动态生成或选择 SSL 证书
- 支持 SNI(Server Name Indication)动态切换证书
- 结合 Vault 等系统实现证书热加载
2. set_by_lua*
- 阶段:变量设置阶段
- 适合做的事:
- 动态设置变量值(如
$backend) - 计算和赋值用于后续 rewrite 或 proxy_pass 的变量
- 动态设置变量值(如
3. rewrite_by_lua*
- 阶段:重写 URL 规则阶段
- 适合做的事:
- 动态路由重写
- 添加/修改请求头、URL、参数
- 权限预校验(不建议放太复杂逻辑)
4. access_by_lua*
- 阶段:访问控制阶段
- 适合做的事:
- 鉴权、令牌验证、IP 白名单/黑名单
- 与第三方系统联动进行访问校验
- 拒绝不合法请求 (
ngx.exit(403))
🔵 Content Phase(内容生成阶段)
Content generated by?判断是由谁来生成响应内容;如果是 Lua 模块自己生成响应,则走 content_by_lua* 用于构造和返回请求内容(如 JSON、HTML、文件等);如果是由其他指令(如 proxy_pass、fastcgi_pass)转发到上游服务器(Upstream)则走 balancer_by_lua* 自定义负载均衡逻辑,决定请求该转发到哪个 upstream 服务器,这个阶段的 lua 挂载点有以下几个:
1. content_by_lua*
- 阶段:生成响应内容阶段(可替代传统
proxy_pass) - 适合做的事:
- 完全自定义响应内容(如返回 JSON、HTML)
- 实现 API 网关、Mock 接口、调试接口等
- 与 Redis、MySQL、HTTP API 通信返回数据
2. balancer_by_lua*
- 阶段:upstream 负载均衡决策阶段(仅用于
proxy_pass) - 适合做的事:
- 实现自定义负载均衡算法(如权重、最小连接数)
- 动态修改 upstream 地址(IP、端口)
- 服务发现(结合 Nacos、Consul)
3. header_filter_by_lua*
- 阶段:响应头处理阶段
- 适合做的事:
- 修改响应头(如添加 CORS 头、Content-Type)
- 设置缓存头、压缩相关头
- 日志追踪 ID 添加
4. body_filter_by_lua*
- 阶段:响应体过滤阶段(分块处理)
- 适合做的事:
- 替换、压缩或加密响应体内容
- 响应敏感信息脱敏
- 添加 HTML 片段、水印等
🔵 Log Phase(日志处理阶段)
1. log_by_lua*
- 阶段:请求处理完成后(日志阶段)
- 适合做的事:
- 自定义访问日志格式
- 记录请求耗时、状态码、业务日志
- 将日志推送至日志系统(如 Kafka、ElasticSearch)
🔧 Nginx Lua 实际使用
在Nginx