在 C 语言里用 SQLite3一开始最容易被劝退的就是那套prepare/step/finalize的流程看着来回折腾远不如sqlite3_exec一句话执行来得痛快。但等你在真实场景里碰过几回壁就会发现这套流程才是 SQLite 正确打开方式。我这篇笔记聚焦最核心的一对操作——INSERT 写入和 SELECT 读取把 C API 的调用链路、参数绑定的内存细节、以及实际排错的经验一次讲透。前几篇我基本把编译环境、打开数据库、执行建表语句这些流程都过了一遍所以这篇直接从读写开始。无论你是在做嵌入式设备上的本地存储还是给桌面工具加一个数据记录模块只要涉及用原生 C API 操作 SQLite这篇文章都能当一份可以直接照着写的参考。文中给出的代码我都实际跑过避坑的部分也都是真金白银的调试经验不是文档里抄来的。1. 写代码之前先想清楚连接、锁与错误处理很多人一上来就盯着 INSERT 语句本身其实更值得先花几分钟想清楚的是数据库连接和错误处理。这一步没做好后面写多少个sqlite3_step都会莫名其妙地崩。1.1 打开数据库使用 open_v2 而不是 opensqlite3_open虽然简单但它能控制的细节太少。我现在统一用sqlite3_open_v2第二个参数传一个合法的路径第三个参数用三组 flag 的组合常见的是flags SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX前两个的含义你大概能猜到读写模式如果文件不存在就创建。但SQLITE_OPEN_FULLMUTEX是很多新手忽略的——它告诉 SQLite 这台连接本身是线程安全的可以在多线程环境里安全地从多个线程调用这个连接的接口。如果你只是单线程跑一个简单的工具FULLMUTEX不是必需的但加上它没坏处。等你哪天想开一个后台线程做数据归档时就知道这个开关有多重要。还有一个SQLITE_OPEN_NOMUTEX那是反过来告诉 SQLite 你自己保证线程隔离别加锁了性能会更好一些但风险自负。sqlite3 *db NULL; int rc sqlite3_open_v2(app.db, db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, NULL); if (rc ! SQLITE_OK) { fprintf(stderr, open db failed: %s\n, sqlite3_errmsg(db)); return -1; }注意最后一个参数是 VFS 模块名日常留NULL用默认的就行。1.2 错误信息是第一生产力errmsg 的完整用法C API 几乎所有函数都返回整数状态码SQLITE_OK是 0其他非零值各有各的含义。但状态码本身信息量有限真正能告诉你问题在哪里的是这句话const char *msg sqlite3_errmsg(db);很多初学者只看返回码遇到SQLITE_ERROR就懵了。其实你只要把sqlite3_errmsg(db)打出来SQLite 会非常明确地告诉你错误的具体描述比如no such table: user、near slecet: syntax error。我的习惯是封装一个宏统一处理#define CHECK_SQLITE(db, rc) \ do { \ if ((rc) ! SQLITE_OK) { \ fprintf(stderr, line %d: rc%d, %s\n, __LINE__, (rc), sqlite3_errmsg(db)); \ return (rc); \ } \ } while (0)还有一点值得注意sqlite3_errmsg返回的字符串是 UTF-8 编码的如果用printf直接打中文可能乱码在 Windows 终端下尤其明显。真要在日志里输出建议先转成 GBK 或者交给日志库处理。1.3 外键和日志模式这两个开关容易被遗漏建好连接后我通常会立刻执行两条 PRAGMAsqlite3_exec(db, PRAGMA foreign_keys ON;, NULL, NULL, NULL); sqlite3_exec(db, PRAGMA journal_mode WAL;, NULL, NULL, NULL);外键约束默认是关闭的。如果你建表时声明了REFERENCES但没有打开这个开关那约束完全不会生效删数据时照样能删掉被引用的记录。这一点在后来做数据清理时特别容易暴露问题反正一开始就开着没坏处。WAL 日志模式则是另一个改变游戏规则的配置。默认的 rollback journal 模式在写的时候会锁整个数据库文件读操作会被挡住。WAL 模式允许读写并发写操作不会阻塞读读操作也不会阻塞写。对这个系列接下来的 INSERT 和 SELECT 混用场景这个配置能省掉你不少database is locked的困扰。2. INSERT 的完整链路prepare、bind、step、finalize接下来进入正题。INSERT 在 SQLite 的 C API 里不是一句sqlite3_exec就完事的而是由四个步骤组成prepared 准备语句、bind 绑定参数、step 执行、finalize 销毁语句。2.1 为什么我坚持参数绑定而不是拼接 SQL 字符串网上很多入门代码喜欢直接拼字符串像这样char sql[512]; snprintf(sql, sizeof(sql), INSERT INTO user(name, age) VALUES(%s, %d);, name, age); sqlite3_exec(db, sql, NULL, NULL, NULL);小工具这么写没问题但我不推荐你养成这个习惯理由有三个。第一是注入风险。你永远不知道 name 里会不会带上单引号。用户要是输入小明); DROP TABLE user;--拼出来的 SQL 会把你的表删掉。忘记拿用户输入拼 SQL是对数据安全的不负责任。第二是二进制数据没法拼。你要存一张图片或者一段序列化数据里面有不可见字符和\0字符串拼接根本无能为力参数绑定可以用sqlite3_bind_blob直接安全地写入。第三是类型不可控。拼字符串时数字类型全变成了文本SQLite 虽然会做动态类型转换但有时候会带来意外的存储类型不利于后续查询和索引优化。所以正确的姿势是用占位符const char *sql INSERT INTO user(name, age) VALUES(?, ?);; sqlite3_stmt *stmt NULL; sqlite3_prepare_v2(db, sql, -1, stmt, NULL); sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 2, age); sqlite3_step(stmt); sqlite3_finalize(stmt);看到差距了吗SQL 模板和真正的数据彻底分开了。数据是什么类型就调用对应的 bind 函数SQLite 这边清清楚楚。2.2 这条链路的每个环节返回值是什么意思完整的 INSERT 调用过程我会写成这样static int insert_user(sqlite3 *db, const char *name, int age) { const char *sql INSERT INTO user(name, age) VALUES(?, ?);; sqlite3_stmt *stmt NULL; int 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_text(stmt, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 2, age); 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); return SQLITE_OK; }逐个说一下每个环节的返回值。sqlite3_prepare_v2的返回值是SQLITE_OK才算编译成功。这里必须说明一个坑prepare只检查 SQL 语法不检查表是否存在、字段是否存在。比如你写INSERT INTO no_such_table ...语法上完全没有问题prepare 照样返回 OK等sqlite3_step执行时才会报no such table。sqlite3_step的返回值对 INSERT 来说很简单返回SQLITE_DONE就代表执行完成。如果返回SQLITE_CONSTRAINT之类说明触发了约束比如主键重复。sqlite3_finalize这一步容易被漏掉。在短小程序里漏掉一两次不致命但在循环或长期运行的服务里每漏一次就泄漏一次语句资源。更麻烦的是泄漏的语句可能一直持有数据库上的某些锁导致别的地方出现后续的database is locked排查起来非常费劲。2.3 bind_text 第五个参数SQLITE_TRANSIENT 背后的内存安全sqlite3_bind_text的第五个参数是一个专门的 T 标志用来告诉 SQLite 该怎么处理你传入的字符串指针。这个细节不搞清楚程序可能冷不丁给你来个内存错乱。如果传的是SQLITE_STATICSQLite 不会拷贝字符串而只是保存这个指针。这意味着如果你传入的是一个临时缓冲区比如某个局部变量数组在原来的函数返回后那个内存就失效了。SQLite 下次真正执行这个语句时再读这个指针就会读到垃圾数据轻则数据错误重则直接崩溃。如果传的是SQLITE_TRANSIENTSQLite 会在绑定时刻把字符串拷贝到自己的内部存储里。这样你传入的缓冲区之后怎么变都不影响。最稳妥也是我在所有场景下默认的选型。还有一种做法是传SQLITE_DYNAMICSQLite 会调用free去释放这块指针指向的内存。这个用的少因为意味着你要用malloc分配再传进去主动权移交给了 SQLite出错的概率反而变高了。我的经验是统一用SQLITE_TRANSIENT。虽然多了一次拷贝但换来的是内存安全性能损失对于绝大多数应用来说根本感觉不到。再来一个常见误解sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT)这里的第 4 个参数是字符串长度传 -1 表示让 SQLite 用strlen来自动计算。如果你要存的内容里可能含有\0比如序列化数据就不能传 -1而必须显式传实际长度。2.4 批量插入事务对性能的提升是数量级的如果你在循环里一条一条执行上面的insert_user你会发现速度比想象中慢不少。假设要插入一万条记录多数机器的耗时可能要几秒甚至更多。原因在于每条 INSERT 默认都自动提交一个事务每提交一次 SQLite 都要做 fsync这个磁盘同步操作是最贵的。解决方法是把多条 INSERT 包在一个事务里sqlite3_exec(db, BEGIN;, NULL, NULL, NULL); for (i 0; i n; i) { insert_user(db, names[i], ages[i]); } sqlite3_exec(db, COMMIT;, NULL, NULL, NULL);这一改写入速度的提升是数量级的。实测下来几万条数据的写入从秒级降到百毫秒级别一点都不夸张。这里还有两个进阶经验。第一事务别开得太大一次性 Insert 十万条如果中途报错要回滚整个事务全没了。更好用的方式是分批提交比如每 1000 条一个事务。第二如果用的是同一个 SQL 模板循环插入把prepare移出循环每次只重新 bind用sqlite3_reset重置语句状态会节省掉反复prepare的时间。后面第 4 节单独说这块。3. SELECT 的游标逻辑与列值提取SELECT 比 INSERT 多了一层读取的复杂性。关键是要理解sqlite3_step在这里的工作方式不再是“执行一次返回 done”而是“每调用一次返回一行数据”。3.1 step 循环读取一次一行像翻书一样我们把 SELECT 的 SQL 准备好执行sqlite3_step(stmt)。当它返回SQLITE_ROW时表示当前这一行数据已经就绪可以用sqlite3_column_xxx系列函数取出来。取完后再次调用sqlite3_step它会取下一行。直到某一次调用返回SQLITE_DONE代表结果集已经遍历完。这个过程可以理解成一张表从上往下翻每调用一次stepSQLite 内部游标往下移一行SQLITE_ROW就是告诉你“现在这一行可以读取”。这是 SQLite C API 里最重要的心智模型。int rc; const char *sql SELECT id, name, age FROM user;; sqlite3_stmt *stmt NULL; sqlite3_prepare_v2(db, sql, -1, stmt, NULL); 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); printf(%d %s %d\n, id, name, age); } if (rc ! SQLITE_DONE) { fprintf(stderr, select failed: %s\n, sqlite3_errmsg(db)); } sqlite3_finalize(stmt);看到那个const unsigned char *了吗sqlite3_column_text返回的类型就是const unsigned char *如果你要当const char *用一般建议显式转换一下(const char *)sqlite3_column_text(stmt, 1)免得编译器给警告。3.2 先搞清楚有几列、列名叫什么再动手取值有时候 SELECT 的结果不是你写 SQL 时心里默念的那几个列尤其是当语句里带了SELECT *或者用了表达式、别名之后。你按自己的列名索引取值结果对不上查半天还以为数据出错了。我习惯在循环前先取一次元信息int col_count sqlite3_column_count(stmt); for (int i 0; i col_count; i) { const char *col_name sqlite3_column_name(stmt, i); printf(column %d: %s\n, i, col_name); }sqlite3_column_count告诉你有多少列sqlite3_column_name告诉你每一列的名字。这两个函数在SELECT *或动态构造 SQL 时特别有用能避免你硬编码列索引和实际结果对不上。更进一步你还能在取值前先问类型int type sqlite3_column_type(stmt, 0);返回值可能是SQLITE_INTEGER、SQLITE_TEXT、SQLITE_FLOAT、SQLITE_BLOB、SQLITE_NULL。先判断类型再取对应值是最稳妥的因为你不能保证数据库里每条记录的列类型都严格一致——SQLite 的动态类型体系允许一行是整数、下一行是文本。这在别人维护的烂数据库上尤其常见。3.3 字符串列的内存生命周期SQLite 把内存借给你用后要拷走这是整个 C API 里最容易被坑的地方。sqlite3_column_text(stmt, i)返回的指针指向的内存是 SQLite 语句对象内部管理的缓冲区。它的生存期只到下一次对同一个语句执行sqlite3_step或sqlite3_finalize为止。也就是说你不能这样写const char *name; while (sqlite3_step(stmt) SQLITE_ROW) { name (const char *)sqlite3_column_text(stmt, 1); } // 到这里 name 可能已经悬垂 printf(%s\n, name);因为循环里sqlite3_step调用一次SQLite 内部那块缓冲区就可能被覆盖了。正确做法是获取后马上拷贝到自己管理的内存里char name[64]; strncpy(name, (const char *)sqlite3_column_text(stmt, 1), sizeof(name) - 1); name[sizeof(name) - 1] \0;或者用动态分配const char *src (const char *)sqlite3_column_text(stmt, 1); char *copy strdup(src ? src : ); // 用完之后 free(copy)另外一个细节是懒求值。SQLite 只有在第一次调用sqlite3_column_text时才做类型转换并分配内部缓冲区。所以如果你先取了第 1 列的 text再去取第 0 列的 int完全没问题只要别跨step保留字符串指针就行。3.4 一个实用的查询封装示例把上面的套路封装成一个按条件查询所有用户的小工具方便直接用到你的项目里static void query_users_by_age(sqlite3 *db, int min_age) { const char *sql SELECT id, name, age FROM user WHERE age ?1 ORDER BY age;; sqlite3_stmt *stmt NULL; if (sqlite3_prepare_v2(db, sql, -1, stmt, NULL) ! SQLITE_OK) { fprintf(stderr, prepare: %s\n, sqlite3_errmsg(db)); return; } sqlite3_bind_int(stmt, 1, min_age); int rc; 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); printf(id%d, name%s, age%d\n, id, name, age); } if (rc ! SQLITE_DONE) { fprintf(stderr, query failed: %s\n, sqlite3_errmsg(db)); } sqlite3_finalize(stmt); }注意这里 SQL 语句里用了?1就是给这个参数取了个序号名字。之后 bind 的时候也用 1 作为索引代码读起来更明确。你可以混用?和?N但建议统一用?NSQL 一长谁还记得第 5 个问号是什么参数。4. 读写通用类型映射、NULL 处理和语句复用INSERT 和 SELECT 讲完了有几件读写都适用的通用细节必须单独拿来说。这些内容往往是官方文档有、但实际教程不怎么强调的恰恰又是最容易出错的地方。4.1 SQLite 五大数据类型与 C API 的对应关系SQLite 存储类型的粒度比较粗总共就五种。C API 里几乎都有一一对应的取数函数我习惯用这张表作为速查。SQLite 存储类型写入函数读取函数对应 C 类型注意事项INTEGERsqlite3_bind_int/sqlite3_bind_int64sqlite3_column_int/sqlite3_column_int64int/sqlite3_int64存大整数用 int64不要用 int 截断REALsqlite3_bind_doublesqlite3_column_doubledouble浮点精度按 IEEE 754 处理TEXTsqlite3_bind_textsqlite3_column_textconst unsigned char *bind 用SQLITE_TRANSIENT读后立即拷贝BLOBsqlite3_bind_blobsqlite3_column_blobconst void *配合sqlite3_column_bytes拿长度NULLsqlite3_bind_nullsqlite3_column_type判断NULL与 0、空字符串不是一回事BLOB 是个容易忽略的点。写入时sqlite3_bind_blob(stmt, 1, data_ptr, data_len, SQLITE_TRANSIENT);读取时const void *blob sqlite3_column_blob(stmt, 0); int blob_len sqlite3_column_bytes(stmt, 0);别忘了取长度。sqlite3_column_blob返回的指针不保证以\0结尾你不拿长度根本不知道这个 blob 多大。4.2 NULL 不是 0也不是空字符串C 语言里判断一个整数是不是 0 很容易但在 SQLite 的动态类型体系下一行数据里的某一列可能需要区分三种状态NULL从未赋值、0数值零、空字符串。在 SELECT 结果里判断是否为 NULL不要去看返回的整数是不是 0而应该先调用sqlite3_column_type(stmt, col_index)if (sqlite3_column_type(stmt, 1) SQLITE_NULL) { // 处理 NULL 情况 } else { int val sqlite3_column_int(stmt, 1); }写入时同理。如果你想把某个字段显式置为 NULL不要 bind_int(..., 0)而是调sqlite3_bind_null。这两者在语义上是不同的。写代码时要克制住“NULL 就是 0”的惯性不然数据在库里的状态会乱掉。4.3 同一语句的复用sqlite3_reset 与 sqlite3_clear_bindings批量场景下反复prepare同一个 SQL 模板是纯浪费。正确做法是 prepare 一次然后反复使用。每次使用结束后做两件事sqlite3_reset(stmt)把语句状态恢复到刚 prepare 完的状态sqlite3_clear_bindings(stmt)清空之前的绑定值。sqlite3_stmt *stmt; sqlite3_prepare_v2(db, INSERT INTO log(msg) VALUES(?1), -1, stmt, NULL); for (int i 0; i 10000; i) { sqlite3_clear_bindings(stmt); sqlite3_bind_text(stmt, 1, messages[i], -1, SQLITE_TRANSIENT); sqlite3_step(stmt); // 这里返回 SQLITE_DONE sqlite3_reset(stmt); } sqlite3_finalize(stmt);注意顺序先clear_bindings再bind再step再reset。reset之后语句又可以重新 bind 了。这里有一个细微之处不调clear_bindings也可以重新绑定同名索引它会覆盖旧值。但如果你某一次循环里不需要绑定某个占位符旧的绑定值还会保留容易引入隐蔽 bug所以统一先 clear 再 bind 最保险。其实我这个模式还能优化把clear_bindings去掉每次都重新 bind 同一个索引覆盖上一次的值效果一样而且少一次函数调用。不过宁可多写一次 clear让代码意图更明确以后维护的人不会被残留绑定值坑到。5. 实战踩坑记录三个我排查了很久的问题到这一步基本能写能读的项目已经可以跑起来了。但只有真正上过线、跑过一段时间你才会遇到下面这些看起来很怪的问题。这几个坑我都亲测过每个都花了不少时间定位。5.1 参数索引从 1 开始0 不是你想象的“第一个参数”我第一次用绑定参数时下意识地写了sqlite3_bind_text(stmt, 0, name, -1, SQLITE_TRANSIENT)心想数组从一开始习惯了这里从 0 开始不过分吧。结果 prepare 成功bind 成功step 却直接报SQLITE_RANGE。因为 SQLite 里占位符的索引是从 1 开始的。而索引 0 是留给特殊用途的比如某些虚拟表操作。你 bind 0 号位置SQLite 不会把你当成第一个问号。这个问题我现在下意识避开但还是要写出来提醒一下因为报错信息和你的直觉完全对不上——你会盯着 step 的错误信息看半天没想到错误出在 bind 那一步。顺带一提如果你用sqlite3_bind_parameter_index(stmt, :name)按名字找索引返回也是 1 开始的。个人建议直接用数字索引更直白。5.2 SQLITE_BUSY并发写入时的锁冲突与 busy_timeout程序在单线程环境跑得好好的换成多线程访问同一个数据库文件突然开始时不时返回SQLITE_BUSY。SQLite 对并发的处理方式是当一个连接正在写事务中另一个连接尝试写时直接告诉你“busy”而不是排队等。最常见的处理是设置忙等待超时sqlite3_busy_timeout(db, 5000);这个调用是在每个连接上单独设置的表示“当一个操作碰到锁时最多等 5 秒”。如果有别的连接持有锁SQLite 会在等待时间内自动重试超时才返回 busy。这几乎能解决绝大多数偶发锁冲突。但如果是长事务5 秒可能还不够。或者两个连接在互相等待对方就会触发 deadlock。我的经验是写事务尽量短能几千条一个事务就不要几十万条一个事务并且在打开每个连接时都设置 busy_timeout防止哪个线程的连接没设置导致随机报错。5.3 last_insert_rowid 取错时机拿到的是别人的主键插入一条数据后想立刻拿到自增主键很多人知道用sqlite3_last_insert_rowid(db)。但它有几个严格的限定条件第一必须在同一个数据库连接上调用。你开两个连接 A 和 BA 插入一条记录后到 B 连接上调用sqlite3_last_insert_rowid(B)拿到的完全不是 A 插入的主键而是不知道哪个历史值。第二必须在插入后立刻调用。如果在插入后执行了其他写操作比如又插了一条或者更新了某条记录last_insert_rowid可能已经被覆盖。正确的做法是sqlite3_step(stmt); sqlite3_finalize(stmt); sqlite3_int64 new_id sqlite3_last_insert_rowid(db);注意最后一步放在finalize后调用也一样有效因为 last_insert_rowid 是连接级的记录不是语句级的。要紧的是别等到函数外的其他写操作发生后再取。5.4 忘了 finalize长期运行的程序里的句柄泄漏最后这个坑,它不是立刻爆发的而是在程序跑了很久之后才显形。在一个持续运行的守护进程里如果你反复执行prepare而不finalize每执行一次就泄漏一个语句对象。一段时间后SQLite 会报告SQLITE_MISUSE甚至out of memory。刚开始我以为是自己某个数组越界查内存查了半天。后来用调试器一看打开数据库文件时显示这个文件上挂着大量未释放的语句才意识到问题所在。从那之后我定下规矩任何函数里只要出现了sqlite3_prepare_v2这个函数里的所有错误分支和正常分支都必须调用sqlite3_finalize(stmt)。哪怕step返回了错误码也不要跳过 finalize 就 return。宁可多写几个goto cleanup也不要让它漏掉。C 语言的资源管理就是这样RAII 不存在的环境里只能靠纪律。我自己写 SQLite 相关代码时基本上每个函数结尾都是一段cleanup: sqlite3_finalize(stmt); return rc;这种做法还有一个额外的好处finalize本身是不带 db 指针参数的所以只要你有stmt到哪都能释放不太容易搞错连接。最后再分享一个我验证过的小技巧。如果你用sqlite3_column_text读出的字符串只是临时打印用轮到下一步就作废那直接用printf输出就好不必 strdup。但凡是这个字符串要继续跨过下一次sqlite3_step使用就老老实实拷贝到一个自己的缓冲区里。拿不住这个原则SQLite 的数据会跟你玩“明明打印出来了存下来却是乱码”的魔术。