先说个我自己的真实经历:有一回我给一个小工具加数据清洗功能,用 C API 执行 UPDATE,每次返回都是 SQLITE_OK,程序也不报错,可拖到第二天才发现,几百条记录的字段被改成了同一个值。问题出在哪?绑定参数的索引从 0 开始写了,而 SQLite3 的占位符是从 1 开始计数的——一位之差,全表遭殃。
这件事之后我彻底明白了,SQLite3 的 UPDATE 和 DELETE,单看 SQL 语法谁都懂,但落到 C API 层,细节多到你想象不到。网上大多数教程都在讲建表、查询和 INSERT,改数据反而没人细说。这篇笔记把 UPDATE 和 DELETE 的 C API 用法、参数绑定、受影响行数确认、批量改动性能、以及删除后磁盘空间不释放这些事一次讲透,适合刚把 SQLite3 接入 C/C++ 项目、准备认真处理数据的同学。
1. 先把 SQL 形态定下来:UPDATE/DELETE 在 SQLite3 里的特有语法
1.1 UPDATE 和 DELETE 的基本语法
在动手写 C API 之前,SQL 语句本身得先过关。SQLite3 的 UPDATE 基本形态是这样:
UPDATE [OR 冲突策略] 表名 SET 列名 = 表达式 [, 列名 = 表达式 ...] WHERE 条件;DELETE 更简单:
DELETE FROM 表名 WHERE 条件;有一点必须强调:SQLite3 里 UPDATE 和 DELETE 都支持ORDER BY和LIMIT子句,这是它和其他数据库不太一样的地方。比如你要删掉创建时间最早的 10 条记录,可以这么写:
DELETE FROM logs ORDER BY create_time ASC LIMIT 10;这个特性在批量清理数据时非常有用,后面专门讲分批删除时我会给完整代码。但平时写代码,我建议还是老老实实把 WHERE 写清楚,不要把整个表当成操作对象。
WHERE 条件如果有用户输入,永远不要用字符串拼接。SQLite3 支持几种参数占位符:?、?NNN、:名称、@名称、$名称。最常用的是?1、?2这种带编号的写法,绑定参数时按编号赋值,清晰、不易错。:name这种命名写法更适合 SQL 较长、参数多的场景,可读性好,但绑定时要写sqlite3_bind_text(stmt, 参数索引, ...),索引需要通过sqlite3_bind_parameter_index()查出来,多一步操作。前期学习阶段我推荐先用?NNN,把流程跑通再换命名参数。
1.2 不是所有修改 SQL 都适合直接跑:prepare_v2 和 exec 的分工
刚开始写 SQLite3 的 C 代码,很多人第一反应是用sqlite3_exec,因为前面建表、插入都这么干的,简单粗暴。确实,sqlite3_exec适合没有参数、一次性执行的情况,内部其实也封装了 prepare、step、finalize 三个阶段,只是对用户不可见。比如一条不带用户输入的 UPDATE:
const char *sql = "UPDATE users SET status = 1;"; char *errmsg = NULL; int rc = sqlite3_exec(db, sql, NULL, NULL, &errmsg); if (rc != SQLITE_OK) { fprintf(stderr, "update failed: %s\n", errmsg); sqlite3_free(errmsg); }但如果 SQL 里要带变量,比如"把 id 为 10086 的用户名改成 xxx",用sqlite3_exec就只有两种选择:一是用snprintf把变量拼进 SQL 字符串,二是在 SQL 里直接写死。前一种方案有三个隐患:字符串里的单引号要转义、特殊字符可能破坏 SQL 结构、存在注入风险;后一种完全没有通用性。
sqlite3_prepare_v2才是处理带参数 UPDATE/DELETE 的正道。它把"编译 SQL"和"执行 SQL"拆开,SQL 语句先解析成 prepared statement,参数通过 bind 系列函数逐个绑定,再执行。这样 SQL 结构是固定的,输入值只作为参数传入,从根本上避免了拼接问题。同时 prepare 一次,可以反复绑定不同参数执行,效率也更高。
1.3 一条修改语句的标准七步流程
无论是 UPDATE 还是 DELETE,用 C API 执行的完整流程都是固定的七步,我写成注释方便记:
sqlite3 *db; /* 1. 打开数据库 */ sqlite3_open("test.db", &db); /* 2. 准备 SQL,编译成 statement */ const char *sql = "UPDATE users SET name = ?1 WHERE id = ?2;"; sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "prepare error: %s\n", sqlite3_errmsg(db)); return -1; } /* 3. 绑定参数(注意索引从 1 开始) */ sqlite3_bind_text(stmt, 1, "新名字", -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 2, 10086); /* 4. 执行 */ rc = sqlite3_step(stmt); /* 5. 判断返回值 */ if (rc != SQLITE_DONE) { fprintf(stderr, "execute error: %s\n", sqlite3_errmsg(db)); } /* 6. 释放语句对象 */ sqlite3_finalize(stmt); /* 7. 立即读取受影响行数 */ int affected = sqlite3_changes(db);最后一步sqlite3_changes必须紧跟执行语句之后读,中间不要插入其他修改语句,否则数字就不对了。这一步雷区极大,我放在第 4 章专门展开。
2. UPDATE 实操链路:绑定参数、执行、再确认真改对了
2.1 参数占位符和 bind 系列函数的三个细节
bind 系列函数使用频率最高的就三个:
sqlite3_bind_int(stmt, 索引, 整数值); sqlite3_bind_int64(stmt, 索引, 64位整数值); sqlite3_bind_text(stmt, 索引, 字符串, 长度, 析构回调);sqlite3_bind_text的第四个参数是字符串长度,传 -1 表示让 SQLite3 自己用strlen去数。这里有个隐藏细节:如果你要绑定的字符串中间包含\0,必须把实际长度传进去,否则字符串会从\0处截断。比如绑定一段二进制数据或者带内嵌空字符的文本时,这就是个很大的坑。
第五个参数是析构回调,通常两个选择:
SQLITE_STATIC:表示 SQLite3 不复制字符串,只保存指针,由调用方负责保证字符串在 statement 存活期间有效。如果传的是栈上临时变量、或者后面会被 free 的内存,绝对不能用这个。SQLITE_TRANSIENT:SQLite3 会复制一份数据,绑定完成后原字符串可以随时释放。这是最省心的选择。
经验就是:没把握时一律用SQLITE_TRANSIENT,代价只是多一次内存拷贝,但能避免一堆诡异崩溃。
还有两个容易忽视的细节。第一,绑定索引从 1 开始,不是 0,这是 SQLite3 和很多数组习惯截然不同的地方,开头提到的"所有记录被改成同一个值"就是索引从 0 开始的后果。第二,如果 statement 里有参数没绑定,sqlite3_step会返回错误,错误信息里会明确说哪个参数没绑,但最好还是养成先 bind 再 step 的顺序习惯。
2.2 sqlite3_step 为什么返回 DONE,而不是 ROW
刚开始接触 C API 的开发者容易把sqlite3_step的返回值搞混。SELECT 语句执行时,sqlite3_step每次返回SQLITE_ROW,你要在一个循环里反复调用拿每一行数据,直到返回SQLITE_DONE。但 UPDATE 和 DELETE 不一样,这两类语句不产生结果集,正常情况下第一次调用sqlite3_step就直接返回SQLITE_DONE,表示语句已经执行完了。
如果你把 SELECT 的循环写法套在 UPDATE 上,错误地判断SQLITE_ROW才算成功,那么 UPDATE 明明执行成功了,你的代码却认为它失败了——这类问题排查起来非常耗时间。正确逻辑是:
rc = sqlite3_step(stmt); if (rc == SQLITE_DONE) { /* UPDATE 或 DELETE 执行成功 */ } else { /* 这里再细化错误码 */ }如果返回的是SQLITE_CONSTRAINT这类错误,可以用sqlite3_extended_errcode(db)拿更精确的扩展错误码,比如SQLITE_CONSTRAINT_UNIQUE、SQLITE_CONSTRAINT_NOTNULL、SQLITE_CONSTRAINT_FOREIGNKEY。排错时比只看一个笼统的SQLITE_CONSTRAINT高效太多。
2.3 跟上 sqlite3_changes,别被 SQLITE_OK 骗了
SQLITE_OK只能说明 SQL 语句被成功执行,不能说明"数据确实被改了"。举一个最常见的场景:你要把 id 为 999 的用户改名,但表里根本没有 id=999 的记录。UPDATE 语句合法、执行成功、不报任何错误,只是 WHERE 条件没匹配到任何行。
这时sqlite3_changes(db)返回 0,才是判断"到底有没有改动数据"的正确依据。
int affected = sqlite3_changes(db); if (affected == 0) { printf("没有匹配到任何行,请检查条件\n"); } else { printf("实际修改了 %d 行\n", affected); }之前有同学问我:"我执行 UPDATE 返回 SQLITE_OK,但另一个线程读到的还是旧值,是不是数据库没提交?"这种问题多半就是 WHERE 条件没匹配到数据。所以我在代码里有个固定习惯:凡是 UPDATE,执行完立刻读sqlite3_changes,如果期望影响行数大于 0 而实际是 0,就要打印日志或者直接报错,绝不放过。
2.4 冲突策略:UPDATE OR IGNORE / REPLACE 什么时候用
UPDATE 不像 INSERT 那样经常撞唯一约束,但也不少见。假设业务表里用户名字段有唯一索引,你把一条记录的名字改成另一个已存在的名字,就会触发约束冲突。默认行为是ABORT:冲突发生时,整个 UPDATE 语句直接回滚,返回SQLITE_CONSTRAINT。
SQLite3 提供可选的冲突策略,写在 UPDATE 后面:
UPDATE OR IGNORE users SET name = 'xxx' WHERE id = 1; UPDATE OR REPLACE users SET name = 'xxx' WHERE id = 1; UPDATE OR FAIL users SET name = 'xxx' WHERE id = 1; UPDATE OR ROLLBACK users SET name = 'xxx' WHERE id = 1;几个策略的区别,我整理成一张表方便对比:
| 策略 | UPDAT 遇到冲突时的行为 | 适用场景 |
|---|---|---|
| ABORT(默认) | 回滚当前语句,不结束事务 | 严格要求不出错 |
| FAIL | 语句在冲突点停止,已执行部分不生效 | 避免部分修改 |
| IGNORE | 跳过冲突行,继续执行其他行 | 批量更新,允许个别行不动 |
| REPLACE | 删除冲突的旧行,插入新行 | 需要用新数据覆盖旧数据 |
| ROLLBACK | 回滚整个事务 | 简单粗暴,不常用 |
需要提醒的是,UPDATE OR REPLACE的行为是"先删旧行再插新行",如果你的表有外键约束并且定义了级联删除,那个"先删旧行"的动作可能把其他表里的关联数据也带走了。这个副作用在业务代码里很容易漏看,我自己就在一次测试中无意触发过级联删除,好在数据不关键。
3. DELETE 实操链路:没报错不代表真删掉了
3.1 DELETE 的基础代码和 WHERE 的必要性
DELETE 的 C API 代码和 UPDATE 几乎一样,区别只在 SQL 语句本身。一个带参数绑定的标准 DELETE 长这样:
static int delete_user(sqlite3 *db, int user_id) { const char *sql = "DELETE FROM users WHERE id = ?1;"; sqlite3_stmt *stmt = NULL; int rc; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); return -1; } sqlite3_bind_int(stmt, 1, user_id); rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { fprintf(stderr, "delete failed: %s\n", sqlite3_errmsg(db)); return -1; } return sqlite3_changes(db); }这段代码看着无懈可击,但注意我写 SQL 时永远带着 WHERE。DELETE 语句如果漏掉 WHERE,就是清空整张表。C API 不会像某些图形化工具那样弹窗问你"确定要删除全部吗",它就是直接执行,没有任何二次确认。
我自己见过一次事故:一个清理脚本里 DELETE 语句没写 WHERE,一条语句把几万行历史数据全清了,幸好有备份才救回来。那次之后我给自己定了个代码规范:所有 DELETE 语句在进入sqlite3_prepare_v2之前,代码里做一个字符串检查,如果发现 SQL 里没有 WHERE 关键字(或者没有 LIMIT 子句),直接拒绝执行并报错。这是个人的防呆习惯,不能依赖数据库层。
3.2 清空整表的三种方案:DELETE、DROP+CREATE、VACUUM
虽然我上面说"别删全表",但有些场景确实需要清空整表数据,比如缓存表每天凌晨清一次。这时有三种做法,取舍完全不同:
| 方案 | 操作 | 速度 | 是否重置自增ID | 触发DELETE触发器 | 磁盘空间 |
|---|---|---|---|---|---|
| DELETE FROM t | 逐行删除 | 慢 | 不重置 | 会 | 文件不缩小 |
| DROP + CREATE | 删表再建表 | 极快 | 重置 | 不会 | 文件不缩小 |
| DELETE + VACUUM | 删行后整理 | 中等 | 不重置 | 会 | 会缩小 |
如果表结构固定、且你认为不能再重建(比如有大量索引、触发器),那么DELETE FROM t是唯一符合逻辑的选择,至少它还能触发DELETE触发器。但这样做之后自增 ID 不会从 1 重新开始——即使表空了,下一次插入还是接着之前的最大值 +1。要重置自增计数,SQLite3 里有个特殊做法:
DELETE FROM sqlite_sequence WHERE name = '表名';只有当该表创建时用了AUTOINCREMENT关键字,sqlite_sequence 里才会有对应记录。如果只是 INTEGER PRIMARY KEY,没有 AUTOINCREMENT,那么 DELETE 后不重置 sqlite_sequence 也没意义,因为 ID 是从MAX(rowid)+1继续的,表空了 rowid 会自动从 1 重新开始。
如果表经常被整表清空,而且不依赖 DELETE 触发器,我更推荐DROP TABLE再CREATE TABLE,一条语句的事,快得多。缺点是 DROP 会把表结构也带走,你得有个地方存着 CREATE TABLE 语句。
3.3 外键约束和级联删除:C API 里埋得最深的坑
SQLite3 的外键约束默认是关闭的。你建表时写了REFERENCES,不做任何设置的情况下,DELETE 父表记录时子表完全不受影响,也不会报错。想要外键生效,每个连接都必须显式打开:
PRAGMA foreign_keys = ON;这个PRAGMA是连接级的,不是数据库级的。也就是说,每次sqlite3_open之后都要单独执行一次,不继承、不记忆。很多人建了外键,程序里删数据时发现子表数据还在,第一反应是 SQL 写错了,其实只是没开这个开关。
打开外键约束后,如果你的外键定义带ON DELETE CASCADE,情况就变得隐蔽了:
CREATE TABLE parent ( id INTEGER PRIMARY KEY ); CREATE TABLE child ( id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent(id) ON DELETE CASCADE );此时执行DELETE FROM parent WHERE id = 1,表面上你只删了父表一行,实际上子表里所有parent_id = 1的记录也被自动删掉了。这一点单独看没什么,但结合sqlite3_changes就很有意思:这个函数返回的是"这条语句直接修改 + 所有触发器/外键动作修改"的总行数,也就是说,父表删 1 行、子表删 10 行,sqlite3_changes(db)返回 11。
如果你的业务依赖统计删除数量做日志记录,这个数字可能会让你懵半天。我第 4 章会专门展开。
再提醒一个版本特性:SQLite3 3.35.0 之后的版本支持DELETE ... RETURNING,可以返回被删除行的内容,对归档删除特别有用:
DELETE FROM logs WHERE create_time < '2020-01-01' RETURNING id, title;在 C API 里,RETURNING会让sqlite3_step像 SELECT 一样先返回SQLITE_ROW,你需要循环消费所有返回行,最后才拿到SQLITE_DONE。配合sqlite3_changes还能确认删了多少行。如果你在逐行输出过程中发现返回行数和你预期的删除数对不上,大概率是外键级联或者触发器在背后动了手脚。
4. 最容易搞混的两个函数:sqlite3_changes 与 sqlite3_total_changes
4.1 文档措辞的准确理解
sqlite3_changes(db)和sqlite3_total_changes(db)这两个函数,名字只差一个 total,功能完全不是"近似"的关系,我希望你一次就记清楚:
sqlite3_changes(db):返回最近一条完成的 INSERT/UPDATE/DELETE 语句影响的行数。注意"最近"这个词是滚动变化的,每执行完一条修改语句,这个值就被覆盖一次。sqlite3_total_changes(db):返回这个连接从打开到现在,所有 INSERT/UPDATE/DELETE 语句影响行数的累计总和。
两个函数都包含触发器和外键级联动作造成的行数变化,这一点尤其容易忽略。另外 SELECT 不算,因为它不是修改语句,不会影响这两个函数的返回值(但会影响你对"最近"的理解,见下文)。
实际操作建议是:想判断"刚才这条 UPDATE 改了几行",就在sqlite3_finalize(stmt)之前或之后立刻调用sqlite3_changes(db),中间不要执行任何其他修改语句。如果你的程序是多线程共享一个连接的,那情况更复杂——同一个连接的所有线程共享这个计数,谁后执行谁覆盖,这时候最好给每个线程独立的连接,或者用sqlite3_total_changes做差值计算。
4.2 触发器会改变统计口径
前面提到外键级联删除会让sqlite3_changes返回总行数,触发器也是一样的道理。比如:
CREATE TRIGGER trg_log_delete AFTER DELETE ON users BEGIN DELETE FROM audit_log WHERE user_id = OLD.id; END;现在你执行DELETE FROM users WHERE id = 1,假设 users 表删了 1 行,触发器里的 DELETE 又删了 25 行审计日志。sqlite3_changes(db)返回的不是 1,而是 26。
如果你的代码里用这个值做"删除用户数"统计,就会凭空多出 25。要精确知道"直接删了几行",SQLite3 官方没有提供一个"只统计直接修改行数"的 API,常规做法要么是调整触发器逻辑,要么用RETURNING直接数返回行,要么查询系统表计数。这个细节文档里写得不算醒目,属于典型的一行字带过、实际操作炸坑的类型。
4.3 我踩过的那个"changes 变成 0"的坑
有一次我写批量同步工具,循环执行 UPDATE,然后读sqlite3_changes做进度统计。结果发现第一轮正常,第二轮开始 changes 全部变成 0,但数据其实都改成功了。排查了半天才发现,问题出在循环里的一个sqlite3_exec(db, "SELECT 1", ...)检查语句上——它本身不改数据,不会覆盖 changes,真正的原因是循环内先调用了一个"更新计数器"的辅助函数,里面偷偷执行了一条 UPDATE,这条 UPDATE 匹配 0 行,于是把 changes 覆盖成了 0。
这个案例说明:sqlite3_changes是"最近一条修改语句"的计数,不是"上一个操作"的计数。你在调它之前做的任何操作,哪怕只是一条匹配不到行的 UPDATE,或者一条空操作,都会把这个值冲掉。所以最稳妥的模式是:
sqlite3_step(stmt); int affected = sqlite3_changes(db); /* 紧跟其后,不做任何其他操作 */ sqlite3_finalize(stmt);甚至sqlite3_finalize我建议都放到读完 changes 之后再做。虽然 finalize 本身不会改这个计数,但少一步不确定性,代码从来不怕"过度防御"。
sqlite3_total_changes的典型用途是:在事务里先记录开始时的总和,处理完一批以后再次读取,两个值做差,得到这个批次的准确改动量。这样即使中间有其他代码偷偷跑了修改语句,差值也能把它们算进去,不会像changes那样被覆盖。
5. 批量改删的性能分水岭:事务与分批策略
5.1 为什么逐条改动慢得像蜗牛
很多初学者在 C 代码里循环执行 UPDATE,一条一条改:
for (int i = 0; i < 10000; i++) { /* prepare + bind + step + finalize,一万次 */ }跑起来以后发现慢得离谱。问题不在 prepare 本身,而在于 SQLite3 的自动提交模式:在默认情况下,你没有显式开启事务时,每一条 INSERT/UPDATE/DELETE 语句都是一个独立事务,执行完自动提交。每次提交都要把改动写入日志文件、做磁盘同步,这个fsync的开销是毫秒级的。一万条语句就是一万次磁盘同步,慢是必然的。
把一万条 UPDATE 包进一个事务后,日志文件只要写一次、同步一次,整体耗时可能只有原来的几十分之一。我第一次做这个优化的时候,体感非常明显,从几十秒降到几百毫秒,这就是事务的威力。
这不是 SQLite3 独有的问题,几乎所有数据库在批量写入时都要考虑减少事务提交次数。只不过 SQLite3 的单条自动提交特别"勤快",把问题放大了。
5.2 在 C API 里正确使用事务
事务在 C API 里操作很简单,用sqlite3_exec执行BEGIN和COMMIT即可:
sqlite3_exec(db, "BEGIN TRANSACTION;", NULL, NULL, NULL); for (/* 批量处理循环 */) { /* prepare + bind + step 执行 UPDATE/DELETE */ /* 出错了可以直接跳转到 rollback */ } sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL);注意几个要点:
BEGIN之后,如果循环里遇到错误,必须走到sqlite3_exec(db, "ROLLBACK;", ...),否则连接停留在事务中,其他写入操作会被阻塞。- 同一个 prepared statement 可以重复利用:先
sqlite3_prepare_v2一次,循环里只做 bind + reset + step,比循环里反复 prepare/finalize 又快一个档次。sqlite3_reset(stmt)用于把 statement 重置到可执行状态,同时不清空绑定参数。 - 事务不是越大越好。一个事务里塞了太多改动,日志文件和内存占用都会飙升,提交时也可能因为单次操作太多而变慢。
我之前在一个数据迁移脚本里,一个事务处理了一百多万行,结果COMMIT本身卡了很久,最后干脆改成每 5 万行提交一次,整体时间反而更短。事务粒度需要实测,不是越大越强。
5.3 DELETE ... LIMIT 分批删除的实战写法
批量删除有一个通用难题:如果一次性把所有旧数据都 DELETE 掉,事务很大,写锁持有时间很长,而且日志文件会被撑得很大。更稳的做法是分批删,比如每次删 1000 行,循环执行,直到删除行数为 0。
SQLite3 的 DELETE 支持LIMIT,这个分批写法非常干净:
static int batch_delete_old_logs(sqlite3 *db, const char *cutoff_time) { const char *sql = "DELETE FROM logs " "WHERE create_time < ?1 " "ORDER BY create_time ASC " "LIMIT 5000;"; sqlite3_stmt *stmt = NULL; int total = 0; int rc; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { return -1; } for (;;) { sqlite3_bind_text(stmt, 1, cutoff_time, -1, SQLITE_TRANSIENT); rc = sqlite3_step(stmt); if (rc != SQLITE_DONE) { sqlite3_finalize(stmt); return -1; } int deleted = sqlite3_changes(db); if (deleted == 0) { break; } total += deleted; /* 每批结束,重置 statement 以便下一轮使用 */ sqlite3_reset(stmt); } sqlite3_finalize(stmt); return total; }这个函数的精髓在一个break条件:deleted == 0说明没有更多符合条件的记录了,循环终止。配合ORDER BY create_time ASC,删除顺序是从最老到最新,执行计划也稳定。每批 5000 这个数字不是定死的,小表 1000 也行,大表 10000 也行,没有绝对标准。
还有一点:如果日志表特别大,DELETE 循环期间不要让整个事务包住所有批次,而是每批一个事务。这样即使中途出错,最多回滚 5000 条,不会把一小时的工作量全部作废。
6. 删完文件没变小:freelist、VACUUM 和 auto_vacuum
6.1 数据库文件为什么不缩水
刚用 SQLite3 的人基本都会遇到这个问题:删了几万行数据,用文件管理器一看,数据库文件大小纹丝不动,甚至不降反升。这是因为 SQLite3 删除数据时,只是把那些数据页标记为"空闲",放进了空闲页列表(freelist),文件本身并不截断。
打个比方:你把一个房间里的家具搬走了,但房子的外墙没拆,整体面积不变。这些空闲页会被 SQLite3 留着,将来插入新数据时优先复用。所以如果删完马上又要插入等量的数据,文件不缩水反而是好事,减少了页分配的开销。
想查看当前空闲页数量:
PRAGMA freelist_count;每个空闲页默认大小是 4096 字节(可通过PRAGMA page_size查看),空闲页数量乘以页大小,就是"已经被标记为空闲但文件没有释放"的空间量。
6.2 什么时候值得 VACUUM
VACUUM命令会重建整个数据库文件,把数据页紧凑地排列一遍,丢掉空闲页,文件体积就会真正缩小。调用方式也简单:
int rc = sqlite3_exec(db, "VACUUM;", NULL, NULL, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "vacuum failed: %s\n", sqlite3_errmsg(db)); }但VACUUM不是免费的,代价和注意事项都很明确:
- 它需要自己的临时文件,磁盘磁盘空间大约要为原库大小的 1 到 2 倍,空间不足时 VACUUM 会失败。
- 它需要拿到数据库的排他锁,在 VACUUM 期间其他读写都会受影响,高并发环境要格外小心。
- 它不能在事务内执行,如果当前连接有未提交事务,VACUUM 会报错。
- VACUUM 之后最好重新执行
ANALYZE更新查询统计信息,因为页重新排列后,SQLite3 的查询计划可能受影响。
我的经验是:日常更新删除操作,完全不需要每次删完都 VACUUM。只有当大量删除之后、并且短期内不会再大量插入、数据库文件又确实需要瘦身时,才值得做一次。比如归档脚本每月跑一次,把过期数据清掉后 VACUUM 一下,文件体积能明显降下来。
如果删完数据没有空间压力,就让它留着 freelist,放着也不碍事。VACUUM 过度使用只会带来无谓的磁盘 I/O 和锁竞争。
6.3 auto_vacuum 应该在建库时就决定
如果不希望手动 VACUUM,SQLite3 也提供PRAGMA auto_vacuum自动回收机制。它有三个值:
0(NONE,默认):不自动回收,文件一直保持已有大小。1(FULL):每次事务提交时自动把空闲页截断回文件末尾,文件会持续保持紧凑。2(INCREMENTAL):事务提交时不自动截断,但你可以随时执行PRAGMA incremental_vacuum;手动回收,控制更灵活。
auto_vacuum这个设置有个大坑:它必须在数据库创建之后、写入大量数据之前就设置好,而且设置后往往需要配合一次 VACUUM 才能真正生效。如果你已经运行了一段时间、删了无数数据,再改:
PRAGMA auto_vacuum = FULL; VACUUM;这样才能把现有空闲页清理掉,并且后续开启自动回收。
性能上,auto_vacuum = FULL在频繁删除的场景里很方便,但每次提交都要整理页,额外 I/O 开销其实不小。反之,默认的 NONE 模式虽然文件"虚胖",但写入性能最好。这就是典型的空间换时间,权衡之后我通常选择默认 NONE + 定期 VACUUM,除非数据库文件体积特别敏感、删除又非常频繁,才会用 FULL。
最后再分享一个小经验:任何大批量改动之前,先备份数据库文件。SQLite3 支持在线备份 API,不贵也不复杂,但那是另一篇笔记的内容了。这一篇讲的 UPDATE 和 DELETE,核心就三件事——参数绑定别从 0 开始、行数确认紧跟语句、批量改动务必包事务。这三条记住了,大部分坑你都能绕过去。
我实际写了很多个 SQLite3 项目之后才发现,改数据看起来是最不起眼的操作,真正把细节处理好的人反而少。希望这篇笔记能让你在 C API 这条路上少折腾几天。