重定向看起来很简单:服务器返回一个状态码和一个 Location 响应头,客户端就转到那个地址。但这三位数字决定了浏览器能否缓存这个重定向、POST 请求是否仍然是 POST,以及搜索引擎更可能将哪个 URL 编入索引。
本文介绍 RFC 9110 中定义的 3xx 类状态码、这些状态码含义重叠背后的历史、不使用状态码的重定向,以及如何在常见服务器上配置正确的状态码。
3xx 类状态码的含义
RFC 9110 是现行的 HTTP 语义规范,其中规定,3xx 状态码表示“用户代理需要采取进一步的操作才能完成请求”。第 15.4 节将这些状态码分为 4 种重定向:
- 资源可以通过另一个 URI 获取,该 URI 在
Location中给出:301、302、307 和 308。 - 客户端可以在资源的多种表示形式中进行选择:300。
- 由另一个资源对请求作出间接响应:303。
- 客户端可以重用其缓存中已有的副本:304。
实际上,只有 301、302、303、307 和 308 会作为自动重定向生效。浏览器所实现的 Fetch 标准,恰好把这 5 个状态码称为“重定向状态”(redirect status)。300 通常会像普通页面一样显示,而 304 则告诉客户端使用其缓存中的副本。
Location 的值可以是相对引用,例如 /new-page 或 ../shop/。客户端会以刚刚请求的 URL 为基准来解析它(RFC 9110 第 10.2.2 节)。如果 Location 不含片段标识符(fragment),则保留原 URL 中的片段:指向 /guide#install 的链接如果重定向到 /docs/guide,最终会到达 /docs/guide#install。
对于这 5 种真正的重定向,选择哪个状态码取决于两个问题:
- 迁移是永久的还是临时的?永久重定向可以被缓存,并会向搜索引擎表明应改为将新 URL 编入索引。
- 是否必须保留请求方法?如果原始请求是 POST、PUT 或 DELETE,而不是普通的 GET,这一点就很重要。
重定向状态码一览
| 状态码 | 名称 | 是否永久 | 是否保留方法 | 默认可缓存 | 典型用途 | Google 搜索 |
|---|---|---|---|---|---|---|
| 300 | Multiple Choices | – | – | 是 | 提供多种可选的表示形式(少见) | – |
| 301 | Moved Permanently | 是 | 否:POST 可能变为 GET | 是 | 页面和网站迁移、HTTPS 与主机名规范化 | 永久(强信号) |
| 302 | Found | 否 | 否:POST 可能变为 GET | 否 | 短期重定向、登录墙、A/B 测试 | 临时(弱信号) |
| 303 | See Other | 否 | 否:变为 GET | 否 | 表单提交后的结果页 | 临时 |
| 304 | Not Modified | 不是重定向 | – | – | 对条件请求的响应 | 内容未变化 |
| 305 | Use Proxy | 已弃用 | – | – | 请勿使用 | – |
| 306 | (Unused) | 已保留 | – | – | 请勿使用 | – |
| 307 | Temporary Redirect | 否 | 是 | 否 | 任意方法的临时重定向 | 临时 |
| 308 | Permanent Redirect | 是 | 是 | 是 | API、表单或任意 URL 的永久迁移 | 永久(强信号) |
“默认可缓存”是指根据 RFC 9110 第 15.1 节,该状态码属于可启发式缓存(heuristically cacheable)的状态码:即使响应中没有明确的新鲜度信息,缓存也可以不向服务器确认就重用该响应。不过,任何响应仍然都可以通过 Cache-Control 设为可缓存或不可缓存。“Google 搜索”一列总结了 Google 关于重定向和 HTTP 状态码的文档。
逐一解读各个状态码
300 Multiple Choices
资源有多种表示形式,例如不同的语言或文件格式,由客户端自行选择。响应体通常会列出这些选项,但没有统一的标准格式。服务器可以在 Location 中指定首选项,但不能指望浏览器会跟随它。实际上 300 几乎从未被使用:服务器端内容协商,或者一个带有链接的普通页面,效果都更好。
301 Moved Permanently
目标资源“已被分配了一个新的永久 URI,今后对该资源的任何引用都应使用响应中给出的 URI 之一”。这是页面迁移、域名变更、HTTP 升级到 HTTPS 以及规范主机名重定向的标准状态码。
有两个细节需要注意。第一,301 可以被启发式缓存,因此浏览器可能会记住它,不再询问服务器(见下文关于缓存的一节)。第二,由于历史原因,客户端在跟随 301 时可以把 POST 改为 GET,而浏览器确实会这样做。对于用 GET 加载的页面来说,这无关紧要;但对于表单端点或 API,请使用 308。
302 Found
目标资源“暂时位于另一个 URI 下”,因此客户端今后的请求应继续使用原来的 URL。302 默认不可缓存。它适用于短期情况:例如某个促销页面在一周内临时替代分类页、登录墙、按地理位置分流或 A/B 测试。
与 301 一样,302 也允许把 POST 改为 GET。许多框架在未指定状态码时会发送 302,这也是永久迁移常常被误设为 302 的原因。
303 See Other
303 把客户端引向另一个资源,作为对原始请求的间接响应,客户端用 GET(或 HEAD)获取该资源。它专为 Post/Redirect/Get 模式而设计:服务器处理完表单提交后,返回 303 以及确认页或结果页的 URL。此后重新加载该页面时,重复的只是一次无害的 GET 请求,而不会再次提交表单。
303 默认不可缓存,也并不表示原 URL 已经迁移。Google 会像对待 302 一样对待它。
304 Not Modified(不是重定向)
尽管编号属于 3xx,304 却从不把客户端引向任何地方。它用于响应条件 GET 或 HEAD 请求,即带有 If-None-Match(值为 ETag)或 If-Modified-Since(值为日期)的请求。如果资源没有变化,服务器会返回不带响应体的 304,客户端则使用其缓存中的副本。
GET /styles.css HTTP/1.1
Host: example.com
If-None-Match: "5f1c-3a2b"
HTTP/1.1 304 Not Modified
ETag: "5f1c-3a2b"
Cache-Control: max-age=3600重定向检测工具发送的是无条件请求,因此通常不会遇到 304。要在 TrueURL 中测试 304,先正常检测一次,从响应头中复制 ETag 的值,再在自定义请求头中把它作为 If-None-Match 请求头发送。对 Googlebot 来说,304 表示内容自上次抓取以来没有变化。
305 Use Proxy 与 306 (Unused)
305 曾用于告诉客户端通过 Location 中指定的代理重新发送请求。让一个响应就能更改客户端的代理设置存在安全风险,因此 RFC 7231 弃用了这个状态码,当前的浏览器也不会对它作出处理。
306 曾在旧版规范中有过定义(常被列为 Switch Proxy),现已保留不用。现代应用中不应出现这两个状态码;如果看到了,说明某处配置有误。
307 Temporary Redirect
307 的含义与 302 相同,只多了一条规定:客户端不得更改请求方法。POST 请求会以 POST 方式、连同相同的请求体重新发送到新 URL。因此,307 是适用于 API 和表单端点的临时重定向。
在 Chrome 开发者工具中,您可能还会看到从 http:// 到 https:// 的 307 Internal Redirect。这并不是服务器发送的,而是浏览器自行升级了请求,通常是因为它已为该网站存储了 HSTS(HTTP 严格传输安全)策略。
308 Permanent Redirect
308 是 301 对应的保留请求方法的版本:永久、可启发式缓存,并且客户端不得更改请求方法。它于 2014 年首次发布在实验性的 RFC 7238 中,2015 年随 RFC 7538 成为标准,如今已纳入 RFC 9110。
如果接收 POST、PUT 或 DELETE 请求的端点需要永久迁移,请使用 308。对于普通页面,301 和 308 可以互换使用,Google 也会将两者都视为永久重定向。
为什么会有 303、307 和 308
301/302 与 303/307/308 的含义之所以重叠,纯属历史的偶然。
HTTP/1.0(RFC 1945,1996 年)定义了 301 Moved Permanently 和 302 Moved Temporarily。它规定,未经用户确认,用户代理不得自动重定向 POST 请求,并且当时就已指出,有些用户代理会错误地把 POST 改为 GET。此后,浏览器一直沿用这种做法。
HTTP/1.1 新增了两个没有歧义的状态码。RFC 2068(1997 年)引入了 303 See Other,它总是改用 GET。RFC 2616(1999 年)新增了 307 Temporary Redirect,它总是保留原方法;同时把 302 改名为 Found,并坦言大多数用户代理都把 302 当作 303 处理。
但这样仍然缺少一个保留方法的永久重定向,直到 15 年后才由 308 填补了这个空白。与此同时,规范也向现实妥协:自 RFC 7231(2014 年)起,用户代理在遇到 301 或 302 后可以把 POST 改为 GET。
如今,浏览器遵循 Fetch 标准中的以下规则:
| 状态码 | 原始方法 | 浏览器请求新 URL 时使用 |
|---|---|---|
| 301、302 | POST | GET,不带请求体 |
| 301、302 | 其他任意方法 | 相同的方法 |
| 303 | 除 GET 和 HEAD 以外的任意方法 | GET,不带请求体 |
| 303 | GET 或 HEAD | 相同的方法 |
| 307、308 | 任意方法 | 相同的方法和请求体 |
遇到 301 或 302 时,只有 POST 会被改写;PUT 或 DELETE 会保留原方法。但各个 HTTP 库的行为并不一致,依赖这一点并不可靠,因此只要请求方法很重要,就应使用 307 或 308。
不使用 3xx 状态码的重定向
meta refresh 重定向
<meta http-equiv="refresh" content="0; url=https://example.com/new-page">页面本身以 200 OK 返回,浏览器在加载页面之后才按照这个标签进行重定向。其中的数字表示延迟的秒数。Google 会把即时的 meta refresh(HTML 页面自动刷新)理解为永久重定向,把有延迟的理解为临时重定向。
定时刷新还会损害无障碍访问。WCAG 认可将即时 meta refresh 用作客户端重定向(技术 H76),但把定时刷新列为失败案例(F40),因为页面会按照用户无法控制的定时自动切换。
JavaScript 重定向
<script>
window.location.replace('https://example.com/new-page');
</script>location.replace() 不会在会话历史记录中留下执行重定向的页面,因此点击“后退”按钮时,不会退回到这个页面而再次被重定向。而给 location.href 赋值则会把该页面留在历史记录中。
Google 只有在渲染页面时才会运行 JavaScript,而渲染发生在抓取之后,并且可能失败。Google 的文档建议,只有在无法使用服务器端重定向和 meta refresh 重定向时,才使用 JavaScript 重定向。许多其他爬虫和工具根本不会运行 JavaScript。
Refresh 响应头
HTTP/1.1 200 OK
Content-Type: text/html
Refresh: 0; url=https://example.com/new-pageRefresh 响应头并未在任何 HTTP RFC 中定义。浏览器支持它已有几十年,HTML 标准也把它视为与 meta refresh 标签等效。但包括 curl 在内的许多 HTTP 客户端和工具都会忽略它,因此应优先使用真正的 3xx 重定向。
如何正确配置重定向
nginx
# 两个主机名上的所有 http:// 请求都一跳直达规范 HTTPS URL
server {
listen 80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
# https://www.example.com -> https://example.com
server {
listen 443 ssl;
server_name www.example.com;
ssl_certificate /etc/ssl/example.com.crt;
ssl_certificate_key /etc/ssl/example.com.key;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
server_name example.com;
# ssl_certificate 和 ssl_certificate_key 同上
# 单个已迁移的页面
location = /old-page {
return 301 /new-page;
}
# 整个栏目,保留路径的其余部分
location /blog/ {
rewrite ^/blog/(.*)$ /articles/$1 permanent;
}
}$request_uri 包含原始路径和查询字符串,因此不会丢失任何信息。rewrite ... permanent 发送 301,rewrite ... redirect 发送 302;如需 307 或 308,请使用 return 并写明相应的状态码。
Apache (.htaccess)
# mod_alias:单个页面和整个目录(路径的其余部分会附加到目标后面)
Redirect 301 /old-page https://example.com/new-page
Redirect 301 /blog/ https://example.com/articles/
# mod_alias 搭配正则表达式
RedirectMatch 301 ^/products/([0-9]+)\.html$ https://example.com/p/$1
# mod_rewrite:把所有 http:// 和 www 请求一跳重定向到 https://example.com
RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]不写状态码的 Redirect 会发送 302,所以务必写明状态码。如果 Apache 位于负载均衡器或 CDN 之后,而它们通过普通 HTTP 连接 Apache,那么 %{HTTPS} 始终为 off,最后一条规则就会造成循环;这时应改为判断 %{HTTP:X-Forwarded-Proto}。mod_alias 和 mod_rewrite 相互独立地工作,文件中各行的先后顺序并不能决定哪个模块先执行。同时匹配两个模块中规则的 URL(例如本例中的 http://www.example.com/old-page)可能需要两跳;对于必须一跳完成重定向的 URL,应把与它相关的所有规则都放在同一个模块中。
Cloudflare
Cloudflare 可以在请求到达您的服务器之前,直接在边缘完成重定向:
Single Redirects(单一重定向,位于控制面板的 Redirect Rules 部分)通过筛选表达式匹配请求,并以 301、302、303、307 或 308 将其重定向到静态或动态目标。一条典型的从 www 重定向到根域名的规则设置如下:
When incoming requests match: (http.host eq "www.example.com") URL redirect type: Dynamic Expression: concat("https://example.com", http.request.uri.path) Status code: 301 Preserve query string: OnBulk Redirects(批量重定向)用于处理很长的源 URL 和目标 URL 列表,例如以 CSV 格式上传的迁移映射表。每个条目都有各自的状态码和选项(例如是否保留查询字符串),列表需要通过一条 Bulk Redirect Rule 来启用。
Workers 可以用
Response.redirect()在代码中构造重定向:
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/old-page') {
url.pathname = '/new-page'; // 查询字符串会保留
return Response.redirect(url.toString(), 301);
}
return fetch(request);
},
};Response.redirect() 默认使用 302,并且只接受 301、302、303、307 和 308,传入其他状态码会抛出 RangeError。
Node.js 与 Express
import express from 'express';
const app = express();
app.set('trust proxy', 1); // 位于负载均衡器或 CDN 之后时
// 一跳同时完成 HTTPS 和规范主机名重定向
app.use((req, res, next) => {
if (req.secure && req.hostname === 'example.com') return next();
res.redirect(301, `https://example.com${req.originalUrl}`);
});
// 单个已迁移的页面
app.get('/old-page', (req, res) => {
res.redirect(301, '/new-page');
});不带状态码的 res.redirect(url) 会发送 302。Express 5 只支持 res.redirect(status, url) 这种参数顺序。在原生 Node.js 中,需要自己设置状态码和响应头:
res.writeHead(301, { Location: 'https://example.com/new-page' });
res.end();PHP
<?php
// 必须在发送任何输出之前执行
header('Location: https://example.com/new-page', true, 301);
exit;只设置 Location 响应头时,PHP 会发送 302(除非之前已经设置了 201 或 3xx 状态码),因此要把状态码作为第 3 个参数传入,并调用 exit,确保后续代码不再执行。在 WordPress 中,wp_redirect() 和 wp_safe_redirect() 默认也使用 302,需要把 301 作为第 2 个参数传入。
如何选择合适的重定向状态码
- 页面已永久迁移,且是用 GET 加载的普通页面:使用 301。用 308 也同样可以。
- 永久迁移,且客户端可能发送 POST、PUT 或 DELETE(例如 API、表单提交地址和 Webhook):使用 308。
- 临时重定向,且只涉及 GET:使用 302。
- 临时重定向,且必须保留请求方法:使用 307。
- 表单或其他 POST 请求已处理完毕,需要让用户看到结果页:使用 303。
- HTTP 重定向到 HTTPS:使用 301 或 308,并在 HTTPS 网站上发送
Strict-Transport-Security响应头,让浏览器下次直接跳过不安全的请求。 - 内容已被删除,且没有对应的替代内容:不要重定向,而应返回 404 或 410。重定向到不相关的页面(例如首页),可能会被 Google 视为软 404(soft 404)。
- 300、305 和 306:不要使用。304 是服务器或缓存针对条件请求生成的响应,从来不是一种可供选择的重定向方式。
301 和 308 的缓存陷阱
由于 301 和 308 可以被启发式缓存,浏览器可能会把它们存储起来,不再询问服务器。对于没有缓存相关响应头的 301 或 308,基于 Chromium 的浏览器会一直保留,直到该缓存条目被淘汰或用户清空缓存为止。一个错误的永久重定向,即使在您修复服务器很久之后,仍可能继续把以前的访客送到错误的地方,而且无法可靠地从他们的浏览器中撤回。
为稳妥起见:
- 先用 302 或 307 测试新规则,确认目标地址无误后再改为 301 或 308。
- 或者,在逐步上线期间,限制永久重定向可以被缓存的时长:
location = /old-page {
add_header Cache-Control "max-age=3600";
return 301 https://example.com/new-page;
}- 测试时,使用无痕(隐私)窗口、勾选了停用缓存(Disable cache)的开发者工具,或者服务器端检测工具,后者从不使用浏览器缓存。
- 修改重定向后,清除 CDN 和代理的缓存,因为它们也可能存储重定向。
- 不要试图通过把 B 重定向回 A 来撤销已被缓存的 A → B 重定向。浏览器缓存了第一个重定向的访客可能会因此陷入循环。
测试重定向
无论使用什么平台,都应从外部验证最终效果。301 重定向检测可以确认永久迁移的每一跳都是 301 或 308;重定向链检测会显示每一跳及其 Location 和响应头;HTTPS 重定向检测可以一次测试协议与主机名的全部 4 种组合。Meta Refresh 重定向检测能找出只读取响应头的工具会遗漏的客户端重定向,重定向延迟检测则可以测量每一跳的耗时。
在终端中,curl -sIL https://example.com/old-page 会打印每一跳的响应头。我们的如何检测 URL 重定向指南详细介绍了命令行和开发者工具的用法,以及常见的重定向问题。