我那个采集器跑了三个月,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 的空页付钱。