ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Nginx Rewrite从原理到实战:location、last/break与301/302全解析

Nginx Rewrite从原理到实战:location、last/break与301/302全解析 1. 先想清楚一件事Rewrite改的到底是什么做运维这些年被问得最多的不是负载均衡也不是缓存策略反而是Nginx Rewrite。每次业务方说帮我做个无后缀的URL老链接要跳过去HTTP自动跳HTTPS最后都会落到改一两条rewrite规则上。听起来不难但真正能一次写对的人并不多。原因在于很多人把Rewrite简单理解成改网址却没有搞清楚它改的到底是用户看到的网址还是服务器内部处理的路径。这两者的差别决定了你会不会在location、last、break、301、302之间绕晕。用大白话说Rewrite做的事有两类内部重写把进来的内容换一个URI继续处理浏览器地址栏不变用户无感知。比如请求/product/12Nginx内部换成/item.php?id12然后把PHP的解析结果返回给用户但URL上始终显示/product/12。外部重定向告诉浏览器你要找的东西搬家了请去新地址浏览器拿到响应后地址栏会变并且重新发起一次请求。典型的就是301、302跳转比如HTTP强制跳HTTPS。很多人在写规则之前没有先想清楚到底要哪一种于是会在last、redirect、permanent这几个标志之间反复试。其实只要第一步想明白了后面的选择就很自然想让用户在地址栏看到变化就用redirect或permanent不想让用户感知就在内部用last或break。另外要理解一点Rewrite是Nginx请求处理流程中的一个阶段不是一个全局魔法。它改动的是Nginx内部的URI变量而不是用户最先输入的那个原始请求行。这个内部URI会继续影响后面的location匹配、proxy_pass转发、alias和root寻址甚至try_files的兜底顺序。所以一个rewrite写错常常不是跳转不生效而是后面一连串行为都跟着变排查起来特别费劲。我见过不少同事在浏览器里看到一个301跳转就以为rewrite完全生效了然后发现目标页面路径不对又回来改规则。实际上301只是外部重定向这一半的结果里面的路径重写逻辑还可能是错的。所以这篇文章我打算从最底层的语义讲起把Nginx Rewrite的三种语境、正则变量、状态码选择、典型场景和排错链路完整过一遍让你以后写rewrite的时候不是靠这个好像能行而是靠逻辑推理。2. server、location与if同一句话放在不同位置命运完全不同Rewrite指令最坑的一点是语法一样但写在server块、location块、if块里执行时机和行为差异极大。很多人把一条规则从网上复制进配置文件却没意识到复制进来的上下文可能不对结果就是规则存在但不生效或者生效了但产生了奇怪的副作用。2.1 server块里的Rewrite请求还没进location就被处理写在server块内的rewrite会在当前server上下文找到后立即执行也就是说请求还没进入location匹配阶段规则就已经跑完了。这里最常见的用途就是整站强制跳转比如把一个域名的所有请求都转到另一个域名server { listen 80; server_name old.example.com; rewrite ^/(.*)$ http://new.example.com/$1 permanent; }访问http://old.example.com/foo/bar时会被直接301跳转到http://new.example.com/foo/bar根本不会继续匹配下面的location。这也是为什么域名迁移时我们常用的套路就是在老域名对应的server块里放一条这样的规则就行。在server块里还有一个容易忽略的点rewrite执行完之后如果用了last标志Nginx会重新进入location匹配阶段如果用了break标志则会留在当前server继续往下走但后面的行为其实和break在location里的表现还不太一样所以实际生产里server级rewrite我很少用last绝大多数都是redirect、permanent或者直接return。2.2 location块里的Rewritelast与break的分水岭location里的rewrite是最容易出问题的地方。最经典的场景是把一类URL映射到另一个内部URIlocation /old/ { rewrite ^/old/(.*)$ /new/$1 last; }这里的last表示停止当前location中的rewrite处理用重写后的URI再重新做一次location匹配。假设请求/old/article/1被改写成/new/article/1Nginx会带着这个新URI重新走一遍location匹配如果存在location /new/就会进入那个块继续处理。如果把last换成breaklocation /old/ { rewrite ^/old/(.*)$ /new/$1 break; proxy_pass http://backend; }行为就变了Nginx不会重新匹配location而是留在/old/这个location里继续执行后面的指令。比如上面的proxy_pass此时就会用改写后的/new/article/1去和后端通信。理解这个差别非常重要。你写rewrite是为了内部换一个入口继续走就应该思考入口切换之后是否需要重新走location匹配需要重新匹配比如新URI应该落到另一个location走另一套规则用last。不需要重新匹配比如你就想在当前location内继续做后续处理用break。我用一个表格总结一下这两个标志的差异场景lastbreakrewrite后的URI是否重新匹配location会不会当前location内后续指令是否继续执行会但此时location已经被替换会且location保持不变典型用途跨location的规则分流在同一个location内改路径后继续处理风险若新URI再次命中同一规则可能死循环新URI一直在当前location容易和alias等产生路径拼接问题2.3 if块里的Rewrite名声差但这两条指令是例外Nginx官方和社区都反复提醒if是万恶之源尽量不要用。原因是if块里的指令在rewrite阶段就会被处理而后续的location匹配可能已经基于原始URI完成了两者之间容易产生让人摸不着头脑的结果。但if里有一个特例——rewrite和return是安全可控的官方文档也明确说过if里可以做rewrite指令其他的指令不推荐。所以当你确实需要根据不同条件做不同重写时写着if加rewrite或者if加return是合理方案if ($host old.example.com) { return 301 http://www.example.com$request_uri; }这比用rewrite更简洁语义也更清晰。注意这里我用了$request_uri它保留了原始请求中的完整URI和查询参数跳转的时候不会丢失参数。3. 正则与变量Rewrite的语法细节值得花十分钟看懂很多人在网上抄了无数条rewrite规则但最终要自己写一条能够精确匹配、又不误伤其他路径的规则时还是会在正则表达式面前翻车。Nginx的rewrite规则使用的是Perl兼容正则表达式语法和各大语言里的PCRE基本一致不过有一些关键细节是Nginx特有的必须单独拎出来说。3.1 匹配的是规范化后的URI不包含查询参数rewrite的正则匹配对象是$uri也就是经过解码和路径规范化的URI。举个例子请求/product/12?fromadrewrite匹配到的只是/product/12后面的?fromad不会参与匹配。这个设计本身很合理因为查询参数理论上不应该影响路径匹配规则。但要注意一个坑因为匹配的是解码后的URI如果原始请求里有URL编码的特殊字符比如%20代表空格Nginx会在匹配前先解码你写的正则可能就匹配不到了。这种情况下要么写更宽松的规则要么用$request_uri做额外判断。$request_uri是请求行里的原始URI包含查询参数而且不做解码处理。3.2 捕获组的使用$1不是PHP是正则里的括号正则里的括号()用来捕获内容rewrite的替换字符串里通过$1、$2这样的变量引用第一个、第二个捕获组rewrite ^/article/([0-9])\.html$ /article.php?id$1;访问/article/123.html会被内部重写为/article.php?id123。这里需要注意两点替换字符串里的$1会被真实捕获内容替换所以它外面的路径拼接随便写。正则中.必须写成\.否则会匹配任意字符。比如^/article/123.html如果不是写成\.html$它会匹配/article/123Ahtml之类的内容。这种细节在排查为什么这个规则把不该跳的也跳了时最常见。3.3 一个极容易踩的坑替换URI中是否带?rewrite替换URI默认会保留原始请求的查询参数。这个行为很多人不知道以至于写出意料之外的跳转rewrite ^/product/([0-9])$ /item.php?id$1;请求/product/12?fromad最终得到的是/item.php?id12fromad。Nginx会自动把原始查询参数追加到新URI的末尾。但如果你在替换URI里写了问号情况就完全变了rewrite ^/product/([0-9])$ /item.php?id$1srcwap;规则带上?之后Nginx会用?后面的内容作为新的查询串原始的fromad被直接丢弃。如果业务上需要保留原参数就要手动把$args拼回去rewrite ^/product/([0-9])$ /item.php?id$1srcwapold$args;这算得上是我见过的最高频的rewrite失误之一。产品经理说跳转之后把广告来源参数带上开发一查发现参数没了第一反应就是去查后端代码很少有人会怀疑是rewrite悄悄把参数吃了。3.4 匹配是不区分大小写的吗默认不是Nginx的正则默认区分大小写。比如rewrite ^/product/ /new/;不会匹配/Product/。如果你希望大小写不敏感可以在正则开头加(?i)修饰符rewrite ^/product/ /new/ (?i);抱歉这个写法是错误的。正确的写法是在正则模式内部加(?i)rewrite (?i)^/product/ /new/;这是我早年踩过的一个小坑。Nginx的nginx -t不会报错但rewrite就是不生效检查半天才发现是大小写问题。如果你要对整个路径做大小写不敏感的匹配就把(?i)放在正则最前面。3.5 状态码301还是302选择会影响SEO和用户感知rewrite最后一个参数可选redirect或permanent分别对应302临时跳转和301永久跳转。我强烈建议你在生产环境里慎重选择301永久跳转浏览器和搜索引擎会记住新地址后续再访问旧地址时会直接跳到新地址不再回源询问。302临时跳转告诉浏览器这次先跳一下下次你还来问旧地址。这意味着做站点改版、路径迁移时如果还没完全确定新架构先用302等新链路稳定后再切301。否则你发了301后续想调整跳转目标会发现部分用户和搜索引擎已经记住了旧地址的归属改起来很麻烦。需求推荐状态码配置写法整站换域名确定永久迁移301return 301 http://new.example.com$request_uri;A/B测试或临时活动页302rewrite ^/go$ /activity/2025 redirect;用户端体验分流如移动端302或内部lastrewrite ^/(.*)$ /m/$1 last;老链接兼容具体目标还没定302rewrite ^/old-path$ /new-path/ redirect;4. 实战场景从HTTPS跳转到伪静态URL的完整配置前面把原理讲透了这一章直接给生产环境里真正用得上的配置片段。每个场景我都会先说明为什么这么选再给能直接抄的写法。4.1 HTTP强制跳转HTTPS为什么我更推荐return而不是rewrite最常见的需求是把所有HTTP请求跳到HTTPS。很多网上教程会写成server { listen 80; server_name example.com; rewrite ^(.*)$ https://$host$1 permanent; }这个写法能跑但有一个问题它用了正则引擎Nginx要为每个请求执行一次正则匹配。在恶意刷量、高并发场景下这些无谓的正则开销会被放大。更优雅的写法是用returnserver { listen 80; server_name example.com; return 301 https://$host$request_uri; }return直接返回301响应头不进入正则匹配效率更高语义也更清晰。不少新同学会问$request_uri不是包含查询参数吗对所以跳转后原来的?参数都会完整保留不需要再额外拼接$args。4.2 主域名与二级域名的统一跳转很多站点希望把所有二级域名都收敛到主域名避免因为不同域名导致SEO权重分散。可以这样写server { listen 80; server_name example.com *.example.com; if ($host ! www.example.com) { return 301 http://www.example.com$request_uri; } }要注意这里的if放在server块中配合return使用属于前面说的安全姿势。如果有人顺手在if里写了proxy_pass或者其他指令我会劝他先重新评估需求——if和这些指令的组合很容易出现难以预料的行为。4.3 伪静态URL与前端入口框架常见的PHP框架比如ThinkPHP、LaravelURL风格是/index.php?s/xxx。想让用户看到更友好的/xxx.html可以通过rewrite把伪静态映射回入口文件。但这里我要强调一个判断标准如果你的框架入口是所有不存在的文件都交给index.php处理那最佳选择不是rewrite而是try_fileslocation / { try_files $uri $uri/ /index.php?$query_string; }try_files的逻辑是先看磁盘上的这个URI是否存在存在就直接处理不存在再看目录目录也不存在就把请求交给/index.php?$query_string。这是一种兜底式转发很适合框架路由。而rewrite更适合确定性映射比如某个具体路径必须固定对应到某个脚本或文件location /list/ { rewrite ^/list/(\d)\.html$ /list.php?id$1 last; }这里/list/12.html是确定的规则映射到list.php?id12用rewrite干净利落。如果统一用try_files处理反而需要额外判断哪些URI是合法列表页逻辑会更松散。4.4 移动端适配跳转有些站点PC端和移动端是两个入口需要根据User-Agent做分流location / { if ($http_user_agent ~* (iPhone|Android|Mobile)) { rewrite ^(.*)$ /m/$1 last; } }这里~*表示大小写不敏感的匹配。需要注意两点正则里的|是或的意思可以用括号把多个关键字包起来。这种根据UA分流的方式只能作为体验优化不能把它当成安全控制手段。UA是客户端可以伪造的真正的权限判断必须放在服务端逻辑里。如果想跳转到完全不同的域名把last换成redirect或permanent即可。一般移动端适配建议用302因为PC和移动端的关系可能随着改版调整用301会导致浏览器长期记住移动端地址。4.5 路径搬迁用map管理映射表别堆一堆if站点结构大调整时可能涉及几十条旧路径到新路径的映射。如果都写成一串if加rewrite配置文件会变成一坨完全没法维护的东西而且if的匹配性能也不划算。我的习惯是用map模块先做映射再配合rewrite或returnmap $uri $redirect_target { /old/product/1 /new/goods/1001; /old/product/2 /new/goods/1002; /old/about /about-us; default ; } server { listen 80; server_name example.com; if ($redirect_target ! ) { return 301 http://example.com$redirect_target; } }map的好处是映射关系全部集中在一张表里新增、删减路径只需要改一行key的查找效率高规则的作用范围清晰。我在生产环境维护过几百条这样的路径映射靠这个方案保持了配置的可读性。5. 生产环境踩坑实录从URL没变到服务假死写完规则后真正的挑战才开始。rewrite排错往往不像网上教程说的那么顺利因为问题并不总是规则不对还可能出在缓存、server块的匹配顺序、甚至alias路径拼接上。下面这几类坑我基本都在生产环境里真实踩过。5.1 规则看起来生效了但没有跳转状态码一直是200很多人会直接用浏览器访问地址发现页面内容变了但URL始终没变就认为是rewrite没生效。其实这恰恰说明那条规则采用的是内部重写而非外部跳转——内容确实换成了新URI处理的结果只是用户地址栏不变。这不算bug反而可能是你想要的效果。但如果你确实希望地址栏变化就要确认规则用的是不是redirect或permanent。比如rewrite ^/product/(.*)$ /new/$1 last;这是内部重写浏览器地址栏永远不会变。如果你想要跳转就得写成rewrite ^/product/(.*)$ /new/$1 redirect;或者直接用return 302 /new/$request_uri;。排查这类问题还有一个技巧用curl -I看响应头而不是直接开浏览器。curl -I会显示状态码和Location字段一眼就能看出是哪种跳转curl -I -H Host: old.example.com http://127.0.0.1/old/path如果看到301 Moved Permanently并且有Location字段说明外部跳转生效了。如果返回200说明是内部重写。这个区别是最基础、也是最容易被忽视的。5.2 浏览器记住了301导致后续修改不生效这个问题极其隐蔽。我第一次遇到时差点以为是CDN缓存出了问题有一天把一条301跳转改成302业务方反馈还是301没改过来。排查半天才发现是浏览器本地缓存的问题。因为301被定义为永久性跳转浏览器一旦访问过会长期记住跳转结果。你测一次301之后再发请求浏览器直接跳到新地址根本不会回源到旧地址。测试服务器上看到的全是200正常响应但用户那边永远停在旧跳转上。解决办法有两个测试阶段尽量使用302等确认稳定后再切301。如果一定想测试301的实际行为用curl而不是浏览器或者加上随机参数避免命中缓存。5.3 重写循环Nginx内部到底发生了什么这是最凶险的坑。一旦rewrite规则写得不好Nginx会进入内部重写的死循环直到触发保护机制报出这样的错误rewrite or internal redirection cycle while processing /a/1我先用一个最简单的例子还原这个场景location /a/ { rewrite ^/a/(.*)$ /a/$1 last; }访问/a/1时rewrite把URI改成/a/1然后因为用了lastNginx带着/a/1重新开始location匹配。结果它又匹配到了location /a/这个location里又有一条一模一样的rewrite又把/a/1改成/a/1于是再次匹配……无限循环。Nginx在内部重写次数达到上限后会直接返回500错误同时error.log里留下上面那条错误日志。解决思路就是确保重写后的URI不会再命中同一条rewrite规则。比如location /a/ { rewrite ^/a/(.*)$ /b/$1 last; } location /b/ { # 正常的业务处理 }或者在同一location内用breaklocation /a/ { rewrite ^/a/(.*)$ /a/$1 break; }因为break不会触发重新匹配循环就被打断了。注意用break时后续路径处理会留在当前location如果你原本没有配套的root或proxy_pass应用逻辑可能会出问题所以还是建议走到一个专门处理该URI的location里。5.4 alias加rewrite路径拼接的经典灾难这个坑我在生产环境里帮人排过好几次。典型配置是location /img/ { alias /var/www/static/; rewrite ^/img/(.*)$ /cdn/$1 last; }访问/img/a.png时rewrite先把URI改成了/cdn/a.png然后因为用了lastNginx重新做location匹配。此时/cdn/a.png可能没有匹配到/img/这个location而是可能匹配了location /甚至因为找不到对应文件直接404。即便匹配到了新location如果这个location里也配了alias路径拼接很可能变成alias /cdn/a.png最终去找一个根本不存在的/var/www/static/cdn/a.png再次制造404。我的建议是rewrite尽量在server级完成或者通过last跳到全新的location然后在新的location里重新规划root/alias不要在原location内同时混用alias和rewrite。如果只是想让用户看到的URL保持某种规则的路径但实际文件在另一个目录更干净的做法是单独用root指定location /img/ { root /var/www/html; # 此时实际寻址是 /var/www/html/img/a.png }5.5 正则写得太宽把不该跳的路径也跳了有一次业务方让我把/detail/123.html跳转到新站点我写了一条规则rewrite ^/detail/(.*)\.html$ http://new.example.com/$1 permanent;结果上线后/details/456.html也被跳了。原因就是正则里.*匹配了太多内容/detail这个前缀也匹配了/details。这类问题用正则的锚点就能解决。把^/detail/改成^/detail/并不能阻止/details/的匹配因为^/detail/中的/要求后面紧跟斜杠而/details/里面detail后面是s不是/所以其实不会匹配。那上面为什么会错因为业务方后来说他当时写的规则是^/detail.*\.html这个写法里面detail和.*之间没有斜杠就顺带把/details/匹配进去了。这类问题没有统一解法核心经验是写完正则后把可能出现的边界路径都列一遍逐一用正则去测试。比如/detail/123.html应该跳/details/123.html不应该跳/detail/abc.html根据业务决定/detail/123.html?from1参数是否保留不要只在配置里写完就完事正则的匹配行为可以用很多在线工具验证不需要等到上了生产再测。6. 一次线上事故Rewrite写错后的恢复与反思最后分享一个我印象比较深的案例。某次项目改版旧的静态资源路径要从/static/搬到/assets/为了兼容老链接我在Nginx配置里写了一组rewrite规则。当时想的是last比较快不会产生外部跳转于是用了类似这样的写法location /static/ { rewrite ^/static/(.*)$ /assets/$1 last; }部署完以后我顺手用curl验证了几个静态文件发现都正常就准备收工。结果没过多久监控告警响了接口大量出现5xx和响应超时。我登录上去看error.log满屏的rewrite or internal redirection cycle。从逻辑上讲这条规则不该有循环请求/static/a.js重写后变成/assets/a.js重新匹配location时应该落到/assets/的location。但问题是项目里原本就有一个较大的location配置里面又有一条规则把/assets/反向重写回/static/。两条规则互相套娃直接死循环。当时的处理方法很简单也很粗暴在nginx -t验证配置没问题后先注释掉出问题的rewritenginx -s reload恢复服务然后再逐条检查规则的匹配流向。根因就是两条不同时期添加的规则互相作用单看每一条都对组合在一起就成了炸弹。这次事故之后我给自己定了几条规矩rewrite规则写完后必须把请求进-规则命中-URI改变-重新匹配location完整走一遍确认不会再次命中自己或别的rewrite。老路径兼容类规则统一用一个map管理避免每个location里都散落着跳转逻辑。凡是可能产生外部跳转的规则先看状态码语义不确认稳定不轻易上301。生产环境修改配置前先跑nginx -t再reload再观察error.log三步缺一不可。很多人觉得rewrite是很简单的功能随手就能写。但真正的复杂性从来不在于会不会写正则而在于你能否准确预测一条规则在整个Nginx阶段链条上会产生什么连锁反应。我在实际运维中的体会是能用return就不要用rewrite能用try_files兜底就不要写复杂的正则映射。Rewrite是一个强大的翻译工具但每一次跳转都是在消耗用户的信任和SEO权重写之前一定要想清楚——这是给人看的跳转还是给服务器看的路径转换。
返回列表