目录

Nginx-04 请求处理流程与 11 个阶段

前置阅读:Nginx-03 进程模型与事件驱动原理

上一篇讲了 worker 怎么高效地「等」事件,这一篇讲事件来了之后具体怎么处理一个 HTTP 请求。理解 11 个阶段是理解 Nginx 各种「为什么这个指令在那个指令之前生效」的钥匙。

1. 请求的完整生命周期

先看一张全景图:

① 客户端 TCP 连接到达
② worker accept,分配 ngx_connection_t,创建 connection pool
③ 等待可读事件 → 读取请求行(GET /a/b?x=1 HTTP/1.1)
   ↓  超时由 client_header_timeout 控制
④ 读取并解析请求头,创建 request pool
   ↓  缓冲区由 client_header_buffer_size / large_client_header_buffers 控制
⑤ 根据 Host + listen 选出 server 块(虚拟主机匹配)
⑥ 根据 URI 选出 location 块
⑦ ★ 走 11 个阶段的 handler 链 ★
⑧ content phase 产生响应(读文件 / 反代到上游 / return)
⑨ 响应经过 header filter 链 和 body filter 链
   ↓  gzip、chunked、ssi、sub 等都在这里
⑩ 通过 sendfile / writev 发给客户端
⑪ 写 access_log,销毁 request pool
⑫ keepalive 则回到 ③ 等下一个请求,否则关闭连接、销毁 connection pool

其中第 ⑦ 步是 Nginx 架构设计的精华。

2. HTTP 处理的 11 个阶段

Nginx 把请求处理切成 11 个有序的阶段(phase)。每个阶段挂着一串 handler,模块通过「往某个阶段注册 handler」来参与请求处理。

# 阶段常量 中文名 典型模块/指令
1 POST_READ 读取请求后 realip(还原真实 IP)
2 SERVER_REWRITE server 级 rewrite server 块里的 rewrite/set/return
3 FIND_CONFIG 查找 location 内部阶段,不可注册
4 REWRITE location 级 rewrite location 里的 rewrite/set/if
5 POST_REWRITE rewrite 后处理 内部阶段,检查是否需要重新匹配 location
6 PREACCESS 访问控制前 limit_reqlimit_conndegradation
7 ACCESS 访问控制 allow/denyauth_basicauth_request
8 POST_ACCESS 访问控制后 内部阶段,处理 satisfy any/all 逻辑
9 PRECONTENT 内容生成前 try_filesmirror(1.13.3 前叫 TRY_FILES)
10 CONTENT 内容生成 proxy_passfastcgi_passroot(static)、return
11 LOG 日志 access_log

2.1 阶段的关键规则

规则一:阶段顺序是固定的,写在配置文件里的位置不影响执行顺序。

这是最重要的一条。看这个配置:

location /api/ {
    proxy_pass http://backend;    # CONTENT 阶段
    limit_req  zone=one;          # PREACCESS 阶段
    deny 1.2.3.4;                 # ACCESS 阶段
    set $foo bar;                 # REWRITE 阶段
}

尽管 proxy_pass 写在最前面,实际执行顺序是:set(4)→ limit_req(6)→ deny(7)→ proxy_pass(10)。

很多「为什么我的配置不生效」的问题,答案就是阶段顺序。比如:

# ❌ 想在 rewrite 之后再判断,但 deny 属于 ACCESS 阶段,总在 rewrite 之后执行,
#    这里的顺序期望本来就不成立
location / {
    deny all;
    rewrite ^/old/(.*)$ /new/$1;
}

规则二:同一阶段内,按模块的编译顺序执行。

所以两个第三方模块都往 ACCESS 阶段注册 handler 时,谁先执行取决于 ./configure --add-module 的顺序。

规则三:handler 的返回值决定后续流程。

返回值 含义
NGX_OK 本阶段完成,进入下一个阶段(跳过本阶段剩余 handler)
NGX_DECLINED 本 handler 不处理,交给本阶段的下一个 handler
NGX_AGAIN / NGX_DONE 需要等待(I/O 未完成),挂起请求,等事件回调再继续
NGX_ERROR 出错,结束请求
NGX_HTTP_*(如 403) 直接以该状态码结束请求,跳到 LOG 阶段

