深蓝黑背景封面:右侧大标题「一个请求,要过几道关卡」,副标题「改写、限流、鉴权、内容生成各挂一段」;左侧原创示意图——一枚请求圆点从左进入,依次穿过五个竖直关卡门(改写、访问控制、限流、内容生成、记录日志),每道门上挂一个小 handler 标签,末端箭头指向上游服务;左上标注「面试经典 · 系统设计」,右上标注「Day 03 · 10-31」 图 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 表示「我不处理,交给同阶段下一个」,返回错误码则直接终结请求。

横向阶段流水线图:请求依次经过读取请求头、改写 URI、访问控制(鉴权/限流/封禁)、内容生成或代理转发、响应过滤与日志记录等阶段,每个阶段画成一个竖条并标注可挂载的典型 handler。改写阶段下方拉出注释「内部跳转会回到阶段起点重走」;访问控制阶段拉出注释「任意 handler 返回拒绝即短路,不再往后走」。下方两张总结卡:左「阶段是固定顺序的挂载点,模块按阶段注册 handler,同阶段多个 handler 串行」;右「模型网关同理:鉴权挂在内容生成之前,统计与日志挂在最后,挂错阶段行为就不符合预期」 图 2:阶段流水线与两条行为规则——短路与会环。

这个模型在工程里天天用到。

给 Nginx 写扩展、给模型网关挂鉴权与限流,第一件事就是决定「挂在哪个阶段」:鉴权挂在 CONTENT 之前就对了,挂在之后就等于先干活后查票;统计与日志挂在 LOG,提前挂会漏掉被短路的请求。阶段挂错,行为就不符合预期,而且这种 bug 在功能测试里往往测不出来,只在特定路径下暴露。

回到面试现场,建议的答题结构:先给出「11 个阶段、模块按阶段注册 handler」的总框架,再按五段语义复述阶段名,然后讲短路与改写回环两条行为规则,最后举一两个模块归属的例子(限流在 PREACCESS、鉴权在 ACCESS、日志在 LOG)。你给网关写过扩展或自定义模块吗?handler 挂错阶段导致的诡异行为,你是怎么定位的?欢迎聊聊。