你有没有遇到过这种场面数据库里删了几万条记录满心欢喜地去看文件大小结果.db文件纹丝不动甚至偶尔还变大了一点。别怀疑自己是不是删错了表这是 SQLite 的典型“假瘦身”现象。我最早做 C/S 架构项目时也踩过这个大坑那时候用 C# 连 SQLite定期清理历史订单后文件长期保持在 1.5GB 左右。后来搞清楚了底层的页存储机制才发现不是没有空间可释放而是我没有把“删数据”和“释放空间”这两件事分开看待。这篇文章我会从原理到实操把 SQLite 数据库“瘦身”这件事讲透。适合正在被 SQLite 文件膨胀困扰的同学也适合想了解VACUUM、空闲页、auto_vacuum这些概念但不想啃官方文档的人。读完你就能自己判断数据库是不是“虚胖”并且知道怎么安全地把文件真正缩小。1. 先搞清楚删了数据不代表文件“还给你空间”——存储机制拆解想要理解 SQLite 文件为什么删了数据不变小得先弄明白它把数据存在哪里、怎么存。1.1 页(page)和空闲页(free list)是怎么回事SQLite 把整个数据库文件划分成固定大小的“页”page默认一页是 4096 字节4KB。不管你存的是一条 5 个字节的短字符串还是一整段散文最终都要占用至少一个完整的页。页是 SQLite 读写磁盘的最小单位。当你要向表里插入一条记录时SQLite 会在文件里找一个“空闲页”来放它没有空闲页就追加新页到文件尾部文件自然变大。而当你执行DELETE时SQLite 要做的事情只是把这条记录所在的页标记为“空闲”然后把这个页挂到一个叫freelist的链表中。它并不会去把文件尾部截断也不会把后面所有的页往前挪。你可以把 SQLite 文件想象成一本写满笔记的本子。删除一条记录相当于在这个本子上拿笔把某几行字划掉而不是撕掉整张纸。日子久了本子里全是划痕和空白区域本子的页数还是那么多文件大小自然不变。顺带说更新UPDATE也可能产生类似的“残留”。某些情况下更新一条记录时若新旧内容长度差异较大SQLite 会把旧数据所在的页释放掉再找新页写入。所以哪怕你没有删除任何数据长期大量更新也会在文件里留下越来越多的空闲页。1.2 为什么 DELETE 不像 TRUNCATE 那样爽快含事务回滚原理解释很多人第一次发现“删了没变小”时会下意识拿 MySQL 或 SQL Server 的TRUNCATE来对比。但 SQLite 的DELETE是逐行删除并且要支持事务回滚。事务回滚意味着事务没提交之前数据库必须保证所有数据可以恢复到操作前的状态。SQLite 默认的 rollback journal 模式会在执行DELETE前把被修改的原始页内容先写入一个单独的日志文件通常是-journal后缀。提交成功后日志文件删除但这些数据页在数据库文件中已经标记为空闲页。如果文件立即缩小回滚事务时数据就没地方放了。同样的道理SQLite 在事务执行过程中即使你删除了大量数据它也不会立刻把空间交还给操作系统。空间是在事务提交以后、执行VACUUM时才真正释放。很多新手在这里都犯过个错误删除完数据后看了一眼-journal临时文件以为文件变小了实际上那是日志文件跟主数据库的大尾巴没有关系。2. 判断“虚胖”的体检方案几个 pragma 就能量化别靠肉眼猜文件是不是虚胖。SQLite 提供了几个非常方便的 PRAGMA 指令让空闲页的数量无所遁形。2.1 核心体检SQL组合page_count / freelist_count / page_size你可以用任意一个能执行 SQL 的客户端连上去执行下面这几条命令PRAGMA page_count; PRAGMA freelist_count; PRAGMA page_size;它们的含义很简单page_count当前数据库文件总页数。freelist_count当前空闲页数量。page_size单页字节数典型值是 4096。算一下“虚胖比例”-- 计算理论能释放的空间 SELECT page_size * freelist_count / 1024.0 / 1024.0 AS loss_mb;举个例子。有一次我接手一个运维系统库文件 743MB查出来page_count 190208freelist_count 76800page_size 4096。算一下76800 × 4096 / 1024 / 1024 300MB。也就是说这个库有四成空间全是空闲页文件当然瘦不下来。我把这个计算过程直接做成了一个查询语句放到管理工具里保存起来。每次程序上线前先跑一遍体检一看freelist_count占比超过 20%就安排一次瘦身。2.2 用 dbstat 虚拟表做精确分析可选如果还想知道究竟是哪张表占用了最多的空间、砍掉哪张表收益最大SQLite 从 3.30 版本开始默认编译时通常不带dbstat但很多图形管理工具如 DB Browser for SQLiteDB4S可以手动启用。dbstat是一个虚拟表把它加载进来后可以查询每个表、每个索引占用的页数SELECT name, SUM(pgsize) AS bytes_used FROM dbstat GROUP BY name ORDER BY bytes_used DESC LIMIT 20;这条语句能算出哪些表实际占用的数据页最多。注意pgsize是“适用聚合”的字段名不同版本字段略有差异一般在 DB4S 的命令行里执行SELECT * FROM dbstat LIMIT 10;看一眼列名再调整。我自己用到 dbstat 的场景是排查那种“表记录不多但文件却很大”的诡异情况。曾经遇到过一张只有几千条记录的表却占掉了 400MB后来查出来是执行过大量UPDATE导致的碎片化。定位到具体表之后简直就像找到了元凶一样痛快。3. 给数据库“瘦身”的完整实操流程搞清楚了病因下一步就是动手。给 SQLite 瘦身不是只能靠VACUUM实际上方案共有三种它们的使用场景完全不同。3.1 VACUUM最常用的瘦身命令VACUUM的底层逻辑很简单它会重建整个数据库文件。把现有的页重新整理把空闲页彻底回收然后重写一个新的文件最后替换掉旧文件。这个过程有点像把笔记本里的内容重新抄到一本新本子上撕掉原有空白页。用法就一行 SQLVACUUM;需要注意几个前提条件执行VACUUM时数据库不能处于事务中。也就是不能在没有COMMIT之前运行它。需要足够的磁盘空间来存放重建后的文件副本。因为它先把整个新库写到临时文件再替换原文件。会重写所有索引因此重建过程中表会被短暂锁定读写请求都会阻塞。在在线服务上执行时要错峰。VACUUM不只能瘦身还能顺便清理索引碎片。如果一个表曾经频繁增删索引结构也会变得稀疏VACUUM会把索引重建得紧凑一些。在实际体验中重建后连查询速度都能明显加快就像整理完房间后再找东西也比堆满杂物时快得多。3.2 打开 auto_vacuum 的注意事项很多人看到auto_vacuum这个名字会以为它是“自动驾驶式瘦身”打开之后删数据文件就会自动变小。实际上没那么简单。auto_vacuum只是让 SQLite 在删除数据时自动把空闲页移到文件尾部并尝试截断文件。它默认有三个选项0NONE默认值完全不做自动回收。1FULL每次事务提交时尝试回收空闲页。2INCREMENTAL需要手动执行PRAGMA incremental_vacuum(N)才回收。如果希望以后所有新建的库都自动开启可以在建库后执行PRAGMA auto_vacuum FULL; VACUUM;注意auto_vacuum不能在已经有数据的库上直接改完生效必须跟着VACUUM一起执行让整个文件先重建一次后续才能保持自动回收。但这个方案有一个很现实的问题auto_vacuum FULL每次提交都会做额外的页移动写入性能比普通模式差一些尤其是高频写入场景可能慢 10%~30%。所以我个人做法是数据量小、写频率低的工具型数据库开启auto_vacuum FULL高吞吐的业务库宁可用普通模式加定时VACUUM也不愿意牺牲写入速度。3.3 代码和工具中的瘦身方案C#/Python/命令行/SQLite工具实际项目里很少有人会专门打开命令行去执行VACUUM基本都是程序里集成。C# 使用 Microsoft.Data.Sqlite 时using (var connection new SqliteConnection(Data Sourceapp.db)) { connection.Open(); var command connection.CreateCommand(); command.CommandText VACUUM;; command.ExecuteNonQuery(); }注意如果你用的是System.Data.SQLite连接字符串里别额外配置Journal Mode之类和VACUUM冲突的参数。一旦连接池里还有别的活跃连接VACUUM可能因为数据库被锁而执行失败。Python 使用 sqlite3 模块时import sqlite3 conn sqlite3.connect(app.db) try: conn.execute(VACUUM) conn.commit() finally: conn.close()有一点很多 Python 新手踩过sqlite3 模块默认会开启隐式事务执行VACUUM前如果存在未提交的写事务VACUUM会直接报Safety level may not be changed inside a transaction之类的错误。保险起见先conn.commit()或者conn.rollback()再执行VACUUM。命令行方式sqlite3 app.db VACUUM;图形化工具方式如果你用的是 DB Browser for SQLiteDB4S打开数据库后点击“数据库”菜单里的“压缩数据库”选项本质就是执行VACUUM。有的工具界面写的是“Compact Repair”也一样。另外一个很有用的变体是VACUUM INTO它可以把瘦身后的结果输出到一个新文件而不是直接覆盖原文件VACUUM INTO app_compact.db;这个命令非常适合做备份前瘦身。我会先把线上数据 VACUUM INTO 到一个新文件再把这个新文件压缩归档。这样既能得到一个较小的备份文件又不影响当前正在被读写的原库。4. 瘦身前后对比与维护策略避免反复膨胀瘦身不是一次性工作这和保养汽车类似。做完一次后如果没有后续的维护策略文件还是会慢慢长回去。4.1 一次完整的实测对比记录我之前对一个 743MB 的库执行了一次VACUUM记录如下指标瘦身前瘦身后文件大小743MB441MBpage_count190208112864freelist_count768000page_size40964096空闲页占比40%0%瘦身后的文件少了整整 300MB。这个数字和我在第 2 节里通过page_size * freelist_count算出来的理论值几乎一致。所以如果你想验证某个脚本或者工具是不是真的在瘦身可以照着这个指标看freelist_count归零才是王道文件大小反而是间接指标。执行过程大约耗时 20 秒期间所有写入请求被阻塞。对于一个随时在接收数据的小型应用来说20 秒还在可接受范围内。但如果是 7×24 小时的在线服务就需要考虑夜间定时执行。4.2 定期执行 VACUUM 的节奏怎么定SQLite 官方没有强制推荐“多久 VACUUM 一次”这完全取决于业务。我给一个经验参考每天大量删除、更新数据的业务表每周 VACUUM 一次。每周才会集中清理一次数据的小工具每月一次。只增不改不删的日志型数据库其实永远不需要 VACUUM。高频写入且对响应时间敏感的业务在夜间批处理窗口执行。我在实际项目里是把 VACUUM 放到定时任务里跑的。写了个小的控制台程序每天凌晨三点检查freelist_count占比是否超过 20%超过才执行否则跳过。这样既避免无谓的重建又保证文件不会膨胀到失控。using var connection new SqliteConnection($Data Source{dbPath}); connection.Open(); var cmd connection.CreateCommand(); cmd.CommandText PRAGMA freelist_count;; long freeCount (long)cmd.ExecuteScalar()!; cmd.CommandText PRAGMA page_count;; long pageCount (long)cmd.ExecuteScalar()!; double ratio (double)freeCount / pageCount; if (ratio 0.2) { cmd.CommandText VACUUM;; cmd.ExecuteNonQuery(); }这里我把“体检”和“瘦身”绑定在一起相当于给数据库做了一次自适应维护不搞一刀切也不做无意义的全量重建。4.3 WAL 模式下的额外注意点如果你的数据库开启了 WALWrite-Ahead Logging模式这一点容易被忽略WAL 模式下删除数据后空间回收节奏不太一样。WAL 模式里写操作先追加到-wal文件普通的checkpoint会把 WAL 文件里的内容合并回主数据库文件。但合并过程中空闲页不会自动回收。真正让-wal文件变小的是PRAGMA wal_checkpoint(TRUNCATE);。所以如果你的业务用了 WAL 模式且发现-wal文件疯狂变大直接跑VACUUM并不是第一选择。应该先执行PRAGMA wal_checkpoint(TRUNCATE);这个操作会把预写日志里已提交的内容合并到主库并把 WAL 文件截断到最小。之后再看主库的freelist_count如果依然很高再执行VACUUM。这里还有个经验之谈WAL 模式下如果长时间不 checkpoint-wal文件可能比主库还大此时你不要慌先 checkpoint再 VACUUM。我见过有同事对 800MB 主库、1.2GB WAL 文件的情况直接删掉了-wal文件结果导致部分未合并数据丢失数据库直接崩溃。WAL 文件不能手贱乱删。5. 常见坑和问题排查碰到就太晚系列这里整理了我自己以及周围朋友遇到的高频问题每个都对应一套排查思路希望能帮你少走弯路。5.1 什么时候不该用 VACUUM大表/长事务/磁盘不足第一个坑磁盘空间不足时执行VACUUM。前面提过VACUUM会先把整个新库写到临时文件再替换旧文件所以它需要的磁盘空间是“原文件大小 新文件大小”。如果磁盘本身已经告急执行 VACUUM 可能直接导致“database or disk is full”错误甚至把库搞坏。第二个坑大事务未提交时执行VACUUM。在未提交事务里跑 VACUUMSQLite 会明确报错。常见的报错信息有cannot VACUUM - SQL statements in progress。解决办法很简单先 COMMIT 或 ROLLBACK。第三个坑在线服务高峰期执行。VACUUM 会把整个库锁住期间所有连接都无法读写。如果有监控系统在实时写入数据这段时间的指标会丢一点可能触发告警。5.2 为什么我 VACUUM 了文件还是没变小这是最让人崩溃的一类问题明明VACUUM成功执行了文件大小却没有任何变化。常见原因有三个一是该文件本来就不存在空闲页。VACUUM只回收空闲页如果你的数据本身填满了所有页比如日志表删了一条又马上插入一条文件确实没有可释放的空间。此时先查freelist_count看看是不是 0。二是执行 VACUUM 时连接不是用的目标数据库。这种情况经常出现在 C# 程序里连接字符串写了错误的路径或者相对路径解析到了别的地方。建议把PRAGMA database_list;的结果打出来确认你到底连的是哪个文件。三是 VACUUM 完成后操作系统层面的页缓存或文件系统预分配。某些文件系统比如 Windows 上的 NTFS 或某些闪存盘会有空间预留机制目录里显示的大小没有及时更新。这个时候用dir命令或者资源管理器刷新一下就好最终系统会自动释放。真正需要关注的是VACUUM 本身有没有报错、freelist_count是否为 0。5.3 常见问题速查表现象原因解决方案DELETE 后文件大小不变空闲页没有回收执行 VACUUMVACUUM 执行报错 “database is locked”有其他连接正在读写关闭所有连接或错峰执行VACUUM 报错 “file is encrypted or is not a database”库文件损坏或路径错误检查是否选了正确文件必要时用 DB4S 修复-wal文件异常大checkpoint 未执行执行PRAGMA wal_checkpoint(TRUNCATE);开启了 auto_vacuum 仍然不变小配置变更后未重建库执行VACUUM;让设置生效文件变小后不重启程序会有异常连接池里缓存了旧文件句柄重启应用或重新打开连接5.4 服务器环境如宝塔面板执行 VACUUM 的注意事项很多用宝塔面板管理服务器的用户会通过 phpMyAdmin 风格的面板或计划任务去执行 SQL。这里要特别提醒如果 SQLite 文件是在 PHP 进程或者 Web 服务进程下被频繁读取的使用面板执行 VACUUM 前一定要确认没有常驻进程持有该库的文件锁。解决办法通常是把计划任务写成 shell 脚本在凌晨低峰期执行#!/bin/bash # 先备份 cp /www/wwwroot/example/data/app.db /www/backup/app_$(date %Y%m%d).db # 再瘦身 sqlite3 /www/wwwroot/example/data/app.db VACUUM;执行完后别忘检查文件属主和权限因为 VACUUM 重建文件后可能在 Linux 下出现属主变成了执行任务的用户比如 root的情况导致 Web 服务无法写入。这个坑我见到不止一次特意写出来。结尾想分享的一个实用技巧做 SQLite 维护这几年我自己最常用的一句话是先把freelist_count查出来再决定要不要 VACUUM千万不要只看文件大小。另外一个好习惯是每次版本发布之前顺手执行一次VACUUM INTO backup_xxx.db用瘦身后的副本做发布包里的默认数据库。这样用户拿到的初始数据库体积小、启动快体验也好。你在自己的项目里试试这个做法应该很快就能感觉到差别。如果你还没给 SQLite 数据库做过“体检”不妨现在打开那个一直疑似虚胖的.db文件跑一下PRAGMA freelist_count;。很可能你会和我第一次查到数据时一样惊讶。