Nginx rewrite 写了却不生效实战:location 匹配优先级、last 与 break 的真实差异、if is evil 的替代写法

为什么 rewrite 写对了却匹配不上:从匹配顺序讲起

很多站长第一次写 Nginx rewrite 都会遇到一种奇怪的情况:正则明明在 regex101 上验证通过,贴到配置里重启 Nginx 却一点效果都没有,页面该 404 还是 404,该跳转还是不跳转。问题往往不在正则本身,而在于 Nginx 处理请求时有一套非常严格的「匹配顺序」规则。你把 rewrite 放在了错误的位置,它可能压根就没有机会被执行。

本文把 Nginx 的 location 匹配顺序、rewrite 的执行阶段、last 与 break 的真实差异、以及几类最常见的「写了不生效」场景讲清楚。假设你已经会写基本的 location 和 proxy_pass,我们直接进入容易踩坑的部分。

location 的匹配优先级:一张必须背下来的表

Nginx 在收到请求后,会先确定用哪个 location 来处理。不同写法的 location 有明确的优先级顺序,从高到低是:

  1. location = /path(精确匹配,优先级最高,命中即停止搜索)
  2. location ^~ /prefix/(前缀匹配并终止正则搜索)
  3. location ~ pattern(区分大小写的正则匹配,按配置文件中的先后顺序)
  4. location ~* pattern(不区分大小写的正则匹配,同样按先后顺序)
  5. location /prefix/(普通前缀匹配,取最长的那一个)
  6. location /(默认兜底)

这里有几个容易误解的地方。第一,普通前缀匹配(第 5 条)是「取最长匹配」,也就是说 /static// 更具体,会优先。第二,正则匹配一旦命中就立即生效并停止搜索其他正则,所以正则 location 的书写顺序至关重要。第三,^~ 的作用是:如果这个前缀 location 命中了,就不再去看后面的正则 location。

理解这套顺序之后,很多「明明写了却没生效」就能解释清楚了:你写了一个正则 location 想去拦截某类请求,但上面有一个 ^~ 前缀 location 先把它截胡了。

# 常见的踩坑配置:正则永远不会执行
location ^~ /api/ {
    proxy_pass http://backend;
}

location ~ ^/api/v1/.*\.json$ {
    # 这段永远不会被执行,因为 ^~ /api/ 已经终止了正则搜索
    add_header X-Debug "v1-json";
}

rewrite 的执行阶段与 flag 的真实语义

很多人以为 rewrite 是在「找到 location 之后」才执行的,其实不然。Nginx 的生命周期里有 server_rewrite 阶段和 rewrite 阶段。写在 server 块里的 rewrite 属于前者,在 location 匹配之前就执行;写在 location 块里的属于后者,在 location 选定之后执行。这个区别决定了一个 rewrite 会不会被触发。

更关键的是 rewrite 的四个 flag,把它们分清楚是写好 rewrite 的前提:

  • last:重写后停止当前 location 内的 rewrite 指令,然后用新的 URI 重新走一遍 location 匹配流程。注意「重新匹配」意味着可能进到完全不同的 location。
  • break:重写后停止当前 location 内的 rewrite,但不再重新匹配 location,继续用当前 location 里剩下的指令(如 proxy_pass、root)处理。
  • redirect:返回 302 临时重定向,浏览器地址栏会变。
  • permanent:返回 301 永久重定向。

最容易搞混的是 last 和 break。一个典型的误区是:在反代场景里想改写 URI 后继续走当前 location 的 proxy_pass,却用了 last,结果 URI 变了导致重新匹配,落到了另一个 location 上,配置行为完全不是你预期的。反代路径改写的正确写法是 break

location /old-api/ {
    rewrite ^/old-api/(.*)$ /new-api/$1 break;
    proxy_pass http://backend;
    proxy_set_header Host $host;
}

这里用 break,URI 被改写成 /new-api/... 后不再重新匹配 location,继续在当前 location 内把请求交给后端。如果写成 last,Nginx 会拿着新 URI 重新跑一遍 location 匹配,很可能落进别的规则里,出现 404 或循环重定向。

