配置 Nginx 时最容易「看起来对、跑起来错」的部分就是 location 匹配。同一个 URI 可能同时命中三个 location,最终生效的却不是你预期的那个;rewrite 写对了规则,却因为 flag 用错导致无限循环或者暴露了内部路径。这些问题的根源都在于匹配顺序与重定向语义没有被真正理解。本文先讲清匹配算法的完整顺序,再逐层拆解前缀、正则与 rewrite 指令族,最后给出路由调试的实操方法。
1. location 匹配的完整优先级
一句话总结: Nginx 先选最长前缀,再按配置顺序试正则,正则一旦命中就立即采用,正则全不中才回退到最长前缀。
匹配算法可以精确描述为六步:
- 遍历所有前缀 location(
=、^~、无修饰符),记录最长匹配项 - 若最长匹配项带
=,直接采用,匹配结束 - 若最长匹配项带
^~,直接采用,不再尝试正则 - 按配置文件中的出现顺序,逐个尝试正则 location(
~与~*) - 第一个命中的正则被采用,匹配结束
- 没有正则命中,采用第 1 步记录的最长前缀
这个顺序解释了最常见的困惑:「为什么我写了更精确的正则,却走了前缀 location」——因为前缀比正则先比,且正则只在「最长前缀没有 ^~」时才有机会被尝试。
server {
listen 80;
server_name example.com;
location = / { # A 精确匹配,优先级最高
return 200 "home\n";
}
location ^~ /static/ { # B 前缀且禁止正则,次高
root /var/www;
}
location ~ \.php$ { # C 正则,在最长前缀无 ^~ 时才尝试
fastcgi_pass unix:/run/php-fpm.sock;
}
location /api/ { # D 普通前缀
proxy_pass http://backend;
}
location / { # E 兜底前缀
return 404;
}
}
一次请求 GET /api/users.php 会怎么走?最长前缀是 /api/(D),它没有 ^~,因此继续尝试正则,\.php$ 命中(C),最终走 C。很多人会误以为走 D,因为 /api/ 看起来更贴合业务语义。只要正则写得足够宽,它会抢走前缀 location 的流量,这是线上事故的常见来源。
1.1 修饰符一览
一句话总结: 四种修饰符对应四种语义,混用时的行为差异只在匹配阶段体现,配置解析阶段不会报错。
| 修饰符 | 语义 | 是否继续尝试正则 |
|---|---|---|
= | 精确匹配 | 否,直接采用 |
^~ | 前缀匹配且优先 | 否,直接采用 |
~ | 正则,区分大小写 | 是 |
~* | 正则,忽略大小写 | 是 |
| 无 | 普通前缀 | 是 |
没有修饰符的前缀 location 是可以被正则「越级」抢走的,这正是它和 ^~ 的唯一区别。若某条前缀路径下全是静态文件、绝不该被正则拦截,就应该写成 ^~。
2. 前缀匹配与精确匹配
一句话总结: 前缀匹配按字符比较取最长,精确匹配要求 URI 完全相等,二者都不会做任何路径规范化。
前缀匹配是纯粹的字符串前缀比较,不做目录语义判断。location /api 能匹配 /api、/api/、/apix 三者,因为它只比较前四个字符。这一点与很多人的直觉相反:它不会因为 /apix 不是「目录下的资源」而拒绝。
# 这三条 URI 都会命中 /api 前缀
# /api
# /api/
# /apixyz <- 容易被忽略的越界匹配
location /api {
proxy_pass http://backend;
}
若确实需要目录语义,应显式写出带斜杠的形式,并用精确匹配处理边界:
location = /api {
# 不带斜杠时重定向到带斜杠,避免相对路径解析歧义
return 301 /api/;
}
location /api/ {
proxy_pass http://backend/;
}
proxy_pass 后面带不带斜杠,行为完全不同,这是另一个高频陷阱:
# 场景一:proxy_pass 不带 URI 部分,原样透传
location /api/ {
proxy_pass http://backend; # /api/users -> /api/users
}
# 场景二:proxy_pass 带 URI 部分,替换匹配到的前缀
location /api/ {
proxy_pass http://backend/; # /api/users -> /users
}
location /api/ {
proxy_pass http://backend/v2/; # /api/users -> /v2/users
}
规则是:只要 proxy_pass 中出现了 URI(哪怕只是一个斜杠),匹配到的 location 前缀就会被替换掉。带正则捕获组时还有额外规则,下一节展开。
3. 正则匹配与捕获组
一句话总结: 正则 location 按书写顺序短路命中,捕获组可用于 proxy_pass 与 rewrite,但性能与可读性都要求正则尽量收敛。
正则 location 的顺序敏感:第一个命中的就采用,后面的不再尝试。这意味着把宽泛的正则写在前面会屏蔽后面的精确正则。
# 错误顺序:.* 会吞掉所有请求
location ~ .* {
return 404;
}
location ~ ^/health$ {
return 200 "ok\n";
}
# 正确顺序:先精确后宽泛
location ~ ^/health$ {
return 200 "ok\n";
}
location ~ .* {
return 404;
}
正则中带捕获组时,proxy_pass 必须写成不含 URI 的形式,否则捕获组会被忽略并触发警告:
# 用捕获组把 /user/1024 转发到 /profile?id=1024
location ~ ^/user/(\d+)$ {
proxy_pass http://backend/profile?id=$1; # 不能写 http://backend/
}
# 多段捕获:/img/2024/06/photo.jpg -> /static/2024/06/photo.jpg
location ~ ^/img/(.*)$ {
proxy_pass http://static_backend/$1;
}
正则匹配的代价明显高于前缀匹配,因为每次都要跑正则引擎。高频路径优先用前缀,正则只用于真正需要模式匹配的场景。当正则数量很多时,还可以用 map 把判定提前到变量层,减轻 location 的负担。
# 用 map 做变量级路由判定,减少正则 location 数量
map $uri $backend_pool {
default "";
"~^/v1/" "v1_pool";
"~^/v2/" "v2_pool";
"~*\.(jpg|png)$" "static_pool";
}
4. rewrite 指令族与 flag
一句话总结: rewrite 改写 URI 并可选地重发内部请求,flag 决定是继续匹配还是立即返回,用错 flag 是循环与状态码异常的根源。
rewrite 的基本语法是 rewrite 正则 替换 [flag],它在 server 与 location 上下文中按顺序执行,一旦某条命中就停止该上下文的后续 rewrite。
# 常见用途一:旧路径迁移,返回 301 让搜索引擎更新索引
rewrite ^/old-blog/(.*)$ /blog/$1 permanent;
# 常见用途二:规范化 URL,去掉重复斜杠
rewrite ^(.*)//+(.*)$ $1/$2 redirect;
# 常见用途三:内部改写,不改动客户端可见 URL
rewrite ^/legacy/(.*)$ /modern/$1 last;
四种 flag 的行为差异必须记牢:
| flag | 是否重发内部请求 | 是否重新匹配 location | 客户端可见 |
|---|---|---|---|
last | 是 | 是,重新走一遍匹配 | 否 |
break | 否 | 否,留在当前 location | 否 |
redirect | 否 | 否 | 是,返回 302 |
permanent | 否 | 否 | 是,返回 301 |
last 与 break 的区别是最容易混淆的:last 会用改写后的 URI 重新跑一遍 location 匹配,因此可能落到另一个 location;break 则就地停止,继续在当前 location 里执行后续指令(如 proxy_pass)。
location /download/ {
# 用 break:改写后仍在当前 location,走下面的 root
rewrite ^/download/(.*)$ /files/$1 break;
root /var/www;
}
location /redirect/ {
# 用 last:改写后重新匹配,可能落到 /files/ 对应的 location
rewrite ^/redirect/(.*)$ /files/$1 last;
}
location /files/ {
root /var/www;
}
5. 内部重定向与循环
一句话总结: last 与 error_page 触发的内部重定向会重新匹配 location,处理不当会形成循环,Nginx 用十次上限做兜底。
内部重定向有四种来源:rewrite ... last、error_page、try_files 的最后一个参数、以及 index 指令。它们都会用新的 URI 重新走一遍 location 匹配。循环一旦形成,Nginx 会在十次内部重定向后返回 500,并在 error_log 中留下 rewrite or internal redirection cycle。
# 典型循环:两个 location 互相 rewrite
location /a/ {
rewrite ^/a/(.*)$ /b/$1 last; # /a/x -> /b/x
}
location /b/ {
rewrite ^/b/(.*)$ /a/$1 last; # /b/x -> /a/x 循环
}
循环排查的关键是看 error_log 中的重定向链:
# 打开 debug 日志观察内部重定向过程
nginx -s reload
tail -f /var/log/nginx/error.log | grep -i "internal redirect\|rewrite"
# 输出示例(截断):
# [debug] rewrite phase: 1
# [debug] "^(.*)//+(.*)$" matches "/a//x", client: 10.0.0.5
# [error] rewrite or internal redirection cycle while internally redirecting to "/a/x"
try_files 是内部重定向最常见的载体,也最容易写错:
# 单页应用(SPA)回退:先找静态文件,找不到就交给 index.html
location / {
root /var/www/app;
try_files $uri $uri/ /index.html;
}
# 反向代理场景:本地找不到就转发给后端
location / {
try_files $uri @backend;
}
location @backend {
proxy_pass http://backend;
}
注意 try_files 的最后一项若是 URI(如 /index.html),会触发内部重定向;若是命名 location(@backend),则直接跳转。二者不可混淆。
6. 典型路由场景实践
一句话总结: 多版本 API、静态资源缓存与 SPA 回退是三类最高频的路由需求,各有稳定的配置范式。
多版本 API 共存:用前缀区分版本,避免正则互相抢流量。
location ^~ /api/v1/ {
proxy_pass http://backend_v1/;
}
location ^~ /api/v2/ {
proxy_pass http://backend_v2/;
}
# 未指定版本时默认走最新版
location = /api/ {
return 308 /api/v2/;
}
静态资源长缓存:给带哈希的文件名设置超长缓存,给入口文件设置不缓存。
location ^~ /assets/ {
root /var/www/app;
expires 1y;
add_header Cache-Control "public, immutable";
}
location = /index.html {
root /var/www/app;
add_header Cache-Control "no-cache, must-revalidate";
}
旧域名与旧路径迁移:用 301 把权重传递到新地址,并保留查询参数。
server {
listen 80;
server_name old.example.com;
# $request_uri 已包含查询串,直接拼接即可保留参数
return 301 https://new.example.com$request_uri;
}
若需要按路径分别迁移,用 rewrite 配合 permanent:
rewrite ^/docs/(.*)$ https://docs.example.com/$1 permanent;
rewrite ^/(.*)$ https://www.example.com/$1 permanent;
7. 调试与常见陷阱
一句话总结: 路由问题几乎都能靠 debug 日志里的匹配过程定位,前提是打开 rewrite 与 location 相关的调试开关。
调试第一步是确认到底命中了哪条规则。最直接的办法是临时给每个 location 加一个可区分的响应头:
location ^~ /static/ {
add_header X-Matched-Location "static-prefix" always;
root /var/www;
}
location ~ \.php$ {
add_header X-Matched-Location "php-regex" always;
fastcgi_pass unix:/run/php-fpm.sock;
}
# 一次请求即可看出命中的 location
curl -sI https://example.com/static/app.php | grep -i x-matched
更彻底的方式是打开 debug 日志:
# 只对特定 IP 开 debug,避免全站日志爆炸
events {
debug_connection 10.0.0.5;
}
error_log /var/log/nginx/error.log debug;
陷阱一:正则抢走前缀流量。 用 ^~ 或把正则写得更严格。陷阱二:proxy_pass 斜杠导致路径多一段或少一段。 记住「带 URI 就替换」规则。陷阱三:if 里写 proxy_pass。 if 在 location 中创建隐式嵌套 location,混用会产生难以预测的行为,能用 map 或 try_files 替代就替代。
# 反例:if 中直接 proxy_pass,行为不可预期
location / {
if ($arg_debug) {
proxy_pass http://debug_backend; # 不推荐
}
proxy_pass http://backend;
}
# 正例:用 map 决定上游,再统一 proxy_pass
map $arg_debug $chosen_backend {
default "backend";
"1" "debug_backend";
}
location / {
proxy_pass http://$chosen_backend;
}
陷阱四:大小写敏感。 ~ 区分大小写,/API/ 不会命中 location ~ ^/api/。需要忽略大小写时用 ~*,或在前缀匹配下不做区分(前缀匹配始终区分大小写)。
8. 总结
| 环节 | 要点 |
|---|---|
| 匹配顺序 | 精确 → 最长前缀(^~ 短路)→ 正则按序 → 回退最长前缀 |
| 前缀语义 | 纯字符串前缀,/api 会匹配 /apixyz,需边界时用 = 补 |
| 正则 | 顺序敏感且短路,捕获组要求 proxy_pass 不带 URI |
| proxy_pass | 只要写了 URI 就会替换掉匹配前缀,斜杠决定路径拼接 |
| rewrite flag | last 重新匹配、break 就地停止、redirect 与 permanent 对外可见 |
| 循环 | 内部重定向有十次上限,靠 error_log 的 cycle 记录定位 |
| 调试 | 加响应头标记 location,或用 debug_connection 定向开 debug |
路由配置的可靠性来自「顺序明确、语义唯一、可观测」。把匹配优先级背下来只是第一步,更重要的是养成写完就用 debug 日志或响应头验证一次的习惯。下一篇我们看上游连接池与健康检查,那是决定代理层在高并发下是否稳定的关键。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。