一次迁移事故:文章都还在,但用户全被踢下了线
去年帮人把一个跑了三年的 Typecho 站从一台 1G 内存的旧 VPS 挪到新机器上,整个过程我自认为很标准:tar 打包网站目录,mysqldump 导出数据库,新机器上装好 PHP 和 Nginx,把域名解析改过去。前后不到四十分钟,页面打开正常,文章列表正常,图片正常,我以为收工了。
第二天朋友打电话来,说后台进不去了,登录之后跳回登录页,反复输密码也没用。这就是本文要讲的第一个坑,也是最容易被忽略的一个:网站迁移里最麻烦的从来不是文件,而是那些藏在数据库和配置文件里的、与服务器环境绑定的东西。
这篇文章按迁移的真实顺序走一遍:先备份、再在新机准备环境、然后迁移数据、最后处理五个「域名字段」和一批必须手工核对的项目。每一步都给出可执行的命令和验证方法。这篇文章写给一个人维护一台服务器的站长,不讲集群、不讲零停机,就把一台机器搬到另一台机器这件事做干净。
第一步:备份不是 cp,是三个独立的部分
很多人说的「备份网站」就是 cp -r /var/www /backup,这个动作只覆盖了文件,漏掉了数据库、cron 任务、证书和一些系统级配置。真正的迁移备份至少是四份东西,每份单独存、单独校验:
# 1) 网站文件(注意用 tar 保留权限和属主,别用 cp -r)
tar -czpf /backup/site-$(date +%F).tar.gz -C /var/www/html .
# 2) 数据库(单事务导出,保证一致性)
mysqldump -uroot -p --single-transaction --quick \
--default-character-set=utf8mb4 zz1984 \
> /backup/db-$(date +%F).sql
# 3) 配置文件(Nginx 站点配置、PHP 配置、SSL 证书、cron)
tar -czpf /backup/conf-$(date +%F).tar.gz \
/etc/nginx /etc/php /etc/letsencrypt /var/spool/cron 2>/dev/null
# 4) 环境清单(装了哪些包、哪些服务、哪些版本)
dpkg -l | awk '/^ii/{print $2"\t"$3}' > /backup/packages.txt
systemctl list-units --type=service --state=running --no-pager > /backup/services.txt
php -v > /backup/versions.txt; nginx -v 2>> /backup/versions.txt; mysql --version >> /backup/versions.txt第四份「环境清单」是这里面最容易被跳过、事后最救命的一份。迁移完发现某个功能不工作,第一反应往往是对比两边的软件版本。packages.txt 让你能一条 diff 看出新机上少了什么 PHP 扩展,不用凭记忆猜。
# 备份完立刻校验,别看文件存在就放心
ls -lh /backup/
gzip -t /backup/site-$(date +%F).tar.gz && echo "site ok"
tail -3 /backup/db-$(date +%F).sql # 结尾应该有 Dump completed
grep -c 'CREATE TABLE' /backup/db-*.sql # 表数量对不对,心里要有数mysqldump 结束时会输出一行 -- Dump completed on ...。如果没有这一行,说明导出中途断了,这个文件不能用。这是个十秒钟就能做的检查,我看过有人拿着一个截断的 SQL 文件导了半小时,最后报语法错误,白白浪费一晚上。
第二步:新机先准备环境,别等数据到了才发现缺东西
正确的顺序是:先在目标机器上把环境准备好、跑通一个静态页面,再把数据搬过来。反过来做的话,一旦出问题,你分不清是数据的问题还是环境的问题。
# 按 packages.txt 对齐版本,重点是 PHP 版本一致
# Typecho on PHP 5.6 这类老站,千万不能顺手装成 PHP 8
php -v # 旧机
php -v # 新机(必须与旧机同一大版本)
# 对比扩展列表,这是「页面空白但没报错」的头号原因
# 旧机执行
php -m | sort > /tmp/ext-old.txt
# 新机执行
php -m | sort > /tmp/ext-new.txt
diff /tmp/ext-old.txt /tmp/ext-new.txt
# 只在新机有:不用管
# 只在旧机有:必须装,否则依赖它的功能会静默失败PHP 扩展缺失的表现非常阴险。轻则某个函数报「Call to undefined function」,重则整个页面白屏、日志里什么也没有,因为显示错误被关掉了。所以迁移之后第一件事应该是打开临时错误日志:
# 在站点配置里临时打开错误日志(不要开 display_errors)
php_admin_flag display_errors off
php_admin_value log_errors on
php_admin_value error_log /var/log/php/site-error.log
# 确认目录可写
mkdir -p /var/log/php && chown www-data:www-data /var/log/php
tail -f /var/log/php/site-error.log另外,Nginx 的 server_name 也要在新机上先配好,用 curl -H 'Host: www.example.com' http://127.0.0.1/ 验证新机能正确响应,这一步在切 DNS 之前做,等于给自己留了一条随时能回退的后路。
第三步:两个虚拟主机的配置差异,往往藏在这里
「旧机能跑新机不能跑」的问题,八成出在 Nginx 配置的细节上。迁移前把旧机的全部 include 配置拉一遍,重点看这几项:
| 配置项 | 迁移时的坑 | 检查方式 |
|---|---|---|
root 路径 | 旧机 /home/wwwroot/site,新机 /var/www/site | nginx -T \| grep root |
fastcgi_pass | 用 unix socket 还是 127.0.0.1:9000 | ls -l /run/php/*.sock |
rewrite / try_files | 伪静态规则漏了,内页全部 404 | 点开三个内页看是否正常 |
| 文件权限 | 新机 www-data 的 UID 与旧机不同 | ps aux \| grep php-fpm 看实际用户 |
| 上传大小 | client_max_body_size 默认 1M | 后台上传一张 3M 图试试 |
文件和权限这一段要特别小心。用 tar 打包时如果没带 -p,属主和权限会丢;带上了 -p,解包后属主是数字 UID,如果新机上这个 UID 对应的用户不一样(比如旧机 www-data 是 33,新机也是 33,但有的发行版是 48),那么解出来的文件会归到一个陌生用户下,PHP 读写就失败了。检查方式很直接:
# 看看站点目录到底归谁
ls -ln /var/www/html | head -5 # -n 显示数字 UID
id www-data # 当前 www-data 的 UID
# 如果不一致,统一改回来(按目录和文件分别设权限)
chown -R www-data:www-data /var/www/html
find /var/www/html -type d -exec chmod 755 {} \;
find /var/www/html -type f -exec chmod 644 {} \;
# 只给必须可写的目录开写权限,别图省事 -R 777
chmod -R 775 /var/www/html/usr/uploads顺便说一句,chmod -R 777 是迁移后最常见的「修复」手段,也是最糟的一个。它确实能让问题消失,但同时把你站上所有文件变成了任何进程都能改。如果之后被挂马,攻击者可以改你的 PHP 文件。宁可多花十分钟定位到底是哪个目录需要写权限,也不要一键 777。
第四步:数据库迁移,字符集是最大的拦路虎
导出的 SQL 文件搬到新机之后,导入之前先做两件事:看字符集,看表数。
# 导入前
head -20 /backup/db-2026-11-11.sql | grep -i 'character_set\|SET NAMES'
grep -c 'CREATE TABLE' /backup/db-2026-11-11.sql
# 新机上如果还没建库,要先建对字符集
mysql -uroot -p -e "CREATE DATABASE zz1984 \
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 导入(加 --default-character-set 防止中文变问号)
mysql -uroot -p --default-character-set=utf8mb4 zz1984 \
< /backup/db-2026-11-11.sql
# 导入后立刻核对表数,必须与导出时一致
mysql -uroot -p zz1984 -e "SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='zz1984';"字符集问题的典型症状是:文章里的中文全变成了 ???? 或者一串乱码。原因通常是旧库是 utf8(MySQL 里的 utf8 其实是最多 3 字节的 utf8mb3),新机默认建库是 utf8mb4,导入时客户端的连接字符集没指定,两边理解不一致。判据是看一个具体的中文标题:
mysql -uroot -p zz1984 -e "SELECT cid,title FROM typecho_contents LIMIT 3;"
# 如果中文正常显示,但网页上是问号 → 连接字符集问题(配置项)
# 如果这里就已经是问号 → 数据在导入时损坏了(要从备份重导)这两者的修法完全不同。前者改 Nginx/PHP 的字符集配置就行,数据本身没坏;后者必须从备份重新导入,而且不要再尝试「修」,因为一个问号对应的原始字符已经丢失,修不回来。所以导入之后、切 DNS 之前,一定打开一篇带中文的文章看一眼。
第五步:五个「域名字段」,不改就等着掉登录
现在回答开头那个事故。Typecho、WordPress 这类程序都会把站点地址存进数据库,因为它需要知道自己的绝对路径来生成链接。换了服务器通常没问题,但换了域名(或者从 http 切到 https),这些字段里还是旧值,于是出现了「登录成功但立刻掉线」——因为 Cookie 是种在旧域名下的,或者跳转地址指向旧域名。
Typecho 的两个关键字段在 typecho_options 表里:
mysql -uroot -p zz1984 -e \
"SELECT name,value FROM typecho_options WHERE name IN ('siteUrl','theme');"
# 换域名时改 siteUrl(注意结尾不要带斜杠)
mysql -uroot -p zz1984 -e \
"UPDATE typecho_options SET value='https://www.example.com' WHERE name='siteUrl';"改完要清一次缓存(Typecho 没有对象缓存,但浏览器端的 Cookie 要清),然后用无痕窗口登录测试。除了 siteUrl,迁移时还要逐项核对这四类「域名字段」:
| 位置 | 内容 | 怎么找 |
|---|---|---|
| 数据库选项表 | siteUrl、首页地址 | SELECT * FROM typecho_options |
| 文章正文 | 图片和链接里的绝对地址 | UPDATE ... REPLACE(value,'old.com','new.com') |
| 配置文件 | 伪静态、Sitemap 里的域名 | grep -r 'old.com' /var/www/html |
| 第三方设置 | 统计、搜索、CDN 里登记的回调域名 | 逐个后台核对 |
# 文章正文里的绝对地址批量替换(先备份,先 SELECT 看影响行数)
mysql -uroot -p zz1984 -e "SELECT COUNT(*) FROM typecho_contents
WHERE text LIKE '%old.example.com%';"
mysql -uroot -p zz1984 -e "UPDATE typecho_contents
SET text = REPLACE(text,'old.example.com','new.example.com')
WHERE text LIKE '%old.example.com%';"
# 文件里残留的旧域名
grep -rn 'old\.example\.com' /var/www/html --include='*.php' --include='*.html' | head绝对地址的替换务必先 SELECT COUNT 看影响面。有次我直接跑 UPDATE,把一篇讲「怎么配置域名」的教程里的示例域名也替换了,文章里的代码示例变成了自己站的域名,读者照着敲会出问题。这类「文档里的示例」是替换的高危区,看到计数异常大时先抽查几篇。
第六步:切换前后,一份十项检查单
DNS 切换是不可逆的(有缓存,回退要等 TTL),所以在切换之前应该把新机验证到位。下面这份清单是我自己每次迁移都会过一遍的:
# 在新机上,指定 Host 头访问,模拟域名已切过来的效果
H=www.example.com
curl -sI -H "Host: $H" http://NEW_IP/ | head -1 # 200
curl -sI -H "Host: $H" http://NEW_IP/archives/1.html | head -1 # 内页不 404
curl -s -H "Host: $H" http://NEW_IP/ | grep -o '<title>.*</title>' # 标题正确
curl -sI -H "Host: $H" http://NEW_IP/sitemap.xml | head -1 # sitemap 可访问
curl -sI -H "Host: $H" http://NEW_IP/admin/login.php | head -1 # 后台可达
# 后台真正登录一次(用无痕窗口,走完登录 → 发文 → 退出全流程)- 首页、内页、搜索页、分类页、标签页各点一遍,中文显示正常
- 后台上传一张图片,确认能写入并显示
- 发一篇测试文章,确认能保存、能访问
- PHP 错误日志为空(或只有无害的 warning)
- Nginx error.log 里没有 file not found
- 数据库表数量与旧机一致
- cron 任务已迁移(
crontab -l对比两边) - SSL 证书已就位,
curl -vI https://... 2>&1 | grep expire看过期时间 - 定时备份脚本已改到新机的路径
- 旧机暂时不关机,保留至少一周作为回退
第 10 条值得强调。很多人切完 DNS 就顺手把旧机释放了,结果一周后发现某张图片 404、某个链接指向旧机、某个 CDN 回源还指着旧 IP。旧机保留一周的成本不过几块钱,但它是你唯一的退路。真要释放,先把它的磁盘做成镜像快照存着。
迁移后的收尾:三件小事,影响不小
切完之后还有三件事容易被忘。第一是搜索引擎:换 IP 对 SEO 影响很小,但如果换了域名,一定要在 Search Console 里做地址变更,并且把全站的 301 重定向配好(旧域名所有路径 → 新域名对应路径),这个 301 至少要保留一年。第二是监控:如果有 Uptime 检测或告警,检查目标地址是不是还指着旧 IP。第三是统计工具:看新机上线的头两天,访问量是不是突然掉了一半——那通常不是流量丢了,而是统计代码里的某个域名还指着旧站。
最后回到那个掉登录的事故。真正的原因是他换了域名,siteUrl 还停在旧域名上,登录后跳转的地址是旧域名,而旧域名已经解析到新机、Cookie 域不匹配,于是循环跳回登录页。一条 UPDATE 就解决了。但这个坑绕了整整一天,因为当时的思路是「数据库肯定没问题,我都是原样导的」——数据确实没问题,有问题的是数据里那个指向旧世界的地址。迁移的本质不是搬文件,而是把一台机器的身份完整地复刻到另一台上,凡是记录了这个身份的地方,都要一起搬。