MFC 集成 sqlite3 实战:RAII 封装、事务写入与迁移避坑
简介:面向 MFC 初学者的 SQLite3 集成示例工程,基于 Visual Studio 2010 的对话框程序,演示在 Microsoft Foundation Classes 中调用轻量级嵌入式数据库的完整链路:库文件引入、数据库连接、建表与增删改查、用回调函数逐行处理查询结果,以及借助线程或窗口消息实现的后台异步查询与错误码、错误信息检查。工程共 38 个文件,以 8 个 .h 头文件与 4 个 .cpp 源文件为主干,配合 2 个 dll、2 个 lib 及 x86/x64 多版本可执行程序,另有示例数据库与 SQL 建表脚本、sln 解决方案与 vcxproj 工程配置,压缩包约 7.01MB,解压即可编译运行对照。已有 1339 人学习,适合希望跳过环境摸索、直接观察可运行代码的 Windows C++ 开发者。通过阅读数据库封装类与对话框调用代码,可掌握 SQLite3 API 的封装思路、异步查询不阻塞界面的处理方式,并以此为基础扩展出属于自己的本地数据存储模块。
1. 桌面小工具的数据落地:为什么我给 MFC 界面配了 sqlite3
去年帮一个做产线设备的团队改一个老工具,界面是 MFC 的对话框程序,数据原本存在一个几十兆的 CSV 里,每次启动全量读进内存,改一行就整体重写一遍。设备型号涨到两万多条之后,保存一次要卡三秒,断电还会把文件写坏一半。后来我把存储层换成了 sqlite3,界面一行没动,启动时间从四秒掉到两百毫秒,保存变成毫秒级。这个「mfc + sqlite3」的组合看起来老派,但在工控机、测试台、内部管理工具这类场景里它几乎没有对手:单文件、免安装、事务可靠、可以嵌进任何一个 MFC 对话框工程里编译成 exe 直接拷走。这篇笔记面向的是手上已经有 MFC 工程、想找一个能直接抄的 sqlite3 落地姿势的开发者,尤其是被 CSV、ini 折腾过一轮的人。下面从工程怎么摆文件讲到封装类怎么设计、界面怎么联动,最后落到二次开发时改表改字段怎么验证。
2. 工程结构:把 sqlite3 编译进 MFC 项目而不是塞个 DLL
第一步先想清楚 sqlite3 以什么形态进你的工程。常见三种:静态库、动态库、源码直接编译。我一般直接选第三种,也就是官方的 amalgamation 源码包,把 sqlite3.c 和 sqlite3.h 两个文件丢进工程。理由很土:MFC 工程最终交付的往往就是一个 exe,运维同事拷过去双击就能跑,多一个 dll 就多一次「在我这儿好好的」的沟通成本;而 sqlite3.c 编译出来的增量也就是几百 KB,完全能接受。动态库方案适合多个 exe 共享一份数据库引擎的场合,但那种场景在小工具里极少见。
2.1 目录怎么摆:三个文件放进哪个筛选器
我习惯在解决方案目录下建一个third_party\sqlite3\,把sqlite3.c、sqlite3.h放进去,工程里再加一个同名筛选器把这些文件挂进来,视觉上跟业务代码分开。业务代码不直接碰sqlite3.h,只包含自己写的DbHelper.h。这样做的好处是将来换版本或者换成自己裁剪过的编译选项,只影响一个目录。
YourProject/ ├─ YourProject/ // MFC 主工程 │ ├─ Db/ │ │ ├─ DbHelper.h // 对外只暴露 CString / int 这类类型 │ │ ├─ DbHelper.cpp │ │ └─ StmtGuard.h // RAII 小工具 │ ├─ DlgMain.cpp │ └─ stdafx.h └─ third_party/ └─ sqlite3/ ├─ sqlite3.c // 不要用预编译头 └─ sqlite3.h把 sqlite3 放在一个薄封装后面还有一个现实原因:sqlite3 的所有字符串接口都是char*(UTF-8),而 MFC 默认 Unicode 配置下你的CString是wchar_t。这层转换如果散落在业务代码里,二十处里总有那么一两处忘了转,最后表现为「界面上显示乱码,数据库里存进去也乱码」,排查起来非常磨人。集中到 DbHelper 里做,出问题只看一个地方。
2.2 编译配置:预编译头、字符集、运行库三处必须动
sqlite3.c加进工程后直接编译,十有八九报C1010: 在查找预编译头时遇到意外的文件结尾。原因是你的 MFC 工程开了预编译头,编译器要求每个 .c/.cpp 文件开头都要#include "stdafx.h",而第三方源码显然不会有。处理方式是单独给sqlite3.c关掉预编译头,不影响其他文件。
具体操作:在解决方案资源管理器里右键sqlite3.c→ 属性 → 配置选「所有配置」→ C/C++ → 预编译头 → 预编译头选项改成「不使用预编译头」。同时建议在同页的「高级」里把「编译为」显式设成「编译为 C 代码」,避免被当成 C++ 编译后出现一堆链接期符号修饰不一致的问题。这一步做完,工程应该能编过,但先别急着写业务。
字符集这里要注意:MFC 工程的字符集设置在「项目属性 → 高级 → 字符集」,一般选「使用 Unicode 字符集」。sqlite3 本身不关心这个设置,它内部永远是 UTF-8。真正需要关心的是你在sqlite3_open时传的路径字符串,以及所有 bind/column 的文本数据。后面 2.3 和 3.3 会分别处理这两处。
第三处是运行库。如果你的 MFC 工程是「多线程调试 DLL (/MDd)」,而某个依赖库是「多线程 (/MT)」,就会出现堆跨模块分配的问题。纯源码编译的 sqlite3 不存在这个矛盾,它跟着主工程的运行库走,这也是我推荐源码编译的隐藏理由之一。
提示:改完
sqlite3.c的单文件属性后,切到「所有配置 + 所有平台」再确认一遍,很多人只改了 Debug|x86,Release|x64 一编译又炸,发版当天才发现。
2.3 打开数据库的第一段代码:路径、UTF-8 与错误处理
数据库连接是整个模块的地基,我把打开逻辑写成一个独立函数,先看代码。
// DbHelper.h #pragma once #include <string> class CDbHelper { public: CDbHelper(); ~CDbHelper(); // 返回 true 表示成功;失败时 errMsg 带出可读原因 bool Open(const CString& dbPath, CString& errMsg); void Close(); sqlite3* Handle() const { return m_db; } private: sqlite3* m_db; }; // 把一个 Unicode 的 CString 转成 UTF-8 的 std::string static std::string ToUtf8(const CString& str) { if (str.IsEmpty()) return std::string(); // 第一次调用只取长度,第二个参数传 0 表示按实际字符数转换 int n = ::WideCharToMultiByte(CP_UTF8, 0, str.GetString(), str.GetLength(), nullptr, 0, nullptr, nullptr); if (n <= 0) return std::string(); std::string out(n, '\0'); ::WideCharToMultiByte(CP_UTF8, 0, str.GetString(), str.GetLength(), &out[0], n, nullptr, nullptr); return out; } bool CDbHelper::Open(const CString& dbPath, CString& errMsg) { if (m_db != nullptr) return true; std::string pathUtf8 = ToUtf8(dbPath); // READWRITE|CREATE:不存在就建;FULLMUTEX:同一连接多线程串行访问 int rc = ::sqlite3_open_v2(pathUtf8.c_str(), &m_db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nullptr); if (rc != SQLITE_OK) { // 注意:open 失败时 m_db 也可能非空,必须 close 掉 errMsg = CString(::sqlite3_errmsg(m_db)); ::sqlite3_close(m_db); m_db = nullptr; return false; } // 并发写时不要立刻返回 SQLITE_BUSY,先等 5 秒 ::sqlite3_busy_timeout(m_db, 5000); // WAL:读写不互相阻塞;NORMAL:同步开销减半,断电最多丢最后一个事务 ::sqlite3_exec(m_db, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr); ::sqlite3_exec(m_db, "PRAGMA synchronous=NORMAL;", nullptr, nullptr, nullptr); ::sqlite3_exec(m_db, "PRAGMA foreign_keys=ON;", nullptr, nullptr, nullptr); return true; }这段代码里有四个点值得单独说。第一是ToUtf8,CString在 Unicode 配置下是 UTF-16,直接(LPCSTR)强转得到的是 ANSI 编码,中文路径会变成乱码,sqlite3_open就会在你指定的目录之外建出一个名字是乱码的空库,而且它还会返回成功,这是最阴的一类翻车。第二是SQLITE_OPEN_FULLMUTEX,它让同一个连接在多线程里串行访问,代价是每次调用加锁,但如果你的工程里只有界面线程和一条工作线程偶尔写日志,这个代价可以忽略。第三是sqlite3_busy_timeout,没有它,两个连接同时写就会直接返回SQLITE_BUSY,业务层要自己写重试循环,很烦。第四是失败分支里的sqlite3_close(m_db),open 失败时 sqlite 仍可能分配了句柄,不关就是内存泄漏,Valgrind 或者 VS 的内存快照能抓到。
关于 WAL,有个限制要注意:WAL 模式要求数据库文件所在目录可写,因为会额外产生-wal和-shm两个文件。如果你的库放在程序目录下的 Program Files,普通用户没有写权限,PRAGMA journal_mode=WAL会静默失败,回落到默认的 journal 模式。所以数据库路径我一般放在%LOCALAPPDATA%或者程序自己的data\子目录,首次启动时用CreateDirectory建出来。
3. 封装层拆解:从 sqlite3_stmt 到一个能抄的 CSqliteDb
sqlite3 的 C 接口本身已经很干净了,问题在于它的资源管理是手动的:prepare 出来一个 stmt,用完必须 finalize;open 出来的连接,用完必须 close。MFC 工程里到处都是OnInitDialog、按钮响应、异常分支,任何一条路径漏了 finalize,表现就是数据库文件被占用、删不掉、或者连接关闭返回SQLITE_BUSY。所以我封装的第一优先级不是「好看」,而是让资源释放不依赖人的记性。
3.1 连接与语句对象:RAII 比手写 finalize 靠谱
我写一个极简的语句守卫,只做一件事——析构时 finalize。二十行代码,能挡掉这一类问题里九成的坑。
// StmtGuard.h #pragma once #include "sqlite3.h" class CStmtGuard { public: CStmtGuard() : m_stmt(nullptr) {} ~CStmtGuard() { Reset(); } // 禁止拷贝,避免两个对象管同一个 stmt 造成重复 finalize CStmtGuard(const CStmtGuard&) = delete; CStmtGuard& operator=(const CStmtGuard&) = delete; bool Prepare(sqlite3* db, const char* sqlUtf8) { Reset(); // 第四个参数传 nullptr 表示不关心未消费的 SQL 尾巴 return ::sqlite3_prepare_v2(db, sqlUtf8, -1, &m_stmt, nullptr) == SQLITE_OK; } sqlite3_stmt* Get() const { return m_stmt; } bool Valid() const { return m_stmt != nullptr; } void Reset() { if (m_stmt != nullptr) { ::sqlite3_finalize(m_stmt); m_stmt = nullptr; } } private: sqlite3_stmt* m_stmt; };sqlite3_prepare_v2和老的sqlite3_prepare区别在于前者在 schema 变化后会自动重新编译语句,做热更新或者改表结构时不容易出现「语句还在用旧表结构」的怪现象,所以新代码一律用 v2。Prepare里先Reset()再赋值,是为了让这个对象可以复用——批量插入时同一个 guard 对象反复 prepare 同一句 SQL,配合事务能省下大量解析开销。
有了这个守卫,业务函数里的写法就变成:局部CStmtGuard stmt;声明在栈上,任何 return 路径出去都会 finalize。这是 C++ 里最划算的一笔投资,没有之一。
3.2 绑定与取值:参数化查询怎么写才不被人骂
拼接 SQL 字符串是 MFC 老代码里的常见病,CString sql; sql.Format(_T("insert into t values('%s')"), name);这种写法遇上名字里带单引号就直接语法错误,遇上恶意构造的字符串就是注入。sqlite3 的参数绑定接口不难用,关键是记住两条:文本一律走SQLITE_TRANSIENT,取出来的const unsigned char*必须立刻拷走。
bool CDbHelper::InsertParam(const CString& name, double value, CString& errMsg) { CStmtGuard stmt; const char* sql = "INSERT INTO tbl_param(name, value, updated_at) VALUES(?, ?, datetime('now','localtime'));"; if (!stmt.Prepare(m_db, sql)) { errMsg = CString(::sqlite3_errmsg(m_db)); return false; } std::string nameUtf8 = ToUtf8(name); // SQLITE_TRANSIENT:让 sqlite 自己复制一份,局部变量出栈后也安全 ::sqlite3_bind_text(stmt.Get(), 1, nameUtf8.c_str(), static_cast<int>(nameUtf8.size()), SQLITE_TRANSIENT); ::sqlite3_bind_double(stmt.Get(), 2, value); int rc = ::sqlite3_step(stmt.Get()); if (rc != SQLITE_DONE) { errMsg = CString(::sqlite3_errmsg(m_db)); return false; } return true; }参数说明一下:sqlite3_bind_text的第三个参数是字节长度,不是字符数,传nameUtf8.size()而不是name.GetLength(),中文名才不会截断成半个字符。第四个参数SQLITE_TRANSIENT告诉 sqlite 在函数内部复制这份数据;如果你传SQLITE_STATIC,sqlite 只保存指针,而nameUtf8是栈上对象,函数返回后就失效了,接下来 step 时读到的是被覆盖的内存,表现是偶尔存进去一串乱码或者空串,非常玄学。我见过不止一个项目栽在这上面,最后定位到一行SQLITE_STATIC。
查询侧同样要小心:
bool CDbHelper::QueryAll(std::vector<ParamRow>& rows, CString& errMsg) { CStmtGuard stmt; if (!stmt.Prepare(m_db, "SELECT id, name, value FROM tbl_param ORDER BY id;")) { errMsg = CString(::sqlite3_errmsg(m_db)); return false; } int rc = SQLITE_ROW; while ((rc = ::sqlite3_step(stmt.Get())) == SQLITE_ROW) { ParamRow row; row.id = ::sqlite3_column_int(stmt.Get(), 0); // column_text 返回的指针在下一次 step 之后就失效,必须立即复制 const unsigned char* pName = ::sqlite3_column_text(stmt.Get(), 1); int nBytes = ::sqlite3_column_bytes(stmt.Get(), 1); row.name = Utf8ToCString(reinterpret_cast<const char*>(pName), nBytes); row.value = ::sqlite3_column_double(stmt.Get(), 2); rows.push_back(row); } if (rc != SQLITE_DONE) { errMsg = CString(::sqlite3_errmsg(m_db)); return false; } return true; }sqlite3_column_text返回的是一块内部缓冲区的指针,生命周期到下一次sqlite3_step或者finalize为止。如果你把这行改成row.name = (LPCTSTR)sqlite3_column_text(...)再等到循环结束统一处理,恭喜你拿到了一个典型的悬垂指针——它有时候能正常工作,有时候全是乱码,取决于堆有没有被复用,这种 bug 光看代码是看不出来的。所以我的习惯是取完值立刻用sqlite3_column_bytes拿到字节数,走一次 UTF-8 到 CString 的转换,一次也不省。
3.3 查询回调与 CString 的来回转换
老式的sqlite3_exec带一个回调函数指针,回调里第四个参数是char**的二维数组,很多人直接CString s = (LPCTSTR)argv[i];,中文全部乱码。原因是那个字符串是 UTF-8 的,而CString在 Unicode 配置下需要 UTF-16,中间必须走MultiByteToWideChar(CP_UTF8, ...)。这段转换函数我在 2.3 写过正向的,反向版本如下:
// UTF-8 字节流 -> Unicode CString static CString FromUtf8(const char* p, int nBytes) { if (p == nullptr || nBytes <= 0) return CString(); int n = ::MultiByteToWideChar(CP_UTF8, 0, p, nBytes, nullptr, 0); if (n <= 0) return CString(); CString out; ::MultiByteToWideChar(CP_UTF8, 0, p, nBytes, out.GetBuffer(n), n); out.ReleaseBuffer(n); return out; }两个细节:GetBuffer(n)分配的是 n 个wchar_t,MultiByteToWideChar不带MB_ERR_INVALID_CHARS标志时不会追加结尾的\0,所以ReleaseBuffer(n)要显式传长度,传 -1 会让 CString 去搜\0,运气不好越界。另一个是这两个 API 的返回值都是「宽字符个数」,不含结尾零,所以循环处理多条记录时不要拿它当字节数。
sqlite3_exec我现在只用在两处:执行 PRAGMA 这类无返回值的语句,以及批量执行的建表脚本。业务查询一律走 prepare + step,因为 exec 的回调里做类型转换很容易出错,而且没法提前拿到列类型做校验。
4. 界面联动:CListCtrl 分页展示与批量写入的事务姿势
存储层写好了,接上界面才是能交付的东西。MFC 里最常见的是对话框上放一个CListCtrl报表视图显示数据,下面几个按钮做增删改。这一层最容易出的问题不是功能不对,而是数据一多就卡——两万条记录一次性往CListCtrl里塞,界面会假死十几秒,用户以为程序崩了,开始狂点。
4.1 读:把结果集塞进 CListCtrl 的正确顺序
CListCtrl的插入有三处开销:插入行、设置单元格文本、重绘。重绘是最大头,所以标准做法是先把重绘关掉,插入完再开。如果数据量超过一万行,就该切到虚拟列表模式(LVS_OWNERDATA),但那要求你自己管理数据缓存,改造量更大。小工具里我一般先用「关重绘 + 分页」的方案。
void CDlgMain::RefreshList(int page, int pageSize) { m_list.SetRedraw(FALSE); m_list.DeleteAllItems(); // 先清空,避免分页切换时残留旧行 std::vector<ParamRow> rows; CString err; if (!m_db.QueryPage(page, pageSize, rows, err)) { m_list.SetRedraw(TRUE); AfxMessageBox(_T("读取失败:") + err); return; } int idx = 0; for (const auto& row : rows) { // 插入行时返回的索引就是 SetItemText 用的行号 int nItem = m_list.InsertItem(idx++, CString(row.name)); m_list.SetItemText(nItem, 1, FormatDouble(row.value)); } m_list.SetRedraw(TRUE); m_list.Invalidate(); // 一次性重绘,比逐行刷新快一个量级 }SetRedraw(FALSE)之后必须确保所有 return 路径都恢复成 TRUE,包括出错分支,否则这个控件会一直保持冻结状态,看起来像「点了按钮界面不动了」。这也是为什么我在错误分支里先SetRedraw(TRUE)再弹框——弹框是模态的,会触发重绘消息,如果不先恢复,弹框背后的列表是一片空白,用户第一反应是数据丢了。
分页的 SQL 就是LIMIT ? OFFSET ?,参数绑定传页码换算出的偏移量。要注意OFFSET越大越慢,因为 sqlite 仍要扫过前面所有行,十万条以上就得考虑用「上一页的最大 id」做游标分页,而不是算偏移。
4.2 写:一次事务把一万条压到毫秒级
批量导入是这个小工具的核心功能,客户给一张 Excel,导进去几千到几万行。不走事务的话,每次 INSERT 都是一次独立的隐式事务,意味着一次 fsync,机械盘上一条就要几毫秒,一万条就是几十秒。套上事务之后,所有写操作合并成一次落盘,速度能差两到三个数量级。
bool CDbHelper::ImportBatch(const std::vector<ParamRow>& rows, CString& errMsg) { // BEGIN IMMEDIATE:立刻拿写锁,避免写到一半才因为锁冲突失败 if (::sqlite3_exec(m_db, "BEGIN IMMEDIATE TRANSACTION;", nullptr, nullptr, nullptr) != SQLITE_OK) { errMsg = CString(::sqlite3_errmsg(m_db)); return false; } CStmtGuard stmt; if (!stmt.Prepare(m_db, "INSERT INTO tbl_param(name, value) VALUES(?, ?);")) { ::sqlite3_exec(m_db, "ROLLBACK;", nullptr, nullptr, nullptr); errMsg = CString(::sqlite3_errmsg(m_db)); return false; } for (const auto& row : rows) { std::string nameUtf8 = ToUtf8(row.name); ::sqlite3_bind_text(stmt.Get(), 1, nameUtf8.c_str(), (int)nameUtf8.size(), SQLITE_TRANSIENT); ::sqlite3_bind_double(stmt.Get(), 2, row.value); if (::sqlite3_step(stmt.Get()) != SQLITE_DONE) { ::sqlite3_reset(stmt.Get()); // 释放语句上的锁 ::sqlite3_exec(m_db, "ROLLBACK;", nullptr, nullptr, nullptr); errMsg = CString(::sqlite3_errmsg(m_db)); return false; } // reset 复位语句状态,下一轮可以重新 bind;clear_bindings 清掉旧参数 ::sqlite3_reset(stmt.Get()); ::sqlite3_clear_bindings(stmt.Get()); } if (::sqlite3_exec(m_db, "COMMIT;", nullptr, nullptr, nullptr) != SQLITE_OK) { ::sqlite3_exec(m_db, "ROLLBACK;", nullptr, nullptr, nullptr); errMsg = CString(::sqlite3_errmsg(m_db)); return false; } return true; }这段代码的三个关键点:BEGIN IMMEDIATE在事务开始时就抢写锁,如果别的连接正在写,这里会按busy_timeout等待,而不是等到 COMMIT 时才失败;循环里复用同一个 prepared statement,只做 reset 和 rebind,省掉每行一次 SQL 解析;任何一步失败立刻 ROLLBACK,保证不会出现「导了一半」的半成品数据。如果读到一半被用户按了取消,可以注册sqlite3_progress_handler定期回调检查取消标志,返回非零值让当前语句中止。
注意:导入期间不要更新界面进度条,每插一行发一次
PostMessage会让消息队列成为瓶颈,实测比开事务之前还慢。改成每五百行发一次,或者干脆只显示一个忙碌光标。
4.3 分页与索引:数据量上来后先看执行计划
建表语句里主键自增,name上建一个唯一索引,方便导入时用INSERT OR REPLACE去重。但要注意唯一索引会让写入变慢,导入前如果确定数据是干净的,可以先DROP INDEX,导入完再CREATE INDEX,几千行以上差别很明显。
CREATE TABLE IF NOT EXISTS tbl_param( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, value REAL NOT NULL DEFAULT 0, updated_at TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS uk_param_name ON tbl_param(name);查询变慢的时候,先EXPLAIN QUERY PLAN看一眼有没有走索引,这比凭感觉加索引靠谱。命令行里执行EXPLAIN QUERY PLAN SELECT * FROM tbl_param ORDER BY name LIMIT 50 OFFSET 1000;,如果输出里出现SCAN TABLE,说明是全表扫描;出现SEARCH TABLE ... USING INDEX才说明索引生效。ORDER BY 的字段如果没有索引,sqlite 会先把整个结果集排一遍再取 LIMIT,数据量一大就明显了。
5. 避坑与排查:中文路径、文件占用、SQLITE_BUSY 这几件事
这一节是我踩过的坑里最耗时间的几条,每条都按「现象 → 原因 → 解决」写,遇到对应症状可以直接对号入座。
第一条:数据库建出来了,但里面是空的,程序还提示成功。现象是运行目录下多了一个名字像乱码的.db文件,或者压根找不到文件。原因是sqlite3_open收的是char*,你把CString强转成LPCSTR得到的是 ANSI 编码,中文路径下就会被解释成错误字节,sqlite 按那个名字新建了一个文件。解决就是 2.3 里的ToUtf8,所有传给 sqlite 的路径字符串一律走 UTF-8 转换,绝不强转。
第二条:删数据库文件提示「另一个程序正在使用此文件」。现象是关闭窗口后文件还在被占用,必须退出整个进程才能删。原因基本是某个分支上的 stmt 没有 finalize,连接 close 时返回了SQLITE_BUSY,而你忽略了返回值。排查办法是在sqlite3_close之前调用sqlite3_next_stmt(db, nullptr)循环遍历,把还没释放的语句打印出来,一眼就能看到是哪句 SQL 漏了。根治办法就是 3.1 的守卫对象,让 finalize 跟着栈走。
第三条:多线程写日志时偶发SQLITE_BUSY: database is locked。现象是单线程跑得好好的,加了条后台线程写审计日志后,白天没事,晚上跑批的时候报错。原因是两个连接同时尝试写,第二个立刻拿到 BUSY,而你没设等待时间。解决是 open 之后立刻sqlite3_busy_timeout(m_db, 5000),同时把journal_mode设成 WAL,让读和写不互相阻塞。如果还不行,说明事务开得太长,把大事务拆成几个小批次。
第四条:Debug 下正常,Release 下查询返回空。这种玄学问题八成是变量未初始化或者字符串生命周期问题。最常见的一处是sqlite3_bind_text用了SQLITE_STATIC绑定了一个临时CStringA,Debug 下栈上内存没被立刻覆盖所以侥幸能读出数据,Release 下优化一开就全乱。解决就是文本参数永远用SQLITE_TRANSIENT,别为了省一次拷贝赌运气。
第五条:C1010或一堆LNK2005重复定义。前者是sqlite3.c没关预编译头,后者往往是你既把sqlite3.c加进了工程,又链接了一个第三方提供的sqlite3.lib。解决是把其中一个去掉,推荐保留源码编译,因为你能自己控制SQLITE_THREADSAFE、SQLITE_ENABLE_FTS5这些编译开关,出问题时也能单步进去看。
6. 二次开发:加一张表、改一个字段、验证到自己敢上线
拿到这个例子之后,真正要动手的通常不是跑通,而是改。比如客户要求给参数再加一个「单位」字段,或者新增一张「操作日志」表。我的做法是先改 schema,再改封装,最后改界面,顺序不能反。
schema 变更我写成一个版本号驱动的迁移函数,用一个PRAGMA user_version记录当前版本,每次打开数据库时检查,低于目标版本就依次执行升级脚本。这样老用户的库打开后会自动补字段,不需要手工删库重建。
bool CDbHelper::Migrate(CString& errMsg) { int ver = 0; CStmtGuard stmt; if (stmt.Prepare(m_db, "PRAGMA user_version;")) { if (::sqlite3_step(stmt.Get()) == SQLITE_ROW) ver = ::sqlite3_column_int(stmt.Get(), 0); } if (ver < 1) { // v1:补 unit 字段,sqlite 的 ALTER TABLE 只支持加列 const char* sql = "BEGIN;" "ALTER TABLE tbl_param ADD COLUMN unit TEXT DEFAULT '';" "PRAGMA user_version = 1;" "COMMIT;"; if (::sqlite3_exec(m_db, sql, nullptr, nullptr, nullptr) != SQLITE_OK) { errMsg = CString(::sqlite3_errmsg(m_db)); return false; } } if (ver < 2) { const char* sql = "BEGIN;" "CREATE TABLE IF NOT EXISTS tbl_log(" " id INTEGER PRIMARY KEY AUTOINCREMENT," " action TEXT, ts TEXT);" "PRAGMA user_version = 2;" "COMMIT;"; if (::sqlite3_exec(m_db, sql, nullptr, nullptr, nullptr) != SQLITE_OK) { errMsg = CString(::sqlite3_errmsg(m_db)); return false; } } return true; }注意ALTER TABLE在 sqlite 里只支持加列和改名,不支持改类型、删列(较新版本支持DROP COLUMN,但老版本没有)。要改类型只能建新表、INSERT INTO ... SELECT、删旧表、改名,四步走完再重建索引,这一整套必须包在一个事务里,中途断电才不会留下半张表。
验证环节我固定跑三条:一是把老版本产生的数据库文件拷过来,用新程序打开,确认字段补上了、原有数据没丢;二是导入一份五千行的样本,掐表看耗时,超过两秒就说明事务或者索引有问题;三是断网、断进程模拟异常退出,重开之后执行一次PRAGMA integrity_check,返回ok才算过关。这三条跑完,我才敢把 exe 发出去。
从那以后我每次动 schema 都强制走一遍「老库升级 + 大样本导入 + integrity_check」这三步,哪怕只是加一个字段。看起来啰嗦,但省下的是半夜被电话叫起来恢复数据的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取