图 1:本文封面,请求一关一关过,每关挂着不同的 handler。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面是「解释 Nginx 如何处理 HTTP 请求」。很多人答成「接收、解析、转发」三句话,其实 Nginx 官方开发文档把请求处理定义成一条 11 个阶段的流水线,模块按阶段注册 handler——看懂这条流水线,才算答到点子上。
十一个阶段,各管一件事。
请求头读完之后,Nginx 依次跑这些阶段(阶段名取自官方开发文档):
- POST_READ:最先执行,realip 模块在这里替换客户端地址。
- SERVER_REWRITE:处理 server 块里的改写规则。
- FIND_CONFIG:按 URI 选定 location。这是个特殊阶段,不允许任何模块挂 handler。
- REWRITE:处理选定 location 里的改写规则。
- POST_REWRITE:如果 URI 被改写了,请求回到 FIND_CONFIG 重新匹配 location——内部跳转就发生在这里,同样是特殊阶段。
- PREACCESS:与访问控制无关的预处理,限流两大模块 limit_conn、limit_req 挂在这里。
- ACCESS:真正的访问控制,access、auth_basic 等模块在这里验明正身;默认要过全部 handler 才放行,satisfy 指令可以改变这个行为,其组合逻辑在紧随其后的 POST_ACCESS 里结算。
- PRECONTENT:内容生成前的最后准备,try_files、mirror 挂这里。
- CONTENT:生成响应——静态文件、index,或者按 location 配置的 content handler(比如代理转发到上游)。
- LOG:请求释放前最后执行,写访问日志。
记的时候不用背名词表,按语义分五段就好记:开头改地址、中间两段改写与选 location、然后限流鉴权、接着生成内容、最后记日志。
两个决定行为的机制:短路与会环。
阶段流水线不是「必然走完」,有两条规则直接影响线上行为。第一是短路:ACCESS 阶段的 handler 返回拒绝(比如 403),请求立即被终结,后面的 PRECONTENT、CONTENT 都不再执行,只有 LOG 还会跑——所以访问日志里能看到被拒的请求。第二是会环:REWRITE 改了 URI,请求会回到 FIND_CONFIG 重走后半段,这意味着「改写后命中另一个 location 的另一套 handler」。
下面这个最小模拟把两条规则跑出来给你看,用 Node.js 22.15.0 直接运行(node nginx-phases.js):
'use strict';
// Nginx 阶段流水线的最小语义模拟:handler 返回 DECLINED 放行、非 DECLINED 短路
const DECLINED = 'DECLINED';
const PHASES = [
'POST_READ', // 最先执行,realip 在这里改客户端地址
'SERVER_REWRITE', // server 块的改写
'FIND_CONFIG', // 按 URI 选 location,不允许挂 handler
'REWRITE', // location 块的改写
'POST_REWRITE', // URI 变了就回到 FIND_CONFIG 重走
'PREACCESS', // limit_conn / limit_req 挂这里
'ACCESS', // 鉴权,默认要过全部 handler
'POST_ACCESS', // 处理 satisfy any
'PRECONTENT', // try_files / mirror
'CONTENT', // 生成响应或代理转发
'LOG', // 记日志,请求释放前最后执行
];
function runRequest(handlers, req) {
const log = [];
let uriChanged = false;
for (let i = 0; i < PHASES.length; i++) {
const phase = PHASES[i];
for (const h of handlers[phase] ?? []) {
const r = h(req);
log.push(`${phase}: ${r}`);
if (r === 'REWRITE_DONE') { uriChanged = true; }
if (r !== DECLINED && r !== 'REWRITE_DONE') {
log.push(`请求被终结于 ${phase}(${r}),跳过后续阶段`);
log.push(...(handlers.LOG ?? []).map((h) => `LOG: ${h(req)}`));
return log; // 只有 LOG 阶段还会在终结时执行
}
}
if (phase === 'POST_REWRITE' && uriChanged) {
log.push('URI 已改写:回到 FIND_CONFIG 重新匹配 location');
i = PHASES.indexOf('REWRITE'); // 下一轮从 POST_REWRITE 之后等价于重走
uriChanged = false;
}
}
return log;
}
const req = { uri: '/old-path', user: null };
const handlers = {
SERVER_REWRITE: [() => DECLINED],
REWRITE: [(r) => { r.uri = '/new-path'; return 'REWRITE_DONE'; }],
ACCESS: [() => DECLINED, (r) => (r.user ? DECLINED : 'HTTP 403')],
CONTENT: [() => '生成响应'],
LOG: [() => '写入访问日志'],
};
for (const line of runRequest(handlers, req)) console.log(line);
实测输出:
SERVER_REWRITE: DECLINED
REWRITE: REWRITE_DONE
URI 已改写:回到 FIND_CONFIG 重新匹配 location
ACCESS: DECLINED
ACCESS: HTTP 403
请求被终结于 ACCESS(HTTP 403),跳过后续阶段
LOG: 写入访问日志
一次请求把两条规则都演示了:改写触发回环重选 location,鉴权失败短路掉内容生成,但日志照样落盘。真实 Nginx 里 handler 的返回值语义正是这样——DECLINED 表示「我不处理,交给同阶段下一个」,返回错误码则直接终结请求。
图 2:阶段流水线与两条行为规则——短路与会环。
这个模型在工程里天天用到。
给 Nginx 写扩展、给模型网关挂鉴权与限流,第一件事就是决定「挂在哪个阶段」:鉴权挂在 CONTENT 之前就对了,挂在之后就等于先干活后查票;统计与日志挂在 LOG,提前挂会漏掉被短路的请求。阶段挂错,行为就不符合预期,而且这种 bug 在功能测试里往往测不出来,只在特定路径下暴露。
回到面试现场,建议的答题结构:先给出「11 个阶段、模块按阶段注册 handler」的总框架,再按五段语义复述阶段名,然后讲短路与改写回环两条行为规则,最后举一两个模块归属的例子(限流在 PREACCESS、鉴权在 ACCESS、日志在 LOG)。你给网关写过扩展或自定义模块吗?handler 挂错阶段导致的诡异行为,你是怎么定位的?欢迎聊聊。