ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC集成SQLite实战:编码转换、事务处理与踩坑记录

MFC集成SQLite实战:编码转换、事务处理与踩坑记录

简介:一份面向MFC初学者的SQLite3集成示例工程,演示在Visual Studio 2010对话框应用中实现数据库添加、删除、修改、查询,并特别展示标准查询与基于回调函数的异步查询,适合需要轻量级嵌入式数据库的Windows桌面项目参考。压缩包共38个文件,约7.01MB,包内头文件与C++源文件对应功能实现,exe可执行程序可直接运行查看效果,dll与lib为SQLite3引擎组件,db文件提供现成测试数据,sln工程文件便于整体加载。已有1338人学习下载。示例工程目录结构直观,从DBTool封装到DemoDlg界面交互均有清晰划分,通过实际代码可掌握sqlite3_open、sqlite3_exec、sqlite3_close等核心API的调用,以及回调函数处理查询结果、错误检查、资源释放等实用技巧,对在MFC项目中集成SQLite3、实现CRUD与异步任务非常有帮助。

1. 为什么在 MFC 里用 SQLite:从配置到第一条 CRUD 的完整路径

MFC 工程里嵌入 sqlite3 的例子,我前后拆过好几轮。第一次做桌面工具时,数据量不大,但需要在本地保存几百条记录,注册表太散,Access 又要带驱动,最后选了 sqlite3——单文件、零配置、纯 C 接口,问题只有一个:MFC 的 CString 是 Unicode,SQLite 要的是 UTF-8,中间这层转换绕不开。这篇笔记就从一个能跑的 MFC + sqlite3 例子讲起,把工程接入、参数绑定、事务批量写入这些常规活儿怎么干说清楚,再把容易翻车的地方列成踩坑记录。新手按步骤能搭起来,熟手可以跳过前半段直接看第 4 章的坑和第 5 章的封装技巧。

2. 把 SQLite 接进 MFC 工程:编译、链接与连接管理

2.1 源码编译 vs 预编译库:我最终选了哪条路

SQLite 分发时就是一堆源码,网上能找到 sqlite3.c、sqlite3.h,也会有人直接提供编译好的 dll + lib。MFC 项目第一次接的时候,很多人图省事直接拿预编译库,结果后续 Debug/Release、x86/x64、字符集一混,各种“玄学”问题就出来了。其实根源在于预编译库的运行时库和主工程不一致,导致堆上分配的指针在另一方释放,轻则崩溃,重则内存越界。

我一般会把 sqlite3.c 直接加进 MFC 工程编译。理由很直接:sqlite3.c 和 sqlite3.h 两个文件放进目录,编译时跟随工程的所有编译选项,字符集、运行库、优化开关完全和主程序统一,以后基本不用再担心 ABI 不一致。代价是第一次编译会多花几十秒,但这比事后排查指针越界值多了。如果项目体型敏感,也可以用 sqlite3.dll + lib 的方式,但记得把 dll 放到 exe 同目录,并在工程里配置好以下内容。

如果想让 SQLite 带扩展能力,可以在工程预处理器里加 SQLITE_ENABLE_COLUMN_METADATA、SQLITE_ENABLE_FTS5、SQLITE_ENABLE_JSON1。加这些宏后 sqlite3.c 会编译得更慢,但一个工程只编译一次,没必要为了省这几秒钟丢功能。我手动写 SQL 时经常用 column_name,所以至少会开 COLUMN_METADATA。另外,用预编译库时要特别注意平台版本:32 位工程配 32 位 sqlite3.dll,64 位工程配 64 位 sqlite3.dll,配置错了链接能通过,运行时报“应用程序无法正常启动”,这种坑比代码问题难找得多。

2.2 工程配置:头文件、附加依赖和字符集设置