rewrite 与 proxy_pass 尾部斜杠的组合陷阱

反代改写里还有一个经典坑:proxy_pass 后面有没有斜杠,决定了「路径是否被替换」。规则是:如果 proxy_pass 的 URL 带路径(哪怕只是一个 /),那么 location 匹配到的那部分前缀会被替换掉;如果不带路径,则原样透传完整 URI。

# 场景 A:proxy_pass 带斜杠 —— 会替换掉 /app/ 前缀
# 请求 /app/user/1 -> 转发到后端 /user/1
location /app/ {
    proxy_pass http://backend/;
}

# 场景 B:proxy_pass 不带斜杠 —— 原样透传
# 请求 /app/user/1 -> 转发到后端 /app/user/1
location /app/ {
    proxy_pass http://backend;
}

这两行只差一个斜杠,转发路径却完全不同,后端路由对不上就会 404。当你同时使用了 rewrite ... breakproxy_pass 带路径时,两者会叠加作用,务必用 curl -v 或后端 access log 确认最终请求的 URI 到底是什么,而不是凭直觉推断。

if is evil:为什么 if 里的 rewrite 常常失效

Nginx 官方文档里有一篇著名的文章叫「if is evil」,说的就是 if 指令在 location 块中的行为反直觉。在 server 块里的 if 基本是安全的,但在 location 块里,if 会创建一个隐式的嵌套 location,导致 try_filesproxy_pass 等指令的行为变得奇怪。最常见的就是这段被到处抄的「伪静态规则」:

# 危险写法:location 里的 if + rewrite
location / {
    if (!-e $request_filename) {
        rewrite ^/(.*)$ /index.php?$1 last;
    }
}

这段在简单场景下能用,但当 location 里还有 proxy_pass 或 alias 时,if 创建的子语境会让这些指令失效或被忽略。更好的做法是用 try_files 表达同样的意图:

# 推荐写法:用 try_files 表达「文件不存在就交给入口脚本」
location / {
    try_files $uri $uri/ /index.php?$query_string;
}

try_files 是按顺序试探:先找真实文件,再找目录,最后回退到 /index.php。它没有 if 的副作用,语义清晰,执行路径也更好预测。凡是能用 try_files 表达的,都不要用 if + rewrite。

rewrite 不生效的四类高频原因

结合上面的原理,我们总结一下排查顺序。当 rewrite 没效果时,按这个顺序逐条排除:

  • 位置放错:server 块里的 rewrite 先执行,location 里的后执行。如果你依赖变量(如 $arg_xxx)在 location 阶段的值,写在 server 块就会拿到空值。
  • 被更优先的 location 截胡:检查是否存在 = 精确匹配或 ^~ 前缀匹配,它们会让正则 location 根本没有机会被执行。
  • flag 用错:需要「改完继续在本 location 反代」就一定是 break,写成 last 会重新匹配导致跑偏。
  • 正则太贪婪或转义错误.* 贪婪匹配会把整段吃掉,必要时用 .*?;另外 \ 在 Nginx 配置里需要写两次(\\. 才表示匹配字面点号),这是很多「正则明明对却不生效」的真凶。

验证 rewrite 是否生效,最可靠的办法不是看页面,而是打开 rewrite 日志。Nginx 支持在 serverlocation 块里开启 rewrite_log on;,把重写过程写入 error_log(注意是 error_log,不是 access_log,级别需要 info 或 notice)。日志里会明确打印「rewriting ... to ...」以及命中的规则,一眼就能看出实际走了哪条分支:

server {
    rewrite_log on;
    error_log /var/log/nginx/rewrite.log notice;
    # ... 其他配置
}

改完配置后先 nginx -t 检查语法,再 nginx -s reload 平滑重载,然后带着缓存参数请求一次:curl -v "https://你的域名/test-path?_=$(date +%s)",看 < HTTP/1.1 那一行返回的是 200、301 还是 302,以及 Location 头指向哪里。用 curl -o /dev/null -w "%{http_code} %{redirect_url}\n" 可以直接把状态码和目标 URL 打在一行里,比手动翻响应头高效得多。

用 curl 一把测出 rewrite 是否真的命中

排查阶段最忌讳「改完刷新浏览器看一眼」。浏览器有缓存,301 会被永久记住,开发者工具里显示的也可能是缓存结果,很容易让你误判配置已经生效。正确的验证方式是用 curl 做无缓存的命令行测试,把「状态码、跳转目标、响应头」一次性打出来:

curl -o /dev/null -s -w "code=%{http_code} redirect=%{redirect_url} time=%{time_total}\n" \
  "https://你的域名/test-path?_=$(date +%s)"

# 想看完整请求响应过程(含其实发的 URI)
curl -v "https://你的域名/test-path?_=$(date +%s)" 2>&1 | head -30

?_=$(date +%s) 加在 URL 后面是很关键的一步。它给每次请求一个唯一的查询参数,能让 Nginx 和上游的 proxy_cache、浏览器缓存都失效,确保你看到的是「此刻配置的真实行为」。不带这个参数时,一次成功的 rewrite 可能被缓存住,而后你改了配置再测,看到的还是旧结果,白白浪费时间往错误方向排查。

另一个常被忽略的验证点是后端到底收到了什么 URI。当你不确定 proxy_pass 尾斜杠和 rewrite 的叠加效果时,最直接的办法是去后端看 access log,或者在出口处临时加一条调试日志。Nginx 支持在 access_log 里输出 $request_uri$uri$args,把这三个字段拼进日志格式,转发前后的路径变化就一目了然:

log_format debug_uri '$remote_addr "$request" uri=$uri args=$args up=$upstream_addr';
access_log /var/log/nginx/uri-debug.log debug_uri;

$request 是客户端原始请求行(含方法和协议),$uri 是经过 rewrite 处理后的内部 URI,$args 是查询串。三者放在一起对比,就能精确回答「rewrite 到底改了什么」这个核心问题。调试完成后记得把这条日志注释掉,避免长期写双份日志占用磁盘。

常见「看着对但不生效」案例复盘

把前面讲的所有原理串起来,复盘三个真实高频案例。第一个案例是强制 HTTPS 跳转不生效。很多教程给的写法是:

# 看似正确的强制 HTTPS,但在多层反代/CDN 后失效
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

这段配置在直连场景下没问题,但如果站点前面挂了 CDN 或负载均衡,后续的跳转判断就不能只看 $scheme,因为客户端到 CDN 是 HTTPS、CDN 回源可能是 HTTP,$scheme 会一直是 http,导致无限重定向。这种情况下要结合 $http_x_forwarded_proto 判断真实协议。理解「变量取的是哪一跳的值」是排查一切反代相关 rewrite 问题的通用抓手。

第二个案例是伪静态写成 last 导致死循环。当重写规则把 URI 改成一个同样会被同一条规则匹配的形式时,Nginx 会重新匹配 location 并再次进入同一条规则,一旦没有终止条件,就会触发 rewrite or internal redirection cycle 错误,页面返回 500。这类问题的修法是检查重写后的 URI 是否可能再次命中同一规则,必要时用 break 终止,或加上更精确的匹配条件把改写后的 URI 排除在外。

第三个案例是正则里的点号没转义。location ~ \.php$ 里的反斜杠是转义字面点号,如果不小心写成了 location ~ .php$,那么 . 会匹配任意字符,xphp_php 甚至 aphp 都会被当成 PHP 请求处理。

小结

Nginx 的 rewrite 不生效,九成不是正则写错了,而是执行时机或匹配优先级的问题。记住三条主线:location 有严格的优先级表、server 块的 rewrite 在 location 匹配之前执行、last 会重新匹配而 break 不会。写配置时优先用 try_files 代替 if,反代路径改写用 break,最后用 rewrite_log 和带时间戳参数的 curl 验证把猜测变成事实。做到这几点,你就能避开绝大多数「配置看着对、跑起来不对」的坑了。

Last modification:September 22nd, 2026 at 09:24 pm

Leave a Comment