系统设计 / 路由设计 · 稳定入口与可变目标 / 反向代理与 API 网关 待审核 13 / 23

反向代理与 API 网关

匹配引擎讲的是「path → 哪个 handler」。把同一件事搬到服务端最前面那一层,就是 reverse proxy(反向代理)与 API gateway:外界只看见一个入口(shop.vega.link),网关拿一张 location 规则表决定这条请求该转发给哪个 upstream,可能是后端服务、静态 CDN 或 SPA 前端。这是路由这一层里 dispatch 那一半在生产环境的真实形态。

它和平表 matcher 最大的不同在消歧规则。平表是「写在前面的先命中」,而 nginx 用一套与书写顺序无关的固定流程 [1]:先在所有前缀 location 里选出最长匹配的那条并记住;如果它带 ^~ 修饰符,就直接采用、不再检查正则;否则按配置文件里的出现顺序逐条检查正则,第一个匹配即终止;正则全不中时才回落到刚才记住的那条前缀。而 = 的精确匹配一旦命中就立即终止整个搜索,排在最前。

四档由高到低即 =^~、正则、普通最长前缀。注意最后两档的相对次序——正则排在普通前缀之前,这是最容易记反的一处。

图 0-1 · 一条请求如何被择优、改写、转发。可输入 /static/app.js 观察正则反超前缀,再勾开关把前缀标成 ^~ 看这一翻转。

警示 · 请求 /static/app.js 时前缀 /static/ 明明匹配,命中的却是后面那条正则 ~ \.(js|css|png)$,于是走了 CDN 而非 SPA 静态目录。原因就是上面那条次序:普通前缀被记住之后还要过一遍正则,正则中了就用正则。想让前缀赢回来,得把它标成优先前缀 ^~,命中后直接采用、根本不再看正则。这是运维里极易踩的配置坑。

1 · 命中之后的改写

匹配只是上半场。命中一条规则后,网关通常还要改写请求再发给 upstream,最常见的是剥掉 location 前缀:外界访问 /api/v2/orders/42,而后端 api-v2 服务自己只认识 /orders/42,它并不知道被挂在 /api/v2 下。nginx 里 proxy_pass 末尾带不带斜杠决定剥不剥前缀,又一个一字之差、行为两样的经典点。图 0-1 的转发框会显示改写后真正发给后端的 path。

2 · 四种 location 的档位

同一张表里,=^~~ 与普通前缀四种 location 各有固定档位,与书写先后无关。

server {
  listen 443 ssl;
  server_name shop.vega.link;

  location = /healthz         { proxy_pass http://health-check; }
  location ^~ /static/        { proxy_pass http://spa-files; }
  location ~ \.(js|css|png)$  { proxy_pass http://cdn-static; }
  location /api/v2/           { proxy_pass http://api-v2/; }
  location /api/              { proxy_pass http://api-v1/; }
  location /                  { proxy_pass http://spa-fallback; }
}
location 类型 优先级 转发行为
= /healthz 精确 = 最高,命中即停 原样转发给 health-check
^~ /static/ 优先前缀 ^~ 高于正则 命中即采用、跳过正则
~ \.(js|css|png)$ 正则 ~ 高于普通前缀 按定义顺序取第一个命中
/api/v2/ 普通前缀 取最长匹配 末尾带 /,剥掉 /api/v2 再转发
/api/ 普通前缀 /api/v2/ 短,输给它 同样剥前缀转发
/ 普通前缀 最短,兜底 转发给 spa-fallback

3 · 网关的匹配内核

网关择优不是「第一条命中」,而是按四档固定优先级择优:

  • 精确 =:命中即停,最高。
  • 优先前缀 ^~:最长那条若带 ^~,采用并跳过正则。
  • 正则 ~:按定义顺序取第一个命中,排在普通前缀之前。
  • 普通前缀:回退到最长匹配前缀。
function resolve(rules, path) {
  const exact = rules.find(r => r.type === 'exact' && r.loc === path);
  if (exact) return exact;

  let prefix = null;
  for (const r of rules)
    if (r.type === 'prefix' && path.startsWith(r.loc))
      if (!prefix || r.loc.length > prefix.loc.length) prefix = r;

  if (prefix && prefix.caret) return prefix;

  const rx = rules.find(r => r.type === 'regex' && new RegExp(r.loc).test(path));
  if (rx) return rx;

  return prefix;
}

建议 · 网关和前端 router 内核一致,都是「一条 path 跑一张规则表」,差别只在两点。消歧上,router 多用「顺序即优先级」或 specificity 打分,网关用上面那套固定档位;出口上,router 命中后渲染一个视图,网关命中后把请求转发到另一台服务器并可改写 path 与 header。Envoy、Traefik 与 Kubernetes Ingress 概念相同,只是规则写法各异。

匹配引擎讲的「顺序即优先级、specificity、radix tree」是另外三套消歧策略,与本页这套固定档位对照着看。分享入口里 SPA 接入的三条服务端路由,正是本页 location 表的一个最小实例。网关选中一个 upstream 组之后、组里多个版本按权重分流,是加权流量切分的题目。

4 · 参考文献

  1. nginx. Module ngx_http_core_modulelocation 指令的匹配流程与优先级. nginx.org.