1. 为什么说rewrite是Nginx里最容易被低估的功能
接触Nginx越久,越发现rewrite模块是个“用好了能救命,用不好会埋雷”的东西。刚入行那会儿,我觉得rewrite不就是个302跳转嘛,无非是把旧链接转到新链接。后来被线上事故教育了几次,才真正意识到它远不只是“跳转”这么简单——它是Nginx对请求URI做“重写”的完整机制,是网关层做流量治理、URL规范化、系统迁移、伪静态适配的核心手段。
举个最直观的例子:你有一个搜索接口,对外URL是 /search?keyword=nginx,但业务上希望用户访问 /list-nginx.html 也能命中同一个接口。这种“把静态化的URL翻译成动态参数请求”的能力,就是rewrite的典型应用。再比如,网站从http全面切到https时,老链接、收藏夹、外站引用还带着 http:// 前缀,如果直接在应用层做跳转,每个服务都要改一遍,而用Nginx在入口层统一rewrite,一行规则就能覆盖所有上游服务,省时省力还不容易漏。
这篇文章我会从应用场景、语法细节、实操案例、踩坑经验四个维度展开,适合三类读者:一是刚接触Nginx、搞不清rewrite和location谁先执行的新手;二是已经在用rewrite但总遇到“规则不生效”“循环重定向”“参数丢失”等问题的初中级运维和开发;三是正在做系统迁移、URL规范化、网站重构,需要在入口层做统一请求治理的团队。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 重写功能的核心机制拆解
2.1 rewrite的本质:把请求URI改写成另一个URI
rewrite模块做的事情用一句话说透:根据你定义的规则,改写Nginx当前请求的URI。这个“改写”可能是在内部重新匹配location并处理,也可能是直接向客户端返回一个重定向响应(301、302等),让浏览器重新发起新的请求。
它和普通Web框架里的路由机制有一个很重要的区别:rewrite发生在HTTP请求正式进入location处理逻辑之前或之后,依赖Nginx本身的正则引擎和匹配顺序。你可以用精确字符串匹配,也可以用正则去匹配一类URL。正因为它在Nginx内部完成,所以性能开销远小于回源到应用层再让PHP/Java框架去“转发”。
需要注意一个关键点:rewrite只修改URI部分,不修改请求方法(POST/PUT等),也不影响请求体。如果你想根据条件修改请求头、Cookie这些,得配合 if、set 和 proxy_set_header 一起做,那是另一个话题。
2.2 rewrite和location的“谁先谁后”问题
这一点几乎每个学Nginx的人都会绕晕,我直接给结论:
- 如果在
server块里写rewrite,它会在当前server内的location匹配之前执行; - 如果在
location块里写rewrite,它会在命中这个location、处理完后续指令之前执行; - rewrite执行后,如果URI发生了变化,Nginx会基于新的URI重新进行location匹配,除非你用了
break标志让它在当前location内终结。
这个“重新匹配”的行为非常关键,也是很多“不生效”问题的根源。举例来说:
nginx复制server {
listen 80;
server_name example.com;
rewrite ^/old/(.*)$ /new/$1;
location /new/ {
return 200 "hit new location";
}
}
请求 /old/123 时,server层的rewrite先把URI改成 /new/123,然后Nginx重新做location匹配,命中 /new/,返回“hit new location”。如果这里不加 last 或 break 标志(后面会细讲),它的默认行为就是“继续匹配后续规则并最终会重新匹配location”。
2.3 rewrite的四个标志位到底怎么选
rewrite指令的完整语法是:
nginx复制rewrite regex replacement [flag];
flag一共有四种:last、break、redirect、permanent。很多教程喜欢背定义,但我觉得还是用场景讲最清楚。
last:停止执行当前这一轮rewrite指令的匹配,用替换后的URI重新发起location匹配。适合在server层写规则、希望把请求交给另一个location处理的场景。break:停止执行当前这一轮rewrite指令的匹配,并且不再重新做location匹配,就在当前location内继续执行后面其他指令(比如proxy_pass)。适合在location内部做精确定向、不想再跳来跳去的场景。redirect:返回302临时重定向,浏览器地址栏会变化,适合临时迁移、维护页面。permanent:返回301永久重定向,适合域名变更、HTTPS强制跳转等永久性调整。
用一张表总结它们的行为区别:
| 标志 | 是否停止当前rewrite匹配 | 是否重新匹配location | 客户端是否感知 |
|---|---|---|---|
| last | 是 | 是 | 否(内部转发) |
| break | 是 | 否 | 否(内部转发) |
| redirect | 是 | 否 | 是(302) |
| permanent | 是 | 否 | 是(301) |
2.4 关于if指令的兼容性提醒
rewrite常和 if 搭配使用,比如判断UA、判断文件是否存在、判断协议等。但Nginx官方文档对 if 是“尽量避免使用”的态度,因为它和 location 的交互在某些场景下会产生意想不到的行为。
我的建议是:if 里只做rewrite和return这两件事,不要在里面写proxy_pass、set之外过多的指令。比如这种写法就比较容易出问题:
nginx复制# 不推荐的写法
location / {
if ($request_uri ~* "^/old/") {
proxy_pass http://backend;
}
}
换成rewrite + 独立location的方式会更稳。这一点在后面案例部分会再展开。
3. rewrite实战:从URL规范化到全站HTTPS迁移
3.1 场景一:强制HTTPS跳转
这是最基础也最常用的rewrite场景。我的习惯是在80端口server里做统一跳转:
nginx复制server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
这里我没有写rewrite,用的是 return。因为纯跳转场景用return比rewrite更简洁,性能也更好。但如果你的需求里还夹杂着“某些路径不跳”“某个来源不跳”,那就得用rewrite加判断了:
nginx复制server {
listen 80;
server_name example.com;
# 排除健康检查路径
if ($request_uri !~ "^/healthz") {
rewrite ^(.*)$ https://$host$1 permanent;
}
}
这里注意一个小坑:$host 取的是请求头里的Host,如果客户端伪造了Host,跳转的目标也会变。严谨一点可以用 $server_name 替代:
nginx复制rewrite ^(.*)$ https://$server_name$1 permanent;
3.2 场景二:URL伪静态化
做内容站、CMS的时候经常要把动态URL变成静态URL,同时保留参数。比如原始地址是 /article.php?id=123,想对外展示成 /article/123.html。
反向的rewrite规则是这样写的:
nginx复制location / {
rewrite ^/article/(\d+)\.html$ /article.php?id=$1 last;
}
请求 /article/123.html 时,Nginx内部把URI改写成 /article.php?id=123,然后重新匹配location,最终把请求交给PHP-FPM处理。用户看到的是干净的静态URL,搜索引擎收录的也是静态URL,但后台逻辑一行都不用改。
这里有几个容易踩的坑:
- 正则里的
\.必须转义,否则.会匹配任意字符,规则会被放大,原本不该处理的请求也被拖进来; (\d+)捕获的参数在替换串里用$1引用,多个捕获组依序是$1、$2;- 结尾要加
last而不是break,因为需要重新匹配location才能让请求正确落到PHP处理块上。
3.3 场景三:域名迁移和旧链接跳转
换域名时最怕旧用户流失。以前收藏的 /about.html,到了新站可能路径变了,直接访问会404。这时候rewrite配合map或者多条规则,可以快速实现兼容。
基础版,直接把旧域名所有请求平移过去:
nginx复制server {
listen 80;
server_name old.com www.old.com;
rewrite ^(.*)$ https://new.com$1 permanent;
}
进阶版,只迁移部分路径,其他路径返回410:
nginx复制server {
listen 80;
server_name old.com;
# 老博客文章整体迁移
location ~* ^/(\d{4})/(\d{2})/(.*)$ {
rewrite ^/(\d{4})/(\d{2})/(.*)$ https://blog.new.com/archives/$1-$2-$3 permanent;
}
# 其他路径不再提供服务
location / {
return 410;
}
}
注意410是“Gone”,告诉客户端这个资源已经被彻底删除,比404更能表达“不再有”的语义。搜索引擎收到410后会更快移除老URL的索引。
3.4 场景四:参数转发时的“漏网之鱼”
很多时候你只想让“带参数的URL”被转发,或者只想让“不带参数的URL”被重定向。热搜词里提到“nginx限制只转发带参数的url”,这在做灰度、埋点、开放平台回调时很常见。
假设你的接口是 /api/gateway,只允许带 token 参数的请求通过,其他请求直接拒绝。可以这样写:
nginx复制location /api/gateway {
# 只有带token参数的才放行到后端
if ($arg_token = "") {
return 403;
}
proxy_pass http://backend;
}
如果你希望“带参数走A地址,不带参数走B地址”,可以结合rewrite:
nginx复制location /api/route {
if ($args ~* "(^|&)vip=true") {
rewrite ^/api/route$ /api/vip last;
}
rewrite ^/api/route$ /api/normal last;
}
这里 $args 是Nginx内置变量,表示客户端请求的完整查询字符串。$arg_token 则是取名为token的单个参数的便捷变量,比 $args 去正则抠方便得多。
4. 进阶玩法:rewrite与反向代理、负载均衡联动
4.1 在location内做rewrite + proxy_pass
rewrite不是只能做跳转,它最常见的隐藏用法是和反向代理配合。我遇到过这样一个需求:统一入口 /api/ 要分发到多个后端服务,比如 /api/user/* 打到用户服务,/api/order/* 打到订单服务。
用rewrite改写URI后再proxy_pass是最直接的做法:
nginx复制location /api/ {
rewrite ^/api/user/(.*)$ /$1 break;
proxy_pass http://user_service;
}
location /api/ {
rewrite ^/api/order/(.*)$ /$1 break;
proxy_pass http://order_service;
}
这里必须用 break 而不是 last,因为用 last 会重新匹配location,很可能又匹配回 /api/ 这个location,造成死循环。用 break 后,rewrite在当前location内结束,接着执行proxy_pass,后端收到的请求URI是去掉 /api/user 前缀之后的部分。
如果你不在rewrite里去掉前缀,直接在proxy_pass里带路径,也可以实现类似效果,但行为有很大区别。比如:
nginx复制location /api/user/ {
proxy_pass http://user_service/;
}
proxy_pass 结尾带 / 时,Nginx会把location匹配到的部分 /api/user/ 替换成 /。这两种方案都能用,区别在于用rewrite更灵活——你可以在转发前做正则捕获、拼接新路径、加时间戳参数等。
4.2 rewrite影响负载均衡的“会话保持”问题
当rewrite把请求URI改写后,负载均衡的hash策略会受到影响。比如你用的是 ip_hash,那没问题,算法基于客户端IP。但如果你用的是 hash $request_uri,那rewrite之后的URI变化会导致同一个用户的不同请求被分到不同后端节点,可能引发session不一致。
遇到这种情况,建议在rewrite前把原始URI保存下来,用变量参与hash:
nginx复制upstream backend {
hash $request_uri consistent;
server 192.168.1.10;
server 192.168.1.11;
}
server {
location / {
set $original_uri $request_uri;
rewrite ^/old/(.*)$ /new/$1 break;
proxy_pass http://backend;
}
}
注意,set 指令会按书写顺序执行,如果你写在rewrite后面,那 $original_uri 拿到的就是改写后的URI,不是原始值。这个顺序问题非常隐蔽,我在这上面排查过整整两个小时。
4.3 rewrite与缓存命中率的关系
Nginx作为缓存层时,cache key通常包含URI。如果你在缓存层之前做了rewrite,那cache key基于的是rewrite之后的URI。这个特性其实可以用来提升缓存命中率。
举个例子,URL里带跟踪参数 ?utm_source=xxx&utm_campaign=yyy,这些参数一变,缓存就全 miss 了。业务上完全没必要因为utm参数不同而缓存多份副本。可以用rewrite先把无意义的追踪参数去掉,再做缓存:
nginx复制location / {
# 去掉utm系列参数
if ($args ~* "(^|&)utm_(source|medium|campaign|term|content)=[^&]+") {
set $args $1;
rewrite ^(.*)$ $1?$args break;
}
proxy_cache my_cache;
proxy_pass http://backend;
}
不过写这类规则时要注意 $args 变量在rewrite过程中的变化,逻辑比较复杂时直接用 proxy_cache_key 过滤参数可能更简单,但rewrite方案胜在能真正把参数去掉再回源。
4.4 用rewrite做灰度发布的简单实现
灰度发布不一定非要上复杂的网关。在一些特定场景下,用rewrite配合Cookie或者Header就能做个简易版。
比如按User-Agent判断,把特定版本的客户端流量引导到灰度环境:
nginx复制upstream stable_backend {
server 192.168.1.10;
server 192.168.1.11;
}
upstream gray_backend {
server 192.168.1.12;
}
server {
location / {
if ($http_user_agent ~* "app/version=2.0") {
rewrite ^(.*)$ /gray$1 break;
proxy_pass http://gray_backend;
}
proxy_pass http://stable_backend;
}
location /gray/ {
rewrite ^/gray/(.*)$ /$1 break;
proxy_pass http://gray_backend;
}
}
这种做法的好处是不需要改上游服务的任何代码,灰度环境还是用同一套应用,只是在Nginx层做流量切分。缺点是不够灵活精细,适合小规模、临时性的灰度需求。等流量大了,还是建议上正规的流量治理平台。
5. 常见问题与排查技巧实录
5.1 rewrite死活不生效,先查这四件事
我遇到“rewrite不生效”的报障,90%都出在这几个环节,按顺序排查非常高效:
- 看rewrite写在哪个作用域。写在server块但location块里又有更精确的匹配时,server层的rewrite确实会先执行,但如果不满足正则条件,请求还是会落入location处理,不会“顺带”执行location里的rewrite。
- 确认正则没有写错。Nginx的正则是PCRE语法,
~区分大小写,~*不区分。最常见的错误是该转义的.没转义,导致规则范围被放大到不可控。 - 确认没有死循环。如果rewrite后的URI又匹配上同一条规则,就会无限循环,Nginx会返回500错误,错误日志里会有
rewrite or internal redirection cycle的提示。 - 确认curl没被缓存骗了。浏览器301会被缓存,你用Chrome测了一次,后面改成了302,Chrome可能还在执行301。测rewrite建议用curl加
-L和-I看响应码,或者直接开无痕窗口。
5.2 循环重定向(rewrite or internal redirection cycle)
这是最常见的线上事故。我见过一个典型配置:
nginx复制location / {
rewrite ^(.*)$ https://$host$1 permanent;
}
如果这个配置写在443的server里,并且没有做条件判断,那任何请求都会被改写成 https://,但请求本来就是HTTPS访问的,跳转后等于又跳到自己,循环了。
正确做法是先判断协议,或者只在80端口server里写这条规则:
nginx复制# 只处理未加密请求
if ($scheme != "https") {
rewrite ^(.*)$ https://$host$1 permanent;
}
还有个隐蔽的循环场景:last 重写后新URI又匹配到同一条规则。比如规则是 rewrite ^/old/(.*)$ /old/$1,这本身就是个递归。写规则时可以用正则的负向前瞻避免自匹配,但更简单的办法是想清楚:rewrite后的URI会不会再次被同一规则捕获?
5.3 301和302选择错误导致SEO流量大跌
跳转类型选错,短期看只是响应码不同,长期影响非常大。301是永久重定向,搜索引擎会更新索引,把老页面的权重转给新页面;302是临时重定向,搜索引擎会认为老URL还在,继续抓取老URL。
实际工作中我见过最典型的错误:网站从http切https,本来应该用301,有人用了302,结果搜索引擎一直记录旧的http地址,排名始终不稳定。还有的反过来——只是临时做个活动页跳转,用了301,活动结束后这个URL彻底“回不去了”,只能在Nginx里再加一条跳转来补救。
我的经验是:凡是“永久性”变化(域名变更、http→https、路径永久调整)用301;凡是“临时性”变化(活动页、维护页、A/B测试)用302。拿不准时先用302,跑一阵看流量没有异常再切301。
5.4 参数丢失:$1和$args的爱恨情仇
写rewrite时最容易忽略的是查询参数的保留。默认情况下,如果替换串里不包含 ?,Nginx会保留原始查询参数;如果替换串里包含 ?,那 ? 之后的内容会替换掉原始参数。
举个具体例子:
nginx复制rewrite ^/old/(.*)$ /new/$1;
请求 /old/123?a=1&b=2,结果会变成 /new/123?a=1&b=2,参数自动保留。
但如果替换串里带了 ?,例如:
nginx复制rewrite ^/old/(.*)$ /new/$1?;
那参数全部被丢弃,变成 /new/123。这个特性有时候是被有意利用的——比如你想把历史遗留的无效参数清掉,好统一缓存。
如果想保留部分参数并添加新参数,可以显式拼接:
nginx复制rewrite ^/old/(.*)$ /new/$1?from=nginx&orig=$args last;
5.5 rewrite规则的调试三板斧
调试rewrite,我最常用的三个招:
第一,开rewrite日志。在Nginx配置的http块加:
nginx复制rewrite_log on;
error_log /var/log/nginx/error.log notice;
然后tail错误日志,会看到类似 rewritten uri: "/new/123" 的详细记录,能清晰看到每个步骤发生了什么。
第二,用curl分步验证。先看响应码:
bash复制curl -I -L http://example.com/old/123
再用 -H "Host: example.com" 模拟不同域名的请求,确认server块匹配正确。
第三,加一个临时请求头确认命中情况。在后端加一个测试路由,把 $request_uri、$uri 打出来,对照观察rewrite对这两个变量的影响。注意 $request_uri 是原始请求URI,永远不变,$uri 是rewrite处理后的URI,会变化。
6. 一套我踩过坑后沉淀的rewrite配置范式
6.1 规范一:rewrite优先写在server块,不要散落各处
把rewrite规则散落在不同的location里,规则职责边界会越来越模糊,最后变成意大利面。我的做法是:任何“入口层”的改写逻辑放在server块里集中管理;location块里只写和“该location特有逻辑”强相关的改写。
这样配置的直观性也好,后来接手的人一眼就能看到“这个站点有哪些改写规则”,不用翻遍整个配置文件。
6.2 规范二:用named location封装复杂逻辑
如果你要在rewrite后做一个相对独立的处理流程,直接写在location里可能很啰嗦。Nginx支持命名location,用 @ 开头,不会参与常规匹配,只有被内部重定向才能触发。
模式是:先用rewrite判断条件,最后指向一个命名location统一处理:
nginx复制server {
location /api/ {
rewrite ^/api/user/(.*)$ /$1 last;
rewrite ^/api/order/(.*)$ /$1 last;
return 404;
}
location @api_fallback {
proxy_pass http://api_backend;
}
}
不过这里要注意,last 会重新匹配所有location,包括命名location吗?实际上命名location不参与普通匹配,但可以通过 try_files 或rewrite的 last 目标来触发。这个机制在使用时要专门确认目标location是能正确匹配的,别想当然。
6.3 规范三:配置文件分段注释,规则分组清晰
一个被rewrite规则淹没的server块,读起来极其痛苦。我现在习惯在nginx.conf里用段落注释把rewrite分组:
nginx复制server {
# =====================
# 1. HTTPS强制跳转
# 2. 老路径兼容
# 3. 伪静态转换
# =====================
# --- 1. HTTPS强制跳转 ---
if ($scheme != "https") {
rewrite ^(.*)$ https://$server_name$1 permanent;
}
# --- 2. 老路径兼容 ---
rewrite ^/(?:about|contact)\.html$ /pages/$1 last;
# --- 3. 伪静态转换 ---
rewrite ^/archives/(\d+)$ /index.php?archives=$1 last;
}
6.4 规范四:重要规则变更前必须做回归验证
rewrite规则往往牵一发动全身,尤其在入口层。改之前我一般会列一个“受影响URL清单”,包括:
- 现有线上规则会命中的URL;
- 新规则要覆盖的URL;
- 明确不应该被影响的URL(比如静态资源、API、健康检查);
- 带参数和不带参数的URL都要覆盖。
然后在测试环境逐条curl验证响应码和Location头,确认无误再上线。你可以写个简单的shell脚本批量跑,把断言写清楚,能极大降低变更风险。
我见过太多“就改了一条正则,结果全站静态资源都404”的事故了,大部分都是没做回归验证、规则被意外放大导致的。
6.5 规范五:rewrite先治理,再谈扩展
关于rewrite,最后分享一点方法论层面的东西。很多人遇到URL规则混乱,第一反应是“先补一条rewrite顶上”,但补丁越打越多,规则越来越难维护,最后连写规则的人自己都看不懂了。
我的建议是:如果规则超过十到二十条,或者出现了两次以上的“规则互相覆盖”,就该停下来做一次URL规范治理了。列出所有入口URL,梳理它们的真实映射关系,用一两条通用正则而不是多条特例去覆盖。表面上rewrite是配置层面的功能,但要用好它,考验的其实是系统化梳理能力。
7. 从rewrite到Nginx整体URL处理的扩展思考
rewrite只是Nginx对请求URI处理的其中一个环节,如果你想真正理解所有重定向、路由、转发行为,离不开对整套URL处理流程的认知。
Nginx处理一个请求时大致会经历:解析请求行和请求头 → 匹配server块 → 执行server层rewrite规则 → 执行location匹配 → 执行location内rewrite规则 → 执行try_files或内容处理阶段 → 执行log阶段。理解这条流水线,你就能解释为什么很多“奇怪”的跳转行为会在那一步发生。
如果你在Nginx 1.26或更高版本(很多新项目也直接用1.30了)上做实验,rewrite、return、try_files、location、proxy_pass、error_page 这几个指令几乎能串联起所有URL处理场景。建议你搭一个最小环境,把以下场景逐个实验一遍:
- 不同flag对最终响应的影响;
- rewrite在server块和location块对匹配顺序的影响;
- rewrite和try_files的交互,当一个不存在时另一个是否会兜底;
- 结合静态服务场景,rewrite之后请求落到静态文件还是代理服务;
这样操作一轮,你对Nginx的URL控制力会有一个质变。
另外,结合热搜词里“nginx配置证书”“nginx静态html”“nginx平滑升级”这些关键词,说明大家普遍在使用Nginx做入口层服务,从配SSL到配反代,再到用rewrite写规则,整套链路本身就是Web服务治理的核心基本功。rewrite这个功能虽然小,但它关联的其实是整个Nginx处理模型的理解深度。
我在实际项目中有一条很深的体会:rewrite出问题往往不是rewrite本身写错了,而是对整个请求处理流程的理解有偏差。所以与其硬背规则语法,不如把Nginx的处理模型吃透,你会发现自己能写出更简单、更稳的配置。
最后再分享一个小技巧:配置rewrite时,永远先在测试环境用curl打一遍,确认响应码和Location头符合预期,再上生产。看起来是句废话,但我在真实环境里见过太多“改完没验证,线上炸了”的案例。rewrite这条命令,写对了是利器,写错了就是事故源头。谨慎一点,永远不亏。
