Nginx rewrite 与 location 匹配:优先级、正则陷阱与路由调试

彻底讲清 Nginx location 的匹配优先级与 rewrite 指令族行为,覆盖前缀与正则、内部重定向、循环检测以及路由调试方法。

配置 Nginx 时最容易「看起来对、跑起来错」的部分就是 location 匹配。同一个 URI 可能同时命中三个 location,最终生效的却不是你预期的那个;rewrite 写对了规则,却因为 flag 用错导致无限循环或者暴露了内部路径。这些问题的根源都在于匹配顺序与重定向语义没有被真正理解。本文先讲清匹配算法的完整顺序,再逐层拆解前缀、正则与 rewrite 指令族,最后给出路由调试的实操方法。

1. location 匹配的完整优先级

一句话总结: Nginx 先选最长前缀,再按配置顺序试正则,正则一旦命中就立即采用,正则全不中才回退到最长前缀。

匹配算法可以精确描述为六步:

  1. 遍历所有前缀 location(=、^~、无修饰符),记录最长匹配项
  2. 若最长匹配项带 =,直接采用,匹配结束
  3. 若最长匹配项带 ^~,直接采用,不再尝试正则
  4. 按配置文件中的出现顺序,逐个尝试正则 location(~ 与 ~*)
  5. 第一个命中的正则被采用,匹配结束
  6. 没有正则命中,采用第 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 flaglast 重新匹配、break 就地停止、redirect 与 permanent 对外可见
循环内部重定向有十次上限,靠 error_log 的 cycle 记录定位
调试加响应头标记 location,或用 debug_connection 定向开 debug

路由配置的可靠性来自「顺序明确、语义唯一、可观测」。把匹配优先级背下来只是第一步,更重要的是养成写完就用 debug 日志或响应头验证一次的习惯。下一篇我们看上游连接池与健康检查,那是决定代理层在高并发下是否稳定的关键。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期