这个系列更到第 6 篇了。老读者都知道,SQLite3 学习笔记不太喜欢照着官方文档翻译,更愿意记录实际撸代码时踩过的坑、验证过的写法。前几篇我们搞定了打开/关闭数据库、建表、插入、查询,数据能写进去也能查出来,那下一步自然是改和删。这篇就专门把 UPDATE(改)和 DELETE(删)这两类操作在 C API 里怎么落地讲透,重点放在参数绑定、影响行数判断、事务批处理这几个绕不开的卡点上。
先说清楚这篇笔记适合谁。如果你用 C/C++ 调 SQLite3,已经会建表、插数据,但写 UPDATE 和 DELETE 时还在用 sprintf 拼 SQL,或者不确定 sqlite3_step 到底该调用几次,那这篇就是冲着你来的。如果你是刚接触 SQLite3 的新手,前几篇没看过也没关系,这篇会把相关的 API 用法重新过一遍,保证能跟上。
1. UPDATE 和 DELETE 在 C API 里的两条路
1.1 先记住一条红线:WHERE 子句不能丢
SQL 层面其实没什么新东西,UPDATE 就是"找出符合条件的行,改掉指定列",DELETE 就是"找出符合条件的行,删掉"。核心全在 WHERE 条件上。一个最典型的翻车现场:
UPDATE users SET age = 21; -- 上面这行会把整张表的 age 全部改成 21 DELETE FROM orders; -- 上面这行会直接把 orders 表清空,不是删几条,是全删没有 WHERE 的 UPDATE 和 DELETE,SQLite 会非常忠实地把它理解为"所有行"。这一点在任何数据库里都一样,SQLite 也不会特殊照顾你。实际开发里我见过有人把测试环境的一条 UPDATE 直接复制到生产,结果线上所有用户的等级全被重置了。所以后面所有例子里我都会先写 WHERE,这不是啰嗦,是保命。
1.2 两条 C API 路线:sqlite3_exec 和 prepare + bind
在 C API 里执行 UPDATE/DELETE,有且只有两条路可以走。
第一条路是 sqlite3_exec,适合 SQL 语句里不需要带变量的场景。比如定时任务里把所有未激活用户标记为已过期,条件是一个固定值:
const char *sql = "UPDATE users SET status = 'expired' WHERE status = 'active';"; char *errmsg = NULL; int rc = sqlite3_exec(db, sql, NULL, NULL, &errmsg); if (rc != SQLITE_OK) { fprintf(stderr, "更新失败: %s\n", errmsg); sqlite3_free(errmsg); }sqlite3_exec 内部帮你完成了 prepare、step、finalize 的完整流程,回调函数传 NULL 表示不需要结果集。UPDATE 和 DELETE 本来就不返回行数据,所以 NULL 完全够用。凡是 SQL 文本里不需要拼接任何变量的,无脑选这条路就行,代码最短,心智负担最小。
第二条路是 sqlite3_prepare_v2 + sqlite3_bind_xxx + sqlite3_step,适合 SQL 里带变量、参数多、或者同一句 SQL 要反复执行的场景。比如更新某个用户的余额,用户 ID 和金额都是运行时才知道的:
sqlite3_stmt *stmt = NULL; const char *sql = "UPDATE users SET balance = ?1, update_time = ?2 WHERE user_id = ?3;"; int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "prepare 失败: %s\n", sqlite3_errmsg(db)); return; } sqlite3_bind_double(stmt, 1, new_balance); sqlite3_bind_int64(stmt, 2, time(NULL)); sqlite3_bind_int(stmt, 3, user_id); rc = sqlite3_step(stmt); if (rc != SQLITE_DONE) { fprintf(stderr, "step 失败: %s\n", sqlite3_errmsg(db)); } else { printf("影响行数: %d\n", sqlite3_changes(db)); } sqlite3_finalize(stmt);注意那个 ?1、?2、?3,这就是占位符。后面对应的 sqlite3_bind_xxx 函数把实际的 C 变量传进去,bind 完再 sqlite3_step 执行。本质上 prepare 是"编译 SQL 模板",bind 是"填充参数",step 是"真正执行"。
这两条路的选型规则,我用一张表说清楚:
| 对比项 | sqlite3_exec | prepare + bind |
|---|---|---|
| 适用场景 | 固定 SQL、一次性执行 | 带变量的 SQL、反复执行 |
| 代码量 | 少 | 多一些,但结构固定 |
| SQL 注入风险 | 拼接变量时极高 | 天然免疫 |
| 重复执行性能 | 每次重新编译 SQL | 复用语句对象,性能更好 |
| 绑定复杂类型 | 不支持 | 支持 blob、文本、整数、浮点、NULL |
我的习惯是:SQL 里只要出现一个来自外部输入的变量,就直接走 prepare + bind,不给自己留拼接的余地。原因接下来这一节展开讲。
2. 参数绑定:从字符串拼接进化到占位符体系
2.1 为什么不用 sprintf 拼 SQL
很多刚上手 C API 的人,写 UPDATE 第一反应都是这样:
char sql[512]; snprintf(sql, sizeof(sql), "UPDATE users SET balance = %f WHERE user_id = %d;", new_balance, user_id); rc = sqlite3_exec(db, sql, NULL, NULL, &errmsg);这段代码在功能上能跑通,但它是一颗定时炸弹。第一个问题是转义,如果变量是字符串,里面带了单引号,整个 SQL 直接就语法错误了;第二个问题是整数溢出、浮点精度这些类型匹配隐患;第三个问题是最要命的——SQL 注入。用户输入的内容一旦混进 SQL 文本,就等于把执行权交了出去。
可能有人觉得"我这是内部工具,没有外部输入,拼接没毛病"。但我亲眼见过内部工具里拼出来的 SQL,因为一个用户昵称里带着单引号,导致整批更新任务中断。后来统一改成参数绑定,再也没出过这类幺蛾子。所以我的建议很直接:别拼,一次都不要拼。
2.2 绑定函数家族与参数编号
prepare + bind 这套体系,核心是占位符。SQLite 一共支持五种占位符写法:
| 写法 | 示例 | 说明 |
|---|---|---|
| ? | balance = ? | 按顺序匹配,第几个 ? 就是第几个参数 |
| ?NNN | balance = ?1 | 带编号的匿名参数,推荐使用 |
| :AAAA | balance = :amount | 带名字的参数,适合 SQL 较长、参数多的情况 |
| @AAAA | balance = @amount | 同 :AAAA,兼容其他数据库的习惯写法 |
| $AAAA | balance = $amount | 同 :AAAA,历史遗留写法 |
我个人推荐统一用 ?NNN 格式。原因很简单:可读性好,参数顺序一目了然,出问题时好排查。:amount 这种命名参数看起来优雅,但 C 语言里对应绑定要用 sqlite3_bind_parameter_index 去查索引,多一步不说,还不方便代码审查。
绑定函数的选择直接对应 C 变量的类型:
- sqlite3_bind_int:32 位整数,适合 int
- sqlite3_bind_int64:64 位整数,适合 long long、sqlite3_int64、time_t
- sqlite3_bind_double:双精度浮点,适合 double,也适合存金额(前提是你用 double 存钱)
- sqlite3_bind_text:UTF-8 字符串,适合 char*
- sqlite3_bind_text16:UTF-16 字符串,基本用不上
- sqlite3_bind_blob:二进制数据,适合 unsigned char* 缓冲区
- sqlite3_bind_null:NULL
绑定的时候要注意参数编号从 1 开始,不是从 0 开始。这个坑几乎每个人都踩过。第 0 个参数是 SQLite 保留的,绑了它,sqlite3_step 会直接报 SQLITE_RANGE。另外,如果少绑了一个参数,SQLite 不会在 prepare 时报错,而是在 sqlite3_step 执行时报错,错误信息是"not all parameters were bound",排查起来需要回头数一遍占位符。
2.3 sqlite3_bind_text 的第五个参数,坑王之王
绑定字符串时,函数签名是:
int sqlite3_bind_text(sqlite3_stmt*, int, const char*, int n, void(*)(void*));前三个参数好理解:语句对象、参数编号、字符串指针。第四个参数 n 是字符串长度,传 -1 表示让 SQLite 自己用 strlen 算。真正磨人的是第五个参数——析构回调。
这个参数的作用是告诉 SQLite:这个字符串内存归谁管。三个经典取值:
- SQLITE_STATIC:字符串指针在整个 prepare 执行期间一直有效,SQLite 不复制、不管理。适合传字符串字面量。
- SQLITE_TRANSIENT:SQLite 内部立刻复制一份,bind 之后你随便改原字符串,不影响执行。这是最安全的选择。
- 自定义析构函数指针:SQLite 在不再需要字符串时调用这个函数释放内存。适合绑定时 malloc 出来的字符串。
新手最容易犯的错是:在栈上定义一个 char buffer,调用 sqlite3_bind_text(stmt, 1, buffer, -1, SQLITE_STATIC),然后函数返回,栈内存失效,但 SQLite 还拿着那个指针等着用。等到 sqlite3_step 执行时,读到的已经是野指针,轻则数据错乱,重则崩溃。
解决办法就是一律用 SQLITE_TRANSIENT,让 SQLite 自己去复制一份。代价只是多一次内存拷贝,但对于 UPDATE/DELETE 这种写操作来说,这点开销完全可忽略。自己 malloc 出来的字符串,如果懒得管理生命周期,也可以用 SQLITE_TRANSIENT 让 SQLite 复制,复制完原来那块内存自己 free 就行。
3. 影响行数:sqlite3_changes 的妙用
3.1 判断"条件更新"是否真的生效
UPDATE 执行完,你是不是以为只要 sqlite3_step 返回 SQLITE_DONE 就万事大吉了?不是的。SQLITE_DONE 只能说明执行成功,但影响了几行,完全看不出来。这时候就得用 sqlite3_changes。
之前维护一个积分系统时踩过一次坑:用户提交兑换请求,后台先扣积分再发奖品。扣积分用的就是条件更新:
UPDATE users SET points = points - 100 WHERE user_id = ? AND points >= 100;这条 SQL 的精妙之处在于,如果积分不够,WHERE 条件不成立,影响行数是 0,积分不会被扣成负数。但如果只检查 sqlite3_step 的返回值,SQLITE_DONE 照样返回,程序会以为扣款成功,直接发奖。这就产生了严重的业务漏洞。
正确做法是 sqlite3_step 之后立刻读 sqlite3_changes:
rc = sqlite3_step(stmt); if (rc == SQLITE_DONE && sqlite3_changes(db) > 0) { // 更新成功,积分真的扣掉了 } else { // 积分不足或用户不存在,走失败流程 }这个模式在并发场景下尤其重要。SQLite 的 UPDATE 是原子的,条件更新 + 影响行数判断,比"先 SELECT 再 UPDATE"的方式可靠得多,因为不需要额外的锁,也不存在竞态窗口。
3.2 DELETE 的影响行数,用来确认清理结果
DELETE 也一样。如果你写了一个清理过期会话的任务:
const char *sql = "DELETE FROM sessions WHERE expire_at < ?1;"; // prepare + bind rc = sqlite3_step(stmt); int deleted = sqlite3_changes(db);deleted 这个数可以直接决定后续逻辑。如果影响行数为 0,说明没有过期数据,连日志都可以省了;如果影响行数异常地大,比如一次删了几万行,那就要警觉是不是 WHERE 条件写错了。我在项目里给所有 DELETE 都加了影响行数检查,影响行数超过预设阈值就打印 WARNING 日志,方便事后审计。
还有一个细节:sqlite3_changes 返回的是"最近一条 INSERT/UPDATE/DELETE 语句"影响的行数,而且是针对当前数据库连接的。如果你在一个连接里连续执行了多条写语句,sqlite3_changes 只会保留最后一条的结果,所以要在每条语句执行完立刻读取,不要攒着后面一起取。另外注意,UPDATE 时如果新值和老值完全相同,SQLite 会计入影响行数,这是它和其他某些数据库不太一样的地方,判断业务逻辑时要有预期。
3.3 性能与安全兼得:显式事务
SQLite 是文件型数据库,每一条写语句都隐式地开启一个事务,写完后立刻提交。如果循环里执行几千条 UPDATE,每一条都走一遍"开启事务 - 写 - 提交 - 刷盘"的流程,速度会慢到让你怀疑人生。
用一个具体的对比数据来说:在普通机械硬盘上,一次性事务里批量插入一万行只需要几十毫秒到几百毫秒,但如果每条语句单独提交,时间能拉到秒级甚至分钟级,差距是数量级的。
所以批量 UPDATE/DELETE 的标准姿势是手动控制事务:
sqlite3_exec(db, "BEGIN TRANSACTION;", NULL, NULL, NULL); int rc = SQLITE_OK; for (int i = 0; i < list_count; i++) { // prepare + bind + step 一条 UPDATE // 如果出错,记录 rc 并 break } if (rc == SQLITE_OK) { sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL); } else { sqlite3_exec(db, "ROLLBACK;", NULL, NULL, NULL); }这里有几个值得注意的点。
BEGIN 之前要检查返回值,如果返回非 SQLITE_OK,说明事务没开成功,后面就别执行写操作了。COMMIT 和 ROLLBACK 同样要检查,它们也可能失败。更稳妥的做法是 COMMIT 之后再用 sqlite3_changes 验证一下?没必要,COMMIT 成功就说明事务持久化了,前面 step 的影响行数已经被记录,只是 sqlite3_changes 这时候可能已经因为 COMMIT 被重置。所以影响行数要在 COMMIT 之前读取汇总。
还有一点:如果你在事务里执行了多条 UPDATE,并且中途出错回滚,前面成功执行的语句也会一起回滚。这正是事务的好处——要么全部生效,要么全部不生效。之前有一个数据迁移任务,因为没开事务,跑到一半报错退出了,结果表里一半数据是新的,一半是旧的,修了一下午才恢复。从那以后,凡是超过三条的写操作,我全部塞进显式事务。
4. 常见报错排查与避坑实录
4.1 SQLITE_CONSTRAINT:约束冲突不是 SQL 写错,是数据不合法
执行 UPDATE/DELETE 时报 SQLITE_CONSTRAINT(错误码 19),常见原因有这么几种。
第一种是违反唯一约束。比如 UPDATE 把 user_id 改成另一个已经存在的 ID,或者把 email 改成别人已经在用的邮箱,SQLite 会直接拒绝。
第二种是违反外键约束。被其他表引用的记录,DELETE 时默认会被拦住。SQLite 默认外键是关闭的,需要用 PRAGMA foreign_keys = ON 打开才能触发这个检查。很多人不知道这一点,测试时 DELETE 一直成功,上线之后发现外键约束没开,导致孤儿数据一堆。
第三种是 CHECK 约束。表结构里写了 CHECK (age >= 0),你 UPDATE 一个负数进去,照样报错。
排查思路很简单:先把 SQL 单独拿出来,在 sqlite3 命令行里跑一遍,看具体的错误信息。C API 里报错只给错误码,但 sqlite3_errmsg(db) 会返回具体描述,比如 UNIQUE constraint failed: users.email,一眼就能定位是哪条约束。
4.2 SQLITE_BUSY:数据库被锁了
SQLite 允许一个写者和多个读者并发访问,但两个写者不行。如果另一个连接持有了写锁,你这边执行 UPDATE 或 DELETE,就会拿到 SQLITE_BUSY(错误码 5)。
典型场景:程序里开了两个数据库连接,一个在事务里不断地写,另一个在这个时间点执行 DELETE,后者就会瞬间报错。更隐蔽的情况是,一个连接开启事务后忘了 COMMIT,事务一直挂着,导致其他连接永远写不进去。
两个解决方案。第一个是设置 busy_timeout,让 SQLite 在锁冲突时自动重试等待,而不是立刻报错:
sqlite3_busy_timeout(db, 5000); // 等待 5 秒第二个是开 WAL 模式,读写并行能力会好很多:
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);WAL 模式下读操作不会阻塞写操作,写操作之间依然互斥,但整体并发度提升明显。如果你是一个进程里多个线程各持一个连接访问同一个库,这个配置几乎是必需品。
4.3 sqlite3_step 的一步与多步:RETURNING 的陷阱
普通 UPDATE/DELETE 没有结果集,一次 sqlite3_step 就返回 SQLITE_DONE,标志执行完成。但 SQLite 3.35.0 之后支持了 RETURNING 子句,可以这样写:
DELETE FROM sessions WHERE expire_at < ?1 RETURNING user_id;这条语句执行时会返回被删除行的 user_id,等价于"先查出来,再删掉"。这时候 sqlite3_step 返回的是 SQLITE_ROW,需要像 SELECT 那样循环调用,直到返回 SQLITE_DONE 为止。第一次遇到这种情况的人,很容易因为看到 SQLITE_ROW 就以为语句异常,或者只调用一次 step 就停了,导致只处理了一行。
如果不确定自己的 DELETE 会不会返回数据,调试时可以直接看 sqlite3_step 的返回值。返回 SQLITE_ROW 就走循环取列的流程,返回 SQLITE_DONE 就说明语句是普通写操作,结束。
4.4 问题排查速查表
| 错误码 / 现象 | 错误码 | 最常见原因 | 解决办法 |
|---|---|---|---|
| SQLITE_CONSTRAINT | 19 | 违反 UNIQUE / CHECK / 外键约束 | 用 sqlite3_errmsg 看具体约束,修改数据 |
| SQLITE_BUSY | 5 | 另一个连接持有写锁 | 设置 busy_timeout,开启 WAL |
| 绑定后执行报 not all parameters bound | 21 | 占位符数量与 bind 次数不一致 | 数一遍 ?N,补齐缺失的 bind |
| 绑定编号从 0 开始报 SQLITE_RANGE | 25 | 编号写错 | 参数编号从 1 开始 |
| 字符串数据乱码或偶发崩溃 | - | bind_text 用了 SQLITE_STATIC 传临时缓冲区 | 换成 SQLITE_TRANSIENT |
| sqlite3_step 返回 SQLITE_ROW 但以为是成功 | 100 | 使用了 RETURNING 子句 | 循环 step 直到 SQLITE_DONE |
| 事务开了没提交,其他连接全部卡住 | 5 | BEGIN 后忘了 COMMIT | 检查所有分支路径,确保一定提交或回滚 |
4.5 删除前的最后一层保护
DELETE 的危险性比 UPDATE 更高,因为数据删了很难找回来(除非提前备份)。我个人的习惯是,凡是要执行 DELETE,代码里强制加一道检查:
// 先在同一事务里做条件查询,确认要删除的范围 sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); // SELECT COUNT(*) WHERE <条件>,确认数量在预期范围内 // 如果数量异常,ROLLBACK 并报错 // 如果数量正常,DELETE WHERE <条件>,COMMIT这套流程写起来多十几行,但能挡住绝大多数误删事故。尤其是面向用户的功能,删之前看一眼影响行数,心理踏实很多。
写在最后的调试心得
这个系列更到第 6 篇,UPDATE 和 DELETE 终于讲完了。我对 C API 最大的感受是:API 本身并不复杂,真正复杂的是数据安全和状态管理。UPDATE、DELETE 这两个操作,代码写起来也就十几行,但一旦在没加 WHERE、没开事务、没看影响行数的情况下上线,后面就是无穷无尽的麻烦。
根据我自己的经验,给所有 UPDATE/DELETE 封装一个统一的基础函数,内部强制做三件事:参数绑定、事务包裹、影响行数返回。上层业务只关心"影响了几行",完全不需要碰 SQL。这个函数写好之后,后面几百处调用都能受益,值得花一个下午打磨。
最后再分享一个小技巧:在测试环境用 sqlite3 命令行工具,快速跑一遍你准备执行的 UPDATE 或 DELETE,看它打印的 rows updated 或 rows deleted 数字,能在写 C 代码之前就发现 WHERE 条件写错的问题。命令行里的输出和 sqlite3_changes 的返回值是同一个东西,提前验证一遍,能省很多调试时间。