3. 逐阶段详解

3.1 POST_READ —— 还原真实 IP

请求头刚读完就执行,此时还没匹配 location。最典型的用途是 realip 模块。

多层代理的场景:

真实用户 1.2.3.4 → CDN 5.6.7.8 → 公司 SLB 10.0.0.1 → Nginx

此时 Nginx 看到的 $remote_addr10.0.0.1,真实 IP 藏在 X-Forwarded-For: 1.2.3.4, 5.6.7.8 里。

http {
    # 声明哪些是可信代理(必须写全,否则会被伪造)
    set_real_ip_from 10.0.0.0/8;
    set_real_ip_from 172.16.0.0/12;
    set_real_ip_from 5.6.7.0/24;     # CDN 的回源段

    real_ip_header    X-Forwarded-For;
    real_ip_recursive on;    # 从右往左跳过所有可信 IP,取第一个不可信的
}

real_ip_recursive on 很关键:关闭时只取 XFF 的最后一个值,开启时会从右往左逐个跳过 set_real_ip_from 里声明的可信网段,直到遇到第一个不在白名单里的 IP——那才是真实客户端。

安全提示:XFF 是客户端可以随便伪造的头。如果不配 set_real_ip_from 或者配得太宽(比如 0.0.0.0/0),攻击者只要发一个 X-Forwarded-For: 127.0.0.1 就能绕过你所有基于 IP 的限流和白名单。

生效后 $remote_addr 会被改写成真实 IP,原始值保存在 $realip_remote_addr。因为 realip 在 POST_READ 阶段(第 1 个),所以后面所有阶段(限流、ACL、日志)拿到的都是正确的 IP。

3.2 SERVER_REWRITE 与 REWRITE

两个阶段用的是同一套指令,区别只是位置:写在 server 块里的在阶段 2 执行(此时还没选定 location),写在 location 里的在阶段 4 执行。

server {
    # SERVER_REWRITE 阶段:所有请求都会经过
    rewrite ^/old/(.*)$ /new/$1 last;
    set $api_version "v1";

    location /new/ {
        # REWRITE 阶段
        if ($http_x_beta = "1") {
            set $api_version "v2";
        }
        proxy_pass http://backend_$api_version;
    }
}

rewrite 指令的详细语义(last/break/redirect/permanent)放在第 05 篇讲。

3.3 FIND_CONFIG —— 匹配 location

内部阶段,执行 location 匹配算法(第 05 篇详解)。匹配完成后,后续阶段用的就是这个 location 的配置了。

3.4 POST_REWRITE —— 处理内部重定向

如果 REWRITE 阶段改变了 $uri(且用了 last 或没带 flag),这个阶段会跳回 FIND_CONFIG 重新匹配 location

这就是 rewrite ... last 的实现原理。为了防止死循环,Nginx 限制最多循环 10 次,超了报 rewrite or internal redirection cycle

# ❌ 死循环
location / {
    rewrite ^(.*)$ /index.php$1 last;   # 改完还是匹配 location /,无限循环
}

3.5 PREACCESS —— 限流

limit_req(请求速率限流)和 limit_conn(并发连接数限流)在这里。

放在 ACCESS 之前是有讲究的:限流应该在做鉴权这种昂贵操作之前拦截,否则攻击者用大量无效请求就能把你的鉴权服务打垮。

http {
    limit_req_zone  $binary_remote_addr zone=perip:10m rate=10r/s;
    limit_conn_zone $binary_remote_addr zone=conn:10m;

    server {
        location /api/ {
            limit_req  zone=perip burst=20 nodelay;
            limit_conn conn 10;
            auth_request /auth;      # ACCESS 阶段,在限流之后
            proxy_pass http://backend;
        }
    }
}

3.6 ACCESS —— 访问控制

三类常用手段:

location /admin/ {
    # 1. IP 黑白名单(access 模块)
    allow 192.168.1.0/24;
    allow 10.0.0.0/8;
    deny  all;

    # 2. HTTP Basic 认证
    auth_basic           "Admin Area";
    auth_basic_user_file /etc/nginx/.htpasswd;

    # 3. 子请求鉴权(把鉴权委托给后端服务)
    auth_request /auth-check;
}

satisfy 指令控制多个 ACCESS handler 的组合逻辑:

location /internal/ {
    satisfy any;              # 任一通过即可(默认是 all,全部通过才行)
    allow 10.0.0.0/8;         # 内网直接放行
    deny  all;
    auth_basic "Login";       # 外网要输密码
    auth_basic_user_file /etc/nginx/.htpasswd;
}

satisfy any 的逻辑就是在 POST_ACCESS 阶段实现的:POST_ACCESS 检查前面 ACCESS handler 的结果,按 satisfy 的设置决定是放行还是返回 403。

auth_request 的工作方式(面试常问):

location /api/ {
    auth_request /auth;
    # 从鉴权响应头里取值,传给后端
    auth_request_set $user_id $upstream_http_x_user_id;
    proxy_set_header X-User-Id $user_id;
    proxy_pass http://backend;
}

location = /auth {
    internal;                              # 只允许内部访问,外部请求返回 404
    proxy_pass http://auth-service/verify;
    proxy_pass_request_body off;           # 不转发请求体,鉴权服务不需要
    proxy_set_header Content-Length "";
    proxy_set_header X-Original-URI $request_uri;
}

Nginx 会发一个子请求/auth:返回 2xx 就放行,401/403 就把这个状态码返回给客户端,其他状态码算 500。

3.7 PRECONTENT —— try_files 与 mirror

try_files 按顺序检查文件是否存在,找到第一个就用它:

location / {
    # 依次尝试:$uri 文件 → $uri/ 目录 → 交给 /index.html
    try_files $uri $uri/ /index.html;
}

location /api/ {
    # 最后一个参数可以是 =状态码,或者一个 location
    try_files $uri @backend;
}

location @backend {              # 命名 location,只能内部跳转
    proxy_pass http://app;
}

mirror 用于流量复制(灰度验证、压测数据采集):

location /api/ {
    mirror /mirror;
    mirror_request_body on;
    proxy_pass http://prod-backend;
}

location = /mirror {
    internal;
    proxy_pass http://shadow-backend$request_uri;
}

镜像请求的响应会被丢弃,不影响主请求。但要注意:镜像是同步发起的子请求,如果影子后端很慢,会拖慢主请求的响应(因为主请求要等所有子请求完成才能结束)。生产用 mirror 一定要给影子后端配短超时。

3.8 CONTENT —— 生成响应

这是唯一「产生内容」的阶段,一个 location 里只能有一个 content handler:

指令 模块
proxy_pass ngx_http_proxy_module
fastcgi_pass FastCGI(PHP)
uwsgi_pass / scgi_pass / grpc_pass 对应协议
return rewrite 模块
root / alias + 静态文件 static 模块(默认兜底)
stub_status 状态页
empty_gif / autoindex 其他

如果没有任何模块注册 content handler,就走默认的 static 模块处理静态文件。

同一个 location 里配两个 content handler 会怎样? 通常是后面的覆盖前面的,或者直接报配置错误。比如 proxy_passreturn 同时存在,return 属于 rewrite 模块、在 REWRITE 阶段就执行了,所以 return 会赢:

location /test {
    return 200 "hello";
    proxy_pass http://backend;   # 永远执行不到
}

3.9 LOG —— 记录日志

请求结束时执行,access_log 在这里工作。所有 $upstream_*$request_time$status 变量此时都已确定。

即使请求被前面的阶段拒绝(403/429/444),也会走到 LOG 阶段——所以限流和 ACL 拦截的请求在 access_log 里都看得到。

4. 过滤器链(filter chain)

CONTENT 阶段产生的响应不会直接发出去,要先过两条过滤器链。

4.1 header filter 链

