恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SQLite3 C API中UPDATE与DELETE的实战细节与避坑指南
首页
资讯中心
/
SQLite3 C API中UPDATE与DELETE的实战细节与避坑指南
SQLite3 C API中UPDATE与DELETE的实战细节与避坑指南
发布时间:2026/10/11 19:53:20
先说个我自己的真实经历有一回我给一个小工具加数据清洗功能用 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 三个阶段只是对用户不可见。比如一条不带用户输入的 UPDATEconst 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_TRANSIENTSQLite3 会复制一份数据绑定完成后原字符串可以随时释放。这是最省心的选择。经验就是没把握时一律用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 的用户改名但表里根本没有 id999 的记录。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、DROPCREATE、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_changes4.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_vacuum6.1 数据库文件为什么不缩水刚用 SQLite3 的人基本都会遇到这个问题删了几万行数据用文件管理器一看数据库文件大小纹丝不动甚至不降反升。这是因为 SQLite3 删除数据时只是把那些数据页标记为空闲放进了空闲页列表freelist文件本身并不截断。打个比方你把一个房间里的家具搬走了但房子的外墙没拆整体面积不变。这些空闲页会被 SQLite3 留着将来插入新数据时优先复用。所以如果删完马上又要插入等量的数据文件不缩水反而是好事减少了页分配的开销。想查看当前空闲页数量PRAGMA freelist_count;每个空闲页默认大小是 4096 字节可通过PRAGMA page_size查看空闲页数量乘以页大小就是已经被标记为空闲但文件没有释放的空间量。6.2 什么时候值得 VACUUMVACUUM命令会重建整个数据库文件把数据页紧凑地排列一遍丢掉空闲页文件体积就会真正缩小。调用方式也简单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 应该在建库时就决定如果不希望手动 VACUUMSQLite3 也提供PRAGMA auto_vacuum自动回收机制。它有三个值0NONE默认不自动回收文件一直保持已有大小。1FULL每次事务提交时自动把空闲页截断回文件末尾文件会持续保持紧凑。2INCREMENTAL事务提交时不自动截断但你可以随时执行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 这条路上少折腾几天。