做运维这些年,要说 nginx 里哪个功能最不起眼但坑最多,我第一个想到的就是重定向。你没看错,就那个return 301、rewrite、proxy_pass组合出来的跳转逻辑,平时看着简单,可真到线上出问题的时候,排查起来能把人绕晕。这篇东西我就围绕“nginx 的重定向”这个项目,把我自己这些年踩过的坑、总结出来的配置套路,以及每个配置背后的原理,一次性讲清楚。不管你是刚接触 nginx 的初学者,还是被线上跳转问题折磨过的老手,这篇内容都能给你一点参考。
1. 状态码选择:301、302、307、308 到底怎么选
1.1 状态码背后的语义差异
做重定向,第一步不是写配置,而是先想清楚一个问题:你要的是临时跳转还是永久跳转。这个选择直接决定搜索引擎、浏览器以及各种 HTTP 客户端怎么处理你的地址。
先说最常见的 301 和 302。301 是永久移动,语义是“这个资源以后都不在这个地址了,以后请直接用新地址”。302 是临时移动,语义是“这次先来这里,但地址没变,下次还走原来的”。这两者的区别,直接影响 SEO。如果一条老链接需要永久迁移,比如网站从 http 切到 https,或者域名从旧域名换到新域名,应该用 301。搜索引擎看到 301,会逐渐把旧页面的权重转移给新页面;而如果用了 302,搜索引擎会认为跳转是临时的,不一定会更新收录地址,权重也基本不转移。
但问题来了,302 有一个很坑的行为:很多 HTTP 客户端在处理 302 的时候,不管原始请求是 GET 还是 POST,都会把重定向后的请求改成 GET。这在表单提交场景下会出大问题。比如用户提交一个 POST 表单,后端返回 302 跳转到成功页,按照规范,浏览器应该保持 POST 方法跳转,但实际很多实现直接变成了 GET,这样就把用户的提交语义给弄丢了。所以后来规范里加入了 303 和 307。303 明确要求客户端改为 GET 重新请求;307 明确要求保持原方法和原请求体。308 则是在永久跳转里保持方法不变的版本,相当于 301 加上“保持方法”的语义。
为了方便理解,我做了一张对照表:
| 状态码 | 语义 | 缓存性 | 方法保持 |
|---|---|---|---|
| 301 | 永久移动 | 可缓存 | 不保证 |
| 302 | 临时移动 | 默认不缓存 | 不保证 |
| 303 | 查看其他位置 | 默认不缓存 | 强制改为 GET |
| 307 | 临时重定向 | 默认不缓存 | 保持原方法 |
| 308 | 永久重定向 | 可缓存 | 保持原方法 |
1.2 实际业务里怎么选最稳妥
我个人的经验是:场景判断永远比技术参数优先。
如果是整站迁移、http 升级 https、域名规范化这类需要长期生效的跳转,一律用 301。唯一例外是,如果你不能确认目标服务器是否稳定,或者跳转链路可能会在近期内调整,那先用 307 或 302 过渡,等稳定了再切成 301。因为 301 一旦被浏览器缓存,什么时候失效完全不可控。我自己踩过一个坑:配置了 301 之后,当天测试没问题,第二天浏览器还在访问旧地址,浏览器根本不回源,直接用的本地缓存跳转。那一次我才真正明白,301不是配置完就万事大吉的,它是有“记忆”的。
POST 场景下,能用 307/308 就用 307/308。虽然 301/302 绝大多数情况下也能跑,但涉及支付回调、表单提交、登录回跳这类场景,保持请求方法非常关键。我曾经处理过一个支付回调的跳转问题,上游回调接口返回 302 之后,下游系统收到重定向请求时方法变成了 GET,签名校验直接失败,排查了很久才发现是 302 的“方法丢失”导致的问题。从那以后,凡是涉及写操作的跳转,我一律用 307。
至于 303,日常用得不多,一般只出现在 RESTful API 的 POST 提交后告诉客户端去 GET 结果这种场景。如果你不是在设计严格的 API 接口,可以忽略它。
2. 配置指令怎么选:return、rewrite 与 proxy_pass 的边界
2.1 return 就是最直接的选择
nginx 做重定向,最推荐的就是return指令,没有之一。因为它极其简单、性能最好。return是 nginx 在 rewrite 阶段直接返回响应给客户端的,根本不会继续向下匹配 location,也不会执行后面的 content 阶段逻辑。换句话说,一条return下去,nginx 直接结束了这次请求,消耗极小。
基本用法:
server { listen 80; server_name example.com; return 301 https://www.example.com$request_uri; }注意$request_uri这个变量,它保留了原始请求的完整 URI,包括参数。使用它能保证跳转之后路径和参数不丢失。这个细节特别容易被忽略,很多人写跳转规则时不带$request_uri,结果用户访问/article/123?page=2,跳过去就变成/,参数全丢了。轻则影响用户体验,重则让业务出错。
return还可以直接返回一段 JSON 或文本,格式是return 200 '{"code":0}',这在做接口灰度、封禁提示的时候非常有用,完全不需要起一个后端服务。
2.2 rewrite 不是用来做跳转的,但常常被误用
rewrite指令做的事情本质上是“内部改写 URI”,再重新匹配 location。它和return最大的区别在于:rewrite只是把 URI 改了,然后 nginx 会带着新 URI 重新走一遍 location 匹配流程,并不会直接给客户端返回跳转状态码。只有当你显式加了redirect或permanent标志时,它才会真正输出 302 或 301。
很多初学者一上来就写:
location /old { rewrite ^/old(.*)$ /new$1 permanent; }这个写法能工作,但只要站在性能角度看,它就不是最优解。因为rewrite需要做正则匹配,还得重新走 location 流程;而return直接就在 rewrite 模块的最前面返回了,连正则都不需要。更关键的是,rewrite配合if使用时,有 nginx 官方文档里明确警告过的“坑”,这我在后面会专门说。
我的判断标准很简单:如果是单纯的“客户端地址要变”这种跳转,用return;如果是要“内部改路径、改完后继续匹配另一个 location 处理”,才考虑rewrite。换句话说,rewrite是为内部路由服务的,不是为外部跳转服务的。
2.3 proxy_pass 在重定向里的隐形角色
还有一种“重定向”,不是 nginx 返回 301/302 给客户端,而是 nginx 作为反向代理,把请求转发给后端,后端再返回一个带有Location头的 302 响应。这个场景很常见,也很容易出问题,问题通常出在proxy_pass的地址写法上。
location /api/ { proxy_pass http://backend:8080; }如果proxy_pass的 URI 不带路径,nginx 会把原始 URI 完整传给后端;如果带了路径,比如http://backend:8080/newbase/,那 nginx 会把 location 匹配部分去掉,替换成新路径。这个替换规则极其容易踩坑,差别就是到底原生透传,还是整个替换前缀。我后面讲代理场景下的问题排查时会展开说。
3. 高频场景的完整实现:从配置到原理解释
3.1 强制 http 跳 https
这个场景应该是最常见的了。整站启用 https 后,不能允许用户继续用 http 访问,否则会出现“页面能打开但地址栏不安全”的尴尬,而且 301 跳转能保证搜索引擎把 http 的权重转移到 https 页面上。
最标准、最安全的写法:
server { listen 80; listen [::]:80; server_name example.com www.example.com; # 除了 acme 验证等特殊路径,其余全部强制跳转 location / { return 301 https://$host$request_uri; } }注意这里我用的是$host而不是$server_name。$host会优先取请求行里的 Host 字段,这样用户在地址栏输入www.example.com,跳转后还是www.example.com,不会因为 server_name 配置而跳成默认域名。如果你希望所有域名统一跳转到某个固定域名,比如全站最终只保留www.example.com,那可以写死:
return 301 https://www.example.com$request_uri;很多人在这个场景上容易犯一个错:把return 301写在server块里,同时也没有限制只在 http 这个监听端口生效。这没问题,因为第二个 server 块监听 443 时根本不会匹配到这条 return。但如果你把 80 和 443 写在同一段配置里做条件判断,反而容易写乱。最简单的做法就是拆成两个 server 块,干净利落。
3.2 域名规范化:带 www 和不带 www 统一
做 SEO 的人都知道,example.com和www.example.com在搜索引擎眼里是两个站点,如果不做统一,权重会分散。所以必须指定一个主域名,然后把另一个域名 301 过去。
两种操作方向。第一种是把带 www 的跳到根域:
server { listen 443 ssl; server_name www.example.com; return 301 https://example.com$request_uri; }第二种是把根域跳到 www:
server { listen 443 ssl; server_name example.com; return 301 https://www.example.com$request_uri; }如果你是既想收到两个域名的请求,又想只在其中一个做业务,那就可以用一个 server 块做跳转,另一个 server 块做实际业务。注意证书覆盖问题:如果把 www 跳到根域,而根域证书没有包含 www,用户访问https://www.example.com时会在证书校验阶段就直接报错,根本等不到 301 跳转。所以 SSL 证书记得用通配符或者同时包含两个域名,这是我在实际配置里被浏览器安全提示教育过好几次才记住的。
另外补充一点:不能两个域名互相跳,那样会造成无限循环。很多排查不出循环问题的人,都是因为两个 server 块里各写了一条互相指向的 return 301,调试的时候一访问就陷入循环,日志里全是rewrite or internal redirection loop。
3.3 URL 结构升级:老路径搬家到新路径
业务经常会做目录调整,比如原来博客地址是/post/123,现在改成/article/123。由于老地址可能已经被搜索引擎收录、被其他站点外链,所以不能直接 404,必须做 301 兼容。
实现方式有两种。一种是在 server 块级别用正则匹配:
server { listen 80; server_name example.com; location ~ ^/post/(\d+)$ { return 301 /article/$1; } }另一种是用 rewrite:
location /post/ { rewrite ^/post/(\d+)$ /article/$1 permanent; }两种写法的区别在于:前者是正则 location 匹配到之后直接 return,后者是在location /post/内部再执行一次正则 rewrite。前者更直观,但如果老路径有很多种形态(比如/post/123/、/post/123.html),用 rewrite 的灵活性稍微高一点,因为它是在 URI 改写层面直接操作,不依赖 location 匹配的粒度。
我在实际项目中更建议用return+location ~的组合,因为它的匹配阶段更清晰,不容易在多个 location 之间窜规则。除非你的 rewrite 规则非常复杂,需要用到多个捕获组并拼接路径,否则不推荐用 rewrite 做纯跳转。
3.4 SPA 应用的路由回退:try_files 与重定向的配合
单页应用(SPA)的路由回退,严格来说不算重定向,但它和重定向经常一起出现。SPA 的 history 路由模式下,浏览器直接访问/dashboard,如果 nginx 没有配置回退,会直接 404。正确做法是让 nginx 把所有非静态文件请求都回退到index.html:
location / { try_files $uri $uri/ /index.html; }try_files的执行逻辑是:先依次检查$uri、$uri/对应的文件是否存在,如果都不存在,就内部重定向到/index.html。注意,这种“回退”是内部跳转,浏览器地址栏不会变,也不会产生新的 HTTP 请求状态码。这个机制和 301/302 完全不同,但因为经常有人把它误当成“重定向”来查问题,我特意放这里说明。
它和重定向的组合场景往往是这样的:SPA 升级后,老路由/user/123变成了/member/123,你既要做 301 跳转,又不能破坏整个 SPA 回退机制。这时候两个配置可以同时存在:
location / { try_files $uri $uri/ /index.html; } location ~ ^/user/(\d+)$ { return 301 /member/$1; }注意 location 匹配优先级,正则 location 优先于普通前缀 location,所以/user/123会被正则匹配,而其他路径会走普通 location 的 try_files 逻辑,两者不冲突。
3.5 根据客户端来源或语言做分流跳转
这个场景更有意思。有些业务需要根据用户语言、终端类型做不同的落地页跳转。比如使用 Cookie 判断用户语言,如果用户之前选择了英文,访问/时直接跳到/en/。
location / { if ($cookie_lang = "en") { return 302 /en/; } return 302 /zh-CN/; }这里有一个非常重要的点:nginx 的if指令非常容易被误用,官方文档也警告过“if 是邪恶的”。但这里有个例外,if配合return是安全的,因为return会在 rewrite 模块的处理阶段直接返回,不会触发后续 location 重匹配,也就避开了if导致的各种诡异行为。所以如果你想基于变量做跳转,if + return是目前最稳的组合,前提是别再往if里面塞rewrite或者其他指令。
类似地,手机端适配页面也可以用同样的机制:
location / { if ($http_user_agent ~* "(android|iphone|ipad)") { return 302 https://m.example.com$request_uri; } }不过这种场景,我更推荐在 DNS 层面用mobile子域加服务端检测,而不是全都丢给 nginx 的正则匹配。原因不复杂:$http_user_agent的正则匹配在高并发下会消耗一点 CPU,而且 UA 的写法五花八门,正则永远跟不上新设备的 UA 变化。
4. 重定向的常见故障与排查技巧
4.1 循环重定向是怎么造成的
循环重定向是重定向配置里最典型的故障,它表现为浏览器提示“此页面无法正常运作,将您重定向的次数过多”,或者直接报ERR_TOO_MANY_REDIRECTS。
造成循环的原因通常有两类。第一类是配置逻辑自相矛盾,比如两个 server 块互相 301,或者 http 跳 https 后又从 https 跳回 http。第二种常见是后端反向代理场景:nginx 把请求代理给后端,后端返回的 Location 头是http://example.com/login,而浏览器拿到后发起新的 http 请求,又被 nginx 强制跳到 https,但 https 后端的响应依然是 http 的 Location,于是无限循环。
排查手段看日志。nginx 的 error.log 在发生 rewrite 循环时会输出类似这样的内容:
[error] 12345#0: *678 rewrite or internal redirection loop while internally redirecting to "/index.html", request: "GET /dashboard HTTP/1.1"看到这种日志,先检查是不是所有路径最终都 try_files 回退了,或者是否因为两条 return 规则互相指向。
如果是代理场景的循环,我给出的排查方向是:先确认后端返回的 Location 头到底是什么。可以在后端侧临时打印响应头,也可以用 curl 直连后端看返回内容:
curl -I http://127.0.0.1:8080/login如果后端返回的 Location 是绝对地址指向 https,而 nginx 又做了一层代理透传,那就需要在 nginx 侧改写 Location 头:
location / { proxy_pass http://backend:8080; proxy_redirect http://example.com /; }proxy_redirect指令是专门用来改写代理响应里的 Location 头的,很多排查循环的人根本没注意到它。它默认会替代响应头里的http://backend:8080这类后端地址为当前请求的地址,但如果有复杂场景,需要显式指定改写规则。
4.2 跳转后丢失参数和路径
丢失参数是最常见但又最容易忽视的问题。原因很朴素:配置里没有把$request_uri拼接到跳转目标里。
return 301 https://example.com;这条配置,不管用户访问/article/123?page=2还是/product/456?from=ad,最终都只会跳到https://example.com。路径没了,参数也没了。如果这个跳转是用来兼容老连接的,等于白做了。
正确的写法是:
return 301 https://example.com$request_uri;$request_uri包含了原始 URI 以及全部参数。注意它不会被 decode,它保存的是原始请求行里的内容。如果业务需要在跳转前对路径做处理或解码,那就得考虑用$uri加手动拼参数,但绝大多数场景下$request_uri是够用的。
4.3 if 指令的深坑:为什么官方说要避免
nginx 的if指令,官方文档说得非常克制:“if 是重写模块的一部分,不应该被用在 location 上下文中做复杂判断。”原因是if在 location 里会产生不可预期的行为,尤其是和proxy_pass、alias、try_files一起用时,会出现各种诡异的匹配错误。举个例子:
location / { if ($request_uri ~* "/special") { proxy_pass http://special_backend; } proxy_pass http://default_backend; }这段配置在部分版本下会报proxy_pass cannot have URI part之类的错误,或者干脆直接 500。因为if内部执行proxy_pass时,它其实是创建了一个新的内部跳转上下文,和原来的 proxy_pass 指令产生冲突。我自己被这种写法坑过一次,后来再也不敢在if里放proxy_pass了。
但if + return的组合是安全的,因为return直接终止请求流程,不会进入后续的位置重匹配。如果你需要在复杂条件下做跳转,最简单可靠的方案还是if + return。如果真的要按条件转发,建议拆分成多个 location,用正则或者精确匹配来分流,而不是在 location 内部写 if 判断。
4.4 代理模式下的绝对地址劫持
当你给 nginx 配置了反向代理,但后端返回的响应里带有绝对地址的Location头时,客户端会按照这个 Location 去访问,这就会绕过你辛辛苦苦配置的跳转规则。
比如后端是http://127.0.0.1:8080,它返回的 Location 是http://127.0.0.1:8080/login,客户端就会傻乎乎地直接访问http://127.0.0.1:8080/login,而不是https://example.com/login。用户访问了内网地址,能成功才怪。
这种问题的解决方案就是前面提到过的proxy_redirect指令:
location / { proxy_pass http://127.0.0.1:8080; proxy_redirect http://127.0.0.1:8080/ https://example.com/; }proxy_redirect是专门处理响应头Location和Refresh里地址的。它有两种用法:一种是指定“原地址 新地址”,另一种是写default,让 nginx 自动替换 Unknown 状态的地址。默认情况下,nginx 会在代理响应里对 Location 头做一次“原后端地址转成当前请求地址”的处理。所以如果你发现后端返回的相对地址或者内网地址没有被正确改写,基本就是proxy_redirect配置缺失或者写错了。
4.5 排查重定向问题的工具和经验
排查重定向,工具不在多,顺手最重要。我常用的组合是 curl 加一个浏览器开发者工具。
用 curl 查看重定向全链路,一定要加-I或者-L和-v的组合:
curl -IL -v https://example.com/old-path-I只拿响应头,不会下载正文;-L会跟随重定向,一路跟随到最终地址;-v会输出完整请求和响应头的细节。如果只想看某一次跳转的状态码和 Location,不带-L就行。这一步能直接看出每次跳转的状态码、Location 地址,配合--max-redirs参数还能限制最多跟随多少次跳转,防止 curl 一直在循环里打转。
浏览器开发者工具的 Network 面板里勾选“Preserve log”(保留日志),然后刷新页面,就能看到每一次请求的 301/302 记录和最终的请求头。重点关注有没有出现“两次相同的请求地址但状态码不同”的情况,或者有没有 Location 指向了意外地址。
另外,线上环境遇到循环跳转时,我习惯先做一件事:把rewrite_log on;打开,然后看 error.log。这个配置会在日志里输出 rewrite 阶段每一步的详细过程,比如“rewritten redirect”,能非常清楚地看到跳转链路是怎么走的。不过要注意,生产环境打开rewrite_log会显著增大日志量,排查完记得关掉。
5. 关于 HTTPS 跳转和证书配置的一些经验补充
5.1 证书校验发生在真实业务请求之前
这里有一段很容易踩坑的流程:当用户访问https://www.example.com,而证书里没有包含www.example.com这个域名时,浏览器会直接拦截,根本不会把请求发到服务器。这就导致你就算在 nginx 里配了return 301 https://example.com也起不到作用,因为 TLS 握手阶段就已经失败了。
所以域名规范化跳转正确的打开方式是:证书必须同时覆盖跳转前的域名和跳转后的域名。要么用通配符证书*.example.com,要么在证书的 SAN 里同时加根域和 www 子域。很多第一次做域名统一的人,把证书只配在example.com上,然后从 www 往根域跳,结果用户访问 www 时先看到安全警告,根本没有机会跳转。
另外还有一种做法是,在 80 端口(http)收到https://www.example.com的访问请求时,用 301 跳转到https://example.com,这样至少用户在输入框里手动输 http 地址时能正常跳过去。但对于已经习惯收藏https://www.example.com的用户,还是会被证书问题拦在路上。
5.2 跳转目标地址里的协议和端口
跳转时要特别注意目标 URL 的协议和端口,尤其是 nginx 前面还有负载均衡器、CDN 或者云厂商的 HTTPS 终结层时。如果 nginx 本身承接的是 443 端口的 TLS,而前面的负载均衡器已经做了 HTTPS 终结,那么后端 nginx 拿到的可能是明文 HTTP 请求。这时候你在 nginx 里写return 301 https://example.com$request_uri,它会根据配置生成一个 https 跳转;但如果你漏写了协议,直接写return 301 example.com$request_uri,nginx 会认为这是一个相对地址的跳转,实际生成时可能就不带域名了,导致客户端跳到一个乱七八糟的地址。
更稳妥的办法是尽量少写绝对地址中的域名部分,多使用$host、$request_uri这样的变量。例如:
return 301 https://$host$request_uri;这个写法会保留用户访问的原域名,同时强制切换协议。如果你的跳转目标是想统一到一个域名,那么写清楚绝对地址没毛病,但这时候就必须和后端约定的返回协议保持一致。
5.3 补充一个提升效率的小技巧:临时关闭跳转
有时候为了排查某个后端接口在跳转前的真实响应,我需要临时屏蔽跳转规则。最简单的方法不是注释大段配置然后 reload,而是利用 nginx 的return 200做快速占位:
location /api/test { return 200 '{"status":"ok"}'; }或者加一个专门的调试参数变量,比如当请求带特定参数时不跳转:
location / { if ($arg_debug = "1") { return 200 "debug"; } return 301 https://example.com$request_uri; }这种用变量控制跳转开关的方式,在联调和排查问题时非常省事,改完 reload 一下就能生效。但要注意,if和return组合虽然安全,用了$arg_debug这种变量就说明每一个请求都会做一次参数检查,多出来的开销虽然微乎其微,但生产环境还是得谨慎。
6. 最后分享一点个人的实战体会
重定向这件事,配置本身不复杂,难的是理解你写的每一行配置在请求生命周期里到底发生在哪个阶段、会影响什么后续流程。我见过太多人拿着一份从网上复制来的配置直接用,出问题了就把所有指令挨个注释一遍再 reload。与其这样盲人摸象,不如把return、rewrite、proxy_pass、proxy_redirect这四者的边界搞清楚,大部分跳转问题都能快速定位。
我自己保留了一个习惯:每次写重定向配置,都会顺手在配置旁边加注释,说明这条规则是为了解决哪个旧地址、跳转到哪里、为什么用这个状态码。半年后回头维护时,这些注释能省下大量回忆成本。线上环境做跳转修改,务必先在测试环境用 curl 完整跑一遍跳转链路,确认状态码、Location 地址、参数传递都符合预期,再灰度发布到生产。一旦 301 被浏览器缓存,想收回就困难了,这个代价远比你多花五分钟测试要高得多。