SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO

SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO

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

用 SQLite 做博客数据库,最爽的是「整个数据库就是一个文件」——备份应该很简单吧?

cp blog.db backup.db,完事。这个动作在大多数时候是对的,在少数时候会给你一个打不开的副本。

而且更麻烦的是:SQLite 有几个不同的备份接口,.backup、VACUUM INTO、.dump、cp,网上教程混着讲,但它们的一致性保证、产物大小、适用场景完全不同。

这篇把三种可用方式都实测一遍,给出选择依据。所有数字都是我在一台 Ubuntu 20.04(SQLite 3.31.1)上实际跑出来的。

一、先看你的库现在是什么状态

先查四个关键 PRAGMA:

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',
          'auto_vacuum', 'synchronous', 'encoding'):
    print('%-16s = %s' % (k, c.execute('pragma ' + k).fetchone()[0]))
EOF

实测这台机器:

journal_mode     = delete
page_size        = 4096
page_count       = 151
freelist_count   = 2
auto_vacuum      = 0
synchronous      = 2
encoding         = UTF-8

逐个解释:

PRAGMA值含义
journal_modedelete回滚日志模式。有写入时会有 -journal 临时文件
page_size4096每页 4 KB,SQLite 默认值
page_count151共 151 页 → 151 × 4096 = 618,496 B
freelist_count2有 2 页是空闲的(删过数据留下的洞)
auto_vacuum0关闭自动整理,文件只增不减
synchronous2FULL,最安全的写入模式(性能最低)
encodingUTF-8文本编码

验算文件大小:

python3 - <<'EOF'
import sqlite3, os
DB = '/var/lib/typecho/6abdb5f537ed4.db'
c = sqlite3.connect('file:%s?mode=ro' % DB, uri=True)
pc = c.execute('pragma page_count').fetchone()[0]
ps = c.execute('pragma page_size').fetchone()[0]
sz = os.path.getsize(DB)
print('page_count × page_size = %d B' % (pc * ps))
print('实际文件大小           = %d B' % sz)
print('差值                   = %d B' % (sz - pc * ps))
EOF

实测:

page_count × page_size = 618496 B
实际文件大小           = 618496 B
差值                   = 0 B

差值为 0,说明文件里没有浪费的空间(除了那 2 个 freelist 页,它们也算在 page_count 里)。

关键:看有没有 -wal / -shm / -journal

ls -la /var/lib/typecho/ | grep -E "wal|shm|journal" || echo "当前无日志文件"

实测这台机器:

-wal       不存在
-shm       不存在
-journal   不存在

三个都没有。但这只代表"此刻没有",不代表 cp 的那一瞬间没有。

二、为什么 cp 在多数时候能用,在少数时候会坏

journal_mode=delete 时

写入流程是:

  1. 把要被修改的原始页复制到 xxx.db-journal;
  2. 修改 xxx.db 里的页;
  3. 提交成功 → 删除 -journal;
  4. 如果中途崩溃 → 下次打开时用 -journal 回滚。

如果你在第 2 步和第 3 步之间 cp,得到的副本可能是「一半新一半旧」的状态——文件头的页数、freelist 链表、索引都可能是矛盾的。

后果有两种:

  • 打开时立刻报 database disk image is malformed(好的情况,你知道坏了);
  • 打开成功,但某些表的数据不对/缺行(坏的情况,你可能几个月后才发现)。

journal_mode=WAL 时

WAL 模式下就更危险了:最近的写入根本不在 xxx.db 里,而在 xxx.db-wal 里。

只 cp 主文件,等于丢掉了所有还没 checkpoint 的写入。

