Nginx rewrite重写实战:指令语法、last与break区别及配置案例详解
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四要素位置、正则、替换、flagrewrite指令的基本写法非常固定就一行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?page2rewrite匹配时拿到的只是一个干净的/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”往往是两回事。所以绝大多数场景下你需要明确指定一个flagflag选错是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是lastNginx就会用改写后的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.htmlrewrite成/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。但如果你漏写了permanentrewrite ^ 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?fromapp跳过去之后fromapp参数丢了轻则影响统计重则影响业务逻辑。那有人会说既然有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死循环报错。解决办法是让入口文件走一个独立的locationlocation / { 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?a1预期返回HTTP/1.1 301 Moved Permanently Location: https://blog.example.com/post/12?a1这里用$request_uri就是为了保证/post/12?a1这个原始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?fromandroid预期返回HTTP/1.1 301 Moved Permanently Location: http://example.com/api/user/list?fromandroid注意这里的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_id12345第一个请求预期返回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。解决办法就是我上面案例里写的让入口文件走独立的精确匹配locationlocation /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可能会得到和自己直觉完全相反的结果。比如有人写过这样的规则想实现“如果请求
上一篇/下一篇内容由系统自动关联
返回资讯列表 →