博客换域名实战:DNS、301 与数据库链接替换

一、换个博客域名,为什么会牵出一整条链路

上个月我把一个跑了四年的 Typecho 站从 blog.example.com 挪到了 www.example.com。听起来只是改一条 DNS 记录,实际动手才发现要动的地方有十几处:DNS、Nginx server_name、Typecho 的 siteUrl、数据库里正文的绝对链接、主题选项里写死的域名、CDN 的回源配置、证书、robots.txt 里的 sitemap 地址、RSS 输出、邮件模板里的链接,还有最容易被忘的——那些年我在别人站上留的评论和签名里的链接,只能靠 301 过渡。

这里先讲一个原则:迁移期间,两个域名必须同时在线至少 90 天。老域名不能立刻 301 到新域名再删掉 Nginx 配置,因为搜索引擎、外链、读者书签、还有你自己发的邮件里的链接都要时间更新。90 天是我实测比较稳妥的窗口——Google 用大约 2 到 8 周完成重新评估,百度更慢,而 Bing 的 IndexNow 能快一些。

二、DNS:先加记录,别急着删

第一步不是改记录,而是新增记录。假设从 blog.example.com 迁到 www.example.com:

; 新域名先加 A/AAAA,TTL 设短
www.example.com.      300  IN  A     203.0.113.10
www.example.com.      300  IN  AAAA  2001:db8::10

; 老域名保持原样,暂时不动
blog.example.com.     300  IN  A     203.0.113.10

把 TTL 从默认的 3600 或 86400 降到 300,这一步必须提前 24 小时以上做。因为已经缓存的旧 TTL 还没过期,你改了记录但解析商和运营商的递归 DNS 还是会给你旧值,此时如果出了错,回滚要等一个 TTL 周期。提前降 TTL 的意义就在于:等你真正切换的那一刻,全世界最多 5 分钟就能跟着变。

CAA 记录也别忘了。如果之前给 example.com 配过 CAA 0 issue "letsencrypt.org",新子域继承父域的 CAA 策略,通常没问题;但如果你用了 CAA 0 iodef 或者给某个子域单独下过策略,签发证书会失败。用一条命令确认:

dig +short CAA example.com
dig +short CAA www.example.com
dig @1.1.1.1 www.example.com +short   # 换公共 DNS 再看一遍,排除本地缓存

我在这步踩过一个很典型的坑:本地 dig 拿到新 IP,浏览器打开的也是新站,以为切换完成了。实际上是因为我的路由器用了运营商 DNS,而运营商的递归服务器恰好已经刷新。晚上收到读者反馈「打不开」,用 dig @8.8.8.8 一查还是旧 IP——不同递归服务器的缓存过期时间不一致,这种「我这儿好着呢」是最浪费时间的排查状态。所以判断迁移是否完成,永远要查多个来源:

for r in 1.1.1.1 8.8.8.8 223.5.5.5 114.114.114.114; do
  echo "$r -> $(dig +short @$r www.example.com | tr '\n' ' ')"
done

三、Nginx:两段 server 块,一段收、一段发

迁移期的 Nginx 配置要能同时服务两个域名,并且把老域名上的请求全部 301 到新域名上对应的同一个路径。很多人写成 return 301 https://www.example.com;,结果所有老文章都被跳到首页,收录和外链权重全部作废,读者也找不到原文。正确写法:

# 老域名:只做跳转,保留原始 URI 和查询串
server {
    listen 443 ssl http2;
    server_name blog.example.com;
    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    # $request_uri 带原始路径 + 查询串,$uri 只带解码后的路径
    return 301 https://www.example.com$request_uri;
}

# 新域名:正式站点
server {
    listen 443 ssl http2;
    server_name www.example.com;
    root /var/www/example;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }
    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    }
}

这里的关键是 $request_uri 而不是 $uri。区别在编码上:$request_uri 是浏览器发来的原始串(含 %E4%B8%AD 这类百分号编码和 ?page=2 查询串),$uri 是 Nginx 解码后的路径,会丢掉查询串。中文 URL 用 $uri 拼出来的 301 会导致目标端再次解码,出现双重编码变成乱码路径 404。这一条我在两个站上分别踩过。

还有一个容易漏的:80 端口的跳转。上面只写了 443,如果老域名的 80 端口还是老的 server 块,用户访问 http://blog.example.com/xxx.html 会拿到 200 而不是跳转,等于给搜索引擎留了两套内容。80 也要单独一段:

server {
    listen 80;
    server_name blog.example.com www.example.com;
    return 301 https://www.example.com$request_uri;
}

注意 80 端口这一段里 server_name 同时写了两个域名,return 时统一指向 https://www.example.com,能顺带把 http://www.example.com 也收进来。

