网站 URL 迁移与 301 重定向实战:Nginx 批量配置、尾斜杠与查询串陷阱、迁移后四步验证

网站的「死因」里,跳转配置失败被严重低估了

一个跑了三五年的个人站,更换域名、调整 URL 结构、从 http 迁到 https、把某个栏目合并进另一个栏目——这些动作几乎每个站长都会做,而且往往不止一次。做得好,老页面平稳过渡到新页面,权重几乎无损;做得不好,就是一次「静默的流量腰斩」:站点还在,内容还在,服务器也没挂,但搜索引擎里那几百条老链接全部指向 404,权重在几周内被慢慢清零。

更麻烦的是,这种损失不是立刻可见的。GSC 里「已编入索引」的数字会缓慢下降,「找不到页面(404)」缓慢上升,「网页已抓取,但尚未编入索引」缓慢堆积。等到一周后你打开统计看到流量掉了一半,损失已经发生了。而跳转这类东西,本质上是一个「一次性做对、之后几乎不用管」的配置——它的价值就在于一次性做对。

这篇文章把 URL 迁移的完整链条拆开讲:301 到底做了什么、Nginx 上怎么批量配、WordPress 站怎么处理、哪些地方会悄悄丢掉尾斜杠和查询串、以及迁移之后怎么用最少的成本验证「权重确实传过去了」。重点不在配置语法本身,而在那些只有踩过才知道的边界条件。

先建立正确的心智模型:301 到底传递了什么

搜索引擎把 301 当作「永久性搬家声明」。它传递的主要是三类东西:

  • 链接权重(PageRank 类信号):绝大多数搜索引擎会把大部分权重传给目标页,但不是 100%,且链式跳转(A→B→C)会衰减。
  • 抓取与索引信号:旧 URL 会从索引中移除,新 URL 被重新抓取。这个过程是异步的,通常几天到几周。
  • 历史信号:页面存在时长、外链锚文本、用户行为数据的一部分。

由此推出三条铁律,比任何配置技巧都重要:

  1. 必须是一跳,不能是多跳。A→B→C 这种链条会丢失权重,而且爬虫有跳转深度上限(通常 5 跳,但实践中 5 跳基本等于断了)。
  2. 不能是 302。302 表示临时跳转,搜索引擎会保留旧 URL 不迁移,权重全部留在原地。很多站的流量腰斩就是因为在 Nginx 或程序里写了 302。
  3. 不能靠 JavaScript 跳转。window.location 或 meta refresh 都不是 HTTP 层面的跳转,爬虫不认作权重转移,只认作「页面内容」。这是最常见也最致命的错误。

另外要明确一件事:robots.txt 不能删除旧 URL。给旧路径加 Disallow 是一种自毁式操作——爬虫被禁止访问后,就永远读不到那条 301,旧 URL 会以「被屏蔽」的状态长期停留在索引里,权重既传不过去也清不掉。正确顺序永远是:先上 301,等索引更新完,再考虑清理。

Nginx 批量 301:三条路线,按规模选

个人站换 URL 结构,通常有几十到上千条要跳。手写 location 会失控,下面三条路线覆盖绝大多数场景。

路线一:正则一次性改写(结构有规律时首选)

如果旧结构和新结构之间是规则映射,比如旧站是 /archives/123.html,新站是 /post/123.html,一条正则就够了:

server {
    listen 443 ssl http2;
    server_name www.example.com;

    # 旧路径结构 -> 新路径结构(保留 id,丢弃多余层级)
    location ~ ^/archives/(\d+)\.html$ {
        return 301 https://www.example.com/post/$1.html;
    }

    # 旧栏目合并:/oldcat/xxx -> /newcat/xxx
    location ~ ^/oldcat/(.*)$ {
        return 301 https://www.example.com/newcat/$1;
    }
}

关键细节:return 301 里的目标必须是完整绝对 URL(含协议与域名),不要写相对路径。虽然 Nginx 对 301 的相对路径会按规范补全,但补全出的协议可能是 http,导致多一跳。

另外注意 location ~ 的顺序:Nginx 的正则 location 按配置文件中的出现顺序匹配,第一个命中的生效,而前缀 location 的优先级规则又不同。所以要先把「更具体的规则」写在前面,把兜底规则写在后面。这个顺序问题导致的跳转错乱非常典型。

路线二:map + 精确映射表(新旧 URL 无规律时)

如果真的是几百条一对一、毫无规律的映射(老站从某个 CMS 迁过来常见这种情况),用 map 做查表最清晰,且不影响其他 location 的匹配逻辑:

