ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SQLite C API 实战:掌握数据库打开、关闭与建表的关键细节

SQLite C API 实战:掌握数据库打开、关闭与建表的关键细节 把 SQLite 的 C API 这份学习笔记写到第 4 篇我越来越确定一件事真正影响程序稳定性的往往不是那些看起来很酷的查询技巧而是打开数据库、关闭数据库、建表这几个基础动作。很多人从 Python 的 sqlite3 模块或者命令行工具转过来第一反应是sqlite3_open很省事、sqlite3_close很省事、CREATE TABLE拼好丢给sqlite3_exec就行。但实际跑起来就会发现sqlite3_open_v2的 flags 怎么组合、文件名传空字符串会发生什么、sqlite3_close为什么突然返回SQLITE_BUSY、exec 返回的 errmsg 要不要手动 free每个小细节都可能把程序带进一个很难排查的状态。这篇笔记就把这三件事一次讲透该用哪个 API 打开数据库、关闭时如何干净收尾、用 C API 建表时有哪些 SQLite 特有的行为。适合刚把 SQLite 的 C 接口跑通、准备写自己的管理工具或嵌入式数据库逻辑的读者也适合回头查“为什么 close 一直 BUSY”这类旧账的人。1. 打开数据库这一步先别急着用 sqlite3_open1.1 三个 open 接口为什么正式代码我默认选 v2SQLite 给 C 语言提供了三个打开数据库的接口函数原型分别是int sqlite3_open(const char *filename, sqlite3 **ppDb); int sqlite3_open16(const void *filename, sqlite3 **ppDb); int sqlite3_open_v2(const char *filename, sqlite3 **ppDb, int flags, const char *zVfs);三者的核心区别我用一张表直接列出来接口文件名编码是否支持 flags适用场景sqlite3_openUTF-8否快速验证、写 demosqlite3_open16UTF-16否特殊编码环境sqlite3_open_v2UTF-8是正式代码、需要控制行为时sqlite3_open_v2多出来的两个参数一个是flags一个是zVfs。flags用于指定打开模式、线程模式、缓存模式zVfs用于指定底层文件系统模块平时传NULL就好。可以说sqlite3_open只是sqlite3_open_v2的一种默认配置的简化写法它等价于SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE加上默认 VFS。既然最终要学 C API建议直接从 v2 开始用后面需要调整时不用改接口。还有个我自己一开始忽略的点ppDb这个参数传进去的是sqlite3 *指针的地址函数内部会分配一个数据库连接对象。很多人写代码时图省事声明完直接sqlite3_open(a.db, db)漏了取地址符程序编译大概率不报错但运行时会得到一个无效句柄后面所有调用都会失败。别笑这个问题在论坛里出现的频率比我预想的高得多。1.2 flags 参数READONLY、READWRITE、CREATE 怎么组合sqlite3_open_v2的 flags 不是随便乱传的。最常用的三个是SQLITE_OPEN_READONLY只读打开。文件不存在时返回SQLITE_CANTOPEN。SQLITE_OPEN_READWRITE读写打开。文件不存在时同样返回SQLITE_CANTOPEN。SQLITE_OPEN_CREATE和SQLITE_OPEN_READWRITE一起用时文件不存在就自动创建。注意SQLITE_OPEN_CREATE单独传没有意义它必须配合SQLITE_OPEN_READWRITE使用。实际开发中最稳的组合是这样sqlite3 *db NULL; int rc sqlite3_open_v2(test.db, db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL);如果业务上只需要读数据就老老实实传SQLITE_OPEN_READONLY这样即使程序逻辑有 bug也不会误改数据库文件。我在不少工具类项目里见过一种通病明明只做查询却用读写模式打开后来某次升级代码时一条 UPDATE 写错了 where 条件整张表被改坏。用只读模式打开从根上就杜绝了这种可能。除了这三个还有SQLITE_OPEN_NOMUTEX、SQLITE_OPEN_FULLMUTEX等线程相关选项。单线程程序可以不管多线程环境下对同一个连接并发访问时建议用SQLITE_OPEN_FULLMUTEX。线程安全这个话题后面第 5 节专门展开。1.3 文件名参数是坑最多的位置空字符串、:memory:、相对路径打开数据库时文件名参数的行为远比大多数人以为的复杂。先说三个容易混的情况传NULLSQLite 会创建一个临时数据库具体落在一个临时文件里连接关闭后文件自动删除。传空字符串行为类似也会创建一个临时数据库。这和传NULL在实际体验上差别不大但它很容易被误认为“打开当前目录下的某个文件”。传:memory:创建纯内存数据库读写都在内存中完成关闭连接后数据彻底消失。:memory:这个值很特殊官方文档专门说明过只要文件名是:memory:就会打开内存数据库。但如果你在sqlite3_open_v2里开了SQLITE_OPEN_URI写法就变了会变成file::memory:?cacheshared这是共享内存缓存模式多个连接可以访问同一个内存数据库。后一种玩法在单元测试里很有用但也更容易踩坑初学阶段先不用深究。还有一个非常现实的问题相对路径。sqlite3_open打开test.db时实际路径取决于进程的当前工作目录不是程序可执行文件所在目录。我排查过一个定时任务里SQLITE_CANTOPEN的问题程序明明和数据文件在同一目录手动跑一切正常一交给调度进程就报错原因是调度环境的当前工作目录是用户主目录根本不在数据文件目录。排查这类问题最快的方式是在程序里打印当前工作目录或者干脆在代码里用绝对路径拼出数据库路径。1.4 打开失败后的资源处理errmsg 和 db 句柄都要管sqlite3_open_v2返回非SQLITE_OK时*ppDb仍然可能被赋值为一个有效的句柄这点和大部分人直觉相反。官方文档明确说即使打开失败通常也会返回一个数据库连接对象调用方必须负责把它释放掉否则就是一次内存泄漏。正确写法是int rc sqlite3_open_v2(test.db, db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL); if (rc ! SQLITE_OK) { fprintf(stderr, open failed: %d, %s\n, rc, sqlite3_errmsg(db)); sqlite3_close_v2(db); return 1; }注意这里sqlite3_errmsg(db)返回的字符串由 SQLite 内部管理不需要 free。但如果后续用sqlite3_exec执行 SQL它通过errmsg输出参数返回的错误字符串就需要调用sqlite3_free释放两者完全是两套机制后面建表时还会再碰到。2. 关闭数据库为什么返回 SQLITE_BUSY从困惑到套路2.1 close 到底在等什么sqlite3_close的职责是关闭数据库连接、释放连接占用的资源。但很多人的第一次sqlite3_close都遇到过这样的代码int rc sqlite3_close(db); if (rc SQLITE_BUSY) { printf(why busy?\n); }为什么我明明没有在读写数据库close 还会返回SQLITE_BUSY这要从 SQLite 的资源模型说起。sqlite3连接对象只是最外层的一个包装连接内部可以存在多个sqlite3_stmt预处理语句对象。只要还有一个sqlite3_stmt没有被sqlite3_finalize销毁sqlite3_close就会认为连接还在被占用拒绝关闭并返回SQLITE_BUSY。当年我排查一个诡异现象程序运行完数据库文件始终被占用删除文件时系统一直提示“正在使用的文件”。后来一步步加日志才发现代码里执行完 SQL 后sqlite3_stmt只是调用了一次sqlite3_step根本没有sqlite3_finalize。连接虽然 close 了但语句对象泄漏在进程里文件句柄就没被释放干净。除了未释放的 prepared statementsqlite3_blob句柄、sqlite3_backup对象也会导致sqlite3_close返回SQLITE_BUSY。如果连接上还有未提交的事务sqlite3_close会隐式回滚事务但那些仍然活着的语句才是 BUSY 的主要根源。2.2 close_v2 的出现是为了解决什么sqlite3_close_v2是 SQLite 3.7.14 引入的接口它的行为相比sqlite3_close更符合现代 C 语言的资源管理直觉。sqlite3_close是同步等待型如果还有未释放的语句直接返回SQLITE_BUSY什么都不做。你必须手动找到那些语句、逐个 finalize再回来重试 close。sqlite3_close_v2则是异步清理型调用后立即返回SQLITE_OK连接对象进入“待销毁”状态。等所有关联的 prepared statement 都 finalize 后SQLite 会自动把这个连接占用的内存释放掉。也就是说你不需要先清完语句再 close可以先 close再慢慢清理语句SQLite 内部会保证安全。所以现在的推荐很明确新写的代码直接用sqlite3_close_v2。它把“销毁连接”和“销毁语句”两件事解耦了避免了很多因清理顺序导致的内存泄漏和文件占用问题。老项目如果没有特殊兼容需求也可以平滑迁到 v2。不过sqlite3_close_v2不是免死金牌。如果你期望连接立刻释放、立刻删除数据库文件那还是得先把所有sqlite3_stmtfinalize 掉再调用sqlite3_close_v2不然文件删除时机还是不确定。另外sqlite3_close_v2只解决“连接销毁时机”问题不帮你遍历连接上的语句程序设计上的资源管理责任还得自己承担。2.3 三条收尾顺序背下来就够用结合我自己写 C API 代码的固定套路每次用完数据库收尾顺序固定成这样用sqlite3_finalize释放所有sqlite3_stmt。用sqlite3_free释放sqlite3_exec返回的errmsg如果它不为 NULL。用sqlite3_close_v2关闭数据库连接。举个例子sqlite3_finalize(stmt); if (errmsg) { sqlite3_free(errmsg); } sqlite3_close_v2(db);提示如果同一段代码里开了多个连接务必确认每个连接各自 close 一次。我见过复制粘贴代码时只 close 了一个连接另一个连接对象泄漏的情况。这种泄漏不会报错但会表现为程序退出后数据库文件一直被占用特别难查。3. 建表之前必须搞懂的 SQLite 类型与约束3.1 五种存储类和类型亲和性我见过不少从 MySQL 转过来的朋友第一次建 SQLite 表时习惯性地写VARCHAR(100)、DATETIME这种类型然后发现 SQLite 居然也接受就觉得 SQLite 和 MySQL 差不多。这个想法在最初阶段没问题但会在某些时刻产生迷惑。SQLite 的类型系统真的不同。它的存储类只有五种存储类说明NULL空值INTEGER有符号整数1/2/3/4/6/8 字节按需存储REAL浮点数8 字节TEXT字符串按编码存储BLOB二进制大对象原样存储建表时写在字段后面的那些类型名SQLite 并不是硬性执行而是根据规则映射成“类型亲和性”。比如VARCHAR(100)会被识别为 TEXT 亲和性INT、BIGINT会被识别为 INTEGER 亲和性DOUBLE、REAL会被识别为 REAL 亲和性。这意味着你插入一个字符串到 INTEGER 亲和性的列里SQLite 会尝试把它转换成数字如果转换失败它并不会报错而是按 TEXT 存进去。这种“严格中带着宽松”的行为需要在使用时心里有数。用一句话给新手总结建表时字段类型可以写但 SQLite 不会像 MySQL 那样强制类型和长度。真正决定数据怎么存储的是插入时的实际值。3.2 建表时设计约束的注意点这里以一张典型的 user 表为例先看建表语句CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, email TEXT UNIQUE );逐条拆开看id INTEGER PRIMARY KEY AUTOINCREMENT在 SQLite 里很特殊。INTEGER PRIMARY KEY本身就是rowid的别名所以即使你不写 AUTOINCREMENT插入数据时它也会自动增长。区别在哪AUTOINCREMENT 会额外创建一张sqlite_sequence表记录历史最大 id从而保证已经删除的最大 id 不会再被复用。如果你不介意 id 被复用大多数业务场景其实不介意完全可以不写 AUTOINCREMENT性能和存储开销还能省一点。name TEXT NOT NULLNOT NULL 约束在 SQLite 里同样不是绝对强制的。如果你尝试插入 NULL会报约束错误但如果写入的是空字符串SQLite 不会拦。它没有 MySQL 里那种字符串默认值的复杂规则。email TEXT UNIQUEUNIQUE 约束会自动创建唯一索引。这里有个容易忽略的细节在 SQLite 中多个 NULL 值不会被 UNIQUE 约束视为重复。也就是说你可以插很多行email NULL它们彼此不冲突。这个行为和部分关系型数据库一致但不了解的话容易误判。IF NOT EXISTS很值得养成习惯。SQLite 里重复执行CREATE TABLE会报table user already exists加上 IF NOT EXISTS 后表已存在时不会报错可以放心地让建表逻辑重复执行。这在初始化代码里特别好用不用每次先查一遍表是否存在。3.3 sqlite3_exec 执行建表语句的完整姿势建表是 DDL 语句没有结果集用sqlite3_exec是合适的。函数签名int sqlite3_exec( sqlite3 *db, const char *sql, int (*callback)(void*, int, char**, char**), void *arg, char **errmsg );对建表来说callback 传NULLarg 传NULLerrmsg 传errmsg。一个完整的调用const char *sql CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, email TEXT UNIQUE );; char *errmsg NULL; int rc sqlite3_exec(db, sql, NULL, NULL, errmsg); if (rc ! SQLITE_OK) { fprintf(stderr, create table failed: %d, %s\n, rc, errmsg); sqlite3_free(errmsg); sqlite3_close_v2(db); return 1; }这里最容易被忽略的就是errmsg。sqlite3_exec出错时会返回一个由sqlite3_malloc分配的字符串用完必须用sqlite3_free释放否则每次建表失败就泄漏一段内存。很多线上工具跑久了内存持续上涨排查半天最后发现就是这个地方。还有一点sqlite3_exec内部其实是对sqlite3_prepare_v2、sqlite3_step、sqlite3_finalize的封装它可以一次执行多条以分号分隔的 SQL 语句。所以你完全可以把建表和初始化数据放在同一个字符串里一次性调用 exec。等以后需要写更复杂的查询逻辑再改用sqlite3_prepare_v2系列接口也不迟。4. 一个能直接编译的完整例子打开、建表、关闭、验证4.1 完整源码说再多不如跑一遍。我写了一个最小但完整的 C 程序包含本节所有知识点可以直接编译运行。#include stdio.h #include sqlite3.h int main(void) { sqlite3 *db NULL; char *errmsg NULL; int rc; rc sqlite3_open_v2(study.db, db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL); if (rc ! SQLITE_OK) { fprintf(stderr, open failed: %d, %s\n, rc, sqlite3_errmsg(db)); sqlite3_close_v2(db); return 1; } printf(opened study.db\n); const char *sql CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, email TEXT UNIQUE );; rc sqlite3_exec(db, sql, NULL, NULL, errmsg); if (rc ! SQLITE_OK) { fprintf(stderr, create table failed: %d, %s\n, rc, errmsg); sqlite3_free(errmsg); sqlite3_close_v2(db); return 1; } printf(create table user ok\n); sqlite3_close_v2(db); return 0; }代码很直白但每一步对应前面讲过的坑用 v2 接口、判断 rc、失败时打印 errmsg、errmsg 记得 free、最后统一 close_v2。把这个文件保存为main.c。4.2 编译、运行和命令行验证编译命令很简单关键是链接 sqlite3 库gcc -o study main.c -lsqlite3如果你的系统装了 pkg-config也可以写成gcc -o study main.c $(pkg-config --cflags --libs sqlite3)运行程序./study正常会输出opened study.db create table user ok此时当前目录下应该多出一个study.db文件。用命令行工具看一眼表结构sqlite3 study.db .schema user输出应该是CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, email TEXT UNIQUE );再插入一行数据验证表确实可用sqlite3 study.db INSERT INTO user(name, age, email) VALUES(alice, 18, aliceexample.com); sqlite3 study.db SELECT id, name, age, email FROM user;输出1|alice|18|aliceexample.com到这里一个完整的“打开数据库 - 创建表 - 关闭数据库”流程就跑通了。注意程序里我没有做任何 INSERT 和 SELECT全靠 C API 完成建表验证交给命令行工具这样能直观确认建表语句真的生效了。4.3 把 study.db 换成 :memory: 会发生什么把main.c里的文件名从study.db改成:memory:再编译运行程序仍然输出create table user ok但这次不会有任何磁盘文件生成因为所有操作都在内存里完成。关闭连接后表和数据一并消失。这个特性在做测试时特别实用。我写单元测试时会故意用:memory:数据库每个测试用例独立打开一个连接互不污染跑完直接关闭连清理测试数据的代码都省了。不过要注意如果你想验证“数据持久化”的逻辑还是得用真实文件数据库否则测试通过不代表真实场景没问题。5. 程序没崩但行为不对藏在打开与建表之间的隐患5.1 open 报错时errmsg 和 db 可能一个为 NULL、一个不为 NULL很多示例代码里sqlite3_errmsg(db)之前根本没有判空。这在绝大多数情况下不会出问题但有一个边界场景如果sqlite3_open_v2因为内存不足等原因连sqlite3对象都没建立起来db就是 NULL。此时再调用sqlite3_errmsg(db)程序大概率直接段错误。稳妥的写法是if (rc ! SQLITE_OK) { if (db) { fprintf(stderr, open failed: %d, %s\n, rc, sqlite3_errmsg(db)); } else { fprintf(stderr, open failed: %d, no db handle\n, rc); } sqlite3_close_v2(db); return 1; }这是我在实际项目里踩过的坑之一。当时程序在低内存的嵌入式环境里偶发崩溃最后定位到就是这种组合sqlite3_open_v2返回了错误但 db 句柄为 NULL而代码没有判空。嵌入式环境下资源紧张这种问题比普通服务器更容易出现。5.2 建表明明成功表却不在你打开的文件里这个现象很迷惑程序运行结束输出 “create table ok”但你在命令行里打开test.db执行.tables却看不到刚创建的表。最常见的原因有两个。第一个是路径变了程序里打开的是相对路径而你手动执行sqlite3 test.db时所在的目录和程序运行时的工作目录不一致。第二个更隐蔽SQLite 在写入数据时会先生成一个test.db-journal或test.db-wal文件如果程序没有正常关闭连接数据可能还滞留在这些临时文件里主数据库文件表面上没有更新。排查思路很直接第一步在 C 代码里用绝对路径替代相对路径排除工作目录影响第二步检查程序退出后是否存在.db-journal或.db-wal后缀文件如果存在说明关闭流程有问题回到第 2 节检查 close 是否正确执行。这两个排查点可以解决绝大多数“表消失了”的问题。5.3 AUTOINCREMENT 不是万能SQLite 有自己的自增逻辑建表时很多人看到AUTOINCREMENT就直接写上但这里有两个细节值得说。第一AUTOINCREMENT 只在CREATE TABLE时有效。如果你先建了一张普通表再通过ALTER TABLE给它加 AUTOINCREMENTSQLite 会直接报错。这和你以往用过的某些数据库不太一样建表语句必须在一开始就设计好。第二AUTOINCREMENT 会额外创建和维护sqlite_sequence表。每次插入数据时SQLite 都要读写这张表来记录最大 rowid性能和存储上是有代价的。如果只是需要一个自增 id且不关心 id 是否复用INTEGER PRIMARY KEY就够了没必要加 AUTOINCREMENT。我在数据量千万级的表上做过简单对比去掉 AUTOINCREMENT 后插入并发度有可观提升虽然 SQLite 本身不是为高并发设计的但这个差异是真实存在的。5.4 线程安全打开连接时的 mutex 选项解决不了所有问题最后一个比较容易陷入误区的点是线程安全。sqlite3_open_v2的 flags 里可以指定SQLITE_OPEN_FULLMUTEX或SQLITE_OPEN_NOMUTEX。有些人以为加了SQLITE_OPEN_FULLMUTEX就可以随意在不同线程间共享同一个连接。这是危险的误解。SQLITE_OPEN_FULLMUTEX保证的是连接内部底层结构不会因为并发调用而内存损坏但 SQLite 官方文档对连接使用的约定仍然是同一个连接同一时刻只能被一个线程使用。说直白点它防止的是崩溃不防止逻辑错误。如果你两个线程同时用同一个连接执行 SQL虽然可能不会段错误但在某些操作交叉时仍可能出现意想不到的 SQLITE_BUSY 或数据错乱。更稳妥的方案是每个线程独立打开自己的连接或者用外部互斥锁把整个“prepare - step - finalize”流程保护起来。我自己在做多线程数据入库时会维护一个连接池每个线程从池里取连接用完归还而不是让多个线程共享同一个连接。5.3 和 5.4 这两个点在经典的 SQLite 文档里都有明确说明但文档写得相对分散容易被我这种刚上手 C API 的人忽略。建议在写多线程和建表逻辑时心里多留一根弦SQLite 的很多行为和 MySQL 不一样不要用惯性思维去套。这套流程跑顺之后我后来写所有 SQLite 的 C 工具都固定成一个模板sqlite3_open_v2指定READWRITE | CREATEDDL 交给sqlite3_exec错误字符串立刻 free收尾统一 finalize 加sqlite3_close_v2。可能有人觉得这几个接口没什么值得反复讲的但我实际带过的几个项目里很多诡异现象最后都追回到这一类“基础动作”上不是 SQL 写错了而是数据库连接的打开方式、建表时的类型语义理解有偏差。如果你也正在学 SQLite 的 C API建议找一张现成的表结构把 CREATE TABLE 拆成不同的约束组合然后用本文这种方式跑一遍你会比直接抄代码更早摸到 SQLite 的脾气。
返回列表