网站改版之 301 重定向与 URL 结构设计避坑

2026-07-16 网站/博客搭建

2026-10-03 更新:本文改用 RFC 9110/9111/3986/6797、Google Search Central 官方文档与 sitemaps.org 协议原文重写,删去原先的本机测试内容;所有语义结论都标注了规范条款或官方文档出处。

改版时最容易埋雷的不是页面本身,而是 URL:换域名、换协议、加 www、目录改名、参数调整——每一步都可能产生"同一份内容有多个地址"的局面。搜索引擎处理这种情况的依据是明确的规范与官方文档,把规则先搞清楚,再动手写配置。

一、先把"唯一形态"定死,再写第一行代码

在动手之前需要定一个"规范形态"(canonical form),其余形态全部指向它。规范的判断依据来自 RFC 3986 与 Google 官方文档:

  • 协议与主机名大小写不敏感,规范形式是小写。RFC 3986 §3.1 规定 scheme 名大小写不敏感、规范形式为小写,实现应把小写作为唯一输出形式;§3.2.2 规定 host 子组件(IP 字面量或注册名)大小写不敏感。
  • 默认端口要从 URL 里省掉。RFC 3986 §3.2.3 说明:当端口为空或等于该 scheme 的默认端口(http 为 80)时,生产者和归一化器应省略端口及冒号。
  • 空路径等价于 /。RFC 3986 §6.2.3 明确对 http scheme 而言 http://example.com、http://example.com/、http://example.com:/、http://example.com:80/ 四者等价。
  • 路径默认大小写敏感,只有 scheme 与 host 归一为小写(RFC 3986 §6.2.2.1),所以不要把"路径大小写不敏感"当成前提。
  • 百分号编码要归一。RFC 3986 §6.2.2.2 要求把编码后对应 unreserved 字符的百分号编码解码(例如 %7E 与 ~ 等价),§6.2.2.3 要求用 remove_dot_segments 算法去掉路径中的 . 与 .. 段。
  • 站点必须在 www 与裸域之间选一个。sitemaps.org 协议要求:一个 Sitemap 里所有 URL 必须来自单一主机(www.example.com 或 store.example.com,不能混用)。

⚠️ 有一条常见说法需要纠正:"//a 与 /a 是同一个页面"没有规范依据。RFC 3986 只规定了空路径等价 / 与点段移除,并没有声明重复斜杠等价。这种等价性属于具体服务器/CDN 的实现行为,不能当规范结论写进改版方案。

二、301 与 302 的差别,是"新地址是不是永久"

RFC 9110 §15.4.2 对 301 的定义是:目标资源已被分配新的永久 URI,未来对该资源的引用都应使用新 URI,服务器建议可永久替换旧引用;服务器 SHOULD 生成含首选 URI 的 Location 头。

RFC 9110 §15.4.3 对 302(Found)的定义是:目标资源临时位于另一个 URI,由于重定向可能变动,客户端未来的请求应继续使用原目标 URI。

差别不只是措辞。对搜索引擎来说,Google 的官方文档把 HTTP 301、308 归为永久重定向(搜索结果里展示新目标页),把 HTTP 302、303、307 归为临时重定向(搜索结果里展示来源页);并且明确指出永久重定向是"目标应成为 canonical"的强信号。同站点的另一份官方文档给出了信号强度排序:重定向为强信号、rel=canonical 注解为强信号、Sitemap 收录为弱信号,三者可以叠加使用;如果都不指定,Google 会自行判定最佳版本——并且强调这些"指示只是 hint,不是规则"。

三、状态码别乱用:方法语义才是分水岭

改版里真正让人栽跟头的是"跳转后方法变了"。RFC 9110 把这几种码的语义分得很清楚:

状态码 是否永久 方法变化 规范依据
301 Moved Permanently 永久 历史原因下 UA MAY 把后续请求方法从 POST 改成 GET RFC 9110 §15.4.2 及该节 Note
302 Found 临时 同上,可被降级为 GET RFC 9110 §15.4.3
303 See Other 临时 明确用于"用另一个 URI 提供间接响应",UA 可执行检索请求(GET/HEAD) RFC 9110 §15.4.4
307 Temporary Redirect 临时 UA 自动跟随 MUST NOT 改变请求方法 RFC 9110 §15.4.8
308 Permanent Redirect 永久 语义同 301,但不改变请求方法 RFC 9110 §15.4.9

结论很直接:表单 POST 后的跳转、API 的临时跳转,如果不想让方法被改成 GET、不想丢掉请求体,就别用 301/302,用 307/308。反过来说,把 POST 结果的落地页写成 303 是最贴合规范的用法。

四、Location 的写法:相对与绝对都合法,fragment 有继承规则

  • RFC 9110 §10.2.2 规定 Location 头由单个 URI-reference 构成:写成相对引用时,最终值由它与目标 URI 解析计算得出。所以相对地址和绝对地址都是合法写法。
  • 同一节还规定:如果 3xx 响应的 Location 值不含 fragment 组件,UA 必须把它视作继承原请求 URI 引用的 fragment。规范的示例是:对 http://www.example.org/index.html#larry 返回 Location: http://www.example.net/index.html,UA 最终应访问 http://www.example.net/index.html#larry。

