简介面向 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|x86Release|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_modeWAL;, nullptr, nullptr, nullptr); ::sqlite3_exec(m_db, PRAGMA synchronousNORMAL;, nullptr, nullptr, nullptr); ::sqlite3_exec(m_db, PRAGMA foreign_keysON;, nullptr, nullptr, nullptr); return true; }这段代码里有四个点值得单独说。第一是ToUtf8CString在 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_modeWAL会静默失败回落到默认的 journal 模式。所以数据库路径我一般放在%LOCALAPPDATA%或者程序自己的data\子目录首次启动时用CreateDirectory建出来。3. 封装层拆解从 sqlite3_stmt 到一个能抄的 CSqliteDbsqlite3 的 C 接口本身已经很干净了问题在于它的资源管理是手动的prepare 出来一个 stmt用完必须 finalizeopen 出来的连接用完必须 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_castint(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_STATICsqlite 只保存指针而nameUtf8是栈上对象函数返回后就失效了接下来 step 时读到的是被覆盖的内存表现是偶尔存进去一串乱码或者空串非常玄学。我见过不止一个项目栽在这上面最后定位到一行SQLITE_STATIC。查询侧同样要小心bool CDbHelper::QueryAll(std::vectorParamRow 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_castconst 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_tMultiByteToWideChar不带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::vectorParamRow 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::vectorParamRow 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 复位语句状态下一轮可以重新 bindclear_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绑定了一个临时CStringADebug 下栈上内存没被立刻覆盖所以侥幸能读出数据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」这三步哪怕只是加一个字段。看起来啰嗦但省下的是半夜被电话叫起来恢复数据的后悔药。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?