改完配置必须 nginx -t 再 reload,不要 restart。reload 是平滑的,老 worker 处理完手里的连接才退出;restart 会掐断正在下载大文件的连接。Typecho 这种 PHP 站无所谓,但如果站上有大附件下载,restart 会让下载中途失败。

四、证书:两条路,别让迁移卡在签发上

迁移期最尴尬的场景是:新域名的 DNS 已经生效,但 HTTP-01 验证还失败,因为 /.well-known/acme-challenge/ 被老域名的 301 跳转规则吃掉了,验证请求被跳到了新域名,而新域名的 Nginx 还没有那段 location。表现为 certbot 报 Invalid response ... 404。

两个解决办法。第一种,在老域名的 server 块里,把 ACME 路径单独放行,不参与跳转:

server {
    listen 80;
    server_name blog.example.com www.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
        default_type "text/plain";
        try_files $uri =404;
    }
    location / {
        return 301 https://www.example.com$request_uri;
    }
}

^~ 前缀匹配优先级高于正则,保证 ACME 请求不被后面的规则抢走。第二种更省事:整个站用 DNS-01 验证。Cloudflare 的 API 拿 token,certbot 的 dns-cloudflare 插件自动写 TXT 记录,完全绕过 80 端口:

apt install -y python3-certbot-dns-cloudflare
cat > /root/.secrets/cloudflare.ini <<'EOF'
dns_cloudflare_api_token = xxxxxxxxxxxxx
EOF
chmod 600 /root/.secrets/cloudflare.ini

certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  -d example.com -d www.example.com -d blog.example.com \
  --cert-name example.com --expand

--cert-name example.com --expand 的作用是把新域名追加进同一张证书,而不是新建一张。好处是 Nginx 里所有 server 块可以继续引用 /etc/letsencrypt/live/example.com/,不用改路径;坏处是一次改错会影响所有域名,所以改之前先 cp -a 备份 /etc/letsencrypt。90 天后老域名下掉时,再用 certbot ... --cert-name example.com 不带 -d blog.example.com 跑一次,把它从 SAN 里移出去。

证书里域名一多,还有个隐蔽问题:fullchain.pem 的顺序。中间证书如果顺序错误,桌面浏览器能容忍(会自己去追),但 Android 老版本和部分微信内置浏览器会直接握手失败,表现为「只有手机上打不开」。检查方式是:

openssl s_client -connect www.example.com:443 -servername www.example.com < /dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates
# 更彻底:用 SSL Labs 的思路本地检查链条
openssl s_client -connect www.example.com:443 -showcerts < /dev/null 2>&1 | grep -E '^( [0-9] s:|   i:)'

五、Typecho 侧:数据库里那几百条绝对链接

这是迁站最脏的一块活。配置项 siteUrl 在 typecho_options 表里,改起来简单:

UPDATE typecho_options
SET value = 'https://www.example.com'
WHERE name = 'siteUrl' AND user = 0;

但正文里的绝对链接不吃这套。typecho_contents.text 里可能存着 <img src="https://blog.example.com/usr/uploads/2022/03/xxx.jpg">,还有 typecho_fields(如果装了自定义字段插件)和 typecho_metas 里的封面图地址、以及主题配置 theme:handsome 序列化字符串里的 CDN 域名。整库替换之前一定要先做三件事:

  1. 全量备份,并且确认备份能还原(不要只 dump 不验证)。
  2. 统计命中数,先知道会改多少行。
  3. 在测试库上完整跑一遍,再用 mysqldump 把结果导回线上。
-- 命中统计
SELECT COUNT(*) FROM typecho_contents WHERE text LIKE '%blog.example.com%';
SELECT cid, title FROM typecho_contents WHERE text LIKE '%blog.example.com%' LIMIT 20;

-- 替换(注意:序列化字段不能用 REPLACE,会破坏长度前缀)
UPDATE typecho_contents
SET text = REPLACE(text, 'https://blog.example.com', 'https://www.example.com')
WHERE text LIKE '%blog.example.com%';

那句注释不是危言耸听:typecho_options 里主题配置是 PHP serialize 过的字符串,格式是 s:31:"https://blog.example.com",其中 31 是字节长度。你直接把域名换成更长的 www.example.com,字符串内容变了但长度前缀没变,PHP 反序列化时直接失败,整个主题配置回到默认值——菜单、侧栏、广告位全丢,页面看起来「样式还在但内容空了」。这种字段必须用 PHP 自己读出来、改完再 serialize 写回:

php -r '
$db = new PDO("mysql:host=127.0.0.1;dbname=example;charset=utf8mb4","user","pass");
$rows = $db->query("SELECT name,value FROM typecho_options WHERE value LIKE \"%blog.example.com%\"")->fetchAll();
foreach ($rows as $r) {
    $v = @unserialize($r["value"]);
    if ($v === false && $r["value"] !== "b:0;") continue;
    $new = serialize(str_replace("blog.example.com", "www.example.com", $v));
    $db->prepare("UPDATE typecho_options SET value=? WHERE name=?")->execute([$new, $r["name"]]);
    echo "fixed: {$r["name"]}\n";
}'