# 如果你的库是 WAL,必须先看有没有 -wal
ls -la /var/lib/typecho/*.db-wal 2>/dev/null

判断你的库能不能安全 cp 的完整条件:

journal_mode = delete
且 此刻没有 -journal 文件
且 cp 的那一瞬间没有事务在写

第三个条件你无法在 shell 里保证。所以严谨的结论是:

cp 适合"停机后备份"。只要服务还在跑,就该用 .backup() 或 VACUUM INTO。

三、三种方式的实测对比

我在这台机器上把三种都跑了一遍(服务仍在运行,未停机):

方式 1:cp

cp /var/lib/typecho/6abdb5f537ed4.db /tmp/bak_cp.db

方式 2:Connection.backup()

python3 - <<'EOF'
import sqlite3
src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
dst = sqlite3.connect('/tmp/bak_backup.db')
src.backup(dst)
dst.close(); src.close()
EOF

方式 3:VACUUM INTO

python3 - <<'EOF'
import sqlite3
c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
c.execute("vacuum into '/tmp/bak_vacuum.db'")
c.close()
EOF

实测结果

方式产物大小耗时一致性保证
cp618,496 B1.8 ms❌ 无(依赖时机)
.backup()618,496 B13.1 ms✅ 有(遇写入自动重试)
VACUUM INTO585,728 B6.2 ms✅ 有(事务快照)

三种产物我都验证过:

python3 - <<'EOF'
import sqlite3
for name, f in (('cp', '/tmp/bak_cp.db'),
                ('.backup()', '/tmp/bak_backup.db'),
                ('VACUUM INTO', '/tmp/bak_vacuum.db')):
    c = sqlite3.connect('file:%s?mode=ro' % f, uri=True)
    n = c.execute("select count(*) from sqlite_master where type='table'").fetchone()[0]
    rows = c.execute("select count(*) from typecho_contents").fetchone()[0]
    ok = c.execute('pragma integrity_check').fetchone()[0]
    print('%-14s tables=%-3d rows=%-4d integrity=%s' % (name, n, rows, ok))
    c.close()
EOF

实测:

cp             tables=7   rows=55   integrity=ok
.backup()      tables=7   rows=55   integrity=ok
VACUUM INTO    tables=7   rows=55   integrity=ok

这一次 cp 也没坏——因为跑测试的时候没有并发写入。这正是问题所在:cp 的失败是概率性的,你没法靠"试了几次都好"来判断它安全。

注意 VACUUM INTO 的产物小了 32,768 B(正好 8 个页)。因为它把 freelist_count=2 的空闲页和页内碎片整理掉了。

四、该选哪个

日常备份(服务不停)→ .backup()

#!/bin/bash
# /root/backup-blog.sh
DB=/var/lib/typecho/6abdb5f537ed4.db
DEST=/root/backups
mkdir -p "$DEST"
TS=$(date +%Y%m%d-%H%M%S)

python3 - "$DB" "$DEST/blog-$TS.db" <<'EOF'
import sqlite3, sys
src, dst = sys.argv[1], sys.argv[2]
s = sqlite3.connect('file:%s?mode=ro' % src, uri=True)
d = sqlite3.connect(dst)
s.backup(d)
d.close(); s.close()
print('备份完成:', dst)
EOF

# 只保留最近 14 份
ls -1t "$DEST"/blog-*.db | tail -n +15 | xargs -r rm -f

优点:安全、产物和原库同大小、恢复后行为和原库完全一致。
缺点:碎片不整理,文件大小只增不减。

长期归档 / 想压缩体积 → VACUUM INTO

python3 - <<'EOF'
import sqlite3
c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
c.execute("vacuum into '/root/backups/archive-2026-10.db'")
c.close()
EOF

优点:产物小、顺带整理碎片、天然是一致快照。
缺点:需要 SQLite ≥ 3.27(先查版本)、耗时略长、需要额外磁盘空间(原库 + 产物同时存在)。

需要跨版本迁移 / 人工查看 → .dump

# 注意:需要 sqlite3 命令行
sqlite3 /var/lib/typecho/6abdb5f537ed4.db .dump > backup.sql

但先确认它存在:

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

实测这台机器:

sqlite3 CLI NOT FOUND

没有。 这是精简系统的常态(sqlite3 命令行不在默认安装里)。

替代方案——用 Python 导出 SQL:

python3 - <<'EOF'
import sqlite3
src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True)
with open('/root/backup.sql', 'w', encoding='utf-8') as f:
    for line in src.iterdump():
        f.write(line + '\n')
src.close()
print('导出完成')
EOF

iterdump() 是 sqlite3 模块的标准方法,和命令行的 .dump 等价。

⛔ 别用 .dump 做日常备份:它导出的是 SQL 文本,恢复要重新执行所有语句,慢得多;而且大库的 SQL 文件可能比 db 文件大好几倍。

五、恢复怎么做

从 .backup() 或 VACUUM INTO 的产物恢复

直接替换文件就行——它们就是完整的数据库文件:

# 1. 停服务(避免写到旧库)
systemctl stop php8.3-fpm

# 2. 备份当前(坏掉的)库,别直接删
mv /var/lib/typecho/6abdb5f537ed4.db /root/broken-$(date +%s).db

# 3. 复制回去
cp /root/backups/blog-20261024-010000.db /var/lib/typecho/6abdb5f537ed4.db

# 4. 修权限(这一步最容易漏)
chown www-data:www-data /var/lib/typecho/6abdb5f537ed4.db
chmod 640 /var/lib/typecho/6abdb5f537ed4.db

# 5. 验证
python3 -c "import sqlite3;c=sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro',uri=True);print(c.execute('pragma integrity_check').fetchone()[0])"

# 6. 起服务
systemctl start php8.3-fpm

踩坑点:权限。cp 出来的文件属主是执行 cp 的用户(通常是 root),而 PHP-FPM 以 www-data 运行。不改权限的话,站点会报 unable to open database file——而这个报错看起来像"数据库损坏",很容易往错误方向排查。

原库的权限供参考:

ls -la /var/lib/typecho/

实测:

-rw-r----- 1 www-data www-data 606208 Oct 24 00:32 6abdb5f537ed4.db

640 + www-data:www-data。

从 .dump 的 SQL 恢复

python3 - <<'EOF'
import sqlite3
conn = sqlite3.connect('/var/lib/typecho/6abdb5f537ed4.db')
with open('/root/backup.sql', encoding='utf-8') as f:
    conn.executescript(f.read())
conn.commit(); conn.close()
print('恢复完成')
EOF

六、怎么验证备份真的可用

最容易被忽略的一步。 备份文件存在 ≠ 备份可用。

# 1. 完整性检查(每次都做)
python3 -c "
import sqlite3
c = sqlite3.connect('file:/root/backups/blog-latest.db?mode=ro', uri=True)
print('integrity:', c.execute('pragma integrity_check').fetchone()[0])
print('quick:    ', c.execute('pragma quick_check').fetchone()[0])
"

# 2. 对比行数(备份必须和源库一致)
python3 - <<'EOF'
import sqlite3
def counts(path):
    c = sqlite3.connect('file:%s?mode=ro' % path, uri=True)
    r = {}
    for (t,) in c.execute("select name from sqlite_master where type='table'"):
        r[t] = c.execute('select count(*) from "%s"' % t).fetchone()[0]
    c.close()
    return r

src = counts('/var/lib/typecho/6abdb5f537ed4.db')
bak = counts('/root/backups/blog-latest.db')
same = True
for t in src:
    if src[t] != bak.get(t):
        print('!! %s 行数不一致: 源=%d 备份=%s' % (t, src[t], bak.get(t)))
        same = False
print('所有表行数一致' if same else '存在差异')
EOF

第 3 步最重要:真的从备份启动一次。

# 在测试端口上起一个临时实例,指向备份文件
# 或者更简单:临时把站点指向备份,看能不能打开

如果嫌麻烦,至少做这一条——用备份文件跑一次 Typecho 的查询:

python3 - <<'EOF'
import sqlite3
c = sqlite3.connect('file:/root/backups/blog-latest.db?mode=ro', uri=True)
print('已发布文章数 =', c.execute(
    "select count(*) from typecho_contents where type='post' and status='publish'").fetchone()[0])
print('最新一篇     =', c.execute(
    "select cid, substr(title,1,30) from typecho_contents where type='post' order by cid desc limit 1").fetchone())
EOF

能查出正确的结果,才算备份可用。

七、自动化:加进 crontab 并验证它在跑

# 每天凌晨 3:17 备份(避开整点)
crontab -e
17 3 * * * /bin/bash /root/backup-blog.sh >> /root/backup.log 2>&1

验证它在跑(不是"配置存在"):

# 看日志有没有新记录
tail -5 /root/backup.log

# 看产物有没有按日期增加
ls -la /root/backups/ | tail -5

踩坑点:cron 里的环境变量和交互式 shell 不同。

  • PATH 通常只有 /usr/bin:/bin,你的 python3 如果在 /usr/local/bin 就找不到;
  • HOME 可能是空的。

所以脚本里一律写绝对路径:

#!/bin/bash
PY=/usr/bin/python3        # ← 写死,别依赖 PATH
DB=/var/lib/typecho/6abdb5f537ed4.db

查一下你的实际路径:

command -v python3

八、一页速查

需求用什么命令
服务不停,日常备份.backup()src.backup(dst)
想压缩 + 一致快照VACUUM INTOc.execute("vacuum into '...'")
停机后备份cp 也行cp db db.bak
跨版本迁移.dump / iterdump()for line in src.iterdump()
验证备份可用integrity_check + 行数对比 + 真查一次见第六节

一句话结论:服务在跑就用 .backup(),别用 cp。 那 11 毫秒的额外耗时,换的是「备份一定可恢复」。

相关阅读

0个赞
取消