简介:面向 Visual C++ 开发者的 SQLite 集成示例工程,覆盖桌面应用中使用轻量级嵌入式数据库的核心链路:数据库连接、大批数据快速插入、查询结果在 ListCtrl 控件中展示,以及程序结束时的关闭清理。压缩包共 61 个文件,包含可直接打开的 Visual Studio 工程(.sln/.vcxproj)、C++ 源码(.cpp/.h)、SQLite 动态库与静态库(.dll/.lib)、多个 .db 数据库文件及相关资源,整体约 32.07MB,目录结构清晰,便于对照源码和工程配置逐模块理解。随包提供的多个日期命名数据库文件可作为练习数据,配合示例代码能快速掌握 sqlite3_open、批量 INSERT、sqlite3_prepare_v2 查询以及 ListCtrl 逐列填充等关键操作。已有 303 人学习下载,适合刚接触 SQLite 的 VC/MFC 开发者参考,能够少走弯路,快速完成本地数据存储、检索与界面展示的整合。
1. 在 VC 工程里用 SQLite:本地存储从 INI 挣扎到单文件数据库
在 VC 工程里做本地存储,INI 和注册表撑不住结构化数据,Access 又要拖 ODBC 驱动,分发时驱动缺位的玄学问题能把人绕晕。SQLite 用单个 .db 文件加一份 C 源码就解决了这件事,不依赖外部服务。本文讲透 VC 中 sqlite 的使用:合并包怎么编进工程、exec 回调和预编译语句怎么调、中文编码怎么转、坑在哪,最后给一个可直接照抄的 C++ 封装。适合在 MFC/Win32 里被存储方案反复折腾的人,也适合想看明白 SQLite C API 回调触发机制的新手。照着走一遍,增删改查就能跑在自己的代码里。
2. 把 SQLite 接进 VC 工程:合并包、编译开关与 VC6 兼容
2.1 合并包方式:sqlite3.c 直接编进工程
SQLite 提供 amalgamation 合并包,把所有代码合并成 sqlite3.c、sqlite3.h、sqlite3ext.h 三个文件。桌面工程里最省事的方式是源码方式:把 sqlite3.c 作为源文件加进 VC 工程一起编译,运行时不需要任何额外 DLL,分发时拷一个 exe 加一个 .db 文件就完事。这也是我默认推荐的方式,调试时能直接跟进 C 源码里看执行路径,比断点打在黑匣子 DLL 上好用得多。
整体步骤是:下载合并包,把三个文件拷进工程目录;在 VC 工程里 Add Existing Item 加入 sqlite3.c;头文件路径加上该目录;需要用到 SQLite 的源文件里 include "sqlite3.h" 即可。工程配置里不需要额外链接库,所有 API 都在 sqlite3.c 里编译出来了。
另一种做法是先把 sqlite3.c 编成 sqlite3.dll,同时生成 sqlite3.lib 链接进工程。好处是多个工程共享同一个 DLL,升级 SQLite 不用重新编译主程序;坏处是分发时多带一个 DLL,还要处理不同模块版本不一致的问题。单 exe 工具类项目我基本都选源码方式,夹带 DLL 的部署成本不值得。
2.2 三个值得提前设置的编译宏
sqlite3.c 本身提供大量编译开关,在工程预处理器里定义即可,不用改源码。最常见的三个:
| 宏 | 作用 | 常见取值 |
|---|---|---|
| SQLITE_ENABLE_COLUMN_METADATA | 提供 sqlite3_column_table_name 等元信息 API | 可定义 |
| SQLITE_THREADSAFE | 线程安全模式 | 0 / 1 / 2,默认 1 |
| SQLITE_TEMP_STORE | 临时表与排序临时文件的位置 | 0 / 1 / 2,默认 0 |
对应在工程配置的 Preprocessor Definitions 里写入:
SQLITE_ENABLE_COLUMN_METADATA SQLITE_THREADSAFE=1 SQLITE_TEMP_STORE=1SQLITE_THREADSAFE 的三个取值要理解透:0 表示编译成单线程版本,整体执行效率最高,但多线程使用必须自己在外部加锁;1 是 serialized 模式,连接可以被多线程安全共享,代价是少量性能损耗,桌面程序默认选这个最省心;2 是 multi-thread 模式,每个连接一次只能在一个线程里用,但不同连接可以在不同线程间分配。SQLITE_TEMP_STORE 设成 1 或 2 能把排序中间文件放进内存,大表 ORDER BY 会快不少,代价是内存占用和断电丢临时数据的风险,批量导入场景我一般开到 2。
提示:改完编译宏记得全量重新编译 sqlite3.c。增量编译有时不会重新触发,出现“宏改了没生效”的诡异现象多半是这个原因。
2.3 VC6 老工程的兼容处理
还在用 VC6 的工程,要先想清楚一个现实问题:新版 SQLite 合并包是用较新的 C 标准写的,老编译器很可能直接语法报错。这不是工程配置能绕过去的。常见做法是找与编译器年代接近的老版合并包,或者把工程迁移到新版编译环境。我一般建议后者,老合并包缺少后续的性能和安全修复,为了一个编译器版本守着旧 SQLite 不划算。
VC6 还有一个隐性坑:默认 ANSI 编译,CString 里存的是本地代码页字符,而 SQLite 的文本接口固定走 UTF-8。这意味着所有进出 SQLite 的字符串都要显式转换,不能图省事把 CString 当 char* 直接传。第 4 章会给完整的转换函数,ANSI 工程和 Unicode 工程只是中间多一次 UTF-16 中转的区别。
3. 打通增删改查:exec 回调、预编译语句与事务的标准路径
3.1 打开与关闭:sqlite3_open 的正确姿势
sqlite3_open 只传路径和占位指针。路径参数是 UTF-8 编码的 const char*,Windows 下的 CString 不能直接塞进去,尤其是带中文或空格时。还有一个常被忽略的点:sqlite3_open 失败时返回非 SQLITE_OK,此时仍然需要调用 sqlite3_close 释放它内部申请的资源。
sqlite3* db = NULL; int rc = sqlite3_open("D:/data/app.db", &db); if (rc != SQLITE_OK) { CString msg = sqlite3_errmsg(db); // 取错误文本 sqlite3_close(db); // 失败也要 close AfxMessageBox(msg); return; } // 正常使用…… sqlite3_close(db);参数说明:路径统一用正斜杠或转义过的反斜杠都可以,SQLite 内部会处理;强烈建议用绝对路径,避免工作目录变化导致找不到文件。sqlite3_errmsg 返回的字符串在同一连接上只保留到下一次出错,取出来要立刻拷走。关闭前要确保没有未 finalize 的 stmt 和未提交的事务,否则可能返回 SQLITE_BUSY。
3.2 sqlite3_exec 与回调触发机制
exec 是快速执行 SQL 的方式,传一句 SQL 进去,内部完成 prepare、step、finalize。对 SELECT,每查出一行就调用一次回调;对 INSERT/UPDATE/DELETE,不产生结果集,回调不会被触发;对 CREATE TABLE 这类零列语句,回调会触发一次但 argc 是 0。回调返回 0 继续取下一行,返回非 0 会中断查询,sqlite3_exec 此时返回 SQLITE_ABORT。
int RowCallback(void* userData, int argc, char** argv, char** colName) { CListCtrl* pList = (CListCtrl*)userData; CString row; for (int i = 0; i < argc; i++) { // argv[i] 是 UTF-8 文本,先转回本地编码再显示 CStringA utf8 = argv[i] ? argv[i] : ""; row += Utf8ToAnsi(utf8) + _T(" | "); } pList->InsertItem(pList->GetItemCount(), row); return 0; // 返回 0 继续,返回非 0 终止查询 } char* errMsg = NULL; int rc = sqlite3_exec(db, "SELECT id, name FROM user", RowCallback, pList, &errMsg); if (rc != SQLITE_OK && errMsg) { CString msg = errMsg; sqlite3_free(errMsg); // errMsg 必须由 sqlite3_free 释放 }参数说明:userData 是透传指针,回调里拿它做上下文;argc 是列数;argv 每项是列值,colName 是列名;NULL 列值的 argv[i] 是 NULL,先判空再取值。回调里只做数据拷贝和界面刷新,千万不要在回调里对同一个 db 连接再执行写操作,重入会导致无法预料的锁等待和指针失效,第 5 章会单独说这个坑。编码转换函数见第 4 章。
3.3 预编译语句:prepare、bind、step、finalize 的标准流程
需要参数化查询、反复执行同一 SQL、或要精确控制读取时,改用预编译语句。流程固定四步:prepare 把 SQL 编译成 stmt;bind 绑定参数;step 逐行取数据;finalize 释放 stmt。bind 的参数索引从 1 开始,column 的索引从 0 开始,这两个起点经常被搞混。
sqlite3_stmt* stmt = NULL; const char* sql = "SELECT id, name, score FROM user WHERE age > ? AND name LIKE ?"; if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { CString msg = sqlite3_errmsg(db); return; } sqlite3_bind_int(stmt, 1, 18); CStringA utf8Name = AnsiToUtf8("%张%"); sqlite3_bind_text(stmt, 2, utf8Name.GetString(), -1, SQLITE_TRANSIENT); while (sqlite3_step(stmt) == SQLITE_ROW) { int id = sqlite3_column_int(stmt, 0); const char* name = (const char*)sqlite3_column_text(stmt, 1); CString dispName = Utf8ToAnsi(name); // 立即拷走,指针马上会失效 // 用这一行的 id / dispName 干活 } sqlite3_finalize(stmt);参数说明:prepare_v2 的第三个参数传 -1 表示让 SQLite 自动按字符串长度解析;bind_text 最后一个参数传 SQLITE_TRANSIENT,SQLite 会复制一份文本,本地临时变量销毁也不影响执行;如果传 SQLITE_STATIC,则必须保证字符串内存在整个 step 期间都有效。sqlite3_column_text 返回的指针在下一步 step、reset 或 finalize 后会失效,必须行内拷贝。step 返回 SQLITE_ROW 表示还有行,返回 SQLITE_DONE 表示取完。
3.4 批量写入时必须用事务
SQLite 默认每条写语句单独一个事务,也就是自动提交模式。循环几万条 INSERT 时,每条都触发一次磁盘同步,导入慢得让人怀疑人生。包一层 BEGIN/COMMIT 把写入合并成一个大事务,速度能提升一到两个数量级。
sqlite3_exec(db, "BEGIN", NULL, NULL, NULL); for (int i = 0; i < recCount; i++) { sqlite3_stmt* stmt = NULL; sqlite3_prepare_v2(db, "INSERT INTO log(sn, val) VALUES(?, ?)", -1, &stmt, NULL); sqlite3_bind_int(stmt, 1, i); sqlite3_bind_double(stmt, 2, val[i]); if (sqlite3_step(stmt) != SQLITE_DONE) { CString msg = sqlite3_errmsg(db); sqlite3_finalize(stmt); sqlite3_exec(db, "ROLLBACK", NULL, NULL, NULL); return; } sqlite3_finalize(stmt); } sqlite3_exec(db, "COMMIT", NULL, NULL, NULL);参数说明:中途出错要用 ROLLBACK 回滚,COMMIT 和 ROLLBACK 之间不要再夹别的连接操作。追求极致速度时可以用 PRAGMA synchronous=OFF 或 PRAGMA journal_mode=MEMORY,但断电可能丢数据甚至损坏库,只建议在可重建的临时数据上用。日常别省同步这个 fsync,数据安全比那点时间值钱。
4. 中文编码转换:CString 与 UTF-8 之间的两座桥
4.1 乱码问题的根源
SQLite 的 C 接口固定用 UTF-8 存文本,而 Windows 上的 VC 工程有两种情况:Unicode 字符集编译时 CString 是 UTF-16;非 Unicode(ANSI)编译时是本地代码页(中文环境是 GBK)。只要 text 类型的数据进出库,两层编码就必然相遇。
需要转换的位置至少有五处:SQL 语句里的中文字符串字面量、bind 进去的中文参数、column 取出来的中文列值、回调里收到的 argv 文本、sqlite3_open 的路径参数。漏掉任何一处,表象就是插入后变成“锟斤拷”,查询结果全是问号,或者根本打不开中文路径的文件。先把这五个位置在代码里标出来,再决定统一封装,比遇到一处改一处靠谱得多。
4.2 封装一组转换函数
ANSI 工程里,从 CString(GBK)到 UTF-8 要经过 UTF-16 中转:GBK 先转成 UTF-16,再转成 UTF-8,反向同理。下面这两个函数是完整可用的:
CStringA AnsiToUtf8(const CStringA& ansi) { // 第一步:ANSI(GBK) -> UTF-16 int wLen = MultiByteToWideChar(CP_ACP, 0, ansi.GetString(), ansi.GetLength(), NULL, 0); CStringW wStr; MultiByteToWideChar(CP_ACP, 0, ansi.GetString(), ansi.GetLength(), wStr.GetBuffer(wLen), wLen); wStr.ReleaseBuffer(wLen); // 第二步:UTF-16 -> UTF-8 int uLen = WideCharToMultiByte(CP_UTF8, 0, wStr.GetString(), wStr.GetLength(), NULL, 0, NULL, NULL); CStringA utf8; WideCharToMultiByte(CP_UTF8, 0, wStr.GetString(), wStr.GetLength(), utf8.GetBuffer(uLen), uLen, NULL, NULL); utf8.ReleaseBuffer(uLen); return utf8; } CStringA Utf8ToAnsi(const CStringA& utf8) { // 逆向:UTF-8 -> UTF-16 -> ANSI(GBK) int wLen = MultiByteToWideChar(CP_UTF8, 0, utf8.GetString(), utf8.GetLength(), NULL, 0); CStringW wStr; MultiByteToWideChar(CP_UTF8, 0, utf8.GetString(), utf8.GetLength(), wStr.GetBuffer(wLen), wLen); wStr.ReleaseBuffer(wLen); int aLen = WideCharToMultiByte(CP_ACP, 0, wStr.GetString(), wStr.GetLength(), NULL, 0, NULL, NULL); CStringA ansi; WideCharToMultiByte(CP_ACP, 0, wStr.GetString(), wStr.GetLength(), ansi.GetBuffer(aLen), aLen, NULL, NULL); ansi.ReleaseBuffer(aLen); return ansi; }参数说明:CP_ACP 是当前 ANSI 代码页,中文 Windows 上就是 GBK;CP_UTF8 指定 UTF-8 编码。GetBuffer 后必须成对 ReleaseBuffer,否则 CString 的长度元数据错误,后面拼接和显示都会带脏数据。Unicode 字符集工程更简单:CString 本身就是 UTF-16,直接用 WideCharToMultiByte(CP_UTF8, ...) 就能得出 UTF-8,反向用 MultiByteToWideChar(CP_UTF8, ...) 即可。
注意:不要把 GBK 直接转 UTF-8。这个中间步骤省不得,我见过不少直接拿 WideCharToMultiByte 一杆子插到底的封装,中文系统上能用,换到英文系统就全线翻车。
4.3 数据库路径也要转:中文路径打不开的坑
sqlite3_open 的路径参数同样按 UTF-8 处理。中文系统 ANSI 工程里直接传 CString,路径里的中文以 GBK 字节存在,SQLite 按 UTF-8 解析就找不到文件,现象是 sqlite3_open 明明返回 SQLITE_OK,第一次写数据却报 unable to open database file。SQLITE_OK 不一定是真的成功,因为 SQLite 默认延迟建库,真正的打开动作发生在首次读写时。
CStringA dbPath = AnsiToUtf8(CStringA("D:\\数据目录\\app.db")); sqlite3* db = NULL; if (sqlite3_open(dbPath.GetString(), &db) != SQLITE_OK) { AfxMessageBox(sqlite3_errmsg(db)); return; }参数说明:路径先转 UTF-8 再传入。返回 SQLITE_OK 不等于文件没问题,紧接着做一次 SELECT 1 探活能提前暴露路径问题。目录不存在时 SQLite 不会自动创建目录,先确认父目录在位。还有一个相关联的细节:中文列做 ORDER BY 时默认按 UTF-8 字节序排,不是拼音序,想按拼音排序得自己扩展排序规则,这个坑等做到中文排序需求时自然会碰到。
5. VC + SQLite 避坑指南:五个高频翻车现场与排查思路
5.1 open 返回 OK 却报 unable to open:路径编码在捣乱
现象:sqlite3_open 返回 SQLITE_OK,首次 INSERT 或 SELECT 时却报 unable to open database file,甚至偶尔第一次正常、换了工作目录就复现。
原因:大多是路径编码问题。中文路径没转 UTF-8、反斜杠转义出错、目录不存在而 SQLite 延迟建库,错误一直拖到首次读写才暴露。SQLITE_OK 只代表连接对象建立,不代表文件真实可用。
解决:路径统一走 AnsiToUtf8 转换,传给 open 之前用 CreateDirectory 确认父目录在位,open 后立刻执行 SELECT 1 探活,把它当成连接初始化的一部分。这个习惯能过滤掉一大半“换台电脑就坏”的诡异问题。
5.2 exec 回调里做写操作:重入导致卡死或 SQLITE_BUSY
现象:SELECT 的回调里对同一个 db 连接执行 INSERT 或 UPDATE,程序卡死在 sqlite3_step,或返回 SQLITE_BUSY,偶尔直接崩溃。
原因:回调运行在调用线程,但它在一次未结束的查询内部发起了写事务,锁状态全乱。轻则锁等待超时,重则 SQLite 内部指针状态被破坏。
解决:回调只做拷贝和记录,把要写的数据推到一个队列里,等 exec 整个返回后再统一写。需要边查边写的场景改用预编译语句,把写逻辑放在 step 循环体里而不是回调里,绕开 exec 回调这条路。
5.3 查询结果全是最后一行:列指针生命周期没管住
现象:循环里把 sqlite3_column_text 返回的 char* 直接 push 进 vector,循环结束后所有元素内容一样,或者全是乱码。
原因:column_text 返回的是 SQLite 内部缓冲的指针,下一次 step、reset 或 finalize 之后就可能失效。保存裸指针等于保存了一块随时被复用的内存。
解决:行内立即拷贝,用 CString 赋值或 strcpy 到自己的缓冲区,绝不要保存裸指针。拷出来的文本是 UTF-8,显示前要经 Utf8ToAnsi 转回本地编码。这两个问题经常一起出现,排查时先看指针生命周期,再看编码。
5.4 内存稳步上涨:finalize、errMsg、close 三件套漏了
现象:程序跑一段时间内存持续上涨,任务管理器里看得很清楚,重启后恢复。
原因:SQLite 这块的泄漏几乎都是同一个套路:prepare 了没 finalize,sqlite3_exec 的 errMsg 没 sqlite3_free,数据库用完没 close。还有一个隐蔽来源是 CString 的 GetBuffer 没配 ReleaseBuffer,长度元数据坏了之后每次 Append 都在往错的位置写。
解决:把语句和连接的生命周期交给 RAII 对象,第 6 章的封装直接解决这个问题。errMsg 只要非空就必须 sqlite3_free,每次 open 对应一次 close,这个对仗关系要写成肌肉记忆。GetBuffer 和 ReleaseBuffer 永远成对出现。
5.5 database is locked:多线程访问的锁竞争
现象:多线程访问同一个 .db 文件,频繁报 database is locked,或 disk I/O error。
原因:默认编译下 SQLite 是 serialized 模式,同一连接多线程用不会崩,但写入锁竞争激烈时大量操作返回 SQLITE_BUSY,尤其是一个连接长时间持有写事务时。
解决:启动后对每个连接调一次 sqlite3_busy_timeout(db, 3000),让等待锁的操作自动重试而不是立刻失败。更稳的做法是工作线程各自持独立连接,SQLITE_THREADSAFE=2 模式下不同连接跨线程没问题。写密集场景统一走一个写队列,读连接和写连接分离,是桌面程序最不容易踩锁的布局。
6. 封装一个 C++ 访问类:RAII 关闭、事务边界与验证方法
前面 API 的每个细节最终要落到工程里,直接散着写容易到处漏 finalize。我的做法是封装一个最小的 SqliteDb 类,把连接生命周期、语句生命周期和事务边界管住:
class SqliteDb { public: SqliteDb() : m_db(NULL) {} ~SqliteDb() { Close(); } bool Open(const CStringA& utf8Path) { Close(); if (sqlite3_open(utf8Path.GetString(), &m_db) != SQLITE_OK) { m_lastError = sqlite3_errmsg(m_db); return false; } sqlite3_busy_timeout(m_db, 3000); return true; } bool Exec(const char* sql) { char* err = NULL; int rc = sqlite3_exec(m_db, sql, NULL, NULL, &err); if (err) { m_lastError = err; sqlite3_free(err); } return rc == SQLITE_OK; } bool Begin() { return Exec("BEGIN"); } bool Commit() { return Exec("COMMIT"); } bool Rollback() { return Exec("ROLLBACK"); } sqlite3* Handle() { return m_db; } private: void Close() { if (m_db) { sqlite3_close(m_db); m_db = NULL; } } sqlite3* m_db; CString m_lastError; };类的重点在析构函数里统一 Close,任何早退路径都不会漏关连接;Exec 内部统一释放 errMsg,调用方只管返回值。语句对象建议单独写一个 StmtGuard,析构里调 finalize,配合作用域自动清理。事务用 Begin/Commit/Rollback 三个方法包住,比散落的裸 SQL 舒服得多。
封装完之后,验证方式很直接:程序跑一遍增删改查,把生成的 .db 文件用 DB Browser for SQLite 打开,逐表看字段类型和数据,确认中文正常、事务提交后的数据真实落在磁盘上。平时我也习惯在调试器里对 sqlite3_step 的返回值设条件断点,SQLITE_ROW、SQLITE_DONE、SQLITE_BUSY 三个值一眼就能看出执行路径走没走对。这套组合拳已经是我的固定习惯:先把访问层封好再写业务,后面换表结构、加索引都省心,希望帮到你。
本文还有配套的精品资源,点击获取