把 Typecho 搬到新服务器:数据库、附件、伪静态、定时任务一份不漏

把 Typecho 搬到新服务器:数据库、附件、伪静态、定时任务一份不漏

234
234
2026-10-24 / 0 评论 / 24 阅读 / 10340 字 / 正在检测是否收录...
这篇是自建服务笔记。站内的加密 DNS 服务在dns.23-4.cn。

给自己的博客换服务器,看起来很简单的四步:装环境、传文件、导数据库、改配置。但真正做过的人都知道,最容易漏的是第四步之后的东西:

  • 附件目录权限不对,图片全裂;
  • 伪静态规则没带过去,除了首页全是 404;
  • 定时任务没迁,备份悄悄停了两个月;
  • 数据库导入了但 auto_increment 丢了,新文章 ID 冲突。

这篇是按「漏一次就疼一次」的顺序整理的迁移清单,每一节都有可复制的命令和验证方法。以 Typecho + nginx + PHP + SQLite 为例,MySQL 的差异会单独标出。

一、先记录旧机器的完整状态

⛔ 别急着动手传文件。 先在旧机器上把状态记录下来,后面每一步都要拿它对照。

# 1. 系统与版本
cat /etc/os-release | head -3
uname -r

# 2. PHP 版本与模块
php -v | head -1
php -m | tr '\n' ' '

# 3. nginx 版本与配置结构
nginx -v
nginx -T 2>/dev/null | grep -c "^# configuration file"

# 4. 数据库类型与位置(Typecho 的配置文件里就有)
grep -E "Db\(|'file'|'database'|'host'" /var/www/typecho/config.inc.php

# 5. 站点根目录大小与文件数
du -sh /var/www/typecho
find /var/www/typecho -type f | wc -l

# 6. 定时任务
crontab -l
ls -la /etc/cron.d/ 2>/dev/null

# 7. 所有 systemd 服务单元(非系统自带的)
systemctl list-unit-files --state=enabled --no-pager | grep -vE "^(systemd|dbus|getty|cron|ssh|rsyslog|networkd|resolved|udev|apparmor|unattended|snap)" | head -20

把这份输出存成文件,这是你的迁移基线:

{
  echo "=== OS ==="; cat /etc/os-release | head -3; uname -r
  echo "=== PHP ==="; php -v | head -1
  echo "=== nginx ==="; nginx -v 2>&1
  echo "=== files ==="; find /var/www/typecho -type f | wc -l
  echo "=== cron ==="; crontab -l 2>/dev/null
} > /root/migrate-baseline.txt 2>&1

二、备份数据库:别用 cp

这一步是全文最重要的技术点。

为什么不能直接 cp 数据库文件

SQLite 的默认日志模式是 journal_mode=delete(回滚日志)。在有写入进行中的时候,数据可能分散在三个文件里:

  • xxx.db:主文件;
  • xxx.db-journal:回滚日志(delete 模式);
  • xxx.db-wal + xxx.db-shm:WAL 模式(如果开了)。

先看你的库是什么模式:

python3 - <<'EOF'
import sqlite3
c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
for k in ('journal_mode', 'page_size', 'page_count', 'freelist_count'):
    print(k, '=', c.execute('pragma ' + k).fetchone()[0])
EOF

实测我这台机器(注意 SQLite 3.31.1 的 3.31.1 版本号被 python 报出来):

journal_mode       = delete
page_size          = 4096
page_count         = 151
freelist_count     = 2

delete 模式下没有 -wal 文件,所以 cp 看起来"没问题"。但如果备份的那一瞬间正好有事务在写,cp 出来的副本可能包含半提交的页——打开时可能报 database disk image is malformed,也可能静默丢掉最后几次写入。

判断风险的方法:看有没有 -wal / -journal 文件在存在。

ls -la /var/lib/typecho/ | grep -E "wal|shm|journal" || echo "当前无日志文件(但仍不能保证 cp 安全)"

实测这台机器:三个都没有。但这只是"此刻没有",不代表 cp 的那一瞬间没有。

三种备份方式的实测对比

我在这台机器上把三种方式都跑了一遍,产物大小和耗时:

方式产物大小耗时能否在线做
cp618496 B1.8 ms⚠️ 有写入时不安全
.backup() API618496 B13.1 ms✅ 安全
VACUUM INTO585728 B6.2 ms✅ 安全,且会整理碎片

三种产物我都验证了:tables=7 rows=55 integrity_check=ok,完全一致。

推荐 .backup():

python3 - <<'EOF'
import sqlite3, time
src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
dst = sqlite3.connect('/root/backup-%s.db' % time.strftime('%Y%m%d-%H%M%S'))
src.backup(dst)      # 内部按页复制,并且会重试被写入的页
dst.close(); src.close()
print('备份完成')
EOF

Connection.backup() 的好处:它在复制过程中如果遇到正在被修改的页,会自动重试,所以能拿到一致快照。

VACUUM INTO 是另一个好选择(SQLite ≥ 3.27):

python3 - <<'EOF'
import sqlite3
c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
c.execute("vacuum into '/root/backup-vacuum.db'")
c.close()
print('VACUUM INTO 完成')
EOF

