1. 写在前面:为什么读写是绕不开的第一步
SQLite3 在 C 项目里几乎是“自带数据库”的首选方案,小工具、嵌入式设备、本地缓存、桌面软件,到处都能看到它的身影。我自己做过的几个项目里,最少有三次是把数据从文件换成了 SQLite,原因无非就两个:一是查询逻辑比手写遍历文件简单太多,二是数据一致性有事务兜底,不用自己维护索引和落盘时机。
《SQLite3学习笔记》这个系列写到第五篇,前几篇基本把打开数据库、建表、编译语句这些准备工作讲透了。本文就聚焦两件事:怎么把数据写进去,怎么把数据读出来。围绕 C API 里的 INSERT 和 SELECT 展开,配合完整的代码示例、参数说明和我在实际开发中踩过的坑。
这篇笔记适合谁?刚入门 C 语言 + SQLite 的开发者,或者虽然用过 sqlite3_exec 但想深入了解预处理语句机制的人。如果你之前只会拼 SQL 字符串然后调用 sqlite3_exec,这篇笔记尤其值得看——因为你会发现,用预编译语句写代码,性能和安全性都能提升一个台阶。
我先把结论放在前面,免得你看到后面忘记重点:INSERT 和 SELECT 的核心不是 SQL 本身,而是 sqlite3_prepare_v2、sqlite3_bind_、sqlite3_step 和 sqlite3_column_这一组 API 的组合使用方式。理解这四类函数的关系,SQLite 的 C API 你就掌握了八成。
2. 读写数据的整体设计与思路拆解
2.1 核心流程:prepare — bind — step — finalize
SQLite 的 C 语言接口里,执行任何 SQL 语句(包括 INSERT 和 SELECT)都遵循一个固定流程,我用大白话描述一下:
第一步,把 SQL 文本“编译”成内部字节码,对应函数是 sqlite3_prepare_v2。这一步返回一个 sqlite3_stmt 指针,可以理解为一条待执行的语句对象。
第二步,如果 SQL 里有占位符(比如 ? 或 ?1),需要用 sqlite3_bind_* 系列函数给这些占位符绑定具体的值。
第三步,执行语句,对应函数是 sqlite3_step。对 INSERT 来说,调用一次 step 就执行完毕了。对 SELECT 来说,返回 SQLITE_ROW 就说明取到了一行数据,要继续获取下一行就再次调用 sqlite3_step。
第四步,语句用完后调用 sqlite3_finalize 释放资源。
这个流程初学者最容易犯的错是漏掉 prepare 直接 step,或者 bind 了参数却忘了 bind 的索引从 1 开始而不是从 0 开始。这两个小坑,我在后面会专门讲。
2.2 为什么建议用预编译语句而不是直接拼 SQL
很多人刚开始用 SQLite 都会图省事,用 sqlite3_exec 直接执行拼好的 SQL 字符串。比如:
char sql[256]; sprintf(sql, "INSERT INTO users(name, age) VALUES('%s', %d)", name, age); sqlite3_exec(db, sql, NULL, NULL, &err_msg);这么写的问题有两层。第一层是性能:如果要在循环里插入一万条数据,每一条都要把 SQL 字符串重新解析、编译一遍,开销非常大,实测下来往往比预编译语句慢好几倍。第二层是安全:数据里如果包含单引号,SQL 就会被截断或报错,这就是 SQL 注入的典型场景。
用 sqlite3_prepare_v2 加 sqlite3_bind_text 的方式,数据不进 SQL 文本,而是作为参数直接传给语句对象,既避免了注入,又能在循环里复用同一个语句对象,只重新绑定参数即可。这也是我在这篇笔记里例子的标准写法。
2.3 资源生命周期管理
C API 编程绕不开资源管理,SQLite 也不例外。每个 sqlite3_stmt 都是通过 malloc 分配的资源,必须用 sqlite3_finalize 释放。数据库连接 db 也必须用 sqlite3_close 关闭。
我在实际项目里看到过的典型问题:程序退出时忘了 finalize 所有语句,导致 SQLITE_BUSY 或 SQLITE_LOCKED 错误;还有忘记关闭数据库,导致数据没落盘。SQLite 在数据库关闭时如果还有未 finalize 的语句,会返回 SQLITE_BUSY。这个细节很坑,因为错误信息只显示 "database is locked",排查时容易往锁上面想,其实问题在于自己没释放语句对象。
我自己写代码的习惯是:每个语句对象的生命周期控制在 30 行以内,prepare 之后如果中途出错,立刻 finalize 并 return,绝不走到后面才处理。这个习惯帮我在调试时省了大量时间。
3. 核心 API 细节解析与实操要点
3.1 sqlite3_prepare_v2:建议直接用它,而不是旧版 prepare
sqlite3_prepare_v2 是在 SQLite 3.3.9 版本引入的增强版 prepare 函数,它相比旧版 sqlite3_prepare 有一个关键改进:在语句编译时确定了语句的返回值元数据,并且做了更严格的参数绑定检查。如果你用的是 v2 版本,某些错误的 SQL 会在 prepare 阶段就暴露出来,而不是等到 step 阶段才出错。
函数原型:
int sqlite3_prepare_v2( sqlite3 *db, const char *zSql, int nByte, sqlite3_stmt **ppStmt, const char **pzTail );nByte 参数通常传 -1,表示让 SQLite 根据字符串长度自动识别 SQL 结尾。pzTail 参数是一个输出参数,如果 SQL 字符串里包含多条语句,那么第一条语句执行完后,pzTail 会指向剩余部分的起始位置。不过我通常只传入 NULL,因为一次只编译一条语句,保持代码简洁。
返回值是 SQLITE_OK 就表示成功,其他返回值需要通过 sqlite3_errmsg(db) 获取详细错误信息。
3.2 参数绑定:索引从 1 开始,这一点得刻进脑子里
SQLite 的绑定接口设计得非常“反人类”,对习惯了数组下标从 0 开始的人来说,第一次用几乎必错。sqlite3_bind_* 系列函数的参数索引是从 1 开始的,第 1 个问号对应索引 1,第 2 个问号对应索引 2,以此类推。
常用绑定函数如下:
| 函数 | 绑定类型 | 使用场景 |
|---|---|---|
| sqlite3_bind_int | int | 整数、ID、计数 |
| sqlite3_bind_int64 | sqlite3_int64 | 大数据量整数、时间戳 |
| sqlite3_bind_double | double | 浮点数 |
| sqlite3_bind_text | const char*, int n, 回调 | 字符串 |
| sqlite3_bind_blob | const void*, int n, 回调 | 二进制数据 |
| sqlite3_bind_null | 无 | 显式置 NULL |
这些函数的前两个参数都一样:stmt 和 index。第三个参数根据类型变化,字符串和二进制数据额外需要一个长度参数和一个销毁回调。
销毁回调一般写成 SQLITE_TRANSIENT(表示 SQLite 内部会复制数据)或 SQLITE_STATIC(表示数据指针在语句执行期间有效)。如果传入的是栈上临时变量,务必使用 SQLITE_TRANSIENT,我在 3.4 小节详细解释原因。
3.3 字符串绑定和长度陷阱
sqlite3_bind_text 的完整签名是:
int sqlite3_bind_text( sqlite3_stmt *stmt, int index, const char *zData, int nData, void (*xDel)(void*) );其中 nData 是字符串字节长度,不是字符个数,更不是数组大小。如果传 -1,SQLite 会按照 C 字符串的规则自动计算长度,直到遇到 '\0'。
这里有一个容易忽略的问题:如果你有一个包含 '\0' 字节的字符串(比如读文件内容),传 -1 就只会保存到第一个 '\0' 为止,后面的数据全都丢了。此时必须显式传入字节长度。
另外注意中文和多字节字符:SQLite 保存文本时按字节处理,UTF-8 编码的汉字占 3 个字节,如果你的程序内部是 UTF-8 编码,直接绑 UTF-8 数据即可,长度还是按字节算,不是按字符算。
const char *name = "张三"; sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT);如果 name 是 std::string,长度用 (int)str.size(),如果 name 是 C 字符数组,长度可以用 strlen(name),推荐统一传字节长度,避免歧义。
3.4 SQLITE_TRANSIENT 和 SQLITE_STATIC 怎么选
这是 C API 里最容易造出隐晦 bug 的一个细节。
SQLITE_TRANSIENT 告诉 SQLite 把绑定数据复制一份到内部缓冲区,之后就算你修改或释放了原始缓冲区,也不影响语句执行。所以数据指针来自临时变量时,用 SQLITE_TRANSIENT。
SQLITE_STATIC 告诉 SQLite 数据指针在语句 finalize 之前一直有效,可以做“零拷贝”优化,节省一次内存复制。但是代价是,如果你在语句执行完之前修改了那块内存里的数据,查询和写入就会出错。
我的建议:除非你有十分明确的性能诉求,并且能保证缓冲区生命周期,否则一律用 SQLITE_TRANSIENT。一个绑定数据的 cstring 变量的生命周期是当前作用域,而语句的生命周期可能很长,这个时间差坑了不少人。
3.5 SELECT 的结果读取
SELECT 执行时,sqlite3_step 返回 SQLITE_ROW,就表示当前指针指向一行结果,可以用 sqlite3_column_* 系列函数读取这一行的各列值。读取完毕之后再次调用 sqlite3_step 获取下一行。当返回 SQLITE_DONE 时,表示结果集已遍历完毕。
列索引依旧从 0 开始(和绑定函数的索引从 1 开始不同),别搞混了。别问我为什么不对称设计,我也想知道。
int sqlite3_column_int(sqlite3_stmt*, int iCol); // int sqlite3_int64 sqlite3_column_int64(sqlite3_stmt*, int iCol); // 64位整数 double sqlite3_column_double(sqlite3_stmt*, int iCol); // 浮点 const unsigned char *sqlite3_column_text(sqlite3_stmt*, int iCol); // 文本 const void *sqlite3_column_blob(sqlite3_stmt*, int iCol); // 二进制 int sqlite3_column_bytes(sqlite3_stmt*, int iCol); // 字节数sqlite3_column_text 返回的指针指向 SQLite 内部缓冲区,它只在当前行有效。调用 sqlite3_step 取下一行后,上一行的指针就失效了。所以如果你要把读取的字符串保存下来,必须立即复制到自己的缓冲区。
同时,sqlite3_column_text 返回的缓冲区最多能容纳 2GB 数据(受限于 int),这个上限一般够用,但如果你是做数据迁移或者大字段存储,建议考虑分块读取或者直接用 sqlite3_column_blob 处理二进制数据。
3.6 sqlite3_column_count 和 sqlite3_column_name
有时候你想写一个通用的查询函数,不提前知道查询会返回哪些列,需要动态处理结果。sqlite3_column_count(stmt) 返回当前结果集的列数,sqlite3_column_name(stmt, i) 返回第 i 列的名称。
用得少,但遇到字段增删频繁的需求时会非常方便。我在一个动态配置表里就用了这套接口,表结构经常变,代码可以稳定不动。
int nCols = sqlite3_column_count(stmt); for (int i = 0; i < nCols; i++) { const char *colName = sqlite3_column_name(stmt, i); // 按列名匹配,动态处理 }4. 实操过程与核心环节实现
4.1 准备:建表和必要的头文件
为了跑通示例,先建一张简单的用户表:
CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER, email TEXT );代码里需要的头文件就两行:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include "sqlite3.h"编译时记得链接 sqlite3 库。Linux 下是-lsqlite3,Windows 下在项目配置里加上 sqlite3.lib/newsqlite3.lib 即可,具体取决于你用的 SQLite 构建方式,但一般查找 lib 的地方都会有。
4.2 INSERT 标准写法(单条插入)
用一个 add_user 函数封装插入逻辑,带错误处理和资源释放,这个是我日常项目里会直接复制去用的版本:
static int add_user(sqlite3 *db, const char *name, int age, const char *email) { int rc = SQLITE_OK; sqlite3_stmt *stmt = NULL; const char *sql = "INSERT INTO users(name, age, email) VALUES(?, ?, ?);"; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); return rc; } /* 注意:bind 的索引从 1 开始 */ sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 2, age); sqlite3_bind_text(stmt, 3, email, -1, SQLITE_TRANSIENT); rc = sqlite3_step(stmt); if (rc != SQLITE_DONE) { fprintf(stderr, "step failed: %s\n", sqlite3_errmsg(db)); sqlite3_finalize(stmt); return rc; } sqlite3_finalize(stmt); printf("inserted: %s\n", name); return SQLITE_OK; }执行过程和要点:
- sqlite3_step 返回 SQLITE_DONE 表示 INSERT 执行成功;返回 SQLITE_CONSTRAINT 表示违反约束(比如非空约束、主键冲突),需要根据错误码进一步处理。
- 如果要在插入后获取自动生成的 id(自增主键),可以用 sqlite3_last_insert_rowid(db),这个在任何 API 场景下都通用,不必非要通过 SELECT 再查一次。
4.3 循环批量插入的性能优化
如果你要在循环里插入 1 万条数据,最简单的写法是傻循环里反复调用 add_user 函数,你会发现性能不理想。原因是每条 INSERT 都默认为一个独立事务,而每次事务提交都要同步写磁盘、更新日志文件,这个开销非常大。
优化方案是显式控制事务,把所有插入包进一个事务里,全部插入完成后再统一提交:
sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); for (int i = 0; i < 10000; i++) { char name[32]; snprintf(name, sizeof(name), "user_%d", i); add_user(db, name, (i % 80) + 18, "test@example.com"); } sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL);实测数据:不包事务跑 1 万条插入耗时大约 4~5 秒(普通机械硬盘,SQLite 默认同步模式),包上事务之后就是几十毫秒级别,差距是两个数量级。这个优化对任何嵌入式项目都很关键的。
进一步优化:可以在事务提交时考虑PRAGMA synchronous = OFF;,但牺牲的是崩溃时的数据安全性。我的建议是默认保持 NORMAL,除非你明确知道自己在做什么,并发安全和持久性不能被随意牺牲。
4.4 SELECT 标准写法(基础查询)
查询用户的例子:
static int query_users(sqlite3 *db, int age_threshold) { int rc; sqlite3_stmt *stmt = NULL; const char *sql = "SELECT id, name, age, email FROM users WHERE age >= ?;"; rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); return rc; } sqlite3_bind_int(stmt, 1, age_threshold); while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { int id = sqlite3_column_int(stmt, 0); const unsigned char *name = sqlite3_column_text(stmt, 1); int age = sqlite3_column_int(stmt, 2); const unsigned char *email = sqlite3_column_text(stmt, 3); printf("id=%d, name=%s, age=%d, email=%s\n", id, name ? (const char*)name : "(null)", age, email ? (const char*)email : "(null)"); } if (rc != SQLITE_DONE) { fprintf(stderr, "query error: %s\n", sqlite3_errmsg(db)); sqlite3_finalize(stmt); return rc; } sqlite3_finalize(stmt); return SQLITE_OK; }想重点强调几个点:
- 循环条件写
while (sqlite3_step(stmt) == SQLITE_ROW),这是 SELECT 最典型的遍历方式,其他写法容易出现死循环或者漏行。 - sqlite3_column_text 返回的是
const unsigned char*,一般需要强转成const char*再接 printf。 - 判断 NULL 值不要用等 NULL,而是 sqlite3_column_type(stmt, i) 返回 SQLITE_NULL 来判断。如果列是 NULL,sqlite3_column_text 返回的是 NULL 指针。
4.5 使用 sqlite3_exec 完成不返回结果的琐碎操作
对于不需要读取结果的 SQL,比如 UPDATE、DELETE、建表、删表,可以偷懒用 sqlite3_exec。它内部其实也是 prepare、step、finalize 的封装,胜在代码简洁:
int rc = sqlite3_exec(db, "DELETE FROM users WHERE age < 18;", NULL, NULL, &err_msg); if (rc != SQLITE_OK) { fprintf(stderr, "exec error: %s\n", err_msg); sqlite3_free(err_msg); }sqlite3_exec 的第三个参数是回调函数,第四个参数是回调透传参数,普通场景都传 NULL 即可。
注意:sqlite3_exec 不支持参数绑定,所以它只适合执行不含外部数据的固定 SQL。它内部处理了错误消息和内存分配,你只需要在用完后 sqlite3_free(err_msg) 就好。
4.6 完整可运行的 Demo
把上面的代码串起来,做一个“插入三名用户,再查出年龄大于 20 的用户”的完整示例:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include "sqlite3.h" static int db_open(sqlite3 **db) { int rc = sqlite3_open("test.db", db); if (rc != SQLITE_OK) { fprintf(stderr, "open failed: %s\n", sqlite3_errmsg(*db)); return rc; } const char *sql = "CREATE TABLE IF NOT EXISTS users (" "id INTEGER PRIMARY KEY AUTOINCREMENT," "name TEXT NOT NULL," "age INTEGER," "email TEXT);"; char *err = NULL; rc = sqlite3_exec(*db, sql, NULL, NULL, &err); if (rc != SQLITE_OK) { fprintf(stderr, "create table failed: %s\n", err); sqlite3_free(err); return rc; } return SQLITE_OK; } /* add_user 和 query_users 定义见上文 */ int main(void) { sqlite3 *db = NULL; if (db_open(&db) != SQLITE_OK) return 1; sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); add_user(db, "Alice", 25, "alice@example.com"); add_user(db, "Bob", 18, "bob@example.com"); add_user(db, "Carol", 32, "carol@example.com"); sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL); int last_id = (int)sqlite3_last_insert_rowid(db); printf("last insert rowid = %d\n", last_id); query_users(db, 20); sqlite3_close(db); return 0; }这个 Demo 里值得注意的几点:
- sqlite3_open 在打开失败时,db 参数可能是 NULL,或者也指向一个对象,需要在函数内做健壮的判断,否则后续调用容易崩。
- sqlite3_close 若返回 SQLITE_BUSY,说明有未 finalize 的语句,需要先把所有 stmt finalize 掉,再关数据库,不然数据完整性不可靠。
- 自增 id 从 1 开始,插入失败时如果语句被回滚或事务回滚,自增仍会增加(因为 SQLite 的自增计数器不会回滚)。
5. 常见问题与排查技巧实录
5.1 绑定索引从 0 开始导致报错或错值
这个问题,我在带新人时被问过不下十次。sqlite3_bind_* 系列的索引从 1 开始,sqlite3_column_* 系列的索引从 0 开始。一旦绑定参数写错索引,prepare 通常不会报错,step 时会返回 SQLITE_RANGE 或 SQLITE_MISUSE,或者数据错乱但不报错。
排查技巧:在 prepare 之后立刻打印 sqlite3_expanded_sql(stmt) 或 sqlite3_sql(stmt) 查看展开后的完整 SQL,对照检查绑定位置。
char *expanded = sqlite3_expanded_sql(stmt); printf("expanded SQL: %s\n", expanded); sqlite3_free(expanded);sqlite3_expanded_sql 会把所有占位符替换为当前绑定的值,打印出来一目了然,这招在调试复杂拼接 SQL 的时候很好用。
5.2 sqlite3_column_text 返回的指针只能用一次
很多刚接触的人会这样写:
const char *name = sqlite3_column_text(stmt, 1); const char *email = sqlite3_column_text(stmt, 2); printf("%s %s\n", name, email);看起来没问题,实际上也不一定出错,因为 SQLite 内部可能已经按列拷贝了值。但完整的语义是:每次调用 sqlite3_column_text 返回的指针只保证在当前 row 期间有效,并且连续调用不同列时,列数据不一定都在同一块内存里。稳妥起见,取出值后立即复制到自己的缓冲区:
char name_buf[64]; strncpy(name_buf, (const char*)sqlite3_column_text(stmt, 1), sizeof(name_buf) - 1); name_buf[sizeof(name_buf) - 1] = '\0';后来你会发现,对返回指针做一次拷贝,虽然多了一次 memcpy,但避免掉了一大堆“拿到脏指针”的 bug。
5.3 字符串乱码与编码处理
SQLite 存储的 TEXT 类型默认按 UTF-8 编码处理。如果你的项目在 Windows 上用的是本地编码(GBK/GB2312),直接绑定 char* 数据会出现乱码。
解决思路有两个:
- 统一在应用层把字符串转成 UTF-8 再写入 SQLite,读取后再转回本地编码。跨平台项目推荐这个方向,统一一种编码,能减少很多问题。
- 使用 sqlite3_open 系列接口时,可以设置 sqlite3_create_collation 把比较排序的规则换成自定义的口径,但如果你只是存取和输出,还是建议做编码转换,不要依赖 SQLite 的默认比较。
我曾经在一个老项目里因为编码切换的问题,把用户输入的名字存成乱码,后来全部得重建数据。那次之后,任何涉及文本入库的程序,我都会在数据库操作入口处强制规定编码,不写全编码转换逻辑就不动手。
5.4 SQLITE_BUSY 错误:并不是真的锁表
sqlite3_step 返回 SQLITE_BUSY 时,第一反应通常是去查是不是多线程在同时写同一个数据库。大多数情况确实如此,但还有一种非常常见的原因:你自己打开了同一个数据库的两个连接,一个连接在事务中写入,另一个连接尝试写或读,而 SQLite 默认的 busy_timeout 是 0,立刻返回 SQLITE_BUSY 而不是等待。
解决方式:
sqlite3_busy_timeout(db, 3000); // 3秒在打开数据库后立刻设置。不过这只解决等待问题,真正的并发一致性还是要靠事务设计。如果是多进程同时访问同一个 SQLite 文件,建议优先考虑 WAL 模式:
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);WAL 模式可以让读操作和单写操作并发执行,显著降低 SQLITE_BUSY 出现的概率。WAL 模式的代价是会产生 -wal 和 -shm 两个额外文件,分布式部署或文件同步时要考虑是否携带这些副本,这也是一些场景系统里 SQLite 文件只允许单一进程访问的原因。
5.5 数据完整性检查
如果你发现数据写进去后查出来不对,有一个快速排查项目,任何时候都值得优先做:
PRAGMA integrity_check; PRAGMA foreign_key_check;前一个检查页结构和自由链表完整性,后一个检查外键约束。数据不对、查询卡住、崩溃重启,往往都能通过这个命令暴露问题。
我见过一个比较离谱的 bug:某个程序在未提交事务的情况下直接关闭数据库连接,导致整个表里的数据索引错乱,最后用 integrity_check 才定位到问题。
6. 进阶:从 COMMIT 到高效批次写入的经验
6.1 事务与自动提交
SQLite 默认处于“自动提交”模式,每一条 DML 语句(INSERT、UPDATE、DELETE)都是一个独立事务。多数情况下你写一句,它立刻落盘;写一万句,它就落盘一万次。磁盘 IO 是性能杀手,一万次 fsync 跟一次 fsync 的差距是数量级的。
所以批量写入的正确姿势就是手动控制事务边界:大量的写操作包在 BEGIN...COMMIT 里。注意中间如果出错,要执行 ROLLBACK 或 COMMIT,不然事务会一直挂着,后续写入可能被锁死。
应对外键约束和唯一性约束的插入,也是先在事务里跑,快结束时再统一检查约束,性能和使用体验都更好。
6.2 使用 prepared statement 的复用
如果把 add_user 函数放到批量循环里,每循环一次就 prepare 一次,这相当于把 SQL 编译了上万次,编译开销随数据量线性增长。建议把 prepare 放到循环外,循环只做 bind 和 step:
sqlite3_stmt *stmt; sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); for (int i = 0; i < 100000; i++) { sqlite3_reset(stmt); sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 2, age); sqlite3_step(stmt); } sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL); sqlite3_finalize(stmt);sqlite3_reset 的作用是把语句对象恢复到可执行状态,但不改变已经绑定的参数值(有办法只重绑部分参数,用 sqlite3_clear_bindings 清空绑定,但日常不需要)。复用同一个 stmt 对象是高性能批量插入的精髓。
6.3 按需提交的平衡点
不推荐把所有插入放在一个事务里一直跑,数据量特别大(比如百万行)时,事务日志文件可能会膨胀,而且一旦中途崩溃,整个事务全部回滚,心理负担太“重”了。
我给自己的经验值是:每 500~1000 条提交一次。这样既能享受批量提交的速度提升,又能控制单次事务的粒度和回滚范围。示例:
int batch_count = 0; sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); for (int i = 0; i < total; i++) { /* bind and step */ batch_count++; if (batch_count >= 500) { sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL); sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); batch_count = 0; } } sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL);这个节奏在很多项目里验证过,既不会频繁落盘,又不会一次性包太多导致内存占用过高。
6.4 SELECT 大结果集的流式处理
SELECT 返回一万行时,如果一次性把所有数据读进内存,内存占用立刻飙升。sqlite3_step 本来就是流式处理的:每调用一次 step 只处理一行,结果集不会一次性全部加载到内存。所以你只需要在循环里边读边处理,用完就丢掉,内存占用可以控制在很小的范围。
这个特性在有大量结果集的项目里特别重要。我曾经处理过一个 80 万行的导出需求,如果用 sqlite3_exec 的回调方式,内存峰值直接冲上几个 G;换成流式 sqlite3_step 循环,内存稳定在几十 M 级别,速度也没有明显拖后腿。
7. 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| sqlite3_step 返回 SQLITE_RANGE | bind 用的索引越界(超过占位符数量) | 检查索引,从 1 开始计数 |
| 插入中文变成乱码 | 编码不一致 | 统一转 UTF-8 后再绑定 |
| 字符串被截断 | bind_text 传了 -1,数据里含 \0 | 显式传入字节长度 |
| step 死循环 | while 循环里忘记更新 rc | 用while ((rc=sqlite3_step()) == SQLITE_ROW) |
| 读取到的字符串是脏数据 | 直接用了 column_text 返回指针且之后又 step 了 | 取出后立即 strdup/copy |
| 两个进程互等无响应 | busy_timeout=0 导致快速失败 | 设置 sqlite3_busy_timeout 或用 WAL |
| 关闭数据库提示 BUSY | 有未 finalize 的语句 | 逐一 finalize 后 close |
| 批量插入性能奇慢 | 每条 INSERT 独立事务 | 用 BEGIN/COMMIT 包起来 |
| prepare 后 expanded_sql 显示的 ? 没替换 | bind 没执行或索引绑错 | 检查 bind 逻辑和索引位置 |
| INTEGER 自增主键不连续 | 事务回滚后自增计数不回退 | 属于 SQLite 正常现象 |
| 读取结果时某列返回 NULL | 该列在数据库中确实是 NULL | 用 sqlite3_column_type 判断 |
这张表基本覆盖了我日常开发中遇到的大部分 SQLite 读写问题,剩下的基本都是业务逻辑层面的错误,不是数据库接口层面的了。
8. 一点个人心得收尾
SQLite 的 C API 和很多花哨的 ORM 比起来,确实显得“简陋”,需要自己管理语句对象、自己处理资源、自己拼接步骤。但正是这种直白的设计,让你必须理解每一步到底在做什么,而不是被框架包装掩盖了细节。
从我自己的经验来说,SQLite 的 C API 是一套学习曲线很“友好”的接口——总共也就几十个函数,核心读写相关的更是不到十个。一旦你把 prepare、bind、step、column、finalize 这条链路跑熟了,再去接触别的数据库的 C 接口或者其他嵌入式数据库,会发现思路都是相通的。
最后分享一个小习惯:写 SQLite 相关代码时,我习惯把 prepare 返回的错误信息封装成一个宏,统一打印出 SQL 原文、错误码和 errmsg,调试效率会高出一截。类似这样:
#define CHECK_SQLITE(db, rc, msg) \ if ((rc) != SQLITE_OK) { \ fprintf(stderr, "%s: %s (code=%d)\n", (msg), sqlite3_errmsg(db), (rc)); \ goto error_handler; \ }你可以按自己的风格改一改。序列笔记写到这,INSERT 和 SELECT 都消化透的话,SQLite 日常开发里的“写”和“读”就基本不再有什么秘密了。下一批笔记如果继续做,大概率就是 UPDATE 和 DELETE 的变通写法、触发器,以及 PRAGMA 调优的方向。到时候再和大家继续聊。