我那个采集器跑了三个月,URL 表涨到四百万行,库文件 300M 左右。后来源表规则改了,历史数据不再需要,我写了一条 DELETE FROM urls WHERE fetched_at < ? 清掉四十万行。删完查 SELECT COUNT(*),行数确实降了。
然后去看文件大小,还是 300M。
现象:删了四十万行,文件还是 300M
第一反应是「是不是没生效」或者「磁盘缓存的假象」。都不是。文件大小是真没变,我拿资源管理器看了,一堆空闲页就待在那个文件里。
这个行为不是 SQLite 的 bug,是设计。搞明白之后,我把清理逻辑整个改了一遍。
文件为什么只增不减
SQLite 管理存储的粒度是页,默认一页 4096 字节。一张表的数据分布在一堆页里,每页里有一组 slot 指向具体的行。
删一行的时候,SQLite 做的是把这些 slot 标成空闲,页本身还在,页在文件里的位置也不动。只有当一页里所有 slot 都空了,这一页才会被放进一个叫 freelist 的链表里,等着以后 INSERT 复用。
所以:
1. 你删的行是散在各个页里的,每个页都还剩一些行,那就没有一整页变空,页还占着地方。
2. 就算整页变空了,它也进了 freelist 等着被复用,文件长度还是不变。
3. 文件只在 INSERT 需要新页、而 freelist 里没有可用页的时候才往后长。长了就不缩。
这个设计对「反复增删同样量级的数据」是很合理的 —— 空闲页会被复用,文件大小稳定在一个峰值,不会一直涨。它唯一的问题是:峰值下不去。
所以先别急着 VACUUM。如果你的表每天都在增删,文件大小稳定就不管它,那是正常的。真正需要处理的是两种:一种是数据永久性地少了(像我做的那样),另一种是文件大到影响备份和传输。
VACUUM 能缩,代价是整库重写
VACUUM 做的事很简单:把整个库的数据读出来、写进一个全新的文件、替换掉旧的。所以它同时做了三件事 —— 缩文件、整理碎片、重建索引。
代价也很实在:
1. 要额外的磁盘空间。 300M 的库,VACUUM 过程中最多需要再占 300M。用户机器上磁盘紧张的时候,这一步会失败。
2. 全程持写锁。 期间别人写不进来。桌面应用里就是界面卡住。
3. 时间跟库大小成正比。 300M 大概几秒,几个 G 就是几十秒到几分钟,用户会以为程序挂了。
我在桌面应用里的做法是:启动时判定,不是每次都跑。
const sizeMb = db.pragma('page_count', { simple: true })
* db.pragma('page_size', { simple: true }) / 1048576;
const lastVacuum = Number(store.get('lastVacuumAt') || 0);
const days = (Date.now() - lastVacuum) / 86400000;
if (days > 30 && sizeMb > 200) {
db.exec('VACUUM');
store.set('lastVacuumAt', Date.now());
}
再补一个判断更好:跑一次 PRAGMA freelist_count,拿到空闲页数,乘页大小就是「文件里有多少是死的」。这个数字小于总量的 20%,VACUUM 就没必要跑。
如果不想承担整库重写,还有一个折中:增量回收。
PRAGMA auto_vacuum = INCREMENTAL 之后,SQLite 会在删数据时自动把末尾的空闲页截掉,也可以手动 PRAGMA incremental_vacuum(1000) 一次回收 1000 页。
但有个坑:这个设置只对新建的库生效。 已经存在的库改这个 pragma 不会立刻起作用,必须再跑一次完整的 VACUUM 才能转换成增量模式。也就是说第一次转换躲不掉那次全量重写,好处是以后每次只需要小步回收。
WAL 涨到几个 G,是被长读事务挡住的
比主库文件更吓人的是 -wal 文件。我见过一次它涨到 3G,主库才 200M。
WAL 模式的工作方式是:写操作先写进 -wal 文件,读的时候也要兼顾 -wal 里的新数据,等到某个时机由 checkpoint 把 -wal 里的内容合并回主库,然后把 -wal 截断。
关键在于 checkpoint 有个限制:它不能越过任何一个还没结束的读事务。 因为那个读事务可能还要读 -wal 里更早的版本,你要是把它合并并截断了,读事务就崩了。
所以只要有一个读事务一直不结束,checkpoint 就卡在那儿,-wal 只能一直往后写,越长越大。
这种「一直不结束的读事务」在代码里长这样:
1. 一个 db.prepare() 出来的迭代器,遍历到一半没继续也没释放。
2. 一个忘了 finalize 的 statement。
3. 异步的 sqlite 库(不是 better-sqlite3 那种同步的)里,事务里 await 了一个网络请求。事务开着等网络,几百毫秒到几秒都有可能。
4. 一个 BEGIN 之后走了异常分支,没走到 COMMIT 也没 ROLLBACK。
排查办法是问 SQLite 自己:
PRAGMA wal_checkpoint(TRUNCATE);
它返回三个数:第一个是 busy,1 就说明有东西挡着,checkpoint 没做成;后两个是 WAL 里的总帧数和已经 checkpoint 的帧数。busy 一直是 1,就去代码里找那个没结束的读事务。
治本的几条:别在事务里做 I/O 和网络请求;用 db.transaction() 包住的东西必须全是同步代码;遍历完的结果集要么走完要么显式释放。wal_autocheckpoint 默认是每 1000 页触发一次,一般情况下不用动,动了也只是让它更频繁地尝试,挡着的东西还在那里。
顺便说一句:-wal 和 -shm 是数据库的一部分。备份、拷贝、迁移的时候只拿那个 .db 文件,丢的就是最近的全部写入。这也是我上一篇讲迁移时强调用 VACUUM INTO 的原因。
清大表别用一条 DELETE
清掉四十万行,我第一版写的就是一条 DELETE。它在小表上没问题,在这个量级上会同时踩三个坑:一个巨型事务、-wal 瞬间膨胀、写锁持有到你删完为止。
正确做法是分批,每批一个小事务:
let n = 0;
do {
n = db.prepare(`
DELETE FROM urls
WHERE id IN (SELECT id FROM urls WHERE fetched_at < ? LIMIT 2000)
`).run(cutoff).changes;
} while (n > 0);
每批 2000 行,每批单独提交。这样 -wal 有机会在批与批之间 checkpoint,写锁也是断续持有,界面不会整段卡死。批大小 1000 到 5000 之间都行,看单行大小。
如果删掉的比例很大(比如要留的不到三分之一),还有更快的一条路:建新表,把要留的插进去。
BEGIN;
CREATE TABLE urls_new AS SELECT * FROM urls WHERE fetched_at >= ?;
DROP TABLE urls;
ALTER TABLE urls_new RENAME TO urls;
CREATE INDEX idx_urls_hash ON urls(hash);
COMMIT;
这比逐行 DELETE 快一个量级,因为它跳过了逐行维护索引和 freelist 的工作。代价是事务期间整个表锁着,而且要重建索引。比例超过一半用这个,比例小就分批 DELETE。
顺手提醒:DROP TABLE 之后那些页也是进 freelist,文件照样不缩。所以删完还得配一次 VACUUM,或者干脆用上面提的增量模式。
索引不是越多越好
采集类表的特点是写多读少。每建一个索引,INSERT 的时候就要多写一份 B 树,DELETE 的时候要多维护一份。我在 URL 表上前后建过五个索引,后来删到两个。
判断一个索引有没有用,问 SQLite:
EXPLAIN QUERY PLAN
SELECT * FROM urls WHERE domain = ? AND fetched_at < ?;
输出里出现 USING INDEX idx_xxx 才算用上了,出现 SCAN urls 就是全表扫。麻烦的是「从没被用过的索引」——它只在写的时候产生成本,从来不在读的时候帮忙,纯粹是负担。这种索引只能靠定期翻代码里的查询语句,或者在测试环境打开 sqlite3_stmt_status 看统计。
另外复合索引有前缀原则:(a, b) 这个索引能服务 WHERE a = ? 和 WHERE a = ? AND b = ?,但不能服务 WHERE b = ?。所以 (domain, fetched_at) 就够了,不需要再单独建一个 (domain)。
什么时候该归档,什么时候该分库
VACUUM 和分批删除都是「治已经发生的膨胀」。要在数据量涨上来之前就想好的,是归档。
我的信号是这样几条,任意一条成立就动手:
1. 单表超过 200 万行,或者单库超过 500M。到这个时候,全表扫和 VACUUM 都开始有明显感知了。
2. 某些数据「已经处理完、只会被查历史」——比如抓过的 URL、跑完的任务记录、过期的事件日志。这类数据是归档的首选,因为热表里只需要留待处理的。
3. 备份时间超过 30 秒。备份慢的根源永远是体积。
归档不是建个 xxx_archive 表放在同一个库里 —— 那样文件还是一样大,VACUUM 一样慢。真归档是挪到另一个库文件:
ATTACH DATABASE 'archive-2026.db' AS arch;
BEGIN;
INSERT INTO arch.urls SELECT * FROM main.urls WHERE fetched_at < ?;
DELETE FROM main.urls WHERE fetched_at < ?;
COMMIT;
DETACH DATABASE arch;
VACUUM main;
归档库可以只读、不建索引、不进备份周期,甚至 gzip 之后扔到别处。主库因为数据永久性减少,这时候 VACUUM 才是真有意义的,缩下来的空间不会再涨回去。
分库是最后一步,一般用不到。真到需要的时候(单库上 G、查询要跨年),那已经是「数据架构」的问题,不是「清一清」能解决的。我现在的量级还差得远,这条只是留了个位置。
我现在的做法
把这套整理下来,现在跑的是这几条:
1. 入库前先规范化。 URL 去掉 utm 那一串追踪参数、去掉 fragment、query 参数排序、host 转小写。不规范化就只能靠「字符串完全相等」去重,同一个页面会被抓很多遍。规范化之后建唯一索引,写入用 INSERT OR IGNORE,重复的自然被挡掉。
2. 只存推导不出来的东西。 能算的(域名、路径、扩展名)存的时候现算,不落库。一列多余字段在四百万行上就是几百兆。
3. 每 7 天归档一次,把超过 30 天且已处理完的记录挪到归档库。主表常年维持在几十万行的量级。
4. 每月判一次要不要 VACUUM,条件就是上面那段:距上次超过 30 天、库大于 200M、freelist 占比超过 20%。
5. 写死的几个查询都用索引覆盖,其余索引一律不建。建之前先写 EXPLAIN QUERY PLAN 看一眼。
最后一句:数据库变大这件事本身不可怕,跑采集的、记日志的,涨是必然的。可怕的是你不知道里面有多少是死的 —— 文件 300M、实际有效数据 80M 的时候,你的备份、VACUUM、全表扫全都在为那 220M 的空页付钱。