Skip to content

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 preforkNginx
并发单位一个连接 = 一个进程/线程一个 worker 进程处理成千上万连接
I/O 模型阻塞式,等 I/O 时执行体挂起非阻塞 + 事件驱动(epoll / kqueue)
空闲连接成本一个进程的内存与调度开销一个结构体,几百字节
内存曲线随连接数线性增长近似平坦
编程模型直观(同步)复杂(回调、状态机)

代价是编程模型变难了:不能"等",所有操作必须能中断、能恢复,于是模块要写成状态机。Nginx 把复杂度从运行时转移到了代码里——这是它全部设计的总纲,后面所有细节都是这句话的推论。

于是它天然适合这几类活:

  • 静态文件服务:I/O 密集、逻辑简单,sendfile 直接零拷贝出去;
  • 反向代理与负载均衡:本质是搬字节,不做业务计算;
  • TLS 终结:把加解密从应用剥离;
  • 流量入口的控制点:限流、缓存、鉴权、灰度、改写。

反过来说,它不适合做重业务逻辑。这也是为什么它前面加了个 - 就成了另一个物种:需要写逻辑,就上 OpenResty/Lua 或换网关(最后一节会讲)。

二、五分钟能用起来:三个最小配置

先给能跑的东西,再讲原理。

静态站点

nginx
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——所有找不到的路径都交给前端路由

nginx
    location / {
        try_files $uri $uri/ /index.html;
    }

反向代理

nginx
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

nginx
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.1proxy_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 { }                    # 邮件代理

三条继承规则

规则一:大多数指令向内继承。httpgzip on,所有 serverlocation 都继承。

规则二:内层出现同名指令时是覆盖,不是合并。 这条最容易出事,尤其对 proxy_set_headeradd_header

nginx
server {
    add_header X-From-Server "yes";

    location /api {
        add_header X-From-Location "yes";
        # 结果:只有 X-From-Location!
        # 一旦本层出现 add_header,父层的全部失效
    }
}

同一族的指令,只要子层写了任意一条,父层整族都被丢弃。所以公共 header 的正确做法是抽成文件然后 include,或者干脆在每一层写全:

nginx
# /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;
nginx
    location /api {
        include /etc/nginx/snippets/proxy-headers.conf;
        proxy_pass http://backend;
    }

规则三:rootalias 的语义不同。

nginx
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_timeNginx 视角的整体耗时含客户端收数据的时间
$upstream_response_time后端耗时与上一个的差值是关键诊断信号

$request_time 大而 $upstream_response_time 小,说明什么

说明慢在客户端,不在后端:可能是客户端网络差、在慢慢收响应体,或者请求体上传慢。

很多"接口很慢"的告警其实是这种情况——后端日志显示 20ms,Nginx 日志显示 3s,两边都没错。看两个变量的差值就能定位。

四、请求是怎么被匹配到 location 的

这一节是配置层面最值钱的知识。

第一步:挑 server

Nginx 先按 listen 的 IP:端口筛出候选,再用请求的 Host 头去匹配 server_name,顺序是固定的

  1. 精确匹配:server_name example.com;
  2. 前置通配:server_name *.example.com;
  3. 后置通配:server_name example.*;
  4. 正则(按配置文件出现顺序,第一个命中即止):server_name ~^www\d+\.example\.com$;
  5. 该端口的默认 server——即标了 default_server 的,没标就是配置里第一个

第 5 条是安全隐患

没有任何 server_name 匹配上时,请求会落到默认 server。如果你的第一个 server 恰好是某个内部管理站,那么直接用 IP 访问、或伪造 Host 访问,就能摸到它。

标准做法是显式放一个"黑洞" server 兜底:

nginx
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

nginx
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 建议加 ^~

nginx
location ^~ /static/ {
    root /var/www;
    expires 30d;
}

这样它不会被后面某个 ~ \.(php|jsp)$ 之类的正则截走,也省掉每个静态请求的正则匹配开销——高 QPS 下这不是可以忽略的量。

第三步:内部重定向

