首页
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
搜索
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
登录
搜索
23-4.CN
累计撰写
59
篇文章
累计收到
3
条评论
首页
分类
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
页面
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
注册丨登录
搜索到
2
篇
标签为
自动化
的文章
2026-10-24
SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO
这篇是自建服务笔记。站内的加密 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 Bfreelist_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 / -journalls -la /var/lib/typecho/ | grep -E "wal|shm|journal" || echo "当前无日志文件"实测这台机器:-wal 不存在 -shm 不存在 -journal 不存在三个都没有。但这只代表"此刻没有",不代表 cp 的那一瞬间没有。二、为什么 cp 在多数时候能用,在少数时候会坏journal_mode=delete 时写入流程是:把要被修改的原始页复制到 xxx.db-journal;修改 xxx.db 里的页;提交成功 → 删除 -journal;如果中途崩溃 → 下次打开时用 -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:cpcp /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 INTOpython3 - <<'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 INTOpython3 - <<'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('导出完成') EOFiterdump() 是 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.db640 + 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 -e17 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 毫秒的额外耗时,换的是「备份一定可恢复」。相关阅读把 Typecho 搬到新服务器:数据库、附件、伪静态、定时任务一份不漏服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLite
2026年10月24日
31 阅读
0 评论
0 点赞
2026-10-04
不装 Puppeteer:用 Node 内置 WebSocket 直接驱动 Chrome DevTools 协议
这篇是开发工具笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。要做浏览器自动化,第一反应通常是装 Puppeteer 或 Playwright。但如果你的需求只是「打开页面、等它渲染完、取几个值、点几下」,那 Node 24 已经内置了需要的一切——全局 WebSocket 和全局 fetch,加起来不到 120 行就能驱动 Chrome DevTools 协议(CDP)。这篇是我自己写回归脚本时踩出来的完整做法,含几个会让人白白调试半小时的坑。一、先启动一个带调试端口的 Chromechrome --headless=new --remote-debugging-port=9357 \ --user-data-dir=/tmp/cdp-profile \ --no-first-run --disable-gpu \ --window-size=1440,900 about:blank关键点:--remote-debugging-port 是唯一入口,之后所有操作都通过 CDP 走 WebSocket。--user-data-dir 单独给一个目录,避免污染你日常的浏览器配置。二、开一个新标签页,拿到它的 WebSocket 地址const CDP_PORT = 9357; async function newTab() { // 注意:必须是 PUT,GET 在较新的 Chrome 上已不再创建标签页 const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json/new?about:blank`, { method: 'PUT', }); return r.json(); // { id, webSocketDebuggerUrl, ... } }三、一个最小可用的 CDP 会话封装CDP 是「发一条带 id 的消息,等一条同 id 的回复」,另外还会不停推事件。把它包成 Promise 就够了:async function attach(wsUrl) { const ws = new WebSocket(wsUrl); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); let id = 0; const pending = new Map(); const events = []; ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { // 这是某条命令的回复 const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); } else if (m.method) { // 这是事件推送 events.push(m); } }; const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); setTimeout(() => { // 一定要有超时,否则一条丢失的回复会永久挂住 if (pending.has(i)) { pending.delete(i); rej(new Error('timeout ' + method)); } }, 40000); }); const evaluate = async (expression) => { const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, }); if (r.exceptionDetails) { throw new Error('eval: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text)); } return r.result.value; // returnByValue 时结果在这里 }; return { send, evaluate, events, close: () => ws.close() }; }四、打开页面并「确认它真的渲染了」const s = await attach((await newTab()).webSocketDebuggerUrl); await s.send('Page.enable'); await s.send('Runtime.enable'); await s.send('Log.enable'); // 收集 console 错误 await s.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true, }); await s.send('Page.navigate', { url: 'https://example.com' }); // 等 load for (let i = 0; i < 120; i++) { await new Promise(r => setTimeout(r, 250)); if (await s.evaluate('document.readyState').catch(() => null) === 'complete') break; }到这里就有一个必须做的动作:// 先断言页面确实渲染了,再断言别的东西 const rendered = await s.evaluate(`({ title: document.title, bodyLen: document.body ? document.body.innerHTML.length : 0, items: document.querySelectorAll('.some-selector').length, })`); if (!rendered.bodyLen) throw new Error('页面根本没渲染,后面的断言全是假的');为什么必须先做这一步:如果导航其实没成功(比如 URL 写错了),document 还是 about:blank。此时 document.querySelectorAll(...).length 会老老实实返回 0,而你如果断言的是「不应该有某个元素」,它会通过——测试全绿,功能全坏。这种「空断言」是最难发现的假阳性。五、五个会浪费你半小时的坑1. requestAnimationFrame 在无头模式下不会按你预期触发。没有合成器时,rAF 可能永远不触发,或者只触发一两次。用 setTimeout 代替所有等待:const sleep = (ms) => new Promise(r => setTimeout(r, ms)); await sleep(2500); // 让 JS 跑完2. 不要把你的变量命名成 URL。URL 是 Node 的全局 WHATWG URL 类。写 const URL = 'https://...' 之后,某些库内部 new URL(...) 会抛错或被静默吞掉,导航「看起来成功了」但实际去了 about:blank——于是所有断言真空通过。这是个非常隐蔽的坑,换个名字就好。3. 每个视口开一个新标签页。Emulation.setDeviceMetricsOverride 改尺寸之后,页面里的媒体查询、懒加载、以及已经算好的布局不会自动全部重算。做「桌面 / 移动」两套验证时,开新标签页重新加载,别在同一页上反复改尺寸。4. 用 process.exitCode,不要 process.exit()。process.exit() 会立刻切断进程,WebSocket 里的响应还没读完,输出可能被截断。设置 process.exitCode = 1 然后自然退出。5. 收集 console 错误。Runtime.exceptionThrown 给的是未捕获异常,Log.entryAdded 给的是 console 输出。两个都开,才能看到「页面加载时的报错」:const errors = s.events .filter(e => e.method === 'Runtime.exceptionThrown' || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error')) .map(e => e.method === 'Runtime.exceptionThrown' ? 'EXC: ' + (e.params.exceptionDetails?.exception?.description || '') : 'LOG: ' + e.params.entry.text);六、和 Puppeteer 比,值得吗 原生 CDPPuppeteer / Playwright依赖0,Node 内置需要下载浏览器 + 一堆依赖体积一个脚本文件几百 MB上手成本需要理解协议API 友好能力上限协议本身,最全封装了常用部分,偶尔要 evaluate 绕回 CDP适合场景固定环境的回归脚本、CI 里的定点验证复杂交互、爬虫、端到端测试如果你只是要跑几个固定页面的定点验证(比如「这个链接是不是 200」「这个元素在移动端有没有被遮挡」),原生 CDP 更省事,而且不会因为浏览器版本升级而整体失效——你依赖的是协议,不是封装层。相关阅读在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLite 的完整清单服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序
2026年10月04日
26 阅读
0 评论
0 点赞