它的额外好处:产物是整理过的(585728 B vs 618496 B,小了 32 KB,因为去掉了 freelist_count=2 的空闲页)。适合长期归档。

⛔ 不要用 sqlite3 .dump——不是不好,而是这台机器根本没装 sqlite3 命令行:

command -v sqlite3 || echo "sqlite3 CLI NOT FOUND"

实测:

sqlite3 CLI NOT FOUND

这是很多精简系统的常态。用 Python 的 sqlite3 模块代替,功能完全够,而且不依赖额外安装。

MySQL 的差异

如果你用的是 MySQL 而不是 SQLite:

# 备份(--single-transaction 保证一致性,不停机)
mysqldump --single-transaction --routines --triggers \
  -u root -p typecho > /root/typecho-$(date +%F).sql

# 恢复
mysql -u root -p typecho < /root/typecho-2026-10-24.sql

--single-transaction 是关键——它在一个事务里导出,保证数据一致,不需要锁表。

三、传文件:用 rsync,别用 scp

# 在旧机器上执行,推到新机器
rsync -avz --progress \
  --exclude 'usr/uploads/cache/*' \
  --exclude '*.log' \
  /var/www/typecho/ root@NEW_IP:/var/www/typecho/

rsync 相比 scp 的价值:可断点续传、可增量、可排除。几百 MB 的附件目录用 scp 传到一半断了要重来,rsync 直接续。

踩坑点 1:附件目录权限

传完之后,附件目录的属主往往是错的(rsync 默认保留 uid/gid,但新机器上可能没有同样的用户)。

# 新机器上
chown -R www-data:www-data /var/www/typecho
find /var/www/typecho -type d -exec chmod 755 {} \;
find /var/www/typecho -type f -exec chmod 644 {} \;

# 只有这个目录需要写权限
chmod -R 775 /var/www/typecho/usr/uploads

别偷懒 chmod -R 777。那会让 PHP 能写任何文件,包括 config.inc.php——一个上传漏洞就能改你的数据库配置。

踩坑点 2:config.inc.php 不要覆盖

如果新机器上已经装过一份 Typecho(哪怕是为了拿目录结构),rsync 会把它的 config.inc.php 覆盖掉。而那个文件里的数据库路径、表前缀是新机器的,不该被旧机器的覆盖。

# 正确做法:排除它,单独处理
rsync -avz --exclude 'config.inc.php' /var/www/typecho/ root@NEW:/var/www/typecho/
# 然后在新机器上手工确认 config.inc.php 指向正确的库

踩坑点 3:验证文件数一致

# 旧机器
find /var/www/typecho -type f | wc -l
# 新机器
find /var/www/typecho -type f | wc -l

两个数字必须一样。不一样就说明有文件没传过去,或者被 exclude 掉了不该 exclude 的东西。

四、伪静态:这是 404 的头号原因

文件传完、数据库导完,打开首页正常,点进文章 404——九成是伪静态规则没迁。

nginx 的 try_files

Typecho 需要把不存在的路径交给 index.php:

server {
    listen 443 ssl http2;
    server_name www.example.com;
    root /var/www/typecho;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php$uri?$query_string;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php83-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    # 静态文件直接返回,不经过 PHP
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        expires 7d;
        access_log off;
    }
}

注意 /index.php$uri?$query_string 里的 $uri——这个不能漏。

对比两个写法:

try_files $uri $uri/ /index.php?$query_string;        # ⚠️ 丢了路径
try_files $uri $uri/ /index.php$uri?$query_string;    # ✅ 带上路径

第一种把请求全部改写成「index.php 加查询串」,路径信息丢了。Typecho 靠 PATH_INFO 风格的路由来判断「你在访问哪篇文章」,路径没了就等于告诉它「你在访问首页」——于是所有文章页都返回首页内容,状态码还是 200。

这种故障比 404 更难查:状态码是 200,页面能打开,只是内容不对。

验证伪静态的三条命令

# 1. 首页
curl -sS -o /dev/null -w "home=%{http_code}\n" https://www.example.com/

# 2. 文章页(这条最容易出问题)
curl -sS -o /dev/null -w "post=%{http_code}\n" https://www.example.com/archives/1/

# 3. 分类页
curl -sS -o /dev/null -w "cat=%{http_code}\n" https://www.example.com/category/default/

三条全 200 才算伪静态通了。只测首页是不够的——首页是 index.php 直接命中,根本用不到 try_files。

而且光看状态码也不够,要确认返回的是正确的文章,不是首页顶替的:

# 用标题做区分:文章页的 <title> 应该包含文章标题,不是站点名
curl -sS https://www.example.com/archives/1/ | grep -o "<title>[^<]*</title>"

如果文章页返回的 <title> 是站点名而不是文章名,就是 $uri 丢了。

踩坑点 4:fastcgi_pass 的 socket 路径

Ubuntu 上 PHP-FPM 的 socket 路径带版本号。实测这台机器:

/etc/nginx/conf.d/typecho.conf:895:  fastcgi_pass unix:/run/php/php83-fpm.sock;

而实际监听的 socket:

srw-rw---- 1 www-data www-data 0 Oct 24 00:20 /run/php/php83-fpm.sock
lrwxrwxrwx 1 root     root     30 Oct 11 10:58 /run/php/php-fpm.sock -> /etc/alternatives/php-fpm.sock

注意有个 php-fpm.sock 是软链接(指向 alternatives)。所以两个路径可能都能用——但如果 alternatives 指向了别的版本,软链接就会指错。

从 PHP 8.1 换到 8.3 时,这个路径会变,而 nginx 配置里写死了旧路径:

# 看实际在监听的 socket
ls -la /run/php/*.sock

如果 nginx 日志里有 connect() failed (2: No such file or directory) while connecting to upstream,就是这个原因。

五、定时任务:最容易被忘的一步

# 旧机器
crontab -l > /root/crontab-backup.txt
cat /root/crontab-backup.txt

新机器上恢复:

crontab /root/crontab-backup.txt
crontab -l   # 确认

踩坑点 5:cron.d 里的任务不会跟着 crontab -l 走

ls -la /etc/cron.d/ /etc/cron.daily/ 2>/dev/null

这些目录里的任务要单独复制。很多人只迁了用户 crontab,忘了系统级的。

验证定时任务真的在跑(不只是"配置存在"):

# 方式 1:看 cron 日志
grep CRON /var/log/syslog | tail -10

# 方式 2:让任务写个时间戳,等它执行后检查
# 在 crontab 里临时加一行:
# * * * * * date >> /tmp/cron-alive.txt
sleep 70 && cat /tmp/cron-alive.txt

配置存在 ≠ 任务在跑。 上面第二种方式才能真正验证。

六、迁移后必做的 8 项验证

SITE=https://www.example.com

echo "--- 1. 首页 ---"
curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" $SITE

echo "--- 2. 文章页(伪静态)---"
curl -sS -o /dev/null -w "%{http_code}\n" $SITE/archives/1/

echo "--- 3. 后台登录页 ---"
curl -sS -o /dev/null -w "%{http_code}\n" $SITE/admin/login.php

echo "--- 4. RSS ---"
curl -sS -o /dev/null -w "%{http_code} %{content_type}\n" $SITE/feed/

echo "--- 5. sitemap ---"
curl -sS -o /dev/null -w "%{http_code}\n" $SITE/sitemap.xml

echo "--- 6. 随便一个附件 ---"
curl -sS -o /dev/null -w "%{http_code} %{size_download}\n" $SITE/usr/uploads/$(ls /var/www/typecho/usr/uploads | head -1)

echo "--- 7. 数据库能读 ---"
python3 -c "import sqlite3;c=sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro',uri=True);print('posts=',c.execute(\"select count(*) from typecho_contents where type='post' and status='publish'\").fetchone()[0])"

echo "--- 8. 写入能落库(发一条测试评论或改一篇文章)---"
# 这条必须真做一次,只读测试证明不了写权限

第 8 项特别重要。只读验证(前 7 项)全部通过,不代表写入正常。 数据库文件权限、目录权限、SQLite 的临时目录,这些只在写的时候才暴露。

最简单的写入测试:

# 用后台改一篇文章的标题,点保存,然后看数据库
python3 -c "
import sqlite3
c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
print(c.execute('select cid,modified from typecho_contents order by modified desc limit 1').fetchone())
"

modified 应该是刚才那一刻。

七、切换 DNS 之前的最后一件事

用 --resolve 先在新机器上验证,别直接改 DNS。

# 让 curl 连到新 IP,但用原来的域名(SNI 和 Host 头都不变)
curl -sS -o /dev/null -w "new-server=%{http_code}\n" \
  --resolve www.example.com:443:NEW_IP \
  https://www.example.com/

返回 200 就说明新机器能正常响应。返回证书错误,说明新机器上的证书没装好;返回 301/404,说明 server_name 或 try_files 有问题。

这一条能避免一次线上故障:改 DNS 是有缓存和传播时间的,如果新机器其实没准备好就切了,你会有一段「部分用户访问失败」的窗口,而且回滚也要等传播。

八、迁移清单(打印出来照做)

□ 旧机器:记录 OS / PHP / nginx 版本
□ 旧机器:备份数据库(用 .backup() 或 VACUUM INTO,不用 cp)
□ 旧机器:导出 crontab 和 /etc/cron.d/
□ 传输:rsync(排除 config.inc.php / *.log / cache)
□ 新机器:chown www-data + 正确的目录权限(不是 777)
□ 新机器:确认 config.inc.php 的数据库路径
□ 新机器:伪静态规则(try_files)
□ 新机器:确认 fastcgi_pass 的 socket 路径
□ 新机器:恢复 crontab
□ 验证:首页 / 文章页 / 后台 / RSS / sitemap / 附件 / 读库 / 写库
□ 切换:先用 --resolve 验证,再改 DNS
□ 切换后:观察 1 小时,确认错误日志无新增

相关阅读

0个赞
取消