顺便提醒,str_replace 作用在数组上会递归处理所有元素,所以上面这段能一次搞定嵌套的菜单配置。但序列化数据里如果含对象(O:8:"stdClass"),unserialize 会实例化类,用一个没加载主题类的独立 PHP 进程跑,可能得到 __PHP_Incomplete_Class。稳妥做法是在 WordPress/Typecho 的上下文里跑(写成一个临时插件或者 php -r 里手动 require 主题类文件),或者干脆只替换字符串值里的域名、避开对象字段。

六、站内还有这些地方藏着旧域名

数据库改完之后,还要扫一遍磁盘上的文件,因为主题、插件、静态页面里都可能写死过域名:

grep -rn --include='*.php' --include='*.html' --include='*.js' --include='*.css' \
  'blog\.example\.com' /var/www/example/ \
  | grep -v '/cache/' | grep -v 'node_modules' | head -50

常见的命中位置:usr/themes/xxx/functions.php 里的 CDN 域名、config.inc.php 里的 __TYPECHO_SITE_URL__、robots.txt 里的 sitemap 地址、ads.txt、邮件通知模板、以及某些插件写死的回调 URL(OAuth 登录的回调地址尤其要注意,改了域名之后第三方平台的回调白名单也要同步更新,否则微信/QQ 登录直接报 redirect_uri 错误)。

config.inc.php 里的定义尤其容易被忽略,它优先于数据库里的 siteUrl:

define('__TYPECHO_SITE_URL__', 'https://www.example.com');

如果这里还是老域名,你改数据库也没用——所有生成的链接仍然是旧的,而且后台「设置」页里显示的地址会是灰的改不动,很多人会卡在这里很久。

七、SEO 收尾:把信号干净地交出去

迁移对 SEO 的影响,八成取决于这一步做得干不干净。清单:

  • 301 而不是 302。302 是临时跳转,不会传递权重。用 curl -sI 确认状态码:curl -sI https://blog.example.com/1700.html | head -3。
  • canonical 指向新域名。迁移后页面的 <link rel="canonical"> 必须是新地址,且老域名页面上也应该是新地址(因为老域名只有 301,没有实体页面,一般无所谓,但某些 CDN 会在跳转页里注入 canonical,要检查)。
  • 更新 sitemap 并重新提交。新域名下 /sitemap.xml 的 URL 全部替换,然后 GSC 里新增一个「域名资源」(Domain property),不要只在旧的 URL 前缀资源里改。百度的站长平台也要新建站点,旧的提交入口不会自动继承。
  • 保留老域的 sitemap 301。让 https://blog.example.com/sitemap.xml 也跳到新地址,帮助搜索引擎发现迁移。
  • 别同时改别的。迁移期间不要顺手改 URL 结构、不要一次性删几百篇文章、不要换主题。搜索引擎需要把「域名变了」和「结构变了」分开归因,混在一起做,流量下跌了你根本不知道是哪个原因,也没法回滚。

迁移后两周内,GSC 的「网页」报告里会看到老域名的索引量下降、新域名的上升,这是正常现象,两条曲线交叉大约需要 4 到 8 周。如果两个月后新域名的索引量还不到老域名的 60%,那说明有大批页面 301 没配对,去「已编入索引」里抽几个老 URL 手动 curl 验证跳转目标。

八、回滚方案:写下来,别只放在脑子里

迁移前我准备了回滚清单,虽然最后没用上,但它让我在整个过程中都很放松:

  1. DNS:www.example.com 的 A 记录改回原 IP,TTL 300,5 分钟生效。原始记录截图保存在笔记里。
  2. Nginx:保留 /etc/nginx/sites-available/example.conf.bak-20261019,回滚就是 cp 回来 + nginx -t && systemctl reload nginx。
  3. 数据库:迁移前的 mysqldump 全量包保留 30 天,文件名带日期,恢复命令写在包旁边的 README.txt 里。
  4. 证书:/etc/letsencrypt 整个目录 tar 备份一份,因为 --expand 改过 SAN,回滚时直接还原目录比重新签发快。

做完这一切,我最大的感受是:迁站的技术难点不在于任何单一步骤,而在于把所有步骤列全并排序。任何一步遗漏,都会在几天后以一个「莫名其妙」的现象浮现出来——图片裂了、登录失败了、RSS 里链接是旧的、某个页面 404。所以我在迁移前把上面这些点写成一张 checklist,一项一项打勾,做完后再花半小时把老域名上随机抽 20 个 URL 逐个 curl 验证状态码,这一轮抽查帮我抓到了两条漏配的跳转规则。

Last modification:October 19th, 2026 at 09:23 pm

Leave a Comment