Nginx rewrite规则详解:从301跳转到伪静态,避开常见坑

发布时间:2026/10/15 5:41:25
Nginx rewrite规则详解:从301跳转到伪静态,避开常见坑
年前帮一个项目做站点迁移旧目录/old-news/要整体搬到/news/我在 Nginx 里写了一条 rewriterewrite ^/old-news/(.*)$ /news/$1 permanent;重启完一测旧链接确实 301 跳到了新地址但点进去之后页面 404而且只挂了一部分。当时盯着规则看了很久都没发现问题后来把每个 URL 拆开对比才发现源路径的父子层级和新目录的命名规则根本不是简单的“换前缀”固定的正则捕获根本套不住所有情况。这次折腾让我把rewrite从头到尾认真啃了一遍。如果你也在用 Nginx 改写 URL、做 301 跳转或者伪静态这篇文章值得花十分钟读完整。我会把 rewrite 的匹配原理、四条 flag 的区别、与 location / proxy_pass / try_files 配合的关键细节以及几个我踩过的坑一次性讲清楚刚入门的人能看懂已经写过若干条 rewrite 规则的人大概率也能补上几个盲区。1. 从一次“改写后 404”的事故说起先搞懂 rewrite 到底在改什么1.1 事故复盘规则语法没错错在没弄清改的是 URI 还是 URL先还原一下当时的配置环境。站点原来有一个伪静态路径结构是/old-news/2023/08/31/slug.html运维同事做了内容迁移新的规范 URL 去掉了中间的日期层级改成/news/slug.html。我当时想当然地用了“降级替换”的思路以为把/old-news/前缀换成/news/就行于是写了上面那条规则。问题就出在“替换”这两个字上。rewrite 的第二个参数replacement并不是在原始 URL 上做字符串替换而是把你匹配到的整段内容变成一个新的目标。^/old-news/(.*)$捕获到的是2023/08/31/slug.html这一整段然后原封不动地拼到/news/后面结果是/news/2023/08/31/slug.html。但新站根本没有这个层级自然 404。这是 rewrite 新手最容易犯的误区rewrite 的本质是“重写 URI 字符串”不是“重定向页面”。它给你的是一个内部处理用的 URI至于浏览器地址栏变不变取决于你用的是哪个 flag、replacement 里写的是不是完整 URL。1.2 rewrite 的输入、输出与作用对象搞清楚 rewrite 的输入输出非常重要。Nginx 在收到请求后会经过一系列 phase处理阶段其中 rewrite 模块主要工作在 server 级别的 rewrite phase 和 location 内部的 rewrite phase 里。输入是规范化后的 URI也就是不包含?后面参数的路径部分输出是一个新的 URI或者一个新的完整 URL。举几个例子请求是https://example.com/post?id123page2rewrite 正则能匹配到的部分只有/post如果 replacement 写成/article/123Nginx 只在内部把 URI 改成/article/123然后继续后续阶段如果 replacement 写成https://example.com/article/123Nginx 会直接返回一条 301/302 响应让浏览器去访问新地址之所以很多人分不清重写和重定向是因为 rewrite 的redirect、permanent这两个 flag 让它在某些情况下表现得像重定向。实际上不带 flag 或带last/break的 rewrite 是内部跳转外部看起来地址栏根本没变只是资源从别的地方返回了。1.3 什么时候该用 rewrite什么时候别用我现在的判断标准很简单需求推荐方案原因http 跳 https、主域名跳 wwwreturn 301或return 302没有正则需求return 开销更小旧路径迁移到新路径规则明确rewrite ... permanent需要正则捕获参数动态 URL 转伪静态try_files/rewrite ... last后者更灵活前者更省心反代场景改写上游路径proxy_pass自带 URI 改写或rewrite ... break配合 break 可避免循环复杂业务规则判断交给应用层处理Nginx 层做业务逻辑非常痛苦如果你拿不准一条规则该不该用 rewrite我的建议是先想想这规则是不是只涉及“把 A 变成 B”这种简单映射是的话 rewrite 没问题涉及到多条件、会话状态、权限判断还是让后端程序去处理更合适。2. 一条 rewrite 指令的完整解剖正则、替换串与四个 flag2.1 匹配部分PCRE 正则里最容易踩的三处地雷rewrite 的正则基于 PCRE 库语法和大部分编程语言的正则类似但在 Nginx 这种环境里有几个坑很隐蔽。第一个坑正则默认匹配的是不含 query string 的 URI。很多人写rewrite ^/post/?id123$ /article/123 permanent;然后发现完全不生效因为?以后的内容根本不会参与匹配。如果你确实要针对参数做跳转通常要用$args或$arg_xxx来拿到参数值而不是试图在正则里匹配它location ^~ /post/ { if ($args ~ ^id(\d)$) { rewrite ^/post/$ /article/$1.html? permanent; } }注意这里的?是个小技巧后面讲替换串时会细说。第二个坑开头的^和结尾的$一定要谨慎写。我见过不少规则写成rewrite ^/old-news/(.*) /news/$1 permanent;少了结尾的$在某些情况下不会出问题但一旦新路径本身还可能被后续规则匹配就会产生意料之外的二次改写。反过来如果你原本想匹配整条路径却把$写丢了(.*)会把/news/...之后的内容也吃进去导致替换结果变形。第三个坑贪婪匹配和捕获组的关系。(.*)默认是贪婪的会尽可能多地匹配。比如rewrite ^/old-news/(.*)/(.*)$ /news/$1/$2 permanent;请求/old-news/2023/08/slug.html时第一个(.*)会贪婪地匹配到2023/08第二个捕获组匹配slug.html。如果你想要的是第一层目录作为$1就得用([^/])这种非贪婪写法。我整理了一张表记录我常用的一些正则片段需求推荐正则说明匹配数字 ID^/article/(\d)$捕获纯数字匹配 slug^/news/([\w-])\.html$允许字母、数字、下划线、中划线匹配多级路径^/old-news/(.)$非贪婪的.不会跨目录实际.会注意用(.*)配合非贪婪捕获一小段^/cat/(.*?)/(.*)$先用非贪婪停下忽略大小写末尾加i如rewrite ... last;放在if里时或不加i让 PCRE 区分大小写2.2 替换串的行为内部 URI、外部 URL 和参数的去留replacement参数可以分成两类以/开头内部 URI匹配完成后 Nginx 会在内部继续处理不会产生新的 HTTP 响应以http://或https://开头外部 URLNginx 会返回重定向响应让浏览器去请求新地址一个经常被忽略的细节是替换串里带不带?对原始参数的影响。默认情况下如果 replacement 里没有 query string原请求的参数会被保留并拼到新 URI 后面。如果 replacement 里出现了?则以?后面的内容为准原参数会丢掉。看这个例子rewrite ^/old/.*$ /new permanent;请求/old/page?id123会 301 到/new?id123原参数还在。但如果你写rewrite ^/old/(\d)$ /new?id$1 permanent;请求/old/123?foobar跳过去之后是/new?id123foobar丢失。想保留原参数有两个办法在 replacement 末尾显式加回$argsrewrite ^/old/(\d)$ /new?id$1$args permanent;在 replacement 末尾用?表示“保留原 query”rewrite ^/old/(\d)$ /new?id$1? permanent;不过第二种写法在部分版本的 Nginx 中表现略有差异我更推荐第一种语义最直白。2.3 last、break、redirect、permanent四个 flag 的真正区别这是 rewrite 整个知识点里最核心、最容易被绕晕的部分。先给结论后面再展开flag作用是否重新匹配 location是否发起新请求典型场景无截至当前指令继续执行剩余 rewrite完成后继续处理否否较少单独用last停止当前 rewrite 模块的处理用新 URI 重新进入 location 匹配阶段是否伪静态内部改写路径break停止当前 rewrite 模块的处理不重新匹配 location继续当前请求处理否否反代改写路径redirect返回 302 临时跳转-是临时活动页permanent返回 301 永久跳转-是永久路径迁移、协议跳转last和break是绝大多数人搞不清的一对。简单说last就像你在一张纸上改了地址然后拿着新地址重新去查一次“该走哪个门”break则是改了地址之后直接继续往前走不管这个新地址是不是应该匹配另一个 location举个例子假设你有一个 location/old/里面写了location /old/ { rewrite ^/old/(.*)$ /new/$1 last; }改写后的/new/xxx会重新走进 location 匹配流程如果存在location /new/那请求最终由它处理。如果你改成break那么请求不会重新匹配/new/这个 location而是留在location /old/的上下文里继续处理比如接着执行这个 location 里的 proxy_pass 等指令。这很容易让人懵既然我改成了/new/xxx为什么不走/new/的逻辑原因就是break明确告诉 Nginx“别找新 location 了就用当前这堆配置继续。”这个特性在反向代理场景特别有用尤其是当你不想让改写后的 URI 再次触发其他 location 的规则时。redirect和permanent则是真正的客户端跳转。前者返回 302后者返回 301。一个最常遇到的坑是301 会被浏览器以及各类缓存服务长期缓存测试阶段不小心发了 301后面想改成 302 很难立刻生效改完规则后浏览器可能还在用旧跳转结果得强制刷新甚至等缓存过期。还需要注意一个隐含限制Nginx 内部对于 rewrite 重定向循环有保护默认最多 10 次内部重新匹配超过之后会报rewrite or internal redirection cycle错误直接返回 500。出现这个错误本质就是你的 rewrite 规则让同一请求反复改写、反复重新匹配形成了死循环。3. 高频场景的配置样本为什么这么写坑在哪3.1 协议跳转http 到 https用 return 还是 rewrite最经典的跳转需求。网上搜出来的配置有一半是这么写的server { listen 80; server_name example.com; rewrite ^/(.*)$ https://example.com/$1 permanent; }这个写法能工作但有两个没必要付出的代价多执行了一次正则匹配虽然单次影响很小但在大流量入口每毫秒的 CPU 都是成本如果请求里有 query stringrewrite ^/(.*)$ https://example.com/$1 permanent;这种写法并没有把原参数带过去很多人会漏掉$args更好的方案是用 returnserver { listen 80; server_name example.com; return 301 https://$host$request_uri; }$request_uri是完整原始请求行里的 URI包含参数用它做跳转天然保留所有参数也不用写正则。如果只是协议从 http 换到 https路径完全不变return 是最优选。如果遇到要跳转到不同端口的场景记住$host只表示主机名不含端口。你可以写https://$host:8443$request_uri但要注意$host不包含端口手动拼端口不会重复。3.2 域名与路径迁移保留参数的 301这类需求常见于内容改版。比如旧地址/post?id123要迁移到新站/article/123.html。由于 rewrite 不直接匹配 query string一般用$arg_xxx来取参数location ^~ /post { if ($arg_id ~ ^(\d)$) { set $article_id $1; rewrite ^/post$ /article/$article_id.html permanent; } }这里用set先把捕获的$1存起来因为如果直接在 rewrite 里用$1rewrite 的正则捕获组会和 if 条件里的捕获组产生干扰。Nginx 的变量作用域有时候很反直觉if 块内部的$1在退出 if 作用域之后不一定还可用所以先set成自定义变量最保险。如果你要保留原参数可以在 rewrite 的 replacement 末尾手动追加$argsrewrite ^/post$ /article/$article_id.html?$args permanent;但我一般不建议保留因为迁移后的 URL 应当干净、唯一带着旧参数反而容易造成重复内容。3.3 伪静态动态 URL 改写成脚本入口伪静态是 rewrite 在传统 PHP 站点里用得最多的场景。目标是把/category/tech/page/2这种一看就是语义化路径的 URL在内部改成/index.php?cattechpage2再交给 PHP-FPM 处理。常见写法location / { rewrite ^/category/([\w-])/page/(\d)$ /index.php?cat$1page$2 last; rewrite ^/category/([\w-])$ /index.php?cat$1 last; }为什么这里一定用last因为改写后的/index.php?...需要重新匹配到 PHP 的 location通常是一个由location ~ \.php$定义的正则 location只有last才允许 Nginx 重新进入 location 匹配阶段。如果用了break请求会留在这个location /的上下文里根本不会进 PHP 处理结果就是下载一个 404 页面。新一代 Nginx 更推荐用try_files处理纯伪静态location / { try_files $uri $uri/ /index.php?$query_string; }try_files的逻辑是先看/category/tech/page/2这个文件是否存在再看目录是否存在都不存在就把请求内部重定向到/index.php。它不需要写正则也不用担心循环误伤面小得多。所以我的建议是新项目优先try_files老项目在不改动大量 rewrite 的前提下可以继续沿用 rewrite。rewrite 更适合那种需要精确捕获参数、并且要把参数语义传给后端的场景。3.4 移动端适配与地域跳转有些站点会按 User-Agent 把手机用户导到 m 站。一条比较经典的写法是location / { if ($http_user_agent ~* (Mobile|Android|iPhone)) { rewrite ^/(.*)$ /mobile/$1 last; } }这里有三个雷区if是 Nginx 里出了名的“邪恶指令”在 location 里使用 if 要格外克制它可能引发奇怪行为。如果只是做移动端跳转建议把 if 和 rewrite 尽量放在 server 级或独立的 location 里控制影响面。rewrite 之后的/mobile/$1最好落在一个不含 if 的具体 location避免二次匹配又触发 UA 判断形成循环。移动端跳转还要考虑用户手动点击“查看完整版”的诉求最好给这种 URL 加个?pc1之类的参数在后端判断并跳过跳转。如果只是纯前端展示我更推荐响应式布局而不是服务端跳转服务端跳转牵涉缓存、SEO、用户切换等更复杂的问题不是非必要不要上。3.5 数据校验、规范 URL 之类rewrite 不是万能胶水rewrite 最容易让人上头因为感觉什么 URL 问题都能用它“绕”过去。实际上我建议在写一条 rewrite 之前先问自己三个问题这个改写是 Nginx 层面能做且应该做的吗用try_files或return能不能达到同样目的业务规则未来会不会变会不会需要后端参与计算比如“URL 中参数值不合法直接 404”这类需求rewrite 虽然也能写但可维护性很差。遇到这种需求直接在应用层判断更清晰。rewrite 是 Nginx 的“手术刀”适合精准切割不适合大面积缝补。4. rewrite 如何在 location、proxy_pass、try_files 之间“接力”4.1 location 优先级与 rewrite 落点的“追击游戏”要让 rewrite 不翻车必须理解它和 location 之间的先后关系与循环风险。Nginx 内部处理流程大致是解析请求得到规范化后的 URI执行 server 级别的 rewrite 规则先用正则改写 URI根据当前 URI 进行 location 匹配进入选中的 location执行 location 内的 rewrite 规则如果 rewrite 使用了last则新 URI 会重新触发 location 匹配重复上述第 3 步重复次数达到上限就报循环错误所以从外部看rewrite 就像一个“内部处理器”它可能让同一个请求在多个 location 之间来回跳动。location 匹配有自己的优先级精确匹配最高然后是^~前缀匹配再是正则匹配~/~*最后是普通前缀匹配。如果你 rewrite 出来的新 URI 恰好匹配到一个更优先的 location请求的执行路径和你原本预想的可能完全不一样。我遇到过一个印象很深的例子。某项目伪静态规则写成location / { rewrite ^/news/(.*)$ /index.php?id$1 last; }但项目里同时存在一个更精确的前缀location ^~ /news { # 这里做静态资源缓存 }结果 rewrite 后的/index.php?idxxx没有去匹配 PHP 的 location因为/index.php走了普通前缀匹配落到了一个不太对的 location 里导致请求处理结果诡异。这种问题很难一眼看出只能靠日志定位。4.2 last 和 break 在 location 内外的正确摆放位置很多教程会把 rewrite 写在 server 级或 location 级但没说清楚两种写法在语义上的差异。我总结一套自己的摆放原则server 级 rewrite通常用于域名跳转、协议跳转、路径整体迁移这类规则一般和具体 location 无关并且以permanent/redirect为主location 内 rewrite用于针对特定目录或模式的内部改写最后用last让新 URI 进入正确 location或用break在当前 location 内继续处理代理场景 rewrite优先break因为代理后端通常只需要一个明确的最终 URI不希望它再触发其他 location 规则一个反代场景的标准写法location ~ ^/api/v1/(.*)$ { rewrite ^/api/v1/(.*)$ /internal/svc/$1 break; proxy_pass http://backend; }这里如果用lastrewrite 后的/internal/svc/xxx会去重新匹配 location。假设你还有一个location /internal/请求就会进入那个 location 并执行它自己的业务逻辑原来的proxy_pass http://backend可能根本不会执行也可能执行两遍。用break能确保改写后的 URI 只被当前 location 的后续指令使用不重新匹配。4.3 proxy_pass 自带 URI 改写能力很多时候根本不需要 rewriterewrite 和 proxy_pass 的关系很容易被过度使用。实际上 proxy_pass 本身就具备“把请求路径的一部分替换掉”的能力比如location /api/ { proxy_pass http://backend; }请求/api/user转发到后端时URI 仍然是/api/user。如果改成location /api/ { proxy_pass http://backend/; }请求/api/user转发到后端时匹配location /api/的那一段前缀/api/会被替换成/后端收到的 URI 是/user。这种内置改写足以应对大量“去掉前缀或换前缀”的场景完全不需要 rewrite。只有当你要做的不是简单前缀替换而是“捕获中间某一段再做复杂拼接”时rewrite break 才更有优势。另外提醒一点proxy_pass 如果使用了变量比如proxy_pass http://$upstream$request_uri;它后面必须带 URI即使只是一个/否则 Nginx 会拒绝启动。4.4 try_files 与 rewrite 的分工伪静态的最优解try_files和 rewrite 在伪静态场景看起来很接近但定位不同。try_files更像“兜底路由”它按顺序检查文件是否存在最后给一个 fallback URIrewrite 更像“明确改写”不管目标存不存在先把 URL 变了再说。两者配合的例子location / { try_files $uri $uri/ router; } location router { rewrite ^/(.*)$ /index.php?$1 last; }先用try_files拦截所有真实存在的文件和目录剩下的动态路由统一走 rewrite 进 PHP。这样既保留了 rewrite 的灵活性又避免了 rewrite 把所有请求都拦截一遍的性能损耗。如果你发现一条 rewrite 规则同时需要匹配很多不同形态的 URL与其写一堆正则分支不如考虑用map在进入 location 之前做一次映射map $uri $real_uri { ~^/old/(.*)$ /new/$1; ~^/legacy/(.*)$ /archived/$1; default $uri; }map是纯变量映射比 rewrite 阶段的逐条正则匹配更直观也更适合规则较多的情况。这个技巧我知道的人不多但对维护大量跳转规则的人来说非常好用。5. 翻车现场与排查手段rewrite 调试、性能损耗与大坑名单5.1 打开 rewrite_log看内部跳转每一步排查 rewrite 问题最有效的工具就是 Nginx 自带的 rewrite 日志。配置方法很简单error_log /var/log/nginx/error.log notice; rewrite_log on;打开之后每次请求经过 rewrite 模块error.log 里会输出类似这样的信息[notice] 1234#0: *7 rewrite ^/old-news/(.*)$ matches /old-news/2023/08/slug.html, client: 192.168.1.1, server: example.com [notice] 1234#0: *7 rewritten uri: /news/2023/08/slug.html [notice] 1234#0: *7 rewritten redirect: /news/2023/08/slug.html看到rewritten uri说明是内部改写看到rewritten redirect说明客户端会收到跳转响应。这个日志能直接告诉你规则有没有匹配、改出来的 URI 长什么样比凭肉眼猜正则高效太多。线上排查时边看日志边用 curl 复现是最推荐的方式curl -I https://example.com/old-news/123看返回的Location头和状态码就能判断是 301、302 还是内部改写。我习惯在测试环境把permanent临时改成redirect或者直接加一个时间戳参数来避免浏览器缓存curl -I https://example.com/old-news/123?ts20250101顺便说一句rewrite_log 千万不要在生产常开开一天能写好几个 GB只在排查时短暂开启查完立刻关。5.2 性能影响正则不是免费的rewrite 每多一条每个请求就可能多一次正则匹配。正则匹配是 CPU 密集型操作尤其规则集中在 server 级时意味着所有请求都会经历一遍正则不只是命中伪静态的请求。优化点有几个打开 PCRE JIT。Nginx 编译时带--with-pcre-jit然后在配置里加一行pcre_jit on;这会显著降低正则匹配的耗时。能不用正则就不用。return 301 https://...这种纯协议跳转绝对不要写成正则 rewrite。减少规则数量。可以通过^~精确前缀把完全不相关的 location 提前拦截掉让 rewrite 规则只作用于需要改写的路径。复杂规则拆成map。多条长正则挤在 rewrite 阶段很丑也难维护map更适合做一对一的固定映射。我实测过一个很典型的场景某个入口有 20 条 rewrite 规则全写在 server 级整个站每个请求都要跑 20 次正则高峰期 Nginx CPU 直接吃紧。改成maptry_files组合之后CPU 占用下降了将近四分之一。当然具体数值和机器配置、规则复杂度都有关但方向是明确的。5.3 高频大坑名单我记下来的五条教训大坑一参数没了。rewrite 默认不匹配 query stringreplacement 里带?又会把原参数冲掉。几乎每个刚开始写 rewrite 的人都会踩。处理时要明确自己想保留哪些参数需要就显式写$args或$arg_xxx。大坑二死循环。比如写了rewrite ^/(.*)$ /index.php last;如果/index.php又被同一个 location 里的 rewrite 匹配到相当于每次请求都改写成同一个 URI再次进入 location再次匹配rules 循环Nginx 到第 10 次直接返回 500。凡是遇到 500 且 error.log 里有rewrite or internal redirection cycle优先排查是不是这个原因。大坑三break 放错位置。在 server 级使用rewrite ... break和rewrite ... last的差异不容易直观感知但放在 location 里区别就会被放大。反代场景用break伪静态场景用last这是我目前最稳妥的实践。大坑四301 被浏览器和 CDN 缓存。测试阶段尽量用 302 或 307确认没问题再切 301。如果已经发了 301给你的规则 URI 加个查询参数哪怕是无意义的?v2也能绕开部分 URL 级缓存。大坑五if 环境下的变量捕获。在if里写的正则捕获组出了 if 块大概率不可用。我会先把捕获值set到自定义变量if ($request_uri ~ ^/old/(\d)) { set $page_id $1; } rewrite ^/old/.*$ /new/$page_id permanent;用自定义变量把值提前存下来就不会出现“日志里能看到匹配跳转却拿不到值”的诡异问题。5.4 我自己的几条防坑习惯每个 rewrite 规则都加注释。这听起来很简单但很多人不写。我现在会在规则上方写清楚“原 URL 长什么样、新 URL 长什么样、为什么是用 last 而不是 break”方便几个月后的自己和接手的人。任何修改都先用nginx -t做语法检查再在测试环境用 curl 验证状态码和 Location 头。上线前我还会准备一组“新旧 URL 对照表”写个脚本循环 curl确保每一条规则都符合预期。如果遇到特别复杂的路径转换需求我会优先考虑用map做映射而不是堆 rewrite 规则。这是我在维护一个几百条跳转规则的老项目时总结出的最舒服的解法规则集中、可控、像查表一样清晰出问题也好定位。rewrite 这个东西说难不难说简单也不简单。它的核心就三件事正则匹配、替换串语义、flag 的选取。你把这三点吃透了再加上日志这个工具兜底绝大多数 URL 改写需求都能稳稳拿捏。