http {
    map $uri $redirect_target {
        default               "";
        /about.html           /about;
        /contact.html         /contact;
        /archives/2020/05/linux-tips.html  /linux/tips;
        /old-post-abc.html    /post/123;
        # ... 几百行
    }

    server {
        listen 443 ssl http2;
        server_name www.example.com;

        if ($redirect_target != "") {
            return 301 https://www.example.com$redirect_target;
        }
    }
}

这里必须说明 if 的坑:不要在 location 里滥用 if,Nginx 官方文档明确称其为「evil if」。但在 server 级别、且 if 内只有 return 这一种指令时,它是安全的——因为 return 属于 rewrite 模块,在 rewrite 阶段同步执行,不会产生子请求的诡异行为。这个例外要记住,否则你会看到一堆「永远不要用 if」的告诫却不知道边界在哪。

映射表可以用脚本从数据库导出,避免手抄错字:

# 从 Typecho 导出 旧slug -> 新slug 映射,生成 map 片段
mysql -uroot -p'***' site -N -B -e \
 "SELECT CONCAT('    ', old_uri, ' ', new_uri, ';') FROM url_map" > /etc/nginx/conf.d/redirect_map.inc
nginx -t && nginx -s reload

生成到独立的 .inc 文件再用 include 引入,好处是映射表可以整体替换,且 nginx -t 能校验语法。

路线三:整站域名迁移,连 www 与非 www 一起处理

换域名是最完整的场景,需要处理的组合有四个:旧域名与非 www、新域名与非 www、http 与 https。正确做法是把所有其他组合统一收敛到「新域名的规范形态」:

# 旧域名:全部 301 到新域名,保留路径和查询串
server {
    listen 80;
    listen 443 ssl http2;
    server_name old-example.com www.old-example.com;

    ssl_certificate     /etc/nginx/ssl/old-example.com.pem;
    ssl_certificate_key /etc/nginx/ssl/old-example.com.key;

    return 301 https://www.new-example.com$request_uri;
}

# 新域名的非 www 与 http,收敛到 https://www
server {
    listen 80;
    listen 443 ssl http2;
    server_name new-example.com;

    # http 到达时直接跳 https
    return 301 https://www.new-example.com$request_uri;
}

$request_uri 是这里的关键:它包含原始路径 + 原始查询串,是唯一能完整保留 ?utm_source=... 这类参数的变量。很多人用 $uri 或 $document_uri,结果所有带参数的链接都退化成无参数版本——对 SEO 影响不大,但对广告追踪统计是灾难。

还有一个反直觉的点:旧域名的 DNS 不能立刻删掉。DNS 迁走后 301 就失效了,而搜索引擎完全接受旧域名下的 301 可能需要数月。稳妥做法是至少保留旧域名的 A 记录和证书一年,这期间所有老链接都还能正常跳转。

最容易出事的地方:尾斜杠、查询串、大小写

这三个东西是 301 配置里 90% 的问题来源,值得逐一拆开。

尾斜杠:/about 与 /about/ 是两个 URL

搜索引擎把它们当作不同 URL。如果站内链接一半带斜杠一半不带,会被视为重复内容;如果 301 规则只匹配其中一种,另一种就直接 404。统一策略(二选一,全站一致)并强制跳转:

# 统一「带尾斜杠」:非斜杠的目录请求跳一次
# 注意只对「无扩展名」的路径生效,否则 /a.html 会被加成 /a.html/
location ~ ^([^.]*[^/])$ {
    return 301 $scheme://$host$1/$is_args$args;
}

正则里的 [^.]* 是关键——没有它,/style.css 会被跳成 /style.css/,全站 CSS 立刻失效。这类正则事故上线后表现为「页面全白」,非常显眼但也非常致命。

另一种更安全的写法是明确枚举:

rewrite ^/(about|contact|privacy)$ /$1/ permanent;

查询串:return 301 默认会丢参数

这是最隐蔽的一条。return 301 的目标里如果不含 ?,Nginx 会保留原查询串;但如果目标里含了 ?,原查询串就被丢弃。所以:

# 目标不含 ? → 自动保留原查询串
return 301 https://www.example.com/new-path;

# 目标含 ? → 原查询串被丢弃,需手动拼
return 301 https://www.example.com/new-path?$args;

# 明确要丢掉所有参数(如清理追踪参数)
return 301 https://www.example.com/new-path?;

这三个写法的差异极小但后果完全不同。验证方法很直接:

curl -sI 'https://www.example.com/old-post.html?a=1&b=2' | grep -iE '^(HTTP|location)'

看 Location 里是否带着 a=1&b=2,一眼就能判断。

大小写:Unix 文件系统是敏感的