把 sqlite3.c 和 sqlite3.h 放进工程后,具体步骤可以这样走:

  • 在解决方案资源管理器里把这两个文件加入工程(如果用的是旧版 VS,直接拖到 Source Files 里)。
  • 打开项目属性,确认“配置属性 -> 常规 -> 字符集”选“使用 Unicode 字符集”。MFC 默认就是这样,但如果从老工程迁移过来,可能还是“未设置”,这会直接导致 _T 宏和 CString 都按 ANSI 处理,后面接口就会全部出错。
  • 如果你用的是预编译 lib,在“链接器 -> 输入 -> 附加依赖项”里写入 sqlite3.lib;如果像我们一样源码编译,这一步省略。
  • 确认“C/C++ -> 语言 -> 符合模式”不是“否”。有的老工程为了兼容设成了否,否则 sqlite3.c 里部分类型定义和声明会报警告甚至编译失败。

头文件在 stdafx.h 里统一包含,这样所有 cpp 都能用 sqlite3 接口:

// stdafx.h 片段 #pragma once #include <afxwin.h> #include <afxext.h> #include <afxdisp.h> #ifndef _AFX_NO_AFXCMN_SUPPORT #include <afxcmn.h> #endif // 加入 sqlite3 头文件,注意目录层级 #include "sqlite3/sqlite3.h" // 如果使用的是预编译 lib,则取消下面注释 // #pragma comment(lib, "sqlite3.lib")

这段代码的作用是把 SQLite 的 C 接口暴露给整个工程。#pragma comment(lib, ...)是一种简单的链接指令,写在头文件里能让每个 cpp 在链接时自动带上依赖,省得每次到属性页里改。使用动态库时,记得把 sqlite3.dll 放在 exe 同级目录,否则启动后会弹“找不到动态链接库”。这个错误最常见于换电脑跑程序直接把工程拷走,dll 却只留在 Debug 目录里。

2.3 用 RAII 封装连接句柄,避免 C 接口裸奔

sqlite3_open 返回的sqlite3*是原始指针,MFC 程序里到处是分支 return,一旦中间某个 CString 分配失败(虽然概率低),或者你忘了在某条路径上调用 sqlite3_close,连接就泄漏了。多开几次窗口,内存就上去了。我习惯写一个最小的 RAII 包装类,把生命周期交给 C++ 的栈对象管理。

// SQLiteConn.h #pragma once #include "sqlite3/sqlite3.h" class CSQLiteConn { public: CSQLiteConn() : m_db(nullptr) {} ~CSQLiteConn() { Close(); } // 打开数据库,路径为宽字符,函数内部转成 UTF-8 bool Open(const CString& strDbPath) { Close(); // 先做路径转换,后面 4.2 会详细说 int nLen = WideCharToMultiByte(CP_UTF8, 0, strDbPath, -1, nullptr, 0, nullptr, nullptr); if (nLen <= 0) return false; std::string strPath(nLen, '\0'); WideCharToMultiByte(CP_UTF8, 0, strDbPath, -1, strPath.data(), nLen, nullptr, nullptr); int rc = sqlite3_open(strPath.c_str(), &m_db); return rc == SQLITE_OK; } void Close() { if (m_db) { sqlite3_close(m_db); m_db = nullptr; } } sqlite3* Handle() { return m_db; } bool IsOpen() const { return m_db != nullptr; } private: sqlite3* m_db; // 禁止拷贝 CSQLiteConn(const CSQLiteConn&) = delete; CSQLiteConn& operator=(const CSQLiteConn&) = delete; };

这里把拷贝构造和赋值都禁掉了,防止两个对象持有一个连接然后二次 close。Open 内部做的 UTF-8 转换是 MFC + SQLite 的第一个关键点:CString 是 UTF-16,而 sqlite3_open 只认 UTF-8 的char*。WideCharToMultiByte 需要先把目标长度算出来,再分配缓冲。注意这里用std::string代替char[],避免手动释放。RAII 的好处是,哪怕调用 Open 后函数发生异常,CSQLiteConn 的析构函数也会执行 Close,不会把连接扔在系统里。

sqlite3_close 也可能返回 SQLITE_BUSY,表示还有未释放的 stmt。所以真正的工程里,Close 里最好循环处理:

void Close() { if (m_db) { // 先结束所有未完成的事务和 stmt while (sqlite3_close(m_db) == SQLITE_BUSY) { // 这里可以 sleep 一下,或者强制 finalize 已知 stmt } m_db = nullptr; } }

不过如果代码里每一处 prepare 都配对 finalize,基本走不到这个分支。我加上这段主要是提醒:sqlite3_close 失败时千万别直接判空,要检查返回值,否则连接依然挂着,文件被占用,后续删除数据库文件会失败。

3. 核心操作:查询、插入、更新与事务的 MFC 化写法

3.1 封装一个轻量 CDatabaseHelper

有了连接类还不够,直接用 sqlite3_stmt 写业务代码,会有一堆 sqlite3_prepare、step、finalize 散布在对话框代码里,项目一复杂就难维护。我一般会再包一层薄的辅助类,把常用的打开、执行、查询统一入口,错误信息用一个 CString 带出来。下面是这个类的头文件。

// DatabaseHelper.h #pragma once #include "sqlite3/sqlite3.h" #include <vector> #include <map> #include <afx.h> // 一行数据:列名 -> 值 typedef std::map<CString, CString> RowData; typedef std::vector<RowData> TableData; class CDatabaseHelper { public: CDatabaseHelper(); ~CDatabaseHelper(); bool Open(const CString& strDbPath); void Close(); // 执行 INSERT / UPDATE / DELETE / CREATE 等无返回行的 SQL bool ExecuteNonQuery(const CString& strSQL); // 执行 SELECT,结果填充到 outTable bool ExecuteQuery(const CString& strSQL, TableData& outTable); CString GetLastError() const { return m_strError; } private: sqlite3* m_pDB; CString m_strError; };

这个类没有把多线程同步放进来,意图是每个线程自己持有实例,属于最省心的线程模型。以后想加线程安全,在这个壳里统一改比在业务代码里改容易得多。简单说:数据库访问全部走这个类,业务不直接碰 sqlite3 宏。辅助类的构造函数里把 m_pDB 置空,析构函数里调用 Close,这样即使哪条业务代码忘记释放,栈对象析构时也能兜底。

3.2 参数化 INSERT 和 SELECT:为什么不要用 CString 拼 SQL

很多例子程序喜欢这样写:

CString strSQL; strSQL.Format(_T("INSERT INTO user(name, age) VALUES('%s', %d)"), strName, nAge); sqlite3_exec(m_pDB, CT2A(strSQL), nullptr, nullptr, nullptr);

这是最直白但也是最容易出事的写法。第一是注入风险,名字里只要出现单引号,SQL 就坏了;第二是性能,每条 SQL 都要重新解析;第三是和 SQLite 的 UTF-8 结合后转义更麻烦。我建议所有带变量的 SQL 都走参数绑定。下面是对应 INSERT 的封装写法:

bool CDatabaseHelper::InsertUser(const CString& strName, int nAge) { sqlite3_stmt* pStmt = nullptr; // 注意:SQL 原文中的 ? 是占位符,不是拼进去的值 CStringA strSQL = "INSERT INTO user(name, age) VALUES(?, ?)"; int rc = sqlite3_prepare_v2(m_pDB, strSQL, -1, &pStmt, nullptr); if (rc != SQLITE_OK) { m_strError = CA2TEX<char, CP_UTF8>(sqlite3_errmsg(m_pDB)); return false; } // 将 CString 转成 UTF-8 后绑定 std::string strNameUtf8; if (!strName.IsEmpty()) { int nLen = WideCharToMultiByte(CP_UTF8, 0, strName, -1, nullptr, 0, nullptr, nullptr); strNameUtf8.resize(nLen - 1); // 去掉结尾空字符 WideCharToMultiByte(CP_UTF8, 0, strName, -1, strNameUtf8.data(), nLen, nullptr, nullptr); } sqlite3_bind_text(pStmt, 1, strNameUtf8.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_int(pStmt, 2, nAge); bool bOk = (sqlite3_step(pStmt) == SQLITE_DONE); if (!bOk) { m_strError = CA2TEX<char, CP_UTF8>(sqlite3_errmsg(m_pDB)); } sqlite3_finalize(pStmt); return bOk; }

这段代码里几个参数值得单独说。sqlite3_prepare_v2的第四个参数是pStmt的地址,第五个参数传 nullptr 表示不关心 SQL 解析到哪里结束。sqlite3_bind_text第二个参数是占位符序号,从 1 开始,这跟很多人从 0 开始的习惯不同。最后一个参数 SQLITE_TRANSIENT 告诉 SQLite:如果后面还要用这块内存,就先自己拷贝一份,不要依赖我们传进去的 char* 存活。这一点非常重要——如果传 SQLITE_STATIC,而我用的是局部 std::string,step 时字符串已经析构了,就会读出垃圾数据。

SELECT 的封装更常见,返回一个“列名 -> 值”的 vector,能直接喂给控件。下面这个函数把所有查询行都拉进内存,只适合结果集不大的场景,桌面工具完全够用:

bool CDatabaseHelper::ExecuteQuery(const CString& strSQL, TableData& outTable) { outTable.clear(); CStringA strSqlA = CT2A(strSQL, CP_UTF8); sqlite3_stmt* pStmt = nullptr; int rc = sqlite3_prepare_v2(m_pDB, strSqlA, -1, &pStmt, nullptr); if (rc != SQLITE_OK) { m_strError = CA2TEX<char, CP_UTF8>(sqlite3_errmsg(m_pDB)); return false; } int nCols = sqlite3_column_count(pStmt); while ((rc = sqlite3_step(pStmt)) == SQLITE_ROW) { RowData row; for (int i = 0; i < nCols; ++i) { const char* szName = sqlite3_column_name(pStmt, i); const unsigned char* szText = sqlite3_column_text(pStmt, i); CStringA strNameA(szName ? szName : ""); // 这里先转 CStringA,后面有讲乱码问题 CStringA strValA(szText ? reinterpret_cast<const char*>(szText) : ""); row[CString(strNameA)] = CString(strValA); } outTable.push_back(row); } if (rc != SQLITE_DONE) { m_strError = CA2TEX<char, CP_UTF8>(sqlite3_errmsg(m_pDB)); sqlite3_finalize(pStmt); return false; } sqlite3_finalize(pStmt); return true; }

这里有个很容易被忽略的细节:CT2A(strSQL, CP_UTF8)将宽字符 SQL 转成 UTF-8 的 CStringA,但 CStringA 默认按 ANSI 拷贝,你必须显式指定 CP_UTF8 才正确。同样的道理,从 sqlite3_column_text 拿回来的是 UTF-8 字节流,如果直接赋给 CString(不带 A 的),MFC 会按当前代码页解释,中文马上乱。第 4.4 节我会再专门演示一个可复用的转换函数。

3.3 事务批量写入:把基准从 3 分钟压到 3 秒

如果一次性要插入一万条记录,最差的写法是循环里每条sqlite3_exec(INSERT ...)——SQLite 默认每条 SQL 都隐含独立事务,每条的提交都要做一次磁盘同步,实测一万条能卡好几分钟。开启显式事务后,同样是循环调用 InsertUser,时间能降到一两个数量级以内。代码特别简单:

sqlite3_exec(m_pDB, "BEGIN", nullptr, nullptr, nullptr); bool bFailed = false; for (int i = 0; i < 10000; ++i) { CString strName; strName.Format(_T("user_%d"), i); if (!InsertUser(strName, 20 + i % 10)) { bFailed = true; break; } } if (bFailed) { sqlite3_exec(m_pDB, "ROLLBACK", nullptr, nullptr, nullptr); } else { sqlite3_exec(m_pDB, "COMMIT", nullptr, nullptr, nullptr); }

这里 COMMIT 一旦执行,数据才算真正“落库”。中间某条失败调用 ROLLBACK,前面的全部回滚,不会留下半截数据。我习惯把 BEGIN/COMMIT/ROLLBACK 也封装到辅助类里,因为业务代码有时会忘记配对,调用一个BeginTransaction()/Commit()/Rollback()比裸写 sqlite3_exec 更容易配对。注意,如果你每步都检查返回值,可以发现 BEGIN 失败多半是磁盘权限问题,而 COMMIT 失败通常意味着连接中断,这种情况只能回滚重试。事务虽然好,但别把一条一万行的循环和用户交互控件刷新也放进来,界面会卡到让人怀疑程序假死。

4. 避坑与排查:MFC + SQLite 开发中的 5 个真实踩坑记录

下面这些坑都是我在具体项目里一条条踩出来的,每一条都经历过“现象摆在那、原因查半天、解决就几行”的过程。把它们写在这里,等于给自己留了一份自查单。

4.1 现象:Debug 程序在 sqlite3_close 时崩溃

调试时发现程序退出时在 sqlite3_close 里报堆溢出,但 Release 又好好的。原因:我当初偷懒用了预编译的 Release 版 sqlite3.dll + lib,而 MFC 工程是 Debug 构建,两边使用的 C 运行时库不同。sqlite3_close 里释放的内存可能是由 Debug CRT 分配的,跨模块释放直接触发堆校验失败。解决:如果你是这种情况,要么换 Debug 版 sqlite3 库,要么干脆把 sqlite3.c 源码加进工程一起编译,从根上消除 ABI 不一致。我个人一劳永逸的做法是源码编译,顺便还能自定义宏。

4.2 现象:数据库路径带中文打不开

直接把CString传给 sqlite3_open,很多时候收到的错误是“unable to open database file”。原因:sqlite3_open 接收的路径是 UTF-8 编码的char*,CString 在 Unicode 工程下是 UTF-16 的宽字符,char* 强制转换后路径变成畸形的 ANSI,自然找不到文件。解决:入库前统一做一次宽字符到 UTF-8 的转换。参考实现:

std::string CStringToUtf8(const CString& strW) { if (strW.IsEmpty()) return std::string(); int nLen = WideCharToMultiByte(CP_UTF8, 0, strW, -1, nullptr, 0, nullptr, nullptr); std::string result(nLen, '\0'); WideCharToMultiByte(CP_UTF8, 0, strW, -1, result.data(), nLen, nullptr, nullptr); return result; }

注意WideCharToMultiByte在目标长度传 -1 时会把结尾空字符也算进去,所以返回的 std::string 里末尾会多一个\0。传给 sqlite3_open 没问题,但如果你再把这份字符串拼接到别的 SQL 里,会带上这个暗伤。上面的代码申请长度时保留了这个结尾,也可以自己减 1,两种都行,只要心里有数。我在项目里统一用这个函数,避免每次调用都重复写三行转换。

4.3 现象:多线程同时操作同一个连接,偶发崩溃

界面线程用一个连接,后台线程又拿同一个 sqlite3* 去查询,有时运行几小时才崩一次,完全随机。原因:sqlite3 默认的线程模式是 serialized,即同一个连接最多同时进入一个线程操作,但 MFC 程序里如果你没有加任何锁,两个线程同时调用 step / finalize 就会在你自己的代码里产生数据竞争。SQLite 的锁只在内部保证不会写坏数据库文件,但回调、绑定逻辑(比如我们封装的 std::string 转换)不在保护范围。解决:最稳妥的方案是每个线程建立独立连接。数据库文件可以被多个连接同时打开,SQLite 自己会用锁解决写冲突。如果非要共享连接,必须自己在外面加临界区,或者开连接前调用sqlite3_config(SQLITE_CONFIG_MULTITHREAD),并在每次访问时加互斥锁。我还是那句:MFC 程序里别共享连接,按线程建一个最省心。

4.4 现象:查询结果里的中文全部变成乱码

绑定参数没问题,但列值显示成“我是”这类字节串。原因:sqlite3_column_text 返回的是 UTF-8 编码,而 MFC 的 CString(strValA) 默认用本地代码页(GBK)解读。解决:拿到const char*后不要直接构造 CString,应该先用 MultiByteToWideChar 从 CP_UTF8 转成宽字符。下面是项目里通用的转换工具:

CString Utf8ToCString(const char* utf8) { if (!utf8) return CString(); int nLen = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, nullptr, 0); CString strW; wchar_t* pBuf = strW.GetBufferSetLength(nLen - 1); MultiByteToWideChar(CP_UTF8, 0, utf8, -1, pBuf, nLen); strW.ReleaseBuffer(); return strW; }

把ExecuteQuery里的CString(strValA)替换成Utf8ToCString(szText),中文就正常了。这个问题是 MFC + sqlite3 最容易忽略的,尤其项目里没有统一封装时,每个对话框里各写各的,一半乱码一半正常,排查起来特别耗时间。我通常会把这个工具函数放在一个公共模块里,所有取列值的代码都走它。

4.5 现象:Release 版本链接 sqlite3.lib 失败,报 LNK2001

Debug 链接正常,切到 Release 后一堆 unresolved external symbol。原因:大概率是工程属性里“附加依赖项”只在 Debug 配置里填了 sqlite3.lib,或者你用的是 32 位库但 Release 平台是 x64。解决:打开“配置管理器”分别看 Debug/Release、x86/x64 的平台设置。如果使用预编译库,确保四个组合都有对应库;如果源码编译,则没有这个问题。另外检查头文件里#pragma comment(lib, "sqlite3.lib")是否被#ifdef _DEBUG之类的条件编译挡住了。我见过有人因为 Release 下定义了 NDEBUG 导致这个 pragma 被排除,链接器当然找不到库。建议把 pragma 放在头文件的最不起眼的角落,但不要包在任何条件编译块里。

5. 进阶:把 SQLite 封装成可复用的 DAO 层,再加一个重试与缓存机制

5.1 设计一个轻量 DAO 抽象

到了这一步,工程里可能已经有十几个对话框直接调用 CDatabaseHelper,业务逻辑和 SQL 混在一起。我通常会再抽一层 DAO,把表操作收敛到独立类。界面层不再需要知道“这张表有哪几列”,只要调用LoadAll()得到对象数组即可。下面是一个简化的基类模式:

class CBaseDAO { public: CBaseDAO() : m_pDB(nullptr) {} virtual ~CBaseDAO() {} void SetDatabase(sqlite3* pDB) { m_pDB = pDB; } protected: sqlite3* m_pDB; CString Utf8ToCString(const char* utf8); // 复用上面转换 };

派生类比如 CUserDAO 只需要实现bool LoadAll(vector<CUser>& users)和bool Save(const CUser& user),内部把 bind/text 的转换细节全部处理掉。这样以后如果从 SQLite 换成别的本地库,改动面只在这一层,不会动界面代码。注意我的目的是收敛,不是过度抽象——MFC 里搞三层接口加一堆工厂,只会让维护的人想砸键盘。

5.2 给连接设置 WAL 模式与 busy_timeout

读写并发多的时候,SQLite 默认的 journal 模式会频繁互斥。我习惯在每次 Open 成功后就执行几条 PRAGMA:

sqlite3_exec(m_pDB, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr); sqlite3_exec(m_pDB, "PRAGMA busy_timeout=3000;", nullptr, nullptr, nullptr); sqlite3_exec(m_pDB, "PRAGMA synchronous=NORMAL;", nullptr, nullptr, nullptr);

WAL 模式允许读和写并行,busy_timeout 告诉 SQLite 当数据库被别的连接锁住时最多等 3000 毫秒,而不是立刻返回 SQLITE_BUSY。synchronous=NORMAL 在 WAL 下能减少 fsync,桌面场景掉电丢失一点点数据的概率可以接受,换来的是写入性能提升。这些设置要放在表的创建之前,否则已经建立的连接不会自动切换模式。WAL 模式是库级别的,设置一次后同一个库文件后续连接默认延续,但新文件每次还是要设置,所以建议写成一个 InitDatabase() 函数统一调用。

5.3 一个值得养成的习惯:发布前跑一遍压力用例

最后分享一个让我少挨骂的习惯。接手一个 MFC + sqlite3 的模拟项目X时,当时功能表面正常,但连续操作几分钟后开始出怪毛病。后来我写了个临时对话框,循环一万次插入、一万次查询、五千次更新和回滚,同时用任务管理器盯内存和句柄数。果然,第一次跑就发现内存涨了,揪出来一个 SQLite stmt 忘了 finalize 的漏洞。从那以后,我每次接手类似工程都强制自己先跑一遍压力用例:开两个连接同时读写,看 busy_timeout 是否生效;把数据库文件复制到带中文的路径下再跑,确认转换函数没漏。这套验证脚本留在项目里,比文档管用得多。如果你也打算在 MFC 里长期用 sqlite3,建议把第 4 章那五条坑提前画进自查单,能少走我当年一半的弯路。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进