一条 rewrite 规则带来的 301 死循环
给一个跑了两年的 Typecho 站点做 HTTPS 迁移,我在 Nginx 里加了三行规则,改完 reload,然后看到浏览器报错:ERR_TOO_MANY_REDIRECTS。回滚之后又开始查,查了两个小时才发现问题不在我写的那三行,而是一条在配置里躺了一年的老规则——它把 /index.php? 开头的请求转到 /,而 / 又被另一条规则转回 /index.php。这两条规则各自都"看起来正确",合在一起就是死循环。
这个经历让我明白 rewrite 这类指令的坑不在语法,在于规则之间会互相作用。单条规则对,多条组合可能错;而且错的表现往往不是"报错",而是"看起来能访问,但状态码不对、参数丢了、SEO 掉了"。这类问题极难在测试环境发现,因为测试环境常常没有那几条历史遗留规则。
下面按"最容易踩、最难发现"的顺序讲六类问题,每类都给出现象、成因和改法。全部基于常见的 Nginx + PHP 站点结构,代码可以直接对照自己的配置查。
坑一:rewrite 与 return 的优先级被想反了
很多人的心智模型是"配置从上往下执行,谁先写谁先生效"。在 Nginx 里这个模型只在 server 段内近似成立,一旦进入 location,顺序就完全不同了。
实际规则是:server 段的 rewrite 在选出 location 之前执行;location 段的 rewrite 在该 location 匹配之后执行;而 location 里的 return 是立即生效并终止处理的,优先级高于同一 location 里的其他内容指令。更要紧的是:rewrite ... last 会重新走一遍 location 匹配,而 rewrite ... break 不会,它停在本 location 继续处理(可能触发 try_files 或者是被当作静态文件去找)。
这两者的差别在做一个"伪静态转真实文件"的需求时会立刻暴露:
location / {
rewrite ^/old/(.*)$ /new/$1 break; # 用 break
try_files $uri $uri/ /index.php?$query_string;
}
这里如果写成 last,Nginx 会重新匹配 location,而 /new/xxx 可能命中另一条规则又转回来,形成循环。写成 break 则在本 location 内把 URI 换掉后直接进 try_files,行为符合预期。反过来,如果你的目标是"转到一个由另一个 location 处理的地址",比如 /api/ 需要经过 location /api/ 里的 proxy_pass,那就必须用 last。判断标准只有一句话:你想让新的 URI 重新参与 location 匹配吗?想就用 last,不想就用 break。
坑二:参数(query string)默认会被丢掉
这是最隐蔽的一个。rewrite 的行为是:如果替换后的目标里没有 ?,那么原始的查询参数会自动附加在后面;如果替换后的目标里出现了 ?,那么原始参数就全部丢失,包括那个空的白问号。
# 情况 A:参数保留
rewrite ^/p/([0-9]+)$ /post.php?id=$1 last;
# 请求 /p/12?utm_source=x → 实际转到 /post.php?id=12&utm_source=x
# 情况 B:参数被吃掉(哪怕你只是想清空参数)
rewrite ^/old$ /new? permanent;
# 请求 /old?utm_source=x → 转到 /new (utm_source 没了)
情况 A 看起来"挺贴心",但它也正是很多分页失效事故的原因:某条规则把 ?page=2 保留到了目标地址,而目标地址自己的 id= 与它拼在一起,结果后端收到了两个互相矛盾的参数。
要精确控制,就在替换目标末尾显式写上参数处理方式:
# 只保留指定参数
rewrite ^/p/([0-9]+)$ /post.php?id=$1& last; # 末尾的 & 表示接上原参数
# 完全丢弃原参数
rewrite ^/p/([0-9]+)$ /post.php?id=$1? last; # 末尾的 ? 表示丢弃
末尾那个孤零零的 ? 极其容易被漏看,也不知道它是干什么的。建议所有 rewrite 的替换目标都显式以 ? 或 & 结尾,让意图明确,这样半年后回来看也不需要重新推理。
坑三:rewrite 和 try_files 的协作方式
Typecho、WordPress 这类 PHP 站点的核心跳转通常长这样:
location / {
try_files $uri $uri/ /index.php?$query_string;
}
这行的意思是:文件存在就给文件,目录存在就给目录(触发 index),都不存在就把请求交给 /index.php,并把原始查询串带上。它正确工作了,但它是内部跳转,浏览器地址栏不变,状态码是 200。
问题出现在你希望"不存在的文章返回真 404"的时候。上面这条规则会把任何不存在的 URL 都交给 index.php,由 PHP 决定返回什么。如果 PHP 的 404 页面是用 echo 输出的、没有设置 http_response_code(404),那这个 URL 就会返回 200——这就是典型的软 404,搜索引擎会把大量垃圾 URL 当正常页面收录。
更麻烦的是 $uri/ 这个参数。如果站点根目录下有一个叫 uploads 的目录,那么 /uploads/some-deleted-file 会先匹配 $uri(失败)、再匹配 $uri/(失败)、最后交给 index.php。行为是对的,但每多一个这样的目录就多一次文件系统 stat,在高并发下有可测的开销。真正需要检查的是:try_files 最后那个 fallback 到底是 =404 还是 /index.php。前者会返回真 404,后者交给 PHP。两者选哪个取决于你的 CMS 是否真的会输出 404 状态码。
# 静态站/纯文件站:兜底用 =404,别写成 /index.html
location / {
try_files $uri $uri/ =404;
}
坑四:location 匹配优先级与 ^~ 的用途
Nginx 的 location 匹配顺序不是文件顺序,而是:精确匹配 = 最优;其次是带 ^~ 前缀的(匹配成功后不再尝试正则);然后是正则 ~ 与 ~*(按出现顺序,先匹配先用);最后是普通前缀匹配(取最长者)。
最容易出问题的地方是:你以为 location /static/ 会优先于某条正则。实际上如果存在 location ~* \.(js|css)$,那么 /static/app.js 会命中正则而不是前缀。此时你在 location /static/ 里加的安全头、缓存头全部不生效——而且不会有任何报错。
location ^~ /static/ {
expires 30d;
add_header Cache-Control "public";
}
加上 ^~ 之后,只要路径以 /static/ 开头就停止尝试正则,配置才真正生效。这是个很有用的排查技巧:如果你发现某个目录下的缓存头怎么都不生效,先检查是不是被某个更早写的正则抢走了。
还有一个连带陷阱:add_header 是子块完全覆盖父块的语义。只要子块(比如上面的 location)里写了一个 add_header,父块 server 段里的所有 add_header(包括 HSTS、X-Frame-Options)在这个 location 里全部失效。所以千万别用"在 location 里补一个头"的方式,除非你把父块的头也一起搬下来。
坑五:rewrite 与 301 的 SEO 语义混用
三种跳转的用途必须分清,混用会造成收录事故:
301(永久)用于真正的内容搬家:URL 结构变了、域名换了、文章合并了。搜索引擎会把权重转移过去。
302(临时)用于短期跳转:维护页、A/B 测试、逻辑上的"这次请去那边"。
rewrite 内部跳转(无状态码)用于服务端路由:把 /p/12 改成 /post.php?id=12 交给 PHP,浏览器地址栏不变,对外就是一个 URL。
真正的错误是把内部跳转写成 301。比如把 /p/12 用 rewrite ^/p/(\d+)$ /post.php?id=$1 permanent; 写,结果是:用户访问 /p/12,浏览器被 301 到 /post.php?id=12,地址栏暴露了技术实现,而搜索引擎会把 /p/12 标记为"重定向页"并从索引里移除,权重转移到 /post.php?id=12 上。如果你之后又改回来,就得再等一轮重新收录。
另一个常见问题是 301 链。站点换了域名 A→B,又从 HTTP 跳到 HTTPS,又在最后加了 www,于是变成了 A→B→B(https)→www.B,三个跳转串起来。移动端在弱网下每多一跳就多几百毫秒,而且搜索引擎对长跳转链的抓取效率会降低。能用一条规则跳到终点,就别分三段。
坑六:rewrite 与 CDN / 反代的相互作用
如果站点前面挂了 CDN,那么 rewrite 的中间跳转可能被 CDN 缓存下来。301 响应默认是可缓存的,所以一旦某条规则写错,CDN 会把错误的重定向缓存很久,你改完源站配置后用户仍然被跳到错误地址,因为请求根本没到源站。
验证是否踩到这个坑的方法:用 curl --resolve 直接打源站 IP,绕过 CDN 看响应;再打域名看是否一致。两者不一致就是我们常说的"CDN 缓存串扰"。
# 绕过 CDN 直连源站,看真实状态码与 Location
curl -sI --resolve www.example.com:443:1.2.3.4 https://www.example.com/old-path \\
| head -5
此外,如果站点藏在反向代理后面,rewrite 里的 $scheme 会是 http(因为反代到源站是 HTTP),于是"强制 HTTPS"的规则会在源站上无限跳自己——这就是本文开头那个死循环的另一种形态。修法是让反代传递 X-Forwarded-Proto,并在源站用 map 把它映射成真实协议,而不是直接用 $scheme 判断。
一个可复用的排查流程
遇到"规则写了但没生效""跳转不对""死循环",按这个顺序查,九成问题在前三步就能定位:
第一步,nginx -T 看最终生效的配置。不是看你编辑的那个文件,而是看 Nginx 实际加载的全部配置(包括 include 进来的)。重复的 location、被注释掉的 include、以及从别处继承来的规则,只有 nginx -T 显示得出来。
第二步,curl -sI 看状态码与跳转链。加 -L 能看到完整链条,但默认会吞掉中间状态,所以先用不加 -L 的方式看第一跳,再逐步跟。
第三步,检查 location 匹配顺序。把 ^~、正则、精确匹配的位置捋一遍,确认请求到底落进了哪个 location。Nginx 的 debug 日志(需要 --with-debug 编译)会打印匹配过程,生产环境一般没有,所以手动推理更快。
第四步,确认是否有上游缓存。CDN、fastcgi_cache、浏览器缓存三层任一层命中,都会让你"改了配置却看不到变化"。测试时统一加一个随机查询参数绕开缓存。
第五步,看 PHP 侧。有些"跳转不对"根本不是 Nginx 的问题,而是 CMS 自己发的 header('Location:')。判断方法:看响应头里有没有 X-Accel- 或者直接把 PHP 的跳转临时注释掉对比。
三条写规则的习惯,能省掉大部分返工
第一,所有 rewrite 的替换目标显式以 ? 或 & 结尾。这条习惯让"参数是丢是留"从隐式变显式,半年后再看也不用推理。
第二,改配置前先存档一份 nginx -T 的输出。改完之后 diff,能立刻看出实际生效的配置变了什么,而不是你以为变了什么。我现在的发布流程里这一步是强制的。
第三,每次改跳转规则,用三条 curl 验收。一条正常 URL(应为 200)、一条已迁移的旧 URL(应为 301 且 Location 正确)、一条不存在的 URL(应为 404 而不是 200)。第三条最容易被漏掉,而它恰恰是软 404 和收录事故的源头。
rewrite 这个指令集的复杂度来自它的"隐式行为":参数自动附加、last/break 的匹配语义、location 的优先级规则、add_header 的覆盖语义。每一条单看都不难,组合起来就成了需要经验才能避开的雷区。把上面六类坑对照自己的配置查一遍,多半能找出几条已经错了但还没爆的规则——它们通常正安静地等在某次配置改动之后。