老站的 URL 里混着 /Linux-Tips.html 和 /linux-tips.html 的情况很常见(尤其是 Windows 上开发、Linux 上部署的站)。Nginx 里的 location 是大小写敏感的,除非用 ~*。更麻烦的是 URL 规范化:搜索引擎会把 URL 路径做一定程度的规范化,但大小写不在其列。处理办法是把所有大写路径重定向到小写:

# 注意:这里需要 perl 模块或 lua,纯 Nginx 做不到「转小写」
# 方案 A:枚举已知的大写路径
location ~ ^/Linux-Tips\.html$ { return 301 /linux-tips.html; }

# 方案 B:用 map 处理常见组合
map $uri $lower_redirect {
    ~^/Linux-Tips\.html$  /linux-tips.html;
    ~^/SEO-Guide\.html$   /seo-guide.html;
}

WordPress 与 Typecho 站的特殊处理

WordPress:修改固定链接后的三重保险

WordPress 后台改「固定链接」只会改新文章的 URL,老文章链接立刻 404。必须装重定向插件(Redirection 或 Rank Math 的 301 模块),并在改之前导出旧链接清单。此外,WordPress 的 .htaccess 或 Nginx 伪静态规则里有一行容易被忽略的配置:

# Nginx 版 WordPress 伪静态,注意 try_files 的顺序
location / {
    try_files $uri $uri/ /index.php?$args;
}

try_files 里如果只写 $uri 不写 $uri/,带尾斜杠的路径会全部落到 index.php,看起来正常但内部路由是错的,可能导致 canonical 与实际 URL 不一致。?$args 也不能省,否则分页、搜索参数全部丢失。

Typecho:cid 变化时的重写

Typecho 默认的 /archives/{cid}/ 换成 slug 形式 /archives/{slug}.html 后,老链接会 404。可以在主题或插件里做查库跳转,但更稳的做法是在 Nginx 层用 map + 一次数据库导出:

# 导出 cid -> slug 映射
mysql -uroot -p'***' typecho -N -B -e \
 "SELECT CONCAT('    /archives/', cid, '/  /archives/', slug, '.html;') FROM typecho_contents WHERE type='post'" \
 > /etc/nginx/conf.d/typecho_cid_map.inc

这里有个实务提醒:映射表要在切换前导出并保留。很多人切换后发现 301 没生效,回头想查旧映射,结果数据库里 slug 已经被批量改过,原始对应关系彻底丢失。切换前先 mysqldump 一张只读副本,是零成本的保险。

迁移后的验证清单:别靠感觉判断「应该没问题」

配置写完只是开始,验证才是关键。下面这份清单可以照着敲,五分钟能覆盖 90% 的遗漏。

1. 状态码与跳转次数

# 检查是否 301、是否一跳到位、Location 是否正确
curl -sI 'https://www.example.com/old-post.html' | grep -iE '^(HTTP|location)'

# 跟随跳转,看最终落点与总共几跳(>1 跳就要修)
curl -sIL -o /dev/null -w 'final=%{url_effective} hops=%{num_redirects} code=%{http_code}\n' \
  'https://www.example.com/old-post.html'

num_redirects 大于 1 就该修。这是最直接的量化指标,比人工点开浏览器可靠得多。

2. 批量验证所有旧 URL

单个 curl 只能抽查,真正要的是全量。从 sitemap 或日志里导出旧 URL 列表,批量跑:

# old_urls.txt 每行一个旧 URL
while read -r u; do
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$u")
  loc=$(curl -sI --max-time 8 "$u" | awk -F': ' 'tolower($1)=="location"{print $2}' | tr -d '\r')
  echo -e "$code\t$loc\t$u"
done < old_urls.txt | tee redirect_report.tsv

# 只看有问题的
awk -F'\t' '$1!=301 && $1!=200' redirect_report.tsv

旧 URL 清单从哪来?三个来源合并最全:搜索引擎后台的「已编入索引」报告、服务器历史 access.log 里的 200 请求、以及外链工具导出的被链页面。很多人只导了 sitemap,那只是「自己想被收录的」,不是「搜索引擎已经记住的」。

3. 用 GSC 的「网址检查」验证单条

GSC 的网址检查工具会给出「已检测到重定向」以及目标 URL,这是官方口径的确认。对于重点页面(首页、栏目页、几个流量大文章),值得逐条查一遍。同时要注意:GSC 显示「已检测到重定向」不代表权重已转移,那需要等索引更新,通常几天到几周。

4. 检查 canonical 与 sitemap 是否还指向旧地址

# 抓一页看 canonical 指向哪
curl -s 'https://www.example.com/new-post' | grep -oE '<link[^>]*canonical[^>]*>'
# sitemap 里是否还有旧域名/旧路径
curl -s 'https://www.example.com/sitemap.xml' | grep -c 'old-example.com'

