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_req、limit_conn、degradation |
| 7 | ACCESS |
访问控制 | allow/deny、auth_basic、auth_request |
| 8 | POST_ACCESS |
访问控制后 | 内部阶段,处理 satisfy any/all 逻辑 |
| 9 | PRECONTENT |
内容生成前 | try_files、mirror(1.13.3 前叫 TRY_FILES) |
| 10 | CONTENT |
内容生成 | proxy_pass、fastcgi_pass、root(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_addr 是 10.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_pass 和 return 同时存在,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_filter在gzip之前,所以能对未压缩的内容做替换。但如果上游返回的已经是 gzip 压缩的内容,sub_filter就没法工作了——必须让上游别压缩:proxy_set_header Accept-Encoding "";add_header在gzip之前,所以能正确设置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_request、mirror、SSI、addition_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_req 和 deny 哪个先执行?
limit_req 在 PREACCESS(第 6 阶段),deny 在 ACCESS(第 7 阶段),所以先限流再鉴权。这个设计是为了让廉价的限流先挡住流量,避免昂贵的鉴权操作被打爆。
Q:为什么我把 proxy_pass 写在 limit_req 前面,限流还是生效了?
因为执行顺序由阶段决定,不由书写顺序决定。proxy_pass 是 CONTENT 阶段(第 10),limit_req 是 PREACCESS(第 6),无论怎么写都是限流先执行。
Q:rewrite ... last 和 break 有什么区别?(原理层面)
last 会在 POST_REWRITE 阶段触发内部重定向,跳回 FIND_CONFIG 重新匹配 location;break 停止执行后续 rewrite 指令,但不重新匹配 location,继续用当前 location 的配置往下走后面的阶段。内部重定向最多循环 10 次,超了报 rewrite or internal redirection cycle。
Q:internal 指令的作用?
标记一个 location 只能被内部访问(内部重定向、子请求、X-Accel-Redirect、error_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
xingliuhua