ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SQLite删了数据文件不缩小?用VACUUM彻底解决

2026/10/1 17:58:09 拓冰建站 浏览量
SQLite删了数据文件不缩小?用VACUUM彻底解决 “删了十几万行数据磁盘占用纹丝不动文件还是 800MB。”——这话我在各种技术群里见过无数次几乎每隔一段时间就有人带着截图来问是不是数据没删干净是不是数据库坏了要不要重建数据库都不是。SQLite 不是 MySQL删数据不回收空间是它的正常机制不是 bug。真正的问题在于你没搞清楚 SQLite 的存储模型也没用对“瘦身”工具。我花了不少时间把这块彻底摸了一遍这篇就把 SQLite 文件为什么“只涨不缩”、怎么真正把占用压下去、以及有哪些坑必须避开一次性讲透。内容适合三类人被“删了数据文件不变”这个问题折磨的开发者做嵌入式或工具类产品想把 SQLite 文件体积和性能都管好的工程师以及给客户做数据库迁移、备份方案时需要解释清楚空间行为的实施人员。1. 删了数据文件不减先搞清楚SQLite是怎么“存”数据的1.1 一切从“页”说起要理解 SQLite 为什么删数据不舍得“撒手”必须先搞清楚它物理存储的最小单元——页Page。SQLite 在磁盘上把数据库文件切分成等长的页默认页大小是 4096 字节也就是 4KB。你建表、插数据、建索引最终都会落到一个个页上。一个页可能存着一行的数据也可能存着索引结构里的一个节点还可能是空页等着将来被分配出去。每个页都有自己的“角色”。当你执行INSERT时SQLite 会从空闲页列表Freelist里找一个可用的页把数据写进去。如果空闲页不够就往文件末尾追加新页。反过来当你执行DELETE或DROP TABLE时SQLite 并不会把那些页从磁盘上抹掉也不会把文件截短它只做一件事把这些页标记为“空闲”扔进空闲页列表里供未来写入复用。我打个比方这就像你租了一个大仓库按货架位存放货物。你清理走了一批货但货架还在仓库面积也不会因为少了几箱货就自动缩小。仓库管理员只会把空货架登记在册下次有新货来了优先往空货架上放。SQLite 就是这么“勤俭持家”的。这也解释了一个很常见的误判你以为“删掉表”就等于释放空间实际上DROP TABLE只是把那棵 BTree 的根页标记为可回收真正的磁盘空间还是被文件占着。曾经有客户找我说删了一个几百 MB 的日志表结果整个数据库文件一点没变小还怀疑是数据被残留了什么。这就是没理解页的机制。1.2 删除操作的真实面貌从“标记删除”到“空间复用”为了看清楚这个过程我实际做了个实验。建一个测试表插入 50 万行数据然后看文件大小和页的分配情况。用PRAGMA page_count能看到当前文件总页数用PRAGMA freelist_count能看到空闲页数量。插入 50 万行之后我的测试文件稳定在 60MB 出头。接着执行DELETE FROM t WHERE id % 10 0删掉十分之一的数据相当于删了 5 万行。文件大小呢一点没变。但freelist_count明显涨上去了。这说明删除操作执行得很“干净”——数据逻辑上删掉了物理页被标记为空闲等着新数据进来“接管”。这种设计是有道理的如果每删一条数据就立刻把文件“缩”一下必然涉及大量磁盘 I/O 和文件系统操作性能会差得没法看。SQLite 把“释放”这件事延迟化、批量化了用空闲页列表来吸收删除的冲击。所以文件“只涨不缩”不是缺陷而是一种为了性能做的取舍。当你理解了这套机制就能明白一个结论如果删除数据之后你马上又插入差不多量级的新数据文件大小通常不会暴涨——因为新数据会优先复用空闲页。这也就是为什么有些业务系统删了数据感觉文件还是会“涨”那是删除量远大于后续写入量空闲页列表一直兜不住只能继续在文件尾部追加新页。真正的问题是你这辈子可能都不会再往这个库里插入与删除量相当的数据了。日志清理了、历史订单归档了、用户数据封存了文件里的空闲页就成了“僵尸地产”。这时候就需要强制让 SQLite 把仓库重新整理一遍。2. 核心瘦身工具VACUUM的完整解读2.1 VACUUM到底干了什么不是“清垃圾”而是“重建仓库”VACUUM不是去文件里“打扫卫生”它的本质是把当前数据库里所有有效数据复制到一个全新的数据库文件中只复制“活”的页面空闲页直接扔到一边不要了。复制完成后用新文件替换掉旧文件于是文件大小就按照真实数据量重新归零计算。这个思路跟很多数据库、存储引擎的“压缩重组”操作一脉相承。VACUUM 的过程大致分几步创建一个临时数据库文件按顺序读取原库中所有仍然有效的数据页包括表数据和索引数据把数据页逐个写入临时文件并按最佳顺序重组 BTree 结构把临时文件整体替换为原数据库文件更新相关页缓存和元数据。因为 VACUUM 是一次完整的数据重写它有几个连带效果索引会重新构建页面碎片会减少文件中的“空洞”会被消除。所以如果你发现查询性能明显下降排查之后发现碎片化严重做一次 VACUUM 往往比手动重建索引更彻底。执行方式也特别简单SQLite 原生支持这条命令。命令行工具 sqlite3 里直接敲VACUUM;就这么一行。执行期间 SQLite 会对数据库加 EXCLUSIVE 锁阻止其它连接读写。2.2 VACUUM的参数与执行细节你需要注意的几种写法VACUUM 还支持带参数的形式VACUUM INTO 新文件路径。这个变体不会替换当前数据库而是把整理后的数据输出到一个指定文件中保留原库不动。我经常用它做“在线瘦身备份”VACUUM INTO /tmp/compacted.db;这样做的好处是原库接下来还能继续用拿到的是被整理过的副本可以用来迁移、归档、分发。等确认新文件没问题了再替换原文件。缺点是过程中磁盘占用会变成“原文件大小 新文件大小”磁盘紧张的环境要提前估算好。另外要注意VACUUM 不受当前事务进度影响它必须在事务外执行。如果你用 Python 的 sqlite3 模块执行VACUUM之前要确保没有挂着事务如果用 C# 的 System.Data.SQLite最好把连接串里的Enlistfalse设置好避免被外部提交逻辑干扰。还要记住一个隐藏规则VACUUM 之后page_size可能会被重置为默认值。如果你之前手动设置过 4096 或 8192 的页大小VACUUM 后需要重新设置。这算是一个比较容易踩的坑。提示如果数据库文件非常大VACUUM 需要的临时磁盘空间至少等于原文件有效数据的大小。线上执行前先判断磁盘余量别等写到一半报“disk I/O error”才发现空间不够。3. auto_vacuum与增量回收把“自动瘦身”用明白3.1 auto_vacuum的三种状态与原理差异有人会问既然删除数据会产生空闲页那我能不能让 SQLite 每次删除时自动收缩文件答案是能但代价你得承受。这个功能叫auto_vacuum它有三种状态模式行为特点NONE默认删除数据后空间不回收空闲页进入 freelist 供复用写入快文件只涨不缩FULL删除数据时立即把释放的页回收到文件末尾并截断文件保持精简但删除和更新操作的开销显著增加INCREMENTAL删除数据时只把页移入 freelist不截断文件需要手动执行PRAGMA incremental_vacuum;时才真正回收平衡模式手动控制回收时机设置方法是在创建数据库之后、还没写入什么数据时执行PRAGMA auto_vacuum FULL;但这里有一个很多教程没讲明白的坑对已有数据库光改这个 PRAGMA 是不生效的因为 SQLite 只有在 VACUUM 时才会按新的 auto_vacuum 策略去重建文件结构。你设置完之后必须再跑一次VACUUM;也就是说auto_vacuum 的设置是“一次性生效”的开关但触发改制得靠 VACUUM 来完成。很多人在老库上执行PRAGMA auto_vacuum FULL;后删除数据发现文件依然不变于是以为命令没用。实际上是你少执行了 VACUUM 这一步。FULL 模式的性能负作用也不能忽视。每次 DELETE 或者 UPDATE 导致页移动时SQLite 都必须把被释放页的内容挪到文件末尾并更新页表写入放大明显。对于写密集的应用开启 FULL 之后你可能会观察到磁盘 I/O 明显上升写入延迟变大。所以我个人给业务库的建议是优先用默认 NONE 定期 VACUUM 的组合或者用 INCREMENTAL。FULL 模式更适合那种“删除频率高、文件大小必须时刻可控”的只读转储型应用一般业务系统真没必要。3.2 INCREMENTAL模式的实际用法与适用场景INCREMENTAL 模式是“折中方案”它允许你在日常写入时享受 NONE 模式的速度删除数据时把页归入 freelist然后你挑一个业务低峰期执行PRAGMA incremental_vacuum;它会把 freelist 中排在最前面的页一点点回收掉文件逐步收缩。开启方法PRAGMA auto_vacuum INCREMENTAL; VACUUM; -- 让设置真正生效之后删除数据随时执行PRAGMA incremental_vacuum;你还可以指定一次回收多少页PRAGMA incremental_vacuum(100);括号里的数字表示本次回收多少个空闲页。这个机制像是“手动点一下压缩”不会像 FULL 那样处处实时截断文件。我实际用下来发现 INCREMENTAL 模式特别适合那种“白天高频写入、夜间定时清理”的系统。业务低峰期跑一个定时任务执行PRAGMA incremental_vacuum;文件就能在可控的时间和 I/O 成本下慢慢瘦下来。这个模式下要注意的是incremental_vacuum只回收 freelist 里的页。如果你删的数据量巨大freelist 里的页数非常多单次执行可能耗时较久建议分批执行比如每次回收 5000 页循环执行直到 freelist_count 归零。这比一次性回收全部更稳妥避免单次操作占用太多锁时间。4. 实操过程从诊断到瘦身全流程复刻4.1 第一步用PRAGMA诊断数据库的“肥胖”状态动手瘦身之前先查清楚文件到底“虚胖”到什么程度。SQLite 提供了一组 PRAGMA 诊断命令我用命令行工具演示一遍。假设数据库文件叫app.db先进入命令行sqlite3 app.db依次执行下面几条PRAGMA page_size; -- 正常输出 4096 PRAGMA page_count; -- 当前文件总页数 PRAGMA freelist_count; -- 空闲页数量也就是被删除但还没回收的空间文件的总字节数就是page_size * page_count。空闲字节数就是page_size * freelist_count。两者相除就能算出“虚胖比例”。我在一个 800MB 的实际项目库上执行后看到page_size 4096 page_count 204800 freelist_count 70830换算一下空闲页占了 70830 * 4096 ≈ 277MB也就是说整个文件里约三分之一是已经删除了的数据残留。那这些确实是可以回收的。诊断的时候还可以用PRAGMA dbstat;查看每张表和每个索引占用的页数能进一步定位“谁在浪费空间”。比如有些业务表在逻辑上早就废弃了但表结构还留着数据清空后页还在。提示诊断完先备份。执行任何大动作之前先把原文件复制一份。SQLite 本身挺健壮但 VACUUM 过程中万一断电你至少还能从备份里恢复。4.2 第二步执行VACUUM并验证收缩结果确认了空闲页占比之后直接执行VACUUM;在我的测试里同样一个 800MB 的文件VACUUM 之后降到了 523MB。整个过程耗时大概 20 多秒取决于磁盘读写速度。执行完再看PRAGMA freelist_count; -- 输出 0 PRAGMA page_count; -- 大幅下降这说明空闲页已经全部回收文件里现在只有有效数据了。这里需要强调一下VACUUM 是一个完整重写过程耗时和磁盘 I/O 都会比较明显。如果文件有几个 GB执行期间业务最好停写或者用只读副本做。如果你不想让 VACUUM 锁住原库改用VACUUM INTO写到别的路径。我先跑一个输出到临时文件的版本确认新文件大小符合预期再替换原库。对于生产环境这个操作更安全。VACUUM 过程中你会看到系统多出一个临时的 journal 文件或 wal 文件那是 SQLite 的原子提交机制在兜底。等 VACUUM 完成这些临时文件会自动消失。4.3 第三步编程语言接口中的VACUUM调用示例实际项目中很少手动开命令行敲 VACUUM多数是在代码里定期触发。给几个常见语言的最小示例。Python 用标准库 sqlite3import sqlite3 conn sqlite3.connect(app.db) conn.execute(VACUUM) conn.commit() conn.close()注意 Python 的 sqlite3 默认会自动开启事务而 VACUUM 不能在事务中执行。如果你发现执行时报 “cannot VACUUM from within a transaction”就先把isolation_levelNone设为自动提交模式conn sqlite3.connect(app.db, isolation_levelNone) conn.execute(VACUUM) conn.close()C# 用 System.Data.SQLiteusing (var conn new SQLiteConnection(Data Sourceapp.db)) { conn.Open(); using (var cmd conn.CreateCommand()) { cmd.CommandText VACUUM; cmd.ExecuteNonQuery(); } }这里需要注意的是连接串里最好别开 WAL 模式做 VACUUM或者在执行 VACUUM 前先做一次PRAGMA wal_checkpoint(TRUNCATE);把 WAL 文件先收敛掉避免 VACUUM 过程中日志文件跟着膨胀。如果你用的是 Node.js基本套路一样better-sqlite3库直接执行db.exec(VACUUM)即可。关键是确认当前没有未提交事务。4.4 第三步补充宝塔面板等图形环境下的操作思路有网友问到宝塔面板怎么操作 SQLite。其实宝塔面板默认没法像 phpMyAdmin 管理 MySQL 那样直接跑 SQLite 的 VACUUM 命令但思路是一样的在服务器上用自带 sqlite3 命令进入数据库目录执行VACUUM;或者写一个 PHP 脚本调用 PDO 执行$pdo new PDO(sqlite:/www/wwwroot/xxx/app.db); $pdo-exec(VACUUM);宝塔环境下更推荐的做法是在网站目录里放一个临时脚本执行完就删掉或者用计划任务定时执行。记住 VACUUM 会把整个文件重写执行期间该数据库文件被锁务必避开业务高峰期。5. 常见问题与避坑指南5.1 VACUUM之后文件还是没变小先查这几个地方有一种情况让人最崩溃明明执行了 VACUUM文件大小纹丝不变。这种情况我排查过多次原因通常集中在下面几处第一数据库正处于 WAL 模式大量数据还积压在-wal文件里没合并回主库。VACUUM 针对的是主库文件如果 WAL 里还有一堆未 checkpoint 的数据主库文件暂时不变很正常。先执行PRAGMA wal_checkpoint(TRUNCATE);把 WAL 日志合并回数据库文件再执行 VACUUM。第二你有可能根本没删干净。删除操作如果发生在某个未提交事务里DELETE只是“看起来执行了”实际事务回滚后数据原封不动。用SELECT COUNT(*)确认目标数据真的没了。第三VACUUM 之后又立刻有新数据写入空闲页被新数据占用文件当然不会变小。瘦身之后总得给新数据预留空间这是正常现象。第四auto_vacuum设置成 FULL 之后如果库内有大量“零散释放”的操作文件截断是持续发生的。但如果你在开启 FULL 之前已经产生了大量空闲页必须手动 VACUUM 一次才能清掉历史包袱。现象可能原因处理方式VACUUM 后文件不变WAL 文件未合并先wal_checkpoint(TRUNCATE)再 VACUUM删除数据后 freelist_count 一直很大后续写入不足、从未 VACUUM执行 VACUUM 或 incremental_vacuum文件大小短暂增长VACUUM 创建临时文件等待完成、检查磁盘余量执行 VACUUM 报错有活动事务或其他连接占用断开连接、事务外执行5.2 大表删除与迁移场景下的瘦身策略如果你要删除一张超大表比如几百 GB 的历史数据表直接DELETE再VACUUM不是不行但效率很低——DELETE 逐行标记页VACUUM 再全部重写磁盘 I/O 成本翻倍。更好的做法是直接DROP TABLE用整表丢弃的方式释放。如果表不需要保留这是最快的路径。但如果表里的数据要全删但表结构还要留着更优的方案是DELETE FROM t; VACUUM;或者更激进一点把表结构导出DROP TABLE然后重建表再跑一次 VACUUM。这比逐行 DELETE 要快不少因为 DROP 直接把整棵 BTree 标记为可回收。还有一种常见场景库里面有多个大表但只有其中一两个真正需要保留其他的都是历史残留。选择VACUUM INTO把保留的数据倒出来比在原文件上反复删表真空更干脆。我做过一个项目把建库多年的 2GB 文件瘦身到 300MB就是只导出了生产必需的表剩下的物理数据全部丢弃。5.3 方案选型与日常维护建议简单总结一下不同场景的选型逻辑场景推荐方案原因数据写入频繁、删除相对零散默认 NONE 定期 VACUUM日常写入开销最小低峰期集中瘦身删除量大、频率可控、文件必须实时精简auto_vacuum FULL每次删除都截断文件占用可控业务白天繁忙、夜晚允许批量整理auto_vacuum INCREMENTAL 定时 incremental_vacuum平衡性能与空间占用需要大文件迁移、归档、同步分发VACUUM INTO输出独立副本不锁原库日常建议是给数据库文件大小加个监控空闲页占比超过 30% 且业务低峰期到来时自动触发一次 VACUUM。别动不动就 VACUUM——它是重写操作频繁跑会给磁盘带来不必要的压力。对动辄几 GB 的库来说一般一周一次或一月一次足够了具体看写入和删除的量级。还有个维护细节系统的临时文件目录要够大。VACUUM 重写过程中生成的临时库要占磁盘空间而VACUUM INTO更是在原库之外生成一整份副本磁盘余量不足时先清一清同目录下的-journal、-wal残留文件再安排瘦身任务。最后分享一个我自己的经验做 SQLite 瘦身这种事一定要先把“空间去哪里了”看清楚再动手。PRAGMA freelist_count就是体检指标VACUUM就是治疗方案。我见过不少人一上来就重建数据库、导数据导到一半失败其实就是没搞明白 SQLite 的空间管理机制。把页的概念、空闲页的复用、VACUUM 的重建原理串起来理解处理这类问题就会从容得多。下次再有人问你“为什么删了数据文件没变小”你可以把这篇文章丢给他再补一句先查 freelist_count再跑个 VACUUM搞定。