这一步经常被漏掉,而且后果很严重:301 指向新页,但新页的 canonical 还写着旧地址,等于把信号又指回去了。搜索引擎面对这种自相矛盾的信号,通常会选择忽略 301,导致迁移彻底失效。canonical 必须是自指(指向自身当前 URL)。

5. 内链批量替换

站内所有链接如果还指向旧 URL,每次点击都多一跳,既浪费爬取预算又拖慢用户。用 sub_filter 可以做临时改造,但正解是改数据源:

# 在数据库里批量替换文章正文中的旧域名(Typecho 示例,先备份!)
mysqldump -uroot -p'***' typecho typecho_contents > /root/contents_backup.sql
mysql -uroot -p'***' typecho -e \
 "UPDATE typecho_contents SET text = REPLACE(text, 'old-example.com', 'new-example.com') WHERE text LIKE '%old-example.com%';"
mysql -uroot -p'***' typecho -e \
 "SELECT COUNT(*) FROM typecho_contents WHERE text LIKE '%old-example.com%';"

最后那条 SELECT COUNT(*) 是必须的验证:UPDATE 返回的 affected rows 可能是 0(表示没有实际变化)也可能是错误行数,只有复查确认剩余为 0 才算完成。

那些「跳转做对了,但还是掉流量」的原因

配好 301、验证通过,流量仍然下滑,通常不是跳转的问题,而是下面几件事之一:

  1. 内容也变了。旧页讲 A,新页讲 A+B,主题偏移导致原有的关键词匹配失效。301 只能搬位置,搬不了相关性。
  2. 多篇旧页合并到一篇。这是最危险的迁移方式。三篇各有长尾流量的旧页 301 到一篇新页,搜索引擎通常只认可其中一部分,另两篇的长尾词直接消失。正确做法是保留独立页面并做好内链,而不是合并。
  3. 新旧 URL 同时可访问。301 没生效(比如被 CDN 缓存住了旧的 200 响应),导致两个 URL 都在索引里,互相竞争。检查 CDN 是否缓存了 301:301 默认是可缓存的,某些 CDN 会长期缓存,需要在迁移后主动 purge。
  4. 旧域名的外链没有更新。外链指向旧域名,虽然 301 能传权重,但每次都要经过一跳,且部分外链来源(论坛签名、旧友链)的爬虫不跟随跳转。这部分损失无法完全避免,只能接受。
  5. 迁移时机不对。在搜索引擎正在大规模重抓的时间窗做迁移,会让两个变化叠加,难以归因。尽量选流量平稳期。

其中第 3 条特别值得强调:CDN 缓存会让「配置已生效」变成假象。你用 curl 在源站测试一切正常,用户和爬虫拿到的却是 CDN 缓存的旧响应。验证时必须绕过缓存:

# 带随机参数绕 CDN 缓存
curl -sI "https://www.example.com/old-post.html?_=$(date +%s)" | grep -iE '^(HTTP|location|x-cache)'
# 或者直接打源站 IP(带 Host 头)
curl -sI -H 'Host: www.example.com' 'http://ORIGIN_IP/old-post.html' | grep -iE '^(HTTP|location)'

x-cache 之类的响应头能告诉你这次是命中缓存还是回源,是判断「到底谁在返回这个响应」的关键线索。

小结:迁移是一次性的,验证是必须的

URL 迁移没有「差不多就行」的余地。它是一次性动作,做错了要等几周才能看出损失,而那时候已经很难挽回。把要点收拢成五条:

  • 301,一跳,绝对 URL,保留 $request_uri。绝不用 302、JavaScript 跳转或 meta refresh。
  • 先上 301,再谈 robots 和索引清理。顺序错了就是自毁。
  • 尾斜杠、查询串、大小写三处必须逐一验证。用 curl -w 看 num_redirects 和 http_code,不靠肉眼。
  • canonical 必须自指,sitemap 必须干净。否则 301 会被矛盾的信号抵消。
  • 绕过 CDN 验证。源站正常不等于线上正常。

最后补一个心态上的提醒:迁移之后流量短期下滑是正常的,因为索引重建需要时间。判断「有没有做错」的标准不是第一周的数字,而是两到四周后「旧 URL 是否稳定 301、新 URL 是否开始获得与旧 URL 相当的曝光」。如果四周后新的曝光仍明显低于旧的,那才是真的出了问题——这时候再去逐条比对旧 URL 清单,通常能发现几条被漏掉的规则。把这份清单留好,它比任何工具都可靠。

Last modification:October 17th, 2026 at 12:28 pm

Leave a Comment