Nginx 从会用到看懂:配置、请求生命周期与内部架构
几乎每个后端都写过 Nginx 配置。但很多人写配置的方式是:搜一段、粘贴、reload、能跑就不动了。
这种状态下,你会遇到一些"玄学":
proxy_pass结尾多一个斜杠,404 变成了 200;两个location谁生效说不清;在if里写proxy_pass有时报错有时不报;改了upstream域名解析不生效必须重启;$request_time明明很长但后端日志说自己很快。这些都不是玄学。它们全部来自同一套东西:Nginx 的配置继承模型、11 个阶段的请求生命周期、以及 master/worker + epoll 的并发架构。 理解了这三件事,上面每个问题都变成"显然"。
这篇文章按"浅→深"排列:前四节你看完就能写出正确配置,中间四节讲清所有常见坑的成因,最后几节进到源码层面的设计取舍。
关于本文的口径
本文涉及的版本行为以 nginx 开源版 1.25+ 为基准(HTTP/3 在 1.25.0 转正)。凡是"某版本起才有"或"开源版没有、商业版 Plus 才有"的,我会显式标注——这类差异是踩坑高发区。
配置片段都是可直接使用的最小形式,为了聚焦省略了 TLS 证书路径等样板。生产环境请不要无脑照抄,尤其是超时、缓冲区、限流这几类需要按业务量级调的参数。
一、Nginx 到底解决了什么问题
要理解 Nginx 的设计,得先看它出生时的世界。
2004 年,主流 Web 服务器是 Apache,采用 prefork / worker 模型:每个连接分配一个进程或线程。这个模型在连接数不高时很好——代码写起来是同步阻塞的,直观易懂。
但它有个硬伤:一个连接一个执行体,成本是线性的。 每个进程要几 MB 内存,每次上下文切换要几微秒。当连接数上到一万,光是进程调度就能把 CPU 吃光。这就是当年著名的 C10K 问题(如何在单机上处理一万个并发连接)。
更糟的是,很多连接其实什么都不在做——用户打开页面后挂着不动,连接空闲但占着一个进程。这类"大量空闲长连接"正是 Web 的常态。
Nginx 的答案是把模型倒过来:
| Apache prefork | Nginx | |
|---|---|---|
| 并发单位 | 一个连接 = 一个进程/线程 | 一个 worker 进程处理成千上万连接 |
| I/O 模型 | 阻塞式,等 I/O 时执行体挂起 | 非阻塞 + 事件驱动(epoll / kqueue) |
| 空闲连接成本 | 一个进程的内存与调度开销 | 一个结构体,几百字节 |
| 内存曲线 | 随连接数线性增长 | 近似平坦 |
| 编程模型 | 直观(同步) | 复杂(回调、状态机) |
代价是编程模型变难了:不能"等",所有操作必须能中断、能恢复,于是模块要写成状态机。Nginx 把复杂度从运行时转移到了代码里——这是它全部设计的总纲,后面所有细节都是这句话的推论。
于是它天然适合这几类活:
- 静态文件服务:I/O 密集、逻辑简单,
sendfile直接零拷贝出去; - 反向代理与负载均衡:本质是搬字节,不做业务计算;
- TLS 终结:把加解密从应用剥离;
- 流量入口的控制点:限流、缓存、鉴权、灰度、改写。
反过来说,它不适合做重业务逻辑。这也是为什么它前面加了个 - 就成了另一个物种:需要写逻辑,就上 OpenResty/Lua 或换网关(最后一节会讲)。
二、五分钟能用起来:三个最小配置
先给能跑的东西,再讲原理。
静态站点
server {
listen 80;
server_name example.com;
root /var/www/example;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}try_files 依次尝试:当作文件找、当作目录找、都没有就 404。这是静态站的标准写法。
如果是 Vue/React 这类前端单页应用,最后一项换成入口 HTML——所有找不到的路径都交给前端路由:
location / {
try_files $uri $uri/ /index.html;
}反向代理
upstream backend {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}那四行 proxy_set_header 不是可选的。原因见第六节——不写的话,后端看到的 Host 是 backend,看到的客户端 IP 是 Nginx 自己。
负载均衡 + HTTPS
upstream backend {
least_conn; # 选当前连接数最少的
server 10.0.0.1:8080 weight=3; # 权重 3,多分流量
server 10.0.0.2:8080;
server 10.0.0.3:8080 backup; # 仅当上面全挂才用
keepalive 32; # 与后端保持 32 条长连接
}
server {
listen 443 ssl;
http2 on; # 1.25.1 起用这个指令,不再写在 listen 里
server_name example.com;
ssl_certificate /etc/nginx/certs/example.crt;
ssl_certificate_key /etc/nginx/certs/example.key;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://backend;
proxy_http_version 1.1; # keepalive 到后端必须用 1.1
proxy_set_header Connection ""; # 且必须清掉 Connection 头
proxy_set_header Host $host;
}
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}keepalive 的两个前置条件
upstream 里写了 keepalive 32,但如果不同时设 proxy_http_version 1.1 和 proxy_set_header Connection "",长连接不会生效——Nginx 默认用 HTTP/1.0 回源,且会转发客户端的 Connection 头。这是最常见的"配了 keepalive 但 TIME_WAIT 还是一堆"的原因。
到这里,80% 的日常需求已经覆盖了。下面开始讲为什么。
三、配置文件的心智模型
Nginx 配置不是键值列表,而是带继承的嵌套作用域。理解这一点,配置就从"记忆题"变成"推理题"。
上下文层级
main(文件顶层)
├── events { } # 连接处理方式
├── http { } # HTTP 全局
│ ├── upstream { } # 后端组
│ └── server { } # 虚拟主机
│ └── location { } # URI 路径
│ └── location { } # 可嵌套
├── stream { } # 四层(TCP/UDP)代理,与 http 平行
└── mail { } # 邮件代理三条继承规则
规则一:大多数指令向内继承。 在 http 写 gzip on,所有 server、location 都继承。
规则二:内层出现同名指令时是覆盖,不是合并。 这条最容易出事,尤其对 proxy_set_header 和 add_header:
server {
add_header X-From-Server "yes";
location /api {
add_header X-From-Location "yes";
# 结果:只有 X-From-Location!
# 一旦本层出现 add_header,父层的全部失效
}
}同一族的指令,只要子层写了任意一条,父层整族都被丢弃。所以公共 header 的正确做法是抽成文件然后 include,或者干脆在每一层写全:
# /etc/nginx/snippets/proxy-headers.conf
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; location /api {
include /etc/nginx/snippets/proxy-headers.conf;
proxy_pass http://backend;
}规则三:root 与 alias 的语义不同。
location /static/ {
root /var/www; # 请求 /static/a.png → /var/www/static/a.png(拼接)
}
location /static/ {
alias /var/www/assets/; # 请求 /static/a.png → /var/www/assets/a.png(替换)
}root 是拼接,alias 是替换掉 location 匹配的部分。用 alias 时两边的斜杠要么都有要么都没有,不一致会产生意外路径——历史上这还导致过路径穿越漏洞。
变量
Nginx 变量都以 $ 开头,常用的一批:
| 变量 | 含义 | 易混点 |
|---|---|---|
$host | 请求的主机名 | 优先取请求行,再取 Host 头,再取 server_name |
$http_host | 原始 Host 头 | 客户端没发就是空 |
$remote_addr | 直连对端 IP | 经过 CDN 时这是 CDN 的 IP |
$uri | 规范化并解码后的 URI | 会随内部重定向而变 |
$request_uri | 原始 URI,含查询串 | 不会变,适合做重定向 |
$args | 查询串 | $arg_name 取单个参数 |
$request_time | Nginx 视角的整体耗时 | 含客户端收数据的时间 |
$upstream_response_time | 后端耗时 | 与上一个的差值是关键诊断信号 |
$request_time 大而 $upstream_response_time 小,说明什么
说明慢在客户端,不在后端:可能是客户端网络差、在慢慢收响应体,或者请求体上传慢。
很多"接口很慢"的告警其实是这种情况——后端日志显示 20ms,Nginx 日志显示 3s,两边都没错。看两个变量的差值就能定位。
四、请求是怎么被匹配到 location 的
这一节是配置层面最值钱的知识。
第一步:挑 server
Nginx 先按 listen 的 IP:端口筛出候选,再用请求的 Host 头去匹配 server_name,顺序是固定的:
- 精确匹配:
server_name example.com; - 前置通配:
server_name *.example.com; - 后置通配:
server_name example.*; - 正则(按配置文件出现顺序,第一个命中即止):
server_name ~^www\d+\.example\.com$; - 该端口的默认 server——即标了
default_server的,没标就是配置里第一个
第 5 条是安全隐患
没有任何 server_name 匹配上时,请求会落到默认 server。如果你的第一个 server 恰好是某个内部管理站,那么直接用 IP 访问、或伪造 Host 访问,就能摸到它。
标准做法是显式放一个"黑洞" server 兜底:
server {
listen 80 default_server;
listen 443 ssl default_server;
ssl_reject_handshake on; # 1.19.4+:TLS 层直接拒绝,不需要给证书
return 444; # 444 = 不响应直接关连接,nginx 私有码
}第二步:挑 location(优先级不是从上到下)
这是最反直觉的地方。location 不是按书写顺序匹配,而是按下面这个算法:
1. 有 `=` 精确匹配命中? ────────→ 立即使用,结束
2. 遍历所有前缀 location,记住"最长匹配"的那个
└─ 若这个最长匹配带 `^~`? ──→ 立即使用,跳过正则,结束
3. 按配置文件顺序遍历正则 location(`~` 区分大小写 / `~*` 不区分)
└─ 第一个命中的 ────────────→ 使用,结束
4. 没有正则命中 ─────────────────→ 使用第 2 步记住的最长前缀一句话总结:精确 > 带 ^~ 的最长前缀 > 正则(按顺序) > 普通最长前缀。
看个例子,请求 /images/logo.png:
location /images/ { return 200 "A"; } # 前缀,长度 8
location /images/logo.png { return 200 "B"; } # 前缀,长度 15 ← 最长
location ~ \.png$ { return 200 "C"; } # 正则结果是 C。因为前缀最长匹配是 B,但 B 没带 ^~,所以还要过一遍正则,正则 C 命中就赢了。
把 B 改成 location ^~ /images/logo.png,结果就变成 B——因为 ^~ 的含义正是"我赢了就别看正则了"。
一个实用推论
静态资源的 location 建议加 ^~:
location ^~ /static/ {
root /var/www;
expires 30d;
}这样它不会被后面某个 ~ \.(php|jsp)$ 之类的正则截走,也省掉每个静态请求的正则匹配开销——高 QPS 下这不是可以忽略的量。
第三步:内部重定向
匹配到 location 之后,请求还可能被"重新扔回去再匹配一遍"。触发内部重定向的有三个东西:
try_files的最后一项(如果是 URI 而非=404)rewrite ... last;error_page指向一个 URI
location / {
try_files $uri /index.html; # 找不到文件 → 内部重定向到 /index.html
}
location = /index.html { # ← 会重新走这里
root /var/www;
add_header Cache-Control "no-cache";
}内部重定向次数有上限(默认 10 次),超了报 rewrite or internal redirection cycle。看到这个错误,就去找互相指向的 try_files / rewrite。
五、11 个阶段:Nginx 的请求生命周期
前面所有"为什么"的最终答案都在这里。
Nginx 处理一个 HTTP 请求,走的是一条固定的 11 阶段流水线。每个阶段挂着若干模块的 handler,指令属于哪个阶段,就决定了它什么时候执行——跟它写在配置文件的哪一行无关。
① POST_READ 读完请求头,最早的介入点
└─ realip 模块在这里改写 $remote_addr
│
② SERVER_REWRITE server 块级别的 rewrite
│
③ FIND_CONFIG ★ 此刻才选定 location(第四节那个算法在这里跑)
│
④ REWRITE location 级别的 rewrite / if / set
│
⑤ POST_REWRITE 检查是否需要内部重定向 ──┐
│ │ 若 rewrite ... last
│ └─→ 回到 ③ 重新选 location
⑥ PREACCESS limit_req、limit_conn、degradation
│ (限流在鉴权之前,被限的请求不消耗鉴权成本)
⑦ ACCESS allow/deny、auth_basic、auth_request、auth_jwt
│
⑧ POST_ACCESS 汇总 ⑦ 的结果,处理 satisfy any|all 的逻辑
│
⑨ PRECONTENT try_files、mirror(1.13.4 前叫 TRY_FILES)
│
⑩ CONTENT ★ 真正产出响应:static / proxy_pass / fastcgi_pass /
│ return / index / autoindex
│
⑪ LOG access_log 落盘这张图能直接解释一堆日常困惑:
为什么 if 是邪恶的?
社区那篇著名的 If Is Evil 讲的就是:if 属于 rewrite 模块,只在第 ④ 阶段执行。而 proxy_pass、root 这些是第 ⑩ 阶段的内容指令。把它们写在 if 里,等于让两个不同阶段的东西在同一个块里表达,行为就变得难以预测:
# 危险:两个 if 都会被求值,但只有一个 set 生效,容易出反直觉结果
location /x {
set $a 1;
if ($http_user_agent ~ MSIE) { set $a 2; }
if ($http_cookie ~ "id=([^;]+)") { set $a 3; }
return 200 $a;
}在 if 里可以安全用的只有 return 和 rewrite——因为它们本来就属于 rewrite 阶段。其他一切,用 map 或多个 location 替代:
# 推荐:用 map 做条件,它在配置解析期建表,运行期只是查表
map $http_user_agent $backend_pool {
default "http://normal_pool";
"~*bot|crawler" "http://crawler_pool";
}
location / {
proxy_pass $backend_pool;
}为什么限流看起来"没拦住"某些请求?
limit_req 在阶段 ⑥,而 return 200 属于阶段 ④ 的 rewrite 模块。如果你在同一个 location 里写了 return,请求在阶段 ④ 就返回了,根本走不到阶段 ⑥,限流自然不生效。
为什么 auth_request 能保护到 proxy_pass?
因为鉴权在 ⑦,内容在 ⑩,顺序有保证。反过来,limit_req(⑥)先于 auth_request(⑦)——这个顺序是有意设计的:恶意流量应该在最便宜的地方被丢掉,而不是先花一次子请求去鉴权。
阶段模型的另一个用途:读源码
如果你要写 Nginx 模块,第一件事就是决定"挂在哪个阶段"。ngx_http_core_module.h 里的 ngx_http_phases 枚举就是上面这 11 个常量。内容阶段的模块用 handler,改响应体的用 filter(第十节讲),代理类的用 upstream 框架。
六、反向代理的所有细节
反代是 Nginx 最主要的用途,也是坑最密的地方。
那个改变一切的斜杠
# 请求 /api/users/1
location /api/ {
proxy_pass http://backend; # 无 URI 部分 → 后端收到 /api/users/1
}
location /api/ {
proxy_pass http://backend/; # 有 URI 部分("/")→ 后端收到 /users/1
}
location /api/ {
proxy_pass http://backend/v2/; # → 后端收到 /v2/users/1
}规则只有一条:proxy_pass 的 URL 里带不带路径部分(哪怕只是一个 /),决定了是"原样透传"还是"替换掉 location 匹配的前缀"。
三个附加陷阱
- location 用正则时,
proxy_pass不能带 URI 部分,否则配置检查直接报错——因为"要替换掉哪一段"无法定义。 proxy_pass里带变量时,行为不一样:proxy_pass http://$backend;不带 URI 部分时不会自动透传原 URI,需要自己拼$request_uri。- 带变量还会改变 DNS 行为,见下面。
upstream 域名解析的时机
这是"改了 DNS 不生效必须重启"的根源:
# 写法 A:域名在 upstream 或静态 proxy_pass 里
upstream backend { server api.internal.com:8080; }
# → 启动/reload 时解析一次,之后永久缓存该 IP。
# 后端 IP 变了(K8s 滚动、云 LB 换 IP),Nginx 完全不知道。
# 写法 B:用变量 + resolver
resolver 10.0.0.2 valid=30s ipv6=off;
set $backend "api.internal.com";
proxy_pass http://$backend:8080;
# → 运行时解析,按 valid 指定的 TTL 刷新。在容器环境里,写法 A 几乎一定会咬你一口。开源版没有 upstream 的动态 DNS 重解析(Plus 的 resolve 参数才有),所以变量 + resolver 是标准绕法。
超时三兄弟
proxy_connect_timeout 5s; # 与后端建 TCP 连接的超时(默认 60s,太长)
proxy_send_timeout 60s; # 两次「成功写入」之间的最大间隔
proxy_read_timeout 60s; # 两次「成功读取」之间的最大间隔 ← 504 的主犯注意后两个不是总耗时,而是"相邻两次 I/O 操作之间的空闲上限"。所以一个流式接口只要持续有数据,proxy_read_timeout 60s 也能跑十分钟。反过来,SSE / 大模型流式输出这类场景如果首字节等待很久,就会被 proxy_read_timeout 砍掉。
proxy_connect_timeout 保持默认 60s 是常见错误配置:后端机器网络不可达时,每个请求都要挂 60 秒,连接池瞬间被打满,故障从"一个后端挂了"放大成"整个入口卡死"。
缓冲:Nginx 到底在替谁挡子弹
proxy_buffering on; # 默认开
proxy_buffer_size 4k; # 响应头 + 第一段响应体的缓冲区
proxy_buffers 8 16k; # 响应体缓冲区:8 个 16k
proxy_busy_buffers_size 32k; # 可以边收边发的上限
proxy_max_temp_file_size 1024m; # 内存装不下时落磁盘的上限proxy_buffering on 的意思是:Nginx 尽快把后端响应全部收下来,让后端连接尽早释放,然后再慢慢喂给客户端。这对后端是保护——后端不必陪着一个慢客户端耗着。
但对流式响应是灾难:SSE、Transfer-Encoding: chunked 的实时输出、大模型逐字返回,开着 buffering 就会变成"憋一大段再一起吐"。这类 location 要显式关掉:
location /stream {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding on;
add_header X-Accel-Buffering no; # 顺带告诉上层代理别缓冲
}真实客户端 IP
链路是 客户端 → CDN → Nginx → 后端 时,$remote_addr 是 CDN 的 IP。要拿真实 IP,用 realip 模块(它在阶段 ① 工作,所以后续所有阶段看到的 $remote_addr 都已经是改写后的):
set_real_ip_from 10.0.0.0/8; # 只信任这些来源发来的 XFF
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on; # 从右往左跳过所有可信 IP,取第一个不可信的set_real_ip_from 不能写成 0.0.0.0/0
X-Forwarded-For 是客户端可以随便伪造的普通请求头。如果你信任所有来源,任何人都能通过发一个 X-Forwarded-For: 1.2.3.4 来伪装 IP——基于 IP 的限流、封禁、地域策略全部失效。只信任你自己的 CDN / LB 网段。
WebSocket
WebSocket 需要协议升级,必须显式转发两个头:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s; # 否则空闲 60s 就被断开
}那个 map 是官方推荐写法:不能硬写 Connection: upgrade,因为非 WebSocket 请求也会走这个 location。
七、负载均衡与健康检查
五种调度算法
upstream backend {
# 1. 默认:加权轮询(不写任何算法指令时)
server 10.0.0.1:8080 weight=3;
server 10.0.0.2:8080 weight=1;
}
upstream backend2 {
least_conn; # 2. 最少连接:适合请求耗时差异大的场景
server 10.0.0.1:8080;
}
upstream backend3 {
ip_hash; # 3. 按客户端 IP 哈希:粗糙的会话保持
server 10.0.0.1:8080; # 缺点:一台机器上下线会导致大面积重新分布
}
upstream backend4 {
hash $cookie_sessionid consistent; # 4. 自定义键 + 一致性哈希
server 10.0.0.1:8080; # consistent 让扩缩容只影响少部分键
}
upstream backend5 {
random two least_conn; # 5. 随机选两个,再取其中连接少的("两次选择的力量")
server 10.0.0.1:8080; # 多 Nginx 实例时比纯 least_conn 更均衡
}random two least_conn 值得单独说:当有多个 Nginx 实例同时向同一组后端转发时,纯 least_conn 会出现"所有实例都觉得同一台最闲,一起涌过去"的羊群效应。随机取两个再比较,能显著削平这个尖峰——这是分布式负载均衡里的经典结论。
健康检查:开源版只有被动
upstream backend {
server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.3:8080 backup;
}含义是:在 fail_timeout 时间窗内失败达到 max_fails 次,就把这台标记为不可用,并在 fail_timeout 时间后再试一次。
这是被动健康检查,两个后果要知道
- 它靠真实请求去"探"——也就是说,后端刚挂时,前 3 个用户是要吃到错误的。
- 开源版没有主动探活。要主动定期发探测请求(
health_check指令)需要 Nginx Plus,或者第三方模块nginx_upstream_check_module(需重新编译),或者换 OpenResty / APISIX。
什么算"失败"由 proxy_next_upstream 定义,默认是 error timeout。要不要把 http_500 也算进去,取决于你的 500 是"这台机器坏了"还是"业务本来就报错"——把业务错误算成机器故障,会导致所有后端被逐个摘掉。
重试的隐藏风险
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2; # 最多试 2 台
proxy_next_upstream_timeout 10s; # 重试总耗时上限一定要设 tries 或 timeout 上限。默认是"把所有后端都试一遍"——后端集体过载时,一个请求会被放大成 N 个请求打到 N 台机器上,这是把过载变成雪崩的标准姿势。
另外,默认配置下非幂等请求也会被重试。POST /pay 超时后重试到另一台,可能造成重复扣款。要么在业务侧做幂等,要么显式关掉:proxy_next_upstream off;。
八、缓存:Nginx 最被低估的能力
# http 块:定义缓存区
proxy_cache_path /var/cache/nginx
levels=1:2 # 两级目录,避免单目录文件过多
keys_zone=my_cache:10m # 共享内存放 key,10m 约存 8 万个 key
max_size=10g # 磁盘上限
inactive=60m # 60 分钟没被访问就删(与过期无关)
use_temp_path=off; # 直接写目标目录,少一次跨设备拷贝
location / {
proxy_pass http://backend;
proxy_cache my_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
# ↓ 三个高价值开关
proxy_cache_lock on; # 同一 key 只放一个请求回源,其余等着
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on; # 过期后先给旧内容,后台静默更新
add_header X-Cache-Status $upstream_cache_status; # 排查必备
}三个开关分别防的是不同的事故:
proxy_cache_lock防缓存击穿:热点 key 一过期,一万个请求同时回源把后端打死。开了之后只有一个请求回源。proxy_cache_use_stale防后端故障扩散:后端挂了还能拿过期内容顶着,把"网站 502"降级成"内容旧了几分钟"。proxy_cache_background_update消除过期抖动:用户永远拿到即时响应,刷新在后台做。
$upstream_cache_status 有 MISS / HIT / EXPIRED / STALE / UPDATING / REVALIDATED / BYPASS 七种值,是判断缓存有没有真正生效的唯一可靠依据。加上这个 header,比猜半天有用。
开源版不能主动清缓存
proxy_cache_purge 指令是 Nginx Plus 的。开源版的选项:
- 第三方模块
ngx_cache_purge(要重新编译); - 按 key 算出磁盘路径(
md5(cache_key)拆成 levels 目录)然后删文件——可行但脆弱; - 用
proxy_cache_bypass配一个带密钥的 header,强制回源并覆盖旧缓存:
proxy_cache_bypass $http_x_purge_token;设计缓存方案时就该把"怎么失效"想清楚。最省事的办法其实是在 key 里带版本(静态资源加 hash 文件名),根本不需要清。
九、进程模型:4 个进程凭什么扛住十万连接
从这里开始进入内部。
master 和 worker 的分工
┌──────────────────────────────────────┐
│ master 进程(root 权限) │
│ · 读配置、校验配置 │
│ · bind() + listen() 监听套接字 │
│ · fork worker,监控其存活并重启 │
│ · 接收信号,驱动 reload / 热升级 │
│ · 自己不处理任何请求 │
└───────┬──────────┬──────────┬────────┘
fork │ │ │
┌───────▼──┐ ┌─────▼────┐ ┌───▼──────┐
│ worker 0 │ │ worker 1 │ │ worker 2 │ ← 降权为 nginx/nobody
│ epoll │ │ epoll │ │ epoll │
│ 事件循环 │ │ 事件循环 │ │ 事件循环 │
└──────────┘ └──────────┘ └──────────┘
↕ ↕ ↕
┌────────────────────────────────────┐
│ 共享内存(slab 分配器) │
│ 限流计数、缓存 key、SSL session、 │
│ upstream 状态……跨 worker 的东西 │
└────────────────────────────────────┘几个设计意图:
- 权限分离:只有 master 需要 root(绑 80/443、读私钥),worker 降权运行。worker 被攻破也拿不到 root。
- worker 之间不通信(除共享内存):没有锁竞争,没有跨核缓存行争抢。
worker_processes auto通常等于 CPU 核数,配合worker_cpu_affinity绑核,让每个 worker 独占一个核。 - worker 崩了 master 会重启它:只影响该 worker 上的连接,不影响整体。
这也解释了为什么限流数据必须放共享内存:limit_req_zone 里那个 zone=name:10m 就是在申请一块共享内存,否则 4 个 worker 各算各的,实际限流阈值会变成配置值的 4 倍。
一个 worker 的内部:事件循环
// 极度简化的 ngx_worker_process_cycle 骨架
for (;;) {
// 1. 算出下一个定时器还有多久到期,作为 epoll_wait 的超时
timer = ngx_event_find_timer();
// 2. 阻塞在这里等事件(唯一允许「等」的地方)
events = epoll_wait(epfd, event_list, nevents, timer);
// 3. 处理到期的定时器(超时的连接在这里被关掉)
ngx_event_expire_timers();
// 4. 逐个处理就绪事件,每个事件调用它注册的回调
for (i = 0; i < events; i++) {
ev = event_list[i].data.ptr;
ev->handler(ev); // ← 所有业务逻辑都在这些回调里
}
// 5. 处理延后到本轮末尾的队列(accept 后的处理、Nagle 式的批量写)
ngx_event_process_posted(&ngx_posted_events);
}关键在于 ev->handler(ev) 里绝对不能阻塞。一旦某个回调里做了阻塞操作(同步读磁盘、同步 DNS 解析、同步执行外部命令),这个 worker 上所有几万个连接一起卡住。
这就是那条铁律的由来:Nginx 里不能做慢活。 具体表现为:
- 磁盘 I/O 可能阻塞 → 于是有了
aio和thread_pool(把大文件读扔给线程池); - DNS 解析会阻塞 → 于是 Nginx 自己实现了一个异步 DNS 客户端(
resolver指令),不用 libc 的getaddrinfo; - Lua 里不能用阻塞库 → OpenResty 整套
lua-resty-*生态存在的唯一理由就是它们的替代品。
惊群、accept_mutex 与 SO_REUSEPORT
监听套接字是 master 创建、所有 worker 继承的。那么新连接来了,谁去 accept?
历史上这是个真问题("惊群",thundering herd):内核把所有 worker 都唤醒,大家一起抢,只有一个成功,其余白白被调度一次。
三代解法:
| 方案 | 机制 | 状态 |
|---|---|---|
accept_mutex on | worker 轮流持锁去 accept,一次只有一个在等 | 1.11.3 起默认 off |
| 内核修复 | Linux 内核对 accept 已只唤醒一个进程(epoll 的 exclusive 语义) | 现代内核默认行为 |
reuseport | 每个 worker 各自 bind 同一端口,内核按四元组哈希分发 | 推荐 |
http {
server {
listen 80 reuseport; # 内核直接把连接分给某个 worker,零竞争
}
}reuseport 的额外好处是负载分布由内核决定,避免了 accept_mutex 时代常见的"某个 worker 特别忙"。注意它只需在同一端口的一个 listen 上写一次。
连接数的账怎么算
events {
worker_connections 10240; # 每个 worker 的连接数上限(默认才 512)
use epoll; # Linux 上一般自动选中
multi_accept on; # 一次事件里尽量 accept 多个连接
}理论最大并发:
静态文件场景: worker_processes × worker_connections
反向代理场景: worker_processes × worker_connections / 2
(每个客户端连接要配一条到后端的连接,各占一个槽)同时还要过系统这一关,否则配了也用不上:
# 进程级文件描述符上限
worker_rlimit_nofile 65535; # 写在 nginx.conf 的 main 上下文
# 系统级
sysctl -w fs.file-max=2097152
sysctl -w net.core.somaxconn=65535 # accept 队列长度
sysctl -w net.ipv4.tcp_max_syn_backlog=65535listen ... backlog= 与 somaxconn 的关系
listen 80 backlog=65535; 请求的队列长度会被内核的 net.core.somaxconn 截断(较老内核默认只有 128)。只改 Nginx 不改 sysctl 等于没改——高并发建连时表现为"连接被拒绝"或 SYN 重传。
reload 到底发生了什么
nginx -s reload # 等价于 kill -HUP <master pid>1. master 读取并校验新配置(失败则原地放弃,老配置继续跑)
2. master 按新配置打开新的监听套接字、日志文件
3. master fork 一批「新 worker」,它们开始接受新连接
4. master 给「老 worker」发 QUIT:
· 立刻停止 accept 新连接
· 把手上已有的请求处理完
· 处理完后自行退出
5. 全部老 worker 退出,reload 完成所以 reload 是零丢包的——这也是它和 restart 的本质区别。但有两个前提要注意:
- 长连接(WebSocket、SSE)会让老 worker 迟迟不退出。Nginx 1.11.11 起有
worker_shutdown_timeout 30s;强制终结,不设的话可能积累一堆僵留的老 worker。 - reload 期间短暂存在两套配置。如果新旧配置的 upstream 差异很大,会看到流量在两个版本之间"分裂"几秒。
还有一个更硬核的操作:二进制热升级(换 Nginx 可执行文件都不断连接)。
kill -USR2 <old master pid> # 老 master 用新二进制 fork 出新 master + 新 worker
kill -WINCH <old master pid> # 老 worker 优雅退出,但老 master 留着
# 观察一段时间,确认新版本没问题:
kill -QUIT <old master pid> # 收工
# 如果有问题,回滚:
kill -HUP <old master pid> # 老 master 重新拉起老 worker这套设计在容器时代用得少了(直接滚动新 Pod),但它体现的思路——先并存、再验证、可回滚——至今仍是发布系统的正确形状。
十、内部实现:内存池、缓冲链、模块与 filter
再往下一层,是几个能解释"Nginx 为什么快且省"的实现选择。
内存池:不做通用分配器
C 里最常见的内存 bug 是忘了 free 和重复 free。Nginx 的解法是按请求生命周期的内存池 ngx_pool_t:
// 每个请求创建一个 pool,请求结束时一次性销毁
ngx_pool_t *pool = ngx_create_pool(1024, log);
char *p = ngx_palloc(pool, 100); // 从池里切一块
char *q = ngx_palloc(pool, 200); // 再切一块
// ...处理请求...
ngx_destroy_pool(pool); // 一次性全释放,不需要逐个 free内部策略是分档的:
- 小块(≤ pool 的页大小):从预分配的连续内存里"切"出来,只移动一个指针,且不支持单独释放——因为不需要,请求一结束整块回收。
- 大块:单独
malloc,挂到 pool 的 large 链表上,销毁时统一 free。 - 需要清理的资源(打开的文件、临时文件):注册 cleanup 回调,随 pool 销毁一起执行。
这带来三个后果:
- 分配的开销接近零(指针加法 vs 通用分配器的空闲链表搜索);
- 不会有内存泄漏——只要你从 pool 里分配,请求结束必然回收;
- 内存碎片少,因为大量小对象在连续内存里。
代价是:长生命周期的对象不能放请求 pool(会活到请求结束才释放),以及单个请求内不断分配的循环会撑爆 pool。这也是为什么处理超长 keepalive 连接或流式大响应时,模块要格外小心内存增长。
buf 与 chain:为什么能零拷贝
Nginx 内部所有数据传递都用 ngx_buf_t 和它串成的 ngx_chain_t 链表。关键在于 ngx_buf_t 不一定指向内存:
struct ngx_buf_s {
u_char *pos, *last; // 内存里的数据区间
off_t file_pos, file_last; // 或者:文件里的区间
ngx_file_t *file;
unsigned memory:1; // 数据在内存
unsigned in_file:1; // 数据在文件里 ← 关键
unsigned last_buf:1; // 整个响应的最后一块
unsigned flush:1; // 需要立刻发出
/* ... */
};一个 buf 可以只是"文件的第 0 到 1048576 字节"。于是发送静态文件时,Nginx 根本不把文件读进用户态,而是直接调 sendfile() 让内核把文件页缓存直接推到网卡——用户态零拷贝。
这就是 sendfile on; 的实际含义,也是"Nginx 发静态文件比应用服务器快一个量级"的物理原因。
模块的三种形态
┌─ Handler 模块:产出响应内容
│ (static / autoindex / dav / echo…)
│ 挂在某个 phase 上,一个请求只有一个内容 handler
│
Nginx 的模块 ────────┼─ Filter 模块:加工别人产出的响应
│ header filter 链 + body filter 链
│ (gzip / ssi / sub / chunked / addition…)
│
└─ Upstream 模块:把内容"要"回来
(proxy / fastcgi / uwsgi / grpc / memcached)
共用同一套 upstream 框架,差别只在协议封装Filter 链是倒序注册、顺序执行的洋葱结构:
内容 handler 产出 buf chain
│
▼
ngx_http_top_body_filter
│
┌────┴─────────────────────────────┐
│ sub_filter (替换文本) │
│ ssi_filter (处理 SSI 指令) │
│ gzip_filter (压缩) │
│ chunked_filter (分块编码) │
│ write_filter (真正 writev) │ ← 链尾
└──────────────────────────────────┘
│
▼
socket顺序不是随便定的,是编译时由 auto/modules 脚本按依赖排好的。理解这个链有实际用处:
gzip在sub_filter后面,所以sub_filter能改到未压缩的原文。但如果后端已经返回了 gzip 内容,sub_filter就什么也改不到了——这时要在proxy_set_header Accept-Encoding "";里把压缩关掉,让后端返回明文。image_filter、sub_filter都需要完整响应体,所以它们与proxy_buffering off天然冲突。
动态模块
1.9.11 起支持运行时加载:
load_module modules/ngx_http_geoip2_module.so; # 必须写在最顶层但 ABI 是强绑定的:编译模块的 Nginx 版本必须和运行的完全一致,否则加载失败。所以自己编模块时,nginx -V 输出的那一整串 configure 参数要原样保留,只往后追加 --add-dynamic-module=。
十一、TLS、HTTP/2 与 HTTP/3
TLS 的三个真正影响性能的开关
ssl_protocols TLSv1.2 TLSv1.3; # 1.0/1.1 早该关了
ssl_prefer_server_ciphers off; # TLS 1.3 时代交给客户端选更好
# ① 会话复用:省掉完整握手
ssl_session_cache shared:SSL:50m; # 共享内存,所有 worker 共用
ssl_session_timeout 1d;
ssl_session_tickets off; # 除非你有集群化的 ticket key 轮转
# ② OCSP stapling:由服务器代查吊销状态,省掉客户端一次外部请求
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/certs/chain.pem;
resolver 1.1.1.1 valid=300s; # stapling 需要能解析 OCSP 域名
# ③ 提前发 session ticket / 减少握手 RTT
ssl_early_data off; # TLS 1.3 0-RTT:省一个 RTT,但有重放风险ssl_early_data on 之前想清楚
TLS 1.3 的 0-RTT 让客户端在握手完成前就发数据,省一个 RTT。但 0-RTT 数据可被重放攻击。开启后必须确保 early data 里的请求是幂等的($ssl_early_data 变量可以判断,通常做法是对非 GET 请求返回 425 Too Early)。CDN 场景值得开,业务 API 一般不值得。
ssl_session_cache 用 shared 而不是 builtin 很重要:builtin 是每个 worker 自己一份,客户端第二次请求落到另一个 worker 就复用不上了。
HTTP/2
server {
listen 443 ssl;
http2 on; # 1.25.1 起的写法
http2_max_concurrent_streams 128;
keepalive_requests 1000; # 1.19.10 起默认 1000(此前 100)
}HTTP/2 带来的是多路复用(一条 TCP 连接上并发多个 stream)和HPACK 头部压缩。对 Nginx 的实际影响:
- 连接数大幅下降,但每条连接更忙。原来 6 条连接的浏览器现在只开 1 条。
- 域名分片(domain sharding)成了负优化——它是 HTTP/1.1 时代绕连接数限制的技巧,在 h2 下反而增加连接与握手。
proxy_pass到后端仍然是 HTTP/1.1。Nginx 不会把客户端的 h2 复用透传给后端;后端要 h2 得用grpc_pass或 1.25+ 的 HTTP/2 upstream 支持。
HTTP/3 / QUIC
1.25.0 起转正(此前需要单独的 quic 分支):
server {
listen 443 ssl; # TCP:HTTP/1.1 + HTTP/2
listen 443 quic reuseport; # UDP:HTTP/3
http2 on;
http3 on;
ssl_protocols TLSv1.3; # QUIC 只能 TLS 1.3
# 告诉客户端「我也支持 h3,下次直接走 UDP」
add_header Alt-Svc 'h3=":443"; ma=86400';
}要点:
- QUIC 跑在 UDP 上,所以防火墙、安全组要放通 UDP 443。很多 h3 "不生效"就是这里没开。
- 它把 TLS 握手和传输层握手合并了,首次连接 1-RTT,复用时 0-RTT。
- 解决了 TCP 层的队头阻塞:h2 的多个 stream 共享一条 TCP,任何一个包丢了整条连接都要等重传;QUIC 的 stream 各自独立。
- 连接迁移:连接标识不是四元组而是 Connection ID,手机从 WiFi 切 4G 不断连接。
十二、性能调优清单
按"收益 / 风险"排序,从最该做的开始。
# ── 进程与连接 ──────────────────────────────
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 16384;
multi_accept on;
}
# ── 静态文件(收益最大,风险最低)─────────────
sendfile on; # 零拷贝
tcp_nopush on; # 攒满一个包再发(配合 sendfile 用,Linux 上是 TCP_CORK)
tcp_nodelay on; # 长连接上禁用 Nagle,降低小包延迟
# ↑ 两者不冲突:nopush 管发文件,nodelay 管 keepalive 收尾
open_file_cache max=100000 inactive=60s;
open_file_cache_valid 60s; # 缓存文件描述符 + 元数据,省掉大量 stat/open
open_file_cache_min_uses 2;
open_file_cache_errors on; # 连 404 也缓存,防止扫描器打爆磁盘
# ── 长连接 ────────────────────────────────
keepalive_timeout 65s;
keepalive_requests 1000;
# ── 压缩 ─────────────────────────────────
gzip on;
gzip_vary on; # 必须!否则 CDN 可能把压缩版发给不支持的客户端
gzip_comp_level 5; # 6 以上收益递减、CPU 陡增,5 是甜点
gzip_min_length 1024; # 小文件压了反而更大
gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript
application/xml image/svg+xml;
# 注意:text/html 永远被压缩,不用写,写了会有 warning
# 注意:图片/视频/字体(woff2)已经压过了,别再压
# ── 请求体与超时 ───────────────────────────
client_max_body_size 20m; # 默认才 1m,上传接口 413 的元凶
client_body_buffer_size 128k; # 超过就落临时文件,磁盘 I/O
client_header_timeout 15s;
client_body_timeout 15s;
# ── 日志 ─────────────────────────────────
access_log /var/log/nginx/access.log main buffer=64k flush=5s;
# 缓冲写,减少高 QPS 下的 write 系统调用
# 静态资源可以直接关:
location ~* \.(jpg|png|css|js|woff2)$ { access_log off; }一个容易漏的:gzip_static
如果你的构建流程能预先生成 .gz 文件,开 gzip_static on; 让 Nginx 直接发预压缩文件,把压缩的 CPU 从运行时挪到构建时。配合 brotli_static(第三方模块)效果更好——Brotli 压缩率比 gzip 高 15–20%,但实时压缩很贵,预压缩是它的最佳用法。
关于限流,两个 zone 分工不同,通常一起上:
# 按 IP 限速率:10 请求/秒,突发允许 20 个排队(不 nodelay 则排队等待而非拒绝)
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
# 按 IP 限并发连接数
limit_conn_zone $binary_remote_addr zone=conn:10m;
location /api/ {
limit_req zone=perip burst=20 delay=10; # 前 10 个不延迟,11–20 个逐步延迟
limit_conn conn 20;
limit_req_status 429; # 默认 503,429 语义更准确
limit_conn_status 429;
}用 $binary_remote_addr 而不是 $remote_addr:前者 4 字节(IPv4),后者是字符串要 7–15 字节,同样的 10m 共享内存能多存一倍多的 key。
十三、排错:从日志读出真相
那些状态码分别在说什么
| 码 | 谁产生的 | 真正含义 | 该查什么 |
|---|---|---|---|
| 499 | Nginx 自己 | 客户端在收到响应前主动关了连接 | 通常是后端太慢,客户端超时先走了。499 涨说明有慢接口 |
| 502 | Nginx | 连上了后端但没拿到合法响应 | 后端进程挂了 / 端口不对 / 后端返回了非法 HTTP |
| 504 | Nginx | 后端超时 | 对照 proxy_read_timeout;查后端慢查询 |
| 413 | Nginx | 请求体超限 | client_max_body_size |
| 400 | Nginx | 请求头非法或过大 | large_client_header_buffers(Cookie 太大是常见原因) |
| 444 | Nginx(私有) | 你自己配的"不响应直接断" | — |
| 503 | 多来源 | 可能是 Nginx 限流,也可能是后端返回的 | 看 $upstream_status 区分 |
499 是 Nginx 独有的非标准码,其他服务器不会产生。看到 499 上升,正确反应不是"客户端有问题",而是"我的响应太慢了"。
一个能直接定位问题的日志格式
log_format diag escape=json
'{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"request":"$request",'
'"status":$status,'
'"body_bytes":$body_bytes_sent,'
'"request_time":$request_time,' # Nginx 视角总耗时
'"upstream_addr":"$upstream_addr",' # 实际打到哪台后端
'"upstream_status":"$upstream_status",' # 后端的原始状态码
'"upstream_connect_time":"$upstream_connect_time",'# 建连耗时
'"upstream_header_time":"$upstream_header_time",' # 后端首字节
'"upstream_response_time":"$upstream_response_time",'
'"cache_status":"$upstream_cache_status",'
'"host":"$host",'
'"xff":"$http_x_forwarded_for"'
'}';有了这几个字段,慢请求的责任可以直接切开:
request_time = 客户端发请求体 + 排队 + upstream_connect + upstream_response + 客户端收响应
request_time 大、upstream_response_time 小 → 客户端网络慢(或响应体巨大)
upstream_connect_time 大 → 后端建连慢:TCP 队列满 / 网络问题 / 后端在 GC
upstream_header_time 大,response_time 差不多 → 后端处理慢(典型:慢 SQL)
upstream_header_time 小,response_time 大很多 → 后端在流式输出,或响应体很大
upstream_addr 有多个(逗号分隔) → 发生了重试,第一台失败了upstream_addr 里出现逗号,是排查"偶发 502"的最强线索——它直接告诉你哪台机器在失败。
需要更细就上 debug 日志
# 前提:编译时带了 --with-debug(nginx -V 可确认)
error_log /var/log/nginx/debug.log debug;
# 只对特定 IP 开 debug,避免日志爆炸
events {
debug_connection 1.2.3.4;
debug_connection 10.0.0.0/24;
}debug 日志会打印每个阶段的进入与退出、每次 location 匹配的判断过程。看不懂 location 为什么没生效时,这是终极手段——你能直接看到 Nginx 试了哪些、选了哪个。
常用命令
nginx -t # 语法检查(改完必做,reload 前的安全网)
nginx -T # 检查并「打印完整合并后的配置」← 排查 include 覆盖神器
nginx -V # 版本 + 完整编译参数
nginx -s reload|reopen|quit|stop
# reopen:重开日志文件(logrotate 之后必须做,否则继续写已删除的 inode)nginx -T 被严重低估:当配置分散在十几个 include 文件里时,它直接告诉你最终生效的完整配置是什么,省掉所有猜测。
十四、Nginx 之外:什么时候该换掉它
Nginx 的边界很清楚:它是搬字节的,不是写逻辑的。 一旦需求变成"根据用户身份动态决定路由、调用外部鉴权服务、做复杂灰度",纯 Nginx 配置会迅速变成不可维护的 map 和 if 迷宫。
| 方案 | 本质 | 适合 | 代价 |
|---|---|---|---|
| Nginx + Lua(OpenResty) | 在 Nginx 每个阶段插入 LuaJIT 脚本 | 需要自定义逻辑但想留在 Nginx 生态 | 要写 Lua,且必须全程用非阻塞库 |
| njs | 官方的 JavaScript 子集 | 轻量改写、简单逻辑 | 能力弱于 Lua,生态小 |
| APISIX / Kong | OpenResty 之上的网关产品 | 要插件体系、动态配置、控制台 | 多一层依赖(etcd / DB) |
| Envoy | C++ 编写,xDS 动态配置 | 服务网格、云原生、需要热更新路由 | 配置复杂度高一个量级 |
| Nginx Ingress Controller | K8s 里把 Ingress 资源翻译成 nginx.conf | K8s 集群入口 | 每次变更 reload,规则多时抖动 |
| Caddy | Go 编写,自动 HTTPS | 小项目、想零配置上 TLS | 性能与生态不如 Nginx |
一条实用的分界线:配置能表达的就用 Nginx,需要 if/else 超过两层的就该上脚本或换网关。
至于"Nginx 会不会被取代"——它已经在很多云原生场景里退到了第二层(前面是云 LB 或 Envoy),但作为静态文件服务器和 TLS 终结点,它的位置至今非常稳固。原因就是本文第九、十节讲的那些东西:事件循环、内存池、零拷贝,这些是二十年打磨出来的工程,很难在短期内被超过。
十五、陷阱清单(可以直接当 checklist)
写完配置,逐条对一遍:
- 默认 server 兜底了吗——不然伪造 Host 能摸到你的内部站(第四节)。
proxy_set_header是否被子层整族覆盖——用 include 或每层写全(第三节)。proxy_pass尾斜杠对吗——路径少一段或多一段的唯一原因(第六节)。- upstream 用的是域名吗——静态写法永久缓存 IP,容器环境必须用变量 +
resolver(第六节)。 proxy_connect_timeout还是默认 60s 吗——改成 3–5s(第六节)。proxy_next_upstream_tries设了上限吗——不设会把过载放大成雪崩(第七节)。- 非幂等请求会被重试吗——POST 重试可能重复扣款(第七节)。
set_real_ip_from是不是写成了0.0.0.0/0——等于允许伪造 IP(第六节)。- 流式接口关了
proxy_buffering吗——不关就没有"流"(第六节)。 client_max_body_size改了吗——默认 1m,上传必踩(第十二节)。if里写了proxy_pass/root吗——只有return和rewrite是安全的(第五节)。limit_req的 zone 够大吗、用的是$binary_remote_addr吗(第十二节)。gzip_vary on加了吗——不加会让 CDN 给错版本(第十二节)。worker_rlimit_nofile与系统somaxconn匹配吗——只调一边等于没调(第九节)。- logrotate 后有
nginx -s reopen吗——否则日志写进已删除的文件(第十三节)。 - 有长连接业务时设了
worker_shutdown_timeout吗——不然 reload 后老 worker 不退(第九节)。 - HTTP/3 的 UDP 443 放通了吗(第十一节)。
- 改完跑
nginx -t,疑惑时跑nginx -T(第十三节)。
结语
回头看,Nginx 的所有设计都能收敛到三句话。
第一,它把"等待"当成了敌人。 事件循环、非阻塞 I/O、自研异步 DNS、线程池卸载磁盘读——全都在回答同一个问题:"怎么让进程永远不停下来等"。理解了这一点,你就知道为什么在 Nginx 里做任何同步操作都是禁忌,也知道为什么 OpenResty 要重写一整套 Lua 库。
第二,它把"复杂"前移到了配置与编译期。 11 个阶段是编译期定死的,map 是解析期建表的,filter 链的顺序是构建时排好的,内存池是按请求生命周期划的。运行期只剩下最简单的动作:查表、切指针、移动字节。你觉得配置反直觉的地方,往往正是它把运行期开销砍掉的地方——location 的匹配优先级如此,if 的种种限制也如此。
第三,它诚实地划定了自己的边界。 Nginx 不试图成为应用服务器。if 难用、没有循环、没有变量运算,这些不是缺陷而是拒绝:它不想让你在流量入口写业务逻辑。 一旦你发现自己在跟配置语言搏斗,那就是它在提醒你,这段逻辑不该待在这里。
对使用者来说,最有用的心态转变可能是:别把 nginx.conf 当成配置文件,把它当成一个声明式程序。 它有作用域、有继承、有固定的执行顺序(那 11 个阶段)。用读程序的方式读它,那些"玄学"就都不成立了。