content handler 产生响应头
ngx_http_not_modified_filter    处理 If-Modified-Since,可能改成 304
ngx_http_headers_filter         执行 add_header / expires
ngx_http_userid_filter          设置 cookie
ngx_http_ssi_filter
ngx_http_gzip_filter            决定是否压缩,设置 Content-Encoding
ngx_http_range_filter           处理 Range 请求,可能改成 206
ngx_http_chunked_filter         没有 Content-Length 时改成 chunked
ngx_http_header_filter          把 header 序列化成字节流(链尾)

4.2 body filter 链

content handler 产生响应体(一个 ngx_chain_t 链表)
ngx_http_sub_filter             sub_filter 字符串替换
ngx_http_ssi_filter             SSI 指令处理
ngx_http_gzip_filter            实际压缩数据
ngx_http_range_body_filter      切出 Range 请求的片段
ngx_http_chunked_filter         加 chunk 头
ngx_http_write_filter           调用 sendfile/writev 真正写出去(链尾)

过滤器链的顺序也是编译期定死的,与配置无关。这解释了几个现象:

  • sub_filtergzip 之前,所以能对未压缩的内容做替换。但如果上游返回的已经是 gzip 压缩的内容,sub_filter 就没法工作了——必须让上游别压缩:proxy_set_header Accept-Encoding "";
  • add_headergzip 之前,所以能正确设置 Vary
  • Range 处理在 gzip 之后,所以 Range 请求返回的是压缩后数据的片段。

4.3 缓冲区链 ngx_chain_t

响应体在 Nginx 内部是一个 buffer 链表:

typedef struct ngx_chain_s {
    ngx_buf_t    *buf;
    ngx_chain_t  *next;
} ngx_chain_t;

每个 ngx_buf_t 有一堆标志位:last_buf(是否是最后一块)、flush(是否要立即刷出)、in_file(数据在文件里而不是内存里)、memory(只读内存)等。

in_file 标志是 sendfile 零拷贝的实现基础:静态文件不需要读进内存,buffer 里只记录 fd 和偏移量,最后由 ngx_output_chain 调用 sendfile() 让内核直接把页缓存的数据送到网卡。

5. 子请求与内部重定向

5.1 子请求(subrequest)

子请求是 Nginx 一个很强的机制:在处理主请求的过程中,再发起一个完整的内部请求,走一遍完整的 location 匹配和阶段处理。

用到子请求的功能:auth_requestmirrorSSIaddition_module、OpenResty 的 ngx.location.capture

特点:

  • 子请求共享主请求的内存池和很多上下文
  • 子请求可以嵌套(默认最多 50 层)
  • 子请求是异步的,不会阻塞 worker
  • 子请求的响应可以被主请求读取或丢弃

5.2 内部重定向(internal redirect)

和 HTTP 302 完全不同:内部重定向发生在 Nginx 内部,客户端完全不知情,URL 不变

触发内部重定向的方式:

rewrite ^/old/(.*)$ /new/$1 last;     # last → 内部重定向
try_files $uri /index.html;           # 回退到 /index.html → 内部重定向
error_page 404 /404.html;             # 错误页 → 内部重定向
X-Accel-Redirect                       # 上游返回这个头 → 内部重定向

X-Accel-Redirect 是个很实用的技巧:后端做鉴权,鉴权通过后让 Nginx 去发文件,后端不用自己传输文件内容。

location /download/ {
    proxy_pass http://backend;    # 后端校验权限后返回 X-Accel-Redirect: /protected/file.zip
}

location /protected/ {
    internal;                     # 客户端不能直接访问
    alias /data/files/;
    # Nginx 用 sendfile 高效发送,后端进程立刻释放
}

这样一个 1GB 文件的下载,后端 Go/Python 进程只处理了一次鉴权(几毫秒),传输完全交给 Nginx。这是文件下载类服务的标准做法

6. 用 error_log debug 观察阶段执行

想亲眼看到这 11 个阶段,需要编译时带 --with-debug

error_log /var/log/nginx/debug.log debug;