匹配到 location 之后,请求还可能被"重新扔回去再匹配一遍"。触发内部重定向的有三个东西:

  • try_files 的最后一项(如果是 URI 而非 =404
  • rewrite ... last;
  • error_page 指向一个 URI
nginx
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_passroot 这些是第 ⑩ 阶段的内容指令。把它们写在 if 里,等于让两个不同阶段的东西在同一个块里表达,行为就变得难以预测:

nginx
# 危险:两个 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 里可以安全用的只有 returnrewrite——因为它们本来就属于 rewrite 阶段。其他一切,用 map 或多个 location 替代:

nginx
# 推荐:用 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 最主要的用途,也是坑最密的地方。

那个改变一切的斜杠

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 匹配的前缀"。

三个附加陷阱

  1. location 用正则时,proxy_pass 不能带 URI 部分,否则配置检查直接报错——因为"要替换掉哪一段"无法定义。
  2. proxy_pass 里带变量时,行为不一样proxy_pass http://$backend; 不带 URI 部分时不会自动透传原 URI,需要自己拼 $request_uri
  3. 带变量还会改变 DNS 行为,见下面。

upstream 域名解析的时机

这是"改了 DNS 不生效必须重启"的根源:

nginx
# 写法 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 是标准绕法。

超时三兄弟

nginx
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 到底在替谁挡子弹

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 要显式关掉:

nginx
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 都已经是改写后的):

nginx
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 需要协议升级,必须显式转发两个头:

nginx
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。

七、负载均衡与健康检查

五种调度算法

nginx
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 会出现"所有实例都觉得同一台最闲,一起涌过去"的羊群效应。随机取两个再比较,能显著削平这个尖峰——这是分布式负载均衡里的经典结论。

健康检查:开源版只有被动

nginx
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 时间后再试一次。

这是被动健康检查,两个后果要知道

  1. 它靠真实请求去"探"——也就是说,后端刚挂时,前 3 个用户是要吃到错误的。
  2. 开源版没有主动探活。要主动定期发探测请求(health_check 指令)需要 Nginx Plus,或者第三方模块 nginx_upstream_check_module(需重新编译),或者换 OpenResty / APISIX。

什么算"失败"由 proxy_next_upstream 定义,默认是 error timeout。要不要把 http_500 也算进去,取决于你的 500 是"这台机器坏了"还是"业务本来就报错"——把业务错误算成机器故障,会导致所有后端被逐个摘掉。

重试的隐藏风险

nginx
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;      # 最多试 2 台
proxy_next_upstream_timeout 10s;  # 重试总耗时上限

一定要设 triestimeout 上限。默认是"把所有后端都试一遍"——后端集体过载时,一个请求会被放大成 N 个请求打到 N 台机器上,这是把过载变成雪崩的标准姿势

另外,默认配置下非幂等请求也会被重试POST /pay 超时后重试到另一台,可能造成重复扣款。要么在业务侧做幂等,要么显式关掉:proxy_next_upstream off;

八、缓存:Nginx 最被低估的能力

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_statusMISS / HIT / EXPIRED / STALE / UPDATING / REVALIDATED / BYPASS 七种值,是判断缓存有没有真正生效的唯一可靠依据。加上这个 header,比猜半天有用。

开源版不能主动清缓存

proxy_cache_purge 指令是 Nginx Plus 的。开源版的选项:

  • 第三方模块 ngx_cache_purge(要重新编译);
  • 按 key 算出磁盘路径(md5(cache_key) 拆成 levels 目录)然后删文件——可行但脆弱;
  • proxy_cache_bypass 配一个带密钥的 header,强制回源并覆盖旧缓存:
nginx
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 的内部:事件循环

c
// 极度简化的 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 可能阻塞 → 于是有了 aiothread_pool(把大文件读扔给线程池);
  • DNS 解析会阻塞 → 于是 Nginx 自己实现了一个异步 DNS 客户端resolver 指令),不用 libc 的 getaddrinfo
  • Lua 里不能用阻塞库 → OpenResty 整套 lua-resty-* 生态存在的唯一理由就是它们的替代品。

惊群、accept_mutex 与 SO_REUSEPORT

监听套接字是 master 创建、所有 worker 继承的。那么新连接来了,谁去 accept

历史上这是个真问题("惊群",thundering herd):内核把所有 worker 都唤醒,大家一起抢,只有一个成功,其余白白被调度一次。

三代解法:

方案机制状态
accept_mutex onworker 轮流持锁去 accept,一次只有一个在等1.11.3 起默认 off
内核修复Linux 内核对 accept 已只唤醒一个进程(epoll 的 exclusive 语义)现代内核默认行为
reuseport每个 worker 各自 bind 同一端口,内核按四元组哈希分发推荐
nginx
http {
    server {
        listen 80 reuseport;   # 内核直接把连接分给某个 worker,零竞争
    }
}

reuseport 的额外好处是负载分布由内核决定,避免了 accept_mutex 时代常见的"某个 worker 特别忙"。注意它只需在同一端口的一个 listen 上写一次。

连接数的账怎么算

nginx
events {
    worker_connections 10240;    # 每个 worker 的连接数上限(默认才 512)
    use epoll;                   # Linux 上一般自动选中
    multi_accept on;             # 一次事件里尽量 accept 多个连接
}

理论最大并发:

静态文件场景:  worker_processes × worker_connections
反向代理场景:  worker_processes × worker_connections / 2
                (每个客户端连接要配一条到后端的连接,各占一个槽)

同时还要过系统这一关,否则配了也用不上:

bash
# 进程级文件描述符上限
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=65535

listen ... backlog=somaxconn 的关系

listen 80 backlog=65535; 请求的队列长度会被内核的 net.core.somaxconn 截断(较老内核默认只有 128)。只改 Nginx 不改 sysctl 等于没改——高并发建连时表现为"连接被拒绝"或 SYN 重传。

reload 到底发生了什么

bash
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 可执行文件都不断连接)。

bash
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

c
// 每个请求创建一个 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 销毁一起执行。

这带来三个后果:

  1. 分配的开销接近零(指针加法 vs 通用分配器的空闲链表搜索);
  2. 不会有内存泄漏——只要你从 pool 里分配,请求结束必然回收;
  3. 内存碎片少,因为大量小对象在连续内存里。

代价是:长生命周期的对象不能放请求 pool(会活到请求结束才释放),以及单个请求内不断分配的循环会撑爆 pool。这也是为什么处理超长 keepalive 连接或流式大响应时,模块要格外小心内存增长。

buf 与 chain:为什么能零拷贝

Nginx 内部所有数据传递都用 ngx_buf_t 和它串成的 ngx_chain_t 链表。关键在于 ngx_buf_t 不一定指向内存

c
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 脚本按依赖排好的。理解这个链有实际用处:

  • gzipsub_filter 后面,所以 sub_filter 能改到未压缩的原文。但如果后端已经返回了 gzip 内容,sub_filter 就什么也改不到了——这时要在 proxy_set_header Accept-Encoding ""; 里把压缩关掉,让后端返回明文。
  • image_filtersub_filter 都需要完整响应体,所以它们与 proxy_buffering off 天然冲突。

动态模块

1.9.11 起支持运行时加载:

nginx
load_module modules/ngx_http_geoip2_module.so;   # 必须写在最顶层

但 ABI 是强绑定的:编译模块的 Nginx 版本必须和运行的完全一致,否则加载失败。所以自己编模块时,nginx -V 输出的那一整串 configure 参数要原样保留,只往后追加 --add-dynamic-module=

十一、TLS、HTTP/2 与 HTTP/3

TLS 的三个真正影响性能的开关

nginx
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_cacheshared 而不是 builtin 很重要:builtin 是每个 worker 自己一份,客户端第二次请求落到另一个 worker 就复用不上了。

HTTP/2

nginx
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 分支):

nginx
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 不断连接。

十二、性能调优清单

按"收益 / 风险"排序,从最该做的开始。

nginx
# ── 进程与连接 ──────────────────────────────
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 分工不同,通常一起上:

nginx
# 按 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。

十三、排错:从日志读出真相

那些状态码分别在说什么

谁产生的真正含义该查什么
499Nginx 自己客户端在收到响应前主动关了连接通常是后端太慢,客户端超时先走了。499 涨说明有慢接口
502Nginx连上了后端但没拿到合法响应后端进程挂了 / 端口不对 / 后端返回了非法 HTTP
504Nginx后端超时对照 proxy_read_timeout;查后端慢查询
413Nginx请求体超限client_max_body_size
400Nginx请求头非法或过大large_client_header_buffers(Cookie 太大是常见原因)
444Nginx(私有)你自己配的"不响应直接断"
503多来源可能是 Nginx 限流,也可能是后端返回的$upstream_status 区分

499 是 Nginx 独有的非标准码,其他服务器不会产生。看到 499 上升,正确反应不是"客户端有问题",而是"我的响应太慢了"。

一个能直接定位问题的日志格式

nginx
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 日志

bash
# 前提:编译时带了 --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 试了哪些、选了哪个。

常用命令

bash
nginx -t                    # 语法检查(改完必做,reload 前的安全网)
nginx -T                    # 检查并「打印完整合并后的配置」← 排查 include 覆盖神器
nginx -V                    # 版本 + 完整编译参数
nginx -s reload|reopen|quit|stop
                            # reopen:重开日志文件(logrotate 之后必须做,否则继续写已删除的 inode)

nginx -T 被严重低估:当配置分散在十几个 include 文件里时,它直接告诉你最终生效的完整配置是什么,省掉所有猜测。

十四、Nginx 之外:什么时候该换掉它

Nginx 的边界很清楚:它是搬字节的,不是写逻辑的。 一旦需求变成"根据用户身份动态决定路由、调用外部鉴权服务、做复杂灰度",纯 Nginx 配置会迅速变成不可维护的 mapif 迷宫。

方案本质适合代价
Nginx + Lua(OpenResty)在 Nginx 每个阶段插入 LuaJIT 脚本需要自定义逻辑但想留在 Nginx 生态要写 Lua,且必须全程用非阻塞库
njs官方的 JavaScript 子集轻量改写、简单逻辑能力弱于 Lua,生态小
APISIX / KongOpenResty 之上的网关产品要插件体系、动态配置、控制台多一层依赖(etcd / DB)
EnvoyC++ 编写,xDS 动态配置服务网格、云原生、需要热更新路由配置复杂度高一个量级
Nginx Ingress ControllerK8s 里把 Ingress 资源翻译成 nginx.confK8s 集群入口每次变更 reload,规则多时抖动
CaddyGo 编写,自动 HTTPS小项目、想零配置上 TLS性能与生态不如 Nginx

一条实用的分界线:配置能表达的就用 Nginx,需要 if/else 超过两层的就该上脚本或换网关。

至于"Nginx 会不会被取代"——它已经在很多云原生场景里退到了第二层(前面是云 LB 或 Envoy),但作为静态文件服务器和 TLS 终结点,它的位置至今非常稳固。原因就是本文第九、十节讲的那些东西:事件循环、内存池、零拷贝,这些是二十年打磨出来的工程,很难在短期内被超过。

十五、陷阱清单(可以直接当 checklist)

写完配置,逐条对一遍:

  1. 默认 server 兜底了吗——不然伪造 Host 能摸到你的内部站(第四节)。
  2. proxy_set_header 是否被子层整族覆盖——用 include 或每层写全(第三节)。
  3. proxy_pass 尾斜杠对吗——路径少一段或多一段的唯一原因(第六节)。
  4. upstream 用的是域名吗——静态写法永久缓存 IP,容器环境必须用变量 + resolver(第六节)。
  5. proxy_connect_timeout 还是默认 60s 吗——改成 3–5s(第六节)。
  6. proxy_next_upstream_tries 设了上限吗——不设会把过载放大成雪崩(第七节)。
  7. 非幂等请求会被重试吗——POST 重试可能重复扣款(第七节)。
  8. set_real_ip_from 是不是写成了 0.0.0.0/0——等于允许伪造 IP(第六节)。
  9. 流式接口关了 proxy_buffering——不关就没有"流"(第六节)。
  10. client_max_body_size 改了吗——默认 1m,上传必踩(第十二节)。
  11. if 里写了 proxy_pass / root——只有 returnrewrite 是安全的(第五节)。
  12. limit_req 的 zone 够大吗、用的是 $binary_remote_addr(第十二节)。
  13. gzip_vary on 加了吗——不加会让 CDN 给错版本(第十二节)。
  14. worker_rlimit_nofile 与系统 somaxconn 匹配吗——只调一边等于没调(第九节)。
  15. logrotate 后有 nginx -s reopen——否则日志写进已删除的文件(第十三节)。
  16. 有长连接业务时设了 worker_shutdown_timeout——不然 reload 后老 worker 不退(第九节)。
  17. HTTP/3 的 UDP 443 放通了吗(第十一节)。
  18. 改完跑 nginx -t,疑惑时跑 nginx -T(第十三节)。

结语

回头看,Nginx 的所有设计都能收敛到三句话。

第一,它把"等待"当成了敌人。 事件循环、非阻塞 I/O、自研异步 DNS、线程池卸载磁盘读——全都在回答同一个问题:"怎么让进程永远不停下来等"。理解了这一点,你就知道为什么在 Nginx 里做任何同步操作都是禁忌,也知道为什么 OpenResty 要重写一整套 Lua 库。

第二,它把"复杂"前移到了配置与编译期。 11 个阶段是编译期定死的,map 是解析期建表的,filter 链的顺序是构建时排好的,内存池是按请求生命周期划的。运行期只剩下最简单的动作:查表、切指针、移动字节。你觉得配置反直觉的地方,往往正是它把运行期开销砍掉的地方——location 的匹配优先级如此,if 的种种限制也如此。

第三,它诚实地划定了自己的边界。 Nginx 不试图成为应用服务器。if 难用、没有循环、没有变量运算,这些不是缺陷而是拒绝:它不想让你在流量入口写业务逻辑。 一旦你发现自己在跟配置语言搏斗,那就是它在提醒你,这段逻辑不该待在这里。

对使用者来说,最有用的心态转变可能是:别把 nginx.conf 当成配置文件,把它当成一个声明式程序。 它有作用域、有继承、有固定的执行顺序(那 11 个阶段)。用读程序的方式读它,那些"玄学"就都不成立了。