深入浅出Nginx URL重写:rewrite、return、try_files实战全解析
发布时间:2026/10/14 7:40:11 锦皓数字建站

上周有个朋友找我帮忙说他的内容站要改版域名不变但目录结构全部推翻重来。旧文章的链接如果直接返回404之前积累的搜索权重就全废了。我过去帮他配完Nginx的Rewrite规则让旧链接全部301到新地址顺手把这块知识重新梳理了一遍。因为我也发现就算用了好几年Nginx的人对rewrite、return、try_files三者的使用边界也经常搞混。更别提那两道面试必问题——last和break到底什么区别、URL重写和URL重定向是不是一回事——能当场讲清楚的人真的不多。这篇文章不打算从什么是Rewrite模块这种百科式写法开始而是从实际需求出发把重写的原理、配置语法、常用flag的取舍、真实场景的规则设计、以及我在调试过程中踩过的坑一次性说透。不管你是刚接触Nginx的新手还是已经在生产环境维护过一段时间的老手这篇应该都有值得看的东西。文章会涉及具体配置片段和正则写法建议你打开Nginx配置文件边看边试理解会深很多。1. 从URL翻译官说起Rewrite到底在解决什么问题1.1 一个真实的旧链接迁移场景先说开头那个改版案例。朋友的站点是文章类的老结构是/post/123.html这种静态文件风格新结构换成了/article/detail/123这种带语义的路径。他在后台做了数据库跳转逻辑但搜索引擎和外部外链指向的还是老地址。这时候如果直接把Nginx配置改成新规则访问老链接的人会看到404搜索引擎爬虫也会把老页面的权重丢弃。最简单的做法是让Nginx在收到/post/123.html时直接告诉浏览器和爬虫这个页面已经搬家了新地址是/article/detail/123你们以后请用新地址访问。这就是一个典型的URL重定向场景用Nginx的rewrite配合301状态码就能搞定。但rewrite能干的事远不止301跳转。它还可以在服务器内部重写URL —— 比如用户访问的是/user/9527Nginx可以改写为/index.php?uid9527后交给后端PHP处理这个过程中浏览器地址栏里的URL完全不变。一个是对外重定向一个是对内重写虽然都是rewrite模块在干活但背后的处理逻辑和用户感知完全不同。1.2 重写、重定向和内部文件查找的关系我在社群答疑的时候发现很多人会把rewrite、try_files、return三个指令的职责搞混。其实它们的关系可以这样理解return是直接回复告诉客户端立刻返回某个状态码或跳转到某个URL最干脆性能也最好。rewrite是改写URL改写后Nginx可能重新匹配location也可能把改写后的URL继续交给后端处理也可以对外发起重定向。try_files是按顺序试文件如果试到的文件不存在就内部重定向到最后一个URI常用于伪静态和单入口框架。它们经常搭配使用但千万别把rewrite当成万能工具硬用。比如纯静态文件检查的场景try_files比rewrite if (-f)要简洁高效得多。这个后面我会专门展开。1.3 先建立一个三步走的思维模型这里先抛一个我自己总结的思维模型后面所有配置理解都会用到拿到一条Rewrite需求先问自己三个问题这个URL重写是在浏览器端可见的还是仅在内部发生的匹配条件是固定路径、正则模式还是需要依赖变量判断比如UA、文件是否存在改写之后处理权是交给另一个location、交给后端还是直接返回某个状态码这三个问题的答案组合基本就能决定你该用哪条指令、配在哪个层次server块还是location块里。比如浏览器端可见的老链接迁移答案就是内部不可见、正则匹配、直接返回301 —— 于是你自然会选择rewrite ... permanent或return 301。而伪静态URL转后端入口则是内部可见、正则匹配、交给后端 —— 于是你会用rewrite ... last配合location转发或者直接用try_files。2. Nginx请求处理的路线图Rewrite在哪个环节起作用2.1 从一条请求进入Nginx开始讲一条HTTP请求进入Nginx后并不是简单地从配置文件里找一段匹配规则就完事而是要经过一整套定义好的处理阶段。Nginx官方把这套流程划分成11个阶段post-read、server-rewrite、find-config、rewrite、post-rewrite、preaccess、access、post-access、try-files、content、log其中和rewrite强相关的有四个。我第一次看阶段表时觉得云里雾里后来拿快递分拣做类比就通了请求就像一个包裹Nginx是分拣中心每个阶段是一道流水线台位。包裹先被读取post-read然后可能被贴上改写标签server-rewrite阶段配置在server块里的rewrite指令在这里执行接着按标签找到对应的处理窗口find-config阶段确定location找到之后还可能再被改写一次rewrite阶段location块内的rewrite在这里执行。改写完之后Nginx发现这个包裹的标签变了得重新分拣于是在post-rewrite阶段重新走一遍location查找。2.2 rewrite在server块和location块里的执行差异理解了这个流程很多怪问题就说得通了。比如你在一台服务器上配了多个server每个server里都写了rewrite规则这些规则是在server-rewrite阶段执行的执行完才去匹配location。也就是说server块里的rewrite可以在URL进入location匹配之前就改写它改写后的URL会影响后续location的选择。而location块内的rewrite规则是在Nginx已经选定某个location之后才执行的。如果这个rewrite把URL改写了Nginx会根据改写后的URL重新走一次location匹配前提是你用了last。这就是为什么在location里写rewrite时last和break的行为差异特别明显——一个会重新找location另一个直接在当前location落地处理。2.3 用表格看清四个关键阶段的职责我把和rewrite最相关的阶段整理成了一张表方便对照阶段执行时机主要处理内容server-rewrite匹配location之前server块中的rewrite指令、if指令中的rewrite/returnfind-configserver-rewrite之后根据当前URI查找最佳匹配的locationrewrite选定location之后location块内的rewrite指令post-rewrite上述所有改写完成后检查URL是否被改写若有则重新走location匹配值得注意的是return指令的执行优先级高于rewrite。只要请求命中了带return的配置块后面的rewrite规则就算写了也不会执行。这也是为什么很多老手建议能用return说清楚的事情就别用rewrite绕圈子。3. rewrite指令核心语法正则、捕获组与四个flag的选择3.1 基本语法长什么样rewrite指令的标准格式是这样rewrite regex replacement [flag];一条rewrite规则由三部分组成用来匹配URI的正则表达式、要替换成的新URI、以及可选的flag标记。听起来简单但真正想配得稳正则功底和flag的理解缺一不可。先看一个最小例子server { rewrite ^/post/(\d)\.html$ /article/$1 permanent; }这条规则把/post/123.html永久重定向到/article/123。^/post/(\d)\.html$是正则$1引用的是第一个捕获组也就是\d匹配到的数字permanent表示返回301。3.2 正则匹配的细节与常见误区Nginx的rewrite正则使用的是PCREPerl Compatible Regular ExpressionsPerl兼容正则表达式语法。因为是兼容Perl的所以很多在编程里常见的能力这里也都有捕获组、非捕获组、字符类、量词、锚点等等。但有几个细节容易踩坑默认只匹配URI不包含参数。比如请求/test.php?id1rewrite规则匹配到的是/test.php参数?id1不会参与正则匹配。如果你想保留参数需要借助变量或用$request_uri来获取完整原始URI。^和$锚点很重要。如果不写锚点正则可能匹配到URL的中间部分造成意料之外的替换。比如rewrite ^/images/(.*) /img/$1;和rewrite /images/ /img/;的行为差异就很大后者的/myimages/也可能被匹配改写。.需要转义。很多新手写.html时没注意正则在PCRE中.是任意字符所以^/post/123.html$实际上会把123ahtml也匹配进去。写URL匹配时务必写成\.html。3.3 四个flag各自的语义和选择依据这是整篇文章的重头戏。last、break、redirect、permanent四个flag分别代表四种完全不同的行为。last停止处理当前这个匹配块里的rewrite规则但Nginx会根据改写后的新URI重新走一遍location匹配流程。break停止处理rewrite规则且不再重新匹配location就用当前location继续处理请求。redirect返回302临时重定向URL变化可以在浏览器地址栏看到。permanent返回301永久重定向告诉客户端以后请直接用新地址访问别再用旧地址了。对于last和break的区别我见过太多的口头解释但真正理解还是得靠场景。这里给一个经典例子location /download/ { rewrite ^(/download/.*)/media/(.*)\..*$ $1/mp3/$2.mp3 last; rewrite ^(/download/.*)/audio/(.*)\..*$ $1/music/$2.mp3 break; }如果第一条last被匹配Nginx会把URL改写为/download/xxx/mp3/xxx.mp3然后重新匹配location。因为改写后的URL里包含了/mp3/它可能会落到另一个专门处理mp3的location里。如果第二条break被匹配Nginx不会重新选择location而是继续在当前location里处理好这个请求。3.4 什么时候用301什么时候用302很多初学者的困惑其实是重定向状态码的选择。我的建议很简单网站结构性调整、页面永久迁移、域名变更用permanent301。临时活动页、A/B测试、临时维护页面用redirect302。如果你不确定未来会不会再改回来先用302等确认稳定了再换301。特别注意301一旦被浏览器和搜索引擎缓存就很难收回。之前有个同事图省事把临时维护通知配成了permanent结果第二天发现维护结束要恢复不少用户浏览器里还残留着指向维护页的跳转缓存折腾了大半天才消停。3.5 同样要会的return写法rewrite可以发起重定向但如果是简单的场景直接return更省事server { return 301 https://newdomain.com$request_uri; }这条配置在server-rewrite阶段就返回了301不会走到后面的location匹配也不涉及正则捕获执行效率极高。你能明显感觉到它在压测下的开销比rewrite小。简短的、无复杂正则的重定向能用return就别用rewrite。4. if指令与逻辑判断Nginx里最容易出鬼的配置块4.1 if的条件语法能写什么rewrite经常要和if搭配用来做条件判断。Nginx的if语法其实非常原始并不像编程语言那么全能但支持的条件类型还是挺实用的if ($http_user_agent ~* Spider|bot) { return 403; }它支持以下几种条件变量名直接判断变量值为空串或0开头时被认为是false否则为true。等值比较和!比较变量和字符串是否相等。正则匹配~区分大小写匹配~*不区分大小写!~和!~*分别是对应的否定形式。文件与目录判断-f判断文件是否存在-d判断目录是否存在-e判断文件、目录或软链接是否存在-x判断文件是否可执行。4.2 一个经典场景判断请求的文件是否真实存在伪静态场景里最常见的写法就是如果请求的文件不存在就重写到入口脚本。ThinkPHP、Laravel这类PHP框架的老配置里经常看到location / { if (!-e $request_filename) { rewrite ^/(.*)$ /index.php?s/$1 last; } }这段的意思是如果请求对应的本地文件包括index.php这类脚本文件不存在就重写到/index.php?s/$1让框架来处理请求。$request_filename变量表示当前请求映射到磁盘的路径!-e就是不存在。这条规则本身没问题但我在生产环境里见过太多因为这种写法衍生出的连锁事故。比如某次配置里location块又加了别的rewrite规则导致/index.php这个URL也被重写到入口脚本形成了死循环。所以用if判断文件存在性时一定要想清楚改写后的URL会落到哪个location会不会再次匹配到同一条if规则。4.3 Nginx if的官方劝退与我的使用原则Nginx官方文档对if在location里的使用是持警告态度的原话大意是if在location里有很多难以预料的坑尽量别用特别点名了if里放proxy_pass这类指令可能产生预期外的行为。但你问我要不要彻底不用if我的看法是在server层用if做return/rewrite是安全的在location层用if做简单判断也还能接受但别在if里塞除rewrite/return/内部变量设置之外的复杂指令。简单说if在Nginx里就是个轻量级守门员让它干点判断和重写的活问题不大但别指望它像编程语言里的if那样万能。我自己的经验是能用map指令实现的条件映射就尽量用map能用多个location精确匹配解决的就尽量别用if。Nginx不是编程语言配置文件也不是代码能用声明式思维解决的场景不要硬套过程式逻辑。4.4 多条件判断的替代方案Nginx的if不支持逻辑与、逻辑或||也不支持括号。如果你真的遇到了UA是手机且访问路径是/old/这种双条件场景常见做法有几种。一种是嵌套if。Nginx的if指令在serif语境下其实是支持嵌套的在location内部嵌套可能触发坑但在server层和部分场景下可用。另一种更推荐的方式是使用变量变量名拼接配合map模块。这里列一个map的写法map $http_user_agent $is_mobile { default 0; ~*iPhone|Android|Mobile 1; } server { if ($is_mobile) { rewrite ^/news/(.*)$ /m/news/$1 last; } }用map先把UA这个复杂条件归一化成一个布尔变量再在if里只判断这个变量逻辑清晰得多也避免了复杂的正则写在主配置里。5. 六个实战场景精讲从域名到网关全覆盖5.1 HTTPS强制跳转与主域名归一这是我配置过最多、也最常见的rewrite场景。网站部署了HTTPS之后要确保所有HTTP流量跳转到HTTPS如果要做主域名统一还要把www.example.com跳到example.com或反过来。server { listen 80; server_name example.com www.example.com; return 301 https://example.com$request_uri; } server { listen 443 ssl; server_name example.com www.example.com; if ($host www.example.com) { return 301 https://example.com$request_uri; } }这里有几个细节值得说。$request_uri携带原始的完整路径和参数重定向时用它保留用户访问的URL细节体验最好。443端口的server里用if ($host ...)做域名判断时务必用精确匹配而不是正则避免误伤。这个场景用return而不是rewrite因为不需要改写URI只需要让浏览器跳转到新地址。如果你用的是比较新的Nginx版本443端口下的if ($host ...)写法可以换成增加一个独立的server块用server_name区分可读性更好。但if写法兼容性更通用在一些老配置环境里改动更小。5.2 伪静态规则设计别让正则吃掉你的目录伪静态是很多动态站点的刚需。拿一个典型的CMS规则来拆解location / { try_files $uri $uri/ /index.php?$args; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }try_files按顺序检查$uri请求的路径文件、$uri/请求的目录都不存在的话再内部重写到/index.php?$args。这个方案的核心是它只在文件确实不存在时才走PHP入口避免了大量无意义的改写判断性能比ifrewrite方案好很多。但要注意location的匹配优先级。location ~ \.php$是正则location它会优先于普通的location /前缀location。当try_files内部重写到/index.php后请求会交给这个PHP location处理。这正是我们想要的效果。顺序看起来是先普通location再正则location其实Nginx的匹配规则是先找所有前缀匹配的location记录最长的那个然后再单独看有没有正则location能匹配上请求URI有的话就用正则location。这个机制是理解伪静态配置的底层逻辑务必背下来。5.3 隐藏真实路径和入口参数有时候不想让用户直接看到入口脚本名比如把/api/v1/users对外暴露实际上后端要的是/backend/index.php?routev1/users。这个场景用rewrite在内部改写location /api/ { rewrite ^/api/(.*)$ /internal-api/$1 break; proxy_pass http://api_backend; }注意这里的break理由很关键改写后的URI是/internal-api/xxx如果不加break而用lastNginx会重新匹配location。万一没有/internal-api/这个location请求可能落到location /里走静态文件检查直接被404。加了break后Nginx停留在当前location使用改写后的URI做proxy_pass转发行为可控得多。可能你会问既然要用后端处理为什么不直接proxy_pass带路径原因是proxy_pass带URI和不带URI的行为在转发时会改变请求路径很容易出现路径被吞了一段的问题。用rewrite先整理好URI再交给proxy_pass无URI转发是很多网关层配置的常规手法也是我一直推荐的先改写、再转发模式。5.4 基于UA重写到移动端页面移动适配是老生常谈虽然现在响应式布局普及后用得少了但一些老业务还在用。原理很简单通过判断$http_user_agent是否包含手机端特征重写到专门目录server { listen 80; server_name example.com; if ($http_user_agent ~* (iPhone|Android|Mobile)) { rewrite ^/(.*)$ /mobile/$1 last; } }但如果/mobile/目录本身又要接受访问这个写法会出问题——移动用户访问/mobile/index.html时因为UA仍然符合条件会被再次重写到/mobile/mobile/index.html形成死循环。我见过有人在这上面栽跟头调试了整整半天。正确的做法是给移动端目录加一个排除判断或者用location的嵌套匹配来隔离。稳妥的做法是这个server { listen 80; server_name example.com; location /mobile/ { # 移动端目录本身不需要再重写 } if ($http_user_agent ~* (iPhone|Android|Mobile)) { rewrite ^/(.*)$ /mobile/$1 last; } }呢这里last让改写后的/mobile/xxx重新匹配location正好落到location /mobile/不会再次进入if判断。看起来简单但真需要把location匹配优先级和rewrite执行顺序吃透了才敢这么写。5.5 rewrite配合proxy_pass做内部网关转发在微服务架构里Nginx常被当作API网关。请求的URL和内部服务的URL往往不一致需要rewrite做映射。举个例子location /user-service/ { rewrite ^/user-service/(.*)$ /user/$1 break; proxy_pass http://user_service_upstream; }这里有个重要细节proxy_pass后如果不带URI斜杠后面什么都没有那么请求转发给后端时使用的是rewrite改写后的URI。上面这个配置/user-service/9527会被改写成/user/9527后端服务收到的请求路径就是/user/9527。但如果proxy_pass里带了URI比如location /user-service/ { rewrite ^/user-service/(.*)$ /user/$1 break; proxy_pass http://user_service_upstream/prefix/; }后端收到的路径就会是/prefix//user/9527这种奇怪的结果双斜杠还可能触发后端框架的路由解析问题。所以在rewrite后接proxy_pass时先想清楚要不要带URI这是最容易出低级故障的配置点之一。5.6 全站维护时的临时重定向维护页的场景通常用302临时跳转。有个小技巧值得分享只把所有请求跳转到维护页但放行静态资源图片、CSS、JS和维护页本身不然连维护页样式都加载不出来。location / { if ($request_uri !~ ^/(maintenance\.html|static/)) { return 302 /maintenance.html; } }这段的意思是只要请求的不是maintenance.html本身也不是static/目录下的资源就302跳到维护页。if配合!~做排除匹配简单但有效。维护场景一定要用302而不是301因为维护总会结束301会被浏览器长时间缓存一旦恢复线上环境用户浏览器还残留着跳转。6. 踩坑实录调试Rewrite规则时最常见的五个问题6.1 打开rewrite_log让匹配过程眼见为实我调试rewrite规则的第一件事永远是打开rewrite_log。很多人的第一反应是靠直觉想但rewrite的匹配链路涉及多个阶段脑子里推演容易漏。error_log /var/log/nginx/error.log notice; rewrite_log on;配置后reload Nginx访问一个测试URL然后翻日志[notice] 1234#0: *1 ^/post/(\d)\.html$ matches /post/9527.html, client: 127.0.0.1 [notice] 1234#0: *1 rewritten data: /article/9527, status: 301日志会明确告诉你哪条正则匹配了、改写成了什么、状态码是什么。配合curl -I观察返回的响应头和Location字段几乎可以定位所有rewrite问题。这个习惯我保持了多年强烈建议你也这么干。6.2 死循环最隐蔽的配置事故死循环的本质是改写后的URL再次命中了同一条rewrite规则。最常见的一种写法是location / { rewrite ^/(.*)$ /index.php?s$1 last; }这条规则没有任何前置条件也就是说/index.php这个URL本身也会被匹配改写后变成/index.php?sindex.php再次进入location匹配再次被重写……Nginx默认有10次内部重定向的限制超过会报错并返回500。日志里你会看到rewrite or internal redirection cycle。避免死循环的核心原则rewrite规则一定要有限匹配改写后的URL不能再命中改写规则本身。比如在规则前面加if (!-e $request_filename)或者用更精确的正则锚定都能有效规避。6.3 last和break用错后请求直接404这是一个非常典型的案例。某个前端项目配了如下规则location /app/ { rewrite ^/app/(.*)$ /static/$1 last; proxy_pass http://backend; }问题在哪last会让Nginx重新匹配location改写后的/static/xxx如果没有对应的location就会落到默认的location /很可能变成静态文件404。而如果这里改成break就会停留在当前location继续执行proxy_pass把改写后的URI转发给后端。这个案例充分说明rewrite和紧随其后的转发指令proxy_pass、fastcgi_pass等之间用什么flag衔接必须看改写后的URL是否会落入其它location。6.4 正则优先级和location嵌套顺序互相干扰Nginx的location匹配规则是先最长前缀匹配再按顺序尝试正则匹配。这个顺序与很多人的直觉相反——不是先写的先执行而是正则优先于普通前缀。location /images/ { rewrite ^/images/(.*)$ /cdn/images/$1 break; } location ~ \.(png|jpg|jpeg)$ { rewrite ^/(.*)$ /lossy/$1 last; }这里/images/a.png会命中哪个rewrite因为~ \.(png|jpg|jpeg)$是正则location它会优先于/images/前缀location。所以你期待的图片CDN规则根本不会匹配到png请求最终会进到/lossy/规则里。这个坑非常隐蔽尤其是项目从Apache迁移到Nginx、把.htaccess规则机械翻译过来时几乎人人都会踩一遍。6.5 参数传递的意外丢失rewrite匹配时默认丢了$args参数。比如rewrite ^/news/(\d)$ /article.php?id$1 last;访问/news/9527?page2改写后的URI是/article.php?id9527但原来的page2参数会被丢弃。如果你希望保留原有参数可以这样写rewrite ^/news/(\d)$ /article.php?id$1$args last;或者如果不需要新增参数直接用/article.php?id$1page$arg_page这样显式透传。养成主动考虑参数的习惯能帮你避免很多线上调试的深夜。7. 性能意识与安全底线Rewrite不能乱用7.1 rewrite规则的匹配消耗比你想象的大rewrite规则虽然方便但每条正则都要在请求处理流程中执行匹配。如果server块里堆了几十条正则每条请求进来都要遍历一遍CPU消耗会明显上升尤其是在高并发下。官方文档虽然没给硬性性能指标但我在一次老项目压测中发现同样1000并发把30条无差别的rewrite规则优化成5条带锚点的精确规则后P99延迟从130ms降到了85ms。优化的思路有几个能用return的不写rewrite简单短路的指令开销最小。正则能加锚点就加锚点减少不必要的回溯匹配。能将多个静态规则合并的用一个正则搞定就不要拆成五条。能基于map做变量映射的避免在if里反复做正则匹配。7.2 last和break对性能路径的影响很多人没意识到last会触发一次新的location查找流程这个内部重写是有额外开销的。break则停留在当前location省去了重新匹配的成本。在高流量接口里能break就不要last——当然前提是逻辑上允许。但这里也有个平衡问题如果因为用了break导致请求落到错误的处理逻辑里性能再优也是白扯。所以我的建议是先在逻辑正确的前提下选flag再在正确性有保证后再谈性能优化。7.3 用rewrite做安全防护先冷静网上有很多用rewrite防SQL注入用rewrite封禁恶意UA的教程我的态度是rewrite可以做一些基础层面的友好拦截但它不该是你的安全防线主力。比如拒绝某个路径的访问location ~ ^/(\.env|\.git|composer\.json) { deny all; return 403; }这类配置写起来方便保护敏感文件确实有效。但真要防SQL注入、防CC攻击、防护WebShellrewrite的正则匹配能力太原始很容易被绕过。安全应该靠Web应用防火墙、后端参数校验、权限体系来解决Nginx层做静态路径保护和限流就够了别舍本逐末。7.4 一条我坚持多年的配置自检清单每次排完rewrite相关故障我都会按这个清单检查一遍这里直接分享出来新的URL不会再匹配同一条rewrite规则确认不会形成循环。需要保留参数时显式添加了$args或$arg_xxx。重定向选对了状态码永久迁移用301临时维护用302。rewrite后接proxy_pass时确认代理目标要不要带URI。Nginx -t 测试通过后再reload避免线上变更直接生效导致事故。开启了rewrite_log并观察了一轮实际请求的日志输出。这套清单帮我挡住了不止一次的误操作也推荐你直接抄走。我个人在实际操作中的体会是rewrite模块是Nginx里少有的看起来简单、深入后水很深的配置点。你别指望一次性把语法记全就能写出完美的配置真正的提升来源于每一个线上故障的复盘为什么死循环了、为什么参数丢了、为什么请求走了错误的location。把这些经历沉淀下来再回头看官方文档里的阶段表和flag定义你会发现自己突然什么都通了。上面这些场景和坑基本覆盖了我这些年遇到过的大部分rewrite问题按图索骥去查应该能帮你少走不少弯路。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。