# 或者只对特定 IP 开 debug,避免日志爆炸
events {
    debug_connection 192.168.1.100;
}

日志片段(能清楚看到阶段流转):

[debug] http process request line
[debug] http request line: "GET /api/user HTTP/1.1"
[debug] http process request header line
[debug] test server name: "example.com"
[debug] rewrite phase: 1
[debug] test location: "/api/"
[debug] using configuration "/api/"
[debug] http cl:-1 max:1048576
[debug] rewrite phase: 3
[debug] post rewrite phase: 4
[debug] generic phase: 5          ← PREACCESS
[debug] generic phase: 6
[debug] access phase: 7
[debug] access phase: 8
[debug] post access phase: 9
[debug] generic phase: 10         ← PRECONTENT
[debug] http init upstream, client timer: 0
[debug] http upstream request: "/api/user"
[debug] http upstream process header
[debug] http proxy status 200 "200 OK"
[debug] http output filter "/api/user?"
[debug] http postpone filter
[debug] http write filter: l:1 f:0 s:1024
[debug] http finalize request: 0
[debug] http log handler                      ← LOG 阶段

nginx -T 看配置、error_log debug 看执行流程,这两个是排查复杂配置问题的终极手段。

7. 面试题

Q:Nginx 处理一个 HTTP 请求要经过哪些阶段?

11 个:POST_READ → SERVER_REWRITE → FIND_CONFIG → REWRITE → POST_REWRITE → PREACCESS → ACCESS → POST_ACCESS → PRECONTENT → CONTENT → LOG。阶段顺序编译期固定,与指令在配置文件中的书写顺序无关。

Q:limit_reqdeny 哪个先执行?

limit_req 在 PREACCESS(第 6 阶段),deny 在 ACCESS(第 7 阶段),所以先限流再鉴权。这个设计是为了让廉价的限流先挡住流量,避免昂贵的鉴权操作被打爆。

Q:为什么我把 proxy_pass 写在 limit_req 前面,限流还是生效了?

因为执行顺序由阶段决定,不由书写顺序决定。proxy_pass 是 CONTENT 阶段(第 10),limit_req 是 PREACCESS(第 6),无论怎么写都是限流先执行。

Q:rewrite ... lastbreak 有什么区别?(原理层面)

last 会在 POST_REWRITE 阶段触发内部重定向,跳回 FIND_CONFIG 重新匹配 location;break 停止执行后续 rewrite 指令,但不重新匹配 location,继续用当前 location 的配置往下走后面的阶段。内部重定向最多循环 10 次,超了报 rewrite or internal redirection cycle

Q:internal 指令的作用?

标记一个 location 只能被内部访问(内部重定向、子请求、X-Accel-Redirecterror_page),客户端直接请求会返回 404。用于 auth_request 的鉴权端点、mirror 的镜像端点、受保护的文件目录。

Q:X-Accel-Redirect 是什么,什么场景用?

上游返回这个响应头时,Nginx 会丢弃上游的响应体,转而对头里指定的 URI 做内部重定向。典型场景是受权限控制的大文件下载:后端只做鉴权然后返回这个头,实际文件传输由 Nginx 用 sendfile 完成,后端进程立即释放,不占内存和连接。

Q:sub_filter 对某些页面不生效,为什么?

大概率是上游返回了 gzip 压缩的内容。sub_filter 在过滤器链中处理的是响应体的原始字节,遇到压缩数据无法匹配。解决办法是 proxy_set_header Accept-Encoding ""; 让上游返回未压缩内容,由 Nginx 自己压缩。另外 sub_filter_types 默认只处理 text/html

Q:auth_request 的原理是什么?

在 ACCESS 阶段发起一个子请求到指定 URI。子请求返回 2xx 则主请求继续,返回 401/403 则把该状态码直接返回给客户端,其他状态码视为 500。可以用 auth_request_set 从子请求的响应头里提取值(如用户 ID)传给后端。


上一篇:Nginx-03 进程模型与事件驱动原理 | 下一篇:Nginx-05 location 匹配与 rewrite