五、缓存:301 会被"记住",这是规范层面的结论

  • RFC 9110 §15.4.2 说明 301 响应是 heuristically cacheable(启发式可缓存),除非方法定义或显式缓存控制另有说明。
  • RFC 9111 §4.2.2 进一步说明:没有显式过期时间时,缓存可以分配启发式过期时间;这套启发式只适用于被定义为可缓存的响应(旧规范里的说法是 cacheable by default)。
  • 如果只想临时发一个 301(比如灰度迁移、灰度站点还没准备好),RFC 9111 §5.2.2.5 定义的 no-store 响应指令是官方给的工具:缓存不得存储请求或响应的任何部分,也不得用该响应满足其他请求。

⚠️ 需要说清楚的边界:规范没有规定 301 具体的缓存时长。规范层只到"heuristic cacheable",浏览器实现各自的期限属于实现细节,网上流传的"永久缓存"或"缓存 90 天"都不可当规范结论引用。真要撤回一个 301,最可靠的办法是让目标地址本身仍然可用(或者反过来,让旧地址返回 200 并给新地址 rel=canonical)。

关于"每一跳都是一次往返、所以要合并跳数"这类说法,同样缺少官方来源:Google 的文档只说明永久与临时重定向在索引上的差别,没有给出跳数与性能的量化结论。工程上少一跳当然更好,但请把它当作常识判断,别写成"官方规定了 N 跳上限"。

六、http → https 的 301 与 HSTS 的关系

RFC 6797 §7.2 规定:当 HSTS Host 通过非安全传输收到 HTTP 请求时,SHOULD 返回含表示永久重定向的状态码(例如 301)以及指向 https 的 Location 的响应——这是 SHOULD 而非 MUST。

另一侧的行为在 RFC 6797 §8.3:UA 在准备解引用任何 http URI(包括跟随 HTTP 重定向时)之前,MUST 先判断该 URI 的域名是否匹配已知的 HSTS Host。也就是说,已经访问过的浏览器会在本地把 http 升级为 https,那一跳对它根本不存在。

⚠️ HSTS 按主机生效:RFC 6797 §9 的示例指出,如果 HSTS 头只在 example.com 发出而页面链接都指向 www.example.com,部分 UA 不会把 example.com 记为 Known HSTS Host,www 的用户将不受保护。所以 www 与裸域要各自覆盖。

七、写法示意(语法以各服务器官方文档为准)

下面几段是结构示意,用来表达"要做出的响应形态",具体语法请以各项目的官方文档为准:

# Caddy:把非规范形态 301 到规范形态(示意)
example.com {
    redir https://www.example.com{uri} permanent
}
# nginx:升 https 并补 www(示意)
server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://www.example.com$request_uri;
}
# Apache:裸域 301 到 www(示意)
RewriteEngine On
RewriteCond %{HTTP_HOST} ^example\.com$ [NC]
RewriteRule ^(.*)$ https://www.example.com$1 [R=301,L]

三条共同点,比语法本身重要:

  1. Location(或等价指令)要带上原始路径与查询串,写成固定字符串就等于把所有子页面都丢到首页;
  2. 能一趟解决的别用两趟(http+裸域直接 301 到 https+www,而不是先补 www 再升 https);
  3. 改完必须亲自验证"旧地址 → 新地址"这一步真的返回 301 而不是 302/307,且 Location 正确。

八、上线前检查清单

  • [ ] 定好唯一形态(协议、www、路径大小写、尾斜杠、参数规范),并写进团队文档;
  • [ ] http://example.com → https://www.example.com 一趟到位,返回 301;
  • [ ] 页面换址用 301;表单/API 的临时跳转用 307/308 或 303,别用 301/302;
  • [ ] Location 保留原始路径与查询串;
  • [ ] www 与裸域都发 HSTS 头;
  • [ ] 全站页面的 canonical 指向唯一形态(重定向 + canonical 是强信号,可叠加);
  • [ ] Sitemap 里所有 URL 来自单一主机,loc 以协议开头、按服务器要求决定是否带尾斜杠;
  • [ ] 旧地址不是直接 404:长期存在的旧地址保留 301 映射表;
  • [ ] 灰度期若要"可撤销的永久跳转",用 Cache-Control: no-store 明确表态。

九、换成你的环境要改哪几处

本站用的 换成你的 始终不变的部分
具体 Web 服务器(Caddy / nginx / Apache) 你的服务器与配置语法 响应必须是 301 且 Location 带原始路径与查询串
某个 CDN 你的 CDN,注意其缓存规则对 301 的处理 301 属可缓存响应,灰度期要显式 no-store
某套永久链接结构 你的 URL 结构 唯一形态、大小写与编码归一、默认端口省略
具体的定向映射表 你的旧地址清单 301 是永久信号,改错了要有回滚路径

参考来源