Nginx rewrite重写实战:指令语法、last与break区别及配置案例详解
2026/9/8 14:57:24 网站建设 项目流程

1. rewrite重写功能到底能解决什么问题

前两天一个同事来找我,说他们旧接口/v1/user/list要改成/api/user/list,但安卓和iOS客户端的旧版本还得继续用一段时间,问我有没有什么不改代码的办法。我第一反应就是:这不就是Nginx rewrite最典型的场景嘛。

rewrite说白了就干两件事:一是把用户请求的URL改写成另一个URL,二是让客户端直接跳到另一个地址。前者叫内部重写,用户无感知,Nginx自己在内部把路径换掉再继续处理;后者叫外部重定向,服务器会告诉客户端“你要的东西已经搬到新地址了”,浏览器地址栏也会跟着变。

很多刚开始接触Nginx的人会把rewrite想得很玄乎,觉得它是一套独立的重定向规则。实际上它远不止“跳转”这么简单,伪静态、接口路径兼容、老链接迁移、http强制跳https、移动端和PC端分流,这些高频需求背后都离不开rewrite。

这篇文章我会把rewrite从语法规则、执行顺序、flag选择到实战配置和踩坑排查完整过一遍。适合刚学Nginx的人跟着理解底层逻辑,也适合已经写过一段时间rewrite但老在last和break之间搞混的兄弟查漏补缺。

2. rewrite指令语法与执行流程

2.1 rewrite四要素:位置、正则、替换、flag

rewrite指令的基本写法非常固定,就一行:

rewrite regex replacement [flag];

比如下面这个:

rewrite ^/article/(\d+)\.html$ /article.php?id=$1 last;

它表达的意思就是:如果请求路径匹配^/article/(\d+)\.html$,就把它内部改写成/article.php?id=数字

这里有几个必须搞清楚的细节,不然写规则时很容易踩坑。

第一,正则匹配的是$uri,也就是去掉域名、去掉参数、并且经过URL解码和标准化之后的路径。举个例子,请求http://example.com/article/123.html?page=2,rewrite匹配时拿到的只是一个干净的/article/123.html,问号和page参数根本不会进入正则匹配范围。这也是很多新手第一次写规则失败的原因,明明路径看着一样,规则就是不生效,然后发现正则里写了问号。

第二,rewrite指令可以写在server块、location块、if块里。不同位置对应的执行节点不一样,效果也会有差别,这一点放到后面专门讲。

第三,replacement支持变量捕获,正则需要捕获什么内容,就用()括起来,后面用$1$2去引用。除了$1这样的捕获变量,rewrite里还可以直接用很多Nginx内置变量,比如$host$request_uri$scheme$args,灵活性非常大。

第四,flag不传也是可以工作的,但无flag的rewrite执行完之后,规则匹配就结束了,不会再去重新匹配别的location。这和你想要的“改完URI后重新匹配新location”往往是两回事。所以绝大多数场景下你需要明确指定一个flag,flag选错是rewrite最常见的翻车原因,后面会单独展开。

2.2 rewrite命中的是哪个“URL”

很多人在排错时会发现,我明明匹配了带参数的完整地址,但还是没生效。原因就是我上面说的:rewrite默认只匹配URI路径,参数不在匹配范围内。

如果你确实需要“根据参数的有无或内容来做不同处理”,那就不能靠rewrite自带的那个正则了,你得在if条件里手动判断$query_string或者$args。这两个变量一个带问号、一个不带问号,值是一样的内容。比如:

if ($query_string = "") { return 404; }

这个写法就是用来实现“只允许带参数的URL转发”这类需求的,前面带不带问号没有区别。

另外要区分$uri$request_uri$uri是Nginx内部经过标准化、解码后的路径,rewrite改写之后$uri会跟着变;而$request_uri永远保留客户端最原始请求的完整URI,包括参数,它不会因为内部rewrite而改变。排错的时候,如果你需要在重写后的上下文里拿原始地址,那就得靠$request_uri

2.3 rewrite和location的先后顺序

理解rewrite在Nginx处理请求的哪个阶段执行,是写对规则的前提。

一次HTTP请求进入Nginx后,大致流程是:先按server_name选出一个server块,然后执行server块里的rewrite规则,完成之后才根据当前URI去匹配location,进入到匹配到的location块后,再执行location块内的rewrite规则。如果location内rewrite的flag是last,Nginx就会用改写后的URI再去匹配一次location。

这个机制有两个常见推论。

一个是server级rewrite是在location匹配前发生的,所以在server级别用rewrite做域名跳转,能保证所有location都不需要重复写跳转逻辑。另一个是如果你在某个location里rewrite出来的新URI,其实应该匹配另一个location,而你用了break,那新URI就不会重新匹配,请求还是留在当前location里继续处理,最终结果大概率是404或者跑到了错误的处理逻辑上。

3. 四个flag的底层区别与选型思路

rewrite后面的flag是这个指令的精髓,也是最容易让新手懵掉的地方。很多人配置是抄来的,抄的时候只看到别人写的是last,到另一个场景照着抄却出了问题,原因就是没理解几个flag的本质差异。

3.1 redirect和permanent:对外跳转的状态码差异

先看最简单的一组:redirect和permanent。

rewrite ^/old/(.*)$ /new/$1 redirect; rewrite ^/old/(.*)$ /new/$1 permanent;

redirect告诉客户端这是临时重定向,浏览器会返回302状态码,地址栏更新成新地址,但搜索引擎和客户端不会认为这个跳转是长期的。permanent则返回301,表示永久重定向。

这两个flag的适用场景区别非常明确:如果只是活动期间临时切个页面,或者上线前不确定规则要不要回滚,那就用redirect。如果确定旧地址以后不会再提供服务,希望客户端、浏览器、搜索引擎都把请求收敛到新地址,那就用permanent。

这里有个我踩过的坑必须提醒:301状态码会被浏览器缓存。你以为改一次配置就完事了,结果后来发现规则写错了,改回来以后用户在浏览器里访问旧地址,浏览器压根不会重新发起请求,直接基于之前缓存的301结果跳到新地址。这也是为什么我建议在测试环境或者刚上线还不确定的时候先用302,等规则确认稳定了再切301。

那什么时候用rewrite来做外部跳转,什么时候直接用return?如果你的需求很纯粹,就是域名从A跳到B,或者http跳到https,那直接用return更快、更直观、更不容易出错。return本身就是Nginx rewrite模块里的指令,遇到return会立刻结束当前阶段的处理,不再执行后续匹配和改写。比如:

return 301 https://$host$request_uri;

但如果你的跳转地址需要从原URL里提取一部分内容重新拼接,比如把/old/2024/xxx变成/new/2024/xxx,用rewrite配合正则捕获会更方便。

3.2 last和break:内部改写后去哪儿的差别

last和break都表示“当前rewrite规则处理完,停止继续执行后续rewrite”,但它们最大的区别在于:改写完URI之后,是否重新走一遍location匹配流程。

last的意思是停止当前rewrite,然后用改写后的URI,从头再匹配一次location。这个“重新匹配”是关键。break的意思是停止当前rewrite,但不再重新匹配location,请求继续留在当前location上下文里,用改写后的URI继续做后续处理,比如找静态文件、转给fastcgi、转给后端proxy_pass。

举一个非常经典的例子。线上有个旧路径/api/,后端服务实际接收的路径要去掉/api前缀。如果你这样写:

location /api/ { rewrite ^/api/(.*)$ /$1 break; proxy_pass http://backend_server; }

rewrite把/api/user改成了/user,然后因为用了break,不会重新匹配location,接着就在当前location内执行proxy_pass,把/user转给上游。这样就完成了“前端路径带前缀,后端路径不带前缀”的转换。

如果这里不加break改成了last,改写后的/user会重新匹配location,万一没有专门针对/user的location,请求就可能落到其他location甚至默认location里,和你的预期完全不一样。

反过来,伪静态场景里用last就非常合适。请求/article/123.html,rewrite成/index.php?s=/article/123.html,然后last让Nginx重新匹配location,命中PHP处理的那个location,把请求交给PHP-FPM。

所以记住一个简单的选型逻辑:改写后希望重新匹配location,用last;改写后希望立刻在当前location内进行后续处理(静态文件、proxy_pass、fastcgi_pass),用break。

3.3 为什么能用return就别用rewrite

可能有人会问,既然rewrite可以做301跳转,那为什么网上很多教程推荐域名跳转直接用return?

原因有两个。第一,return的执行效率更高。return在rewrite模块处理时遇到就直接返回响应,后面那些location匹配、正则检查都不需要继续了。第二,return状态码非常明确,而rewrite如果你写成外部跳转却不带flag,默认行为很多人会搞混。

比如下面这种写法:

rewrite ^ https://example.com$request_uri permanent;

这行能正常工作,返回301。但如果你漏写了permanent:

rewrite ^ https://example.com$request_uri;

它会默认返回302。这一点有时是有用的,但更多时候会让人困惑:到底是我没写flag所以302,还是flag写错了?

所以我的习惯是:凡是“直接跳到一个固定地址、固定域名、固定协议”的场景,一律用return,简单直观。凡是“要从URL里提取内容、拼接出新路径、根据不同的正则匹配结果跳到不同地址”的场景,才用rewrite。

4. 不同上下文里的rewrite写法和坑

4.1 server块里的域名跳转

如果你想让old-example.com的所有请求都跳到www.example.com的对应路径,最常规的写法是在一个单独的server块里配置:

server { listen 80; server_name old-example.com; return 301 http://www.example.com$request_uri; }

这里用$request_uri是为了把原始URI和参数完完整整带过去,这是我在实际配置里最喜欢的写法。如果你图省事,写了一个不带参数的跳转,用户访问http://old-example.com/user?from=app,跳过去之后from=app参数丢了,轻则影响统计,重则影响业务逻辑。

那有人会说,既然有rewrite,为什么这里不用rewrite?比如:

rewrite ^(.*)$ http://www.example.com$1 permanent;

这个写法也能实现,但这里有个很细节的差异:$request_uri包含完整的原始参数,而$1是正则捕获的URI路径,是不含参数的。所以如果我非要用rewrite,想完整带参数,写法要变成:

rewrite ^ http://www.example.com$request_uri permanent;

但用return直接写反而更干净。

4.2 server块里用if做带条件的域名收敛

再往下走一步,如果old和new两个域名在同一个server块里监听,你想实现“不带www的统一加www”,常规做法是配合if判断:

server { listen 80; server_name example.com www.example.com; if ($host = "example.com") { return 301 http://www.example.com$request_uri; } # 其他业务配置 }

这里if写在server上下文里是相对安全的,它只是帮助做了一次判断,如果命中就return,如果不命中就继续往下走。

有一个容易忽视的小点:$host变量不会携带端口号,而$http_host会原样携带请求头里的Host,包括端口。所以在做域名判断时建议用$host,避免因为端口号不一致导致判断失效。

4.3 location块内rewrite实现伪静态

伪静态是rewrite最高频的使用场景之一。很多PHP框架,比如ThinkPHP、CodeIgniter,入口都是index.php,但对外我们希望URL是/news/123.html这样好看的结构。

传统写法是在根location里这样写:

location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } }

这段逻辑的意思是:如果当前请求对应的文件在磁盘上不存在,就把请求重写到index.php入口去处理。

这个写法在大量老项目里都有,实际也能跑,但我自己很少在新项目里推荐它了。原因有两点:一是每次请求都要做一次!-e文件是否存在检查,稍微有点额外开销;二是if在location里本身就是个容易出问题的东西,如果后面又叠加其他条件判断,很容易出现莫名其妙的500错误。更推荐用try_files替代:

location / { try_files $uri $uri/ /index.php?s=$uri; }

但如果你因为某种原因一定要用rewrite,那要注意防死循环。rewrite出来的结果本身也是URI,如果它能继续匹配原来的location,就可能陷入rewrite or internal redirection cycle死循环报错。解决办法是让入口文件走一个独立的location:

location / { rewrite ^(.*)$ /index.php?s=$1 last; } location = /index.php { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root/index.php; fastcgi_pass unix:/run/php/php-fpm.sock; }

因为location = /index.php是精确匹配,优先级高于普通前缀匹配的location /,所以rewrite之后带着/index.php的新URI会稳稳命中这个入口location,不会再回到上面那条rewrite规则里。

4.4 if块里用rewrite要谨慎

很多人喜欢在location里写if来判断各种条件,然后rewrite。但Nginx官方对“if在location中使用”是有警告的,网上有一篇非常有名的文章叫“If Is Evil”,说的就是这回事。

我的经验是:if不是完全不能用,但要克制。能在server块做的判断,别放到location里做。能在location里用try_files解决的,别用if加rewrite。如果非要在location里用if,尽量只做return、proxy_pass之类简单的动作,不要在里面叠加复杂的rewrite逻辑。

一个相对安全的例子是拦截特定请求方法:

if ($request_method !~ ^(GET|HEAD|POST)$) { return 403; }

这种写法在server块或location里都不会有太大问题,因为它只做判断和返回,不参与复杂的URI改写流程。

4.5 只允许带参数的转发规则

开头提到一个热搜词,“nginx限制只转发带参数的url”。这个需求在开放接口、回调通知这些场景里其实很常见,做法的核心就是判断query string是否存在。

比如后端的网关接口只允许带参数的请求转发过去,空参数的通通拦下:

location /gateway { if ($query_string = "") { return 404; } rewrite ^/gateway(.*)$ /internal$1 break; proxy_pass http://backend_gateway; }

这个配置做了两步:先判断有没有参数,没有就返回404;有参数的话把/gateway前缀改成/internal,然后通过proxy_pass转发到后端。

这里用的是break而不是last,原因很明确:rewrite之后我们不希望这个/internalxxx的URI再重新匹配一遍Nginx的location,而是希望它留在当前location里直接执行proxy_pass转发。

我经常说,rewrite写得好不好,往往就看你对这个场景的判断准不准。业务要求“参数为空就是非法请求”,那就判断$query_string;如果要求“某个指定的参数必须存在”,判断就再加一层:

if ($query_string !~ "(^|&)token=[^&]+") { return 403; }

这种正则判断接口里非常实用。

5. 真实案例复现:配置示例与验证过程

理论讲再多,不如拿几个典型案例从头到尾走一遍。下面的案例我都按“需求背景 → 配置写法 → 验证方式 → 备注”的结构来拆,照着抄基本能解决工作中八成以上的rewrite问题。

5.1 场景一:HTTP强制跳HTTPS

需求特别明确,站点已经上了HTTPS证书,想把所有HTTP请求永久跳到HTTPS。

server { listen 80; listen [::]:80; server_name blog.example.com; return 301 https://$host$request_uri; }

验证方式:

curl -I http://blog.example.com/post/12?a=1

预期返回:

HTTP/1.1 301 Moved Permanently Location: https://blog.example.com/post/12?a=1

这里用$request_uri,就是为了保证/post/12?a=1这个原始URI和参数一个不落地带到HTTPS那边。这套方法在线上切换HTTPS时非常标准,只要在80端口统一return,其他443端口的server配置不用做任何特殊处理。

5.2 场景二:旧版接口路径迁移

需求是线上老接口/v1/user/list要变成/api/user/list,但旧版本客户端还要继续用一段时间。客户端暂时不发版,服务端代码也不做改动,只靠Nginx把旧路径301到新路径。

配置可以这样做:

location ^~ /v1/ { rewrite ^/v1/(.*)$ /api/$1 permanent; }

我用location ^~ /v1/来限定只要路径里带/v1/前缀的请求走这个规则,避免误伤其他路径。

验证方式:

curl -I "http://example.com/v1/user/list?from=android"

预期返回:

HTTP/1.1 301 Moved Permanently Location: http://example.com/api/user/list?from=android

注意这里的Location里参数是保留的。rewrite在做内部跳转时,如果替换字符串里没有显式加问号,原请求的参数会跟着保留下去;如果你不希望保留,可以在replacement末尾加一个?来清掉。

比如:

rewrite ^/v1/(.*)$ /api/$1? permanent;

加上这个问号后,跳转结果就变成了http://example.com/api/user/list,参数被丢弃。这个细节非常有用,很多时候用户反馈“跳过去以后URL后面多了一串参数”,往往就是没弄明白这个问号的作用。

5.3 场景三:文章页伪静态到PHP入口

需求是访问/post/123.html时,Nginx内部把它转给/index.php,由PHP框架按照路由规则解析。

我的推荐配置是try_files,但既然这里聊rewrite,我还是把rewrite版本写出来并解释清楚。

location / { rewrite ^/post/(\d+)\.html$ /index.php?s=/post/$1 last; } location = /index.php { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root/index.php; fastcgi_pass unix:/run/php/php-fpm.sock; }

这个版本的写法有几个值得学习的地方:一是正则里用(\d+)捕获文章ID,重写时用$1拼到新的路由参数里;二是在文章伪静态场景下用last让改写后的URI重新走location匹配,让它能被专属于index.php的精确location接管;三是用location = /index.php的精确匹配切断循环的路径,避免一条rewrite反复套娃。

如果用try_files替代,配置会更简短:

location / { try_files $uri $uri/ /index.php?s=$uri; } location = /index.php { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root/index.php; fastcgi_pass unix:/run/php/php-fpm.sock; }

两种写法在效果上都能cover需求,try_files的可读性更好,rewrite写法则可以处理更多定制化的路径变换。

5.4 场景四:无参数URL直接拒绝转发

需求是这样的:后端的某个网关接口只处理带参数的合法请求,如果访问者请求的是根路径或者根本没带任何参数,那就直接判定为非法访问,不往后端转发。

配置我上面已经给过一版,这里再给一个带状态码区分的完整版:

location /gateway { if ($query_string = "") { return 403; } rewrite ^/gateway/(.*)$ /forward/$1 break; proxy_pass http://internal_api; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }

验证方式:

curl -I "http://example.com/gateway/" curl -I "http://example.com/gateway/pay?order_id=12345"

第一个请求预期返回403,第二个请求如果后端正常则返回200或302,同时后端拿到的实际请求路径是/forward/pay,而不是/gateway/pay

这个场景其实也说明了一件事:rewrite判断参数有没有,本质上是通过$query_string变量,而不是通过rewrite正则去匹配。很多人在这一步绕了很多弯路,就是没想明白rewrite默认匹配的是URI路径这一段。

5.5 场景五:带www域名统一

需求是用户访问example.com时要自动跳到www.example.com,同时保留完整路径和参数。

server { listen 80; server_name example.com www.example.com; if ($host = "example.com") { return 301 http://www.example.com$request_uri; } }

这里if判断$host是否等于不带www的域名,是的话就返回301。

这里有一种更“rewrite风格”的写法:

if ($host !~* ^www\.) { rewrite ^(.*)$ http://www.example.com$1 permanent; }

但这种写法我在生产环境里越来越少用,因为$1不含参数,而且正则判断不够直观,不如$request_uri加return来得干净。如果你确实要用rewrite,记得把参数也带上:

if ($host !~* ^www\.) { rewrite ^(.*)$ http://www.example.com$request_uri permanent; }

不要小看这个参数保留的细节。很多用户上报“我打开老域名,跳过去之后登录状态丢了”,一查日志,发现是跳转时把参数里的session标识给丢了。

6. 常见问题排查与越用越稳的经验

6.1 rewrite后一直访问到旧地址

这个大概率不是你配置没生效,而是301被客户端缓存了。我在前面已经强调过,permanent返回的301状态码是会被浏览器以及一些HTTP客户端缓存下来的,而且缓存时间可能很长。

遇到这类问题,先别急着改服务器配置,先用curl看服务端真实返回:

curl -I http://example.com/old/path

看看返回的状态码和你预期是否一致。如果服务端已经返回的是新地址,但浏览器还停在旧地址,那就清一下浏览器缓存或者换个无痕窗口再验证。

这也是我建议开发阶段用302、确认稳定后再切301的核心理由。一旦301被缓存错了,返工成本很高。

6.2 rewrite后报redirect cycle死循环

Nginx错误日志里如果出现类似这样的信息:

rewrite or internal redirection cycle while processing "/index.php"

那说明你的rewrite规则改出来的URI,会再次匹配到同一条rewrite规则,然后又改写又匹配,Nginx检测到循环后主动中断了。

最常见的场景就是伪静态rewrite放到了location /,而 rewrite 改写后的URI仍然是/index.php开头,结果location /又把它吞进去继续rewrite。

解决办法就是我上面案例里写的,让入口文件走独立的精确匹配location:

location = /index.php { # PHP处理 }

location =的优先级比普通前缀location高,rewrite后的/index.php会直接落到这个location里,不再回到location /去执行rewrite。

6.3 参数在后面丢了或多了

很多人配置完rewrite,用浏览器一测,发现目标URL的参数要么少了要么多了一堆不认识的参数。这个问题的根源基本都在replacement字符串里的问号上。

简单总结一下规则:

  • 如果replacement里没有显式加问号,Nginx内部做rewrite时,会尽量沿用原请求的参数。
  • 如果replacement里有问号,那问号后面就是你新定义的目标参数,原请求参数默认不会再带过来了。
  • 如果replacement末尾单独加一个问号,表示“我不要任何参数”,这是清空参数最直接的办法。

所以你在写规则时要养成一个习惯:先问自己“原请求的参数我需要保留吗?”。需要,就用$request_uri或者在内部 rewrite 时显式拼接$args;不需要,就在replacement末尾加个?

6.4 if和rewrite组合后出现难以理解的行为

location里用if本来就有各种隐性问题。如果if里面还套着复杂的rewrite,可能会得到和自己直觉完全相反的结果。

比如有人写过这样的规则,想实现“如果请求

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询