简介:本资源面向使用Visual C++进行桌面开发的程序员,聚焦SQLite轻量级嵌入式数据库在VC项目中的集成与实战应用。包内提供可直接编译运行的完整工程,涵盖数据库连接建立、大批量数据快速插入、查询结果读取并填充至ListCtrl控件展示等核心环节,帮助开发者解决数据存储与界面呈现的衔接问题。资源共61个文件,包含sqlite3.h头文件、sqlite3.dll动态库与sqlite3.lib静态库,以及cpp源码、vcxproj工程文件、sln解决方案和多个db示例数据库,另有编译产物与资源文件,压缩包约32.07MB,结构完整便于直接复用。目前已有527人学习下载,适合需要快速掌握VC环境下SQLite增删改查与批量操作技巧的初中级开发者参考借鉴。
1. VC中sqlite的使用:为什么老项目还在用它扛数据层
接手一个维护了七八年的 MFC 工控上位机时,我发现它的配置、报警记录、历史曲线采样点全塞在一个不到 2MB 的.db文件里,没有服务端、没有安装依赖,拷过去就能跑。这就是 VC 中 sqlite 使用的典型场景:单机、离线、数据量中等、部署环境恶劣。SQLite 是进程内嵌的关系型数据库,整个引擎编译进你的 exe 或做成一个 DLL,不需要独立数据库服务进程,不需要网络端口,事务直接落盘。对 VC 开发者来说,它解决的是「我不想为了存几百兆数据去装 MySQL、还要配 ODBC 数据源」这类问题。适合谁?做设备上位机、测试工具、桌面客户端、嵌入式 HMI 的 C++ 工程师。如果你在做高并发写入或跨机共享,这篇的选型结论会告诉你边界在哪。
2. 在 VC 工程里把 sqlite3 跑起来:三种集成方式与最小可运行代码
2.1 源码 amalgamation 集成:为什么我优先选它
SQLite 官方提供 amalgamation 形式的源码,把整个引擎合并成sqlite3.c和sqlite3.h两个文件。VC 工程里最省事的做法就是把这两个文件直接加进项目编译。好处是版本完全可控,不依赖系统里装了什么,编译出来的 exe 自带数据库能力,拷到客户机器上不会因为缺 DLL 而翻车。
常见做法是新建一个静态库工程或者直接把sqlite3.c加入主工程。我一般会单独建一个sqlite3静态库项目,把sqlite3.c加进去,主工程引用它。这样编译一次,多个模块复用,也避免主工程编译单元过大。
需要注意编译选项。SQLite 默认会启用一些线程和内存相关的宏,VC 下建议在预处理器里显式定义几个关键宏,避免默认行为不符合预期:
// 在 sqlite3 静态库工程的预处理器定义中加入 // SQLITE_THREADSAFE=1 多线程安全模式,配合临界区使用 // SQLITE_ENABLE_COLUMN_METADATA 需要获取列元数据时打开 // SQLITE_OMIT_LOAD_EXTENSION 不需要动态加载扩展就关掉,减小体积 // SQLITE_DEFAULT_MEMSTATUS=0 关闭内存统计,提升性能参数说明:SQLITE_THREADSAFE有三个取值,0 是单线程、1 是串行化、2 是多线程。VC 上位机通常有 UI 线程和采集线程同时访问,选 1 最稳,但每次调用会加锁,性能略降。如果确定只有一个线程碰数据库,选 0 能快一点。SQLITE_OMIT_LOAD_EXTENSION关掉后编译产物会小几十 KB,对嵌入式场景有意义。
2.2 动态库方式:什么时候该用 DLL
如果多个 exe 要共享同一个数据库引擎,或者你不想让主工程编译时间变长,可以把 sqlite3 编译成 DLL。导出符号需要处理,因为 sqlite3.c 默认不导出。做法是在编译 DLL 时定义SQLITE_API=__declspec(dllexport),使用方定义SQLITE_API=__declspec(dllimport)。
// DLL 工程预处理器 SQLITE_API=__declspec(dllexport) // 使用方工程预处理器 SQLITE_API=__declspec(dllimport)逻辑说明:SQLite 的 API 声明都带SQLITE_API宏,默认是空。通过重定义这个宏,就能控制符号的导出导入。参数上要注意,DLL 和 exe 必须用同一套运行库选项(/MD 或 /MT),否则跨堆释放内存会出问题。我见过有人 DLL 用 /MT、exe 用 /MD,结果sqlite3_free释放时直接崩,查了半天。
2.3 最小可运行代码:打开、建表、插入、查询
不管哪种集成方式,API 调用是一样的。下面这段代码可以直接抄进你的测试工程:
#include "sqlite3.h" #include <stdio.h> int main() { sqlite3* db = NULL; char* errMsg = NULL; int rc; // 打开数据库,文件不存在会自动创建 rc = sqlite3_open("test.db", &db); if (rc != SQLITE_OK) { printf("open failed: %s\n", sqlite3_errmsg(db)); sqlite3_close(db); return -1; } // 建表,IF NOT EXISTS 避免重复执行报错 const char* createSql = "CREATE TABLE IF NOT EXISTS alarm(" "id INTEGER PRIMARY KEY AUTOINCREMENT," "code TEXT NOT NULL," "level INTEGER DEFAULT 0," "ts DATETIME DEFAULT CURRENT_TIMESTAMP);"; rc = sqlite3_exec(db, createSql, NULL, NULL, &errMsg); if (rc != SQLITE_OK) { printf("create failed: %s\n", errMsg); sqlite3_free(errMsg); sqlite3_close(db); return -1; } // 插入一条记录 const char* insertSql = "INSERT INTO alarm(code, level) VALUES('E001', 2);"; rc = sqlite3_exec(db, insertSql, NULL, NULL, &errMsg); if (rc != SQLITE_OK) { printf("insert failed: %s\n", errMsg); sqlite3_free(errMsg); } // 查询并打印 sqlite3_stmt* stmt = NULL; rc = sqlite3_prepare_v2(db, "SELECT id, code, level FROM alarm;", -1, &stmt, NULL); if (rc == SQLITE_OK) { while (sqlite3_step(stmt) == SQLITE_ROW) { int id = sqlite3_column_int(stmt, 0); const unsigned char* code = sqlite3_column_text(stmt, 1); int level = sqlite3_column_int(stmt, 2); printf("id=%d code=%s level=%d\n", id, code, level); } } sqlite3_finalize(stmt); sqlite3_close(db); return 0; }逻辑说明:sqlite3_open打开或创建数据库文件,返回句柄。sqlite3_exec用于执行不需要逐行处理的 SQL,比如建表、插入、更新。查询用sqlite3_prepare_v2编译成语句对象,再循环sqlite3_step取行,sqlite3_column_*按列索引取值。参数上,sqlite3_prepare_v2的第三个参数是 SQL 长度,传 -1 表示自动计算到结尾。sqlite3_column_text返回的指针在下次 step 或 finalize 后失效,需要拷贝走。
提示:
sqlite3_exec的错误信息用sqlite3_free释放,不要用 free,因为 SQLite 可能用自己的内存分配器。
3. 参数绑定与事务:把插入性能从每秒几十条拉到几万条
3.1 为什么拼接 SQL 字符串是血泪坑
新手最容易写的就是用sprintf拼 SQL,然后sqlite3_exec。两个问题:一是注入风险,虽然单机场景不担心攻击,但字段里带单引号直接语法错误;二是性能,每条 SQL 都要重新解析编译。我实测过,拼接方式插入一万条报警记录要十几秒,换成参数绑定加事务后不到一秒。
参数绑定用sqlite3_bind_*系列函数,配合sqlite3_prepare_v2预编译一次、循环重置执行:
const char* sql = "INSERT INTO alarm(code, level) VALUES(?, ?);"; sqlite3_stmt* stmt = NULL; sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); sqlite3_exec(db, "BEGIN TRANSACTION;", NULL, NULL, NULL); // 显式开事务 for (int i = 0; i < 10000; ++i) { sqlite3_bind_text(stmt, 1, "E001", -1, SQLITE_STATIC); sqlite3_bind_int(stmt, 2, i % 5); sqlite3_step(stmt); sqlite3_reset(stmt); // 重置语句,保留绑定 sqlite3_clear_bindings(stmt); // 清除绑定值,避免残留 } sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL); sqlite3_finalize(stmt);逻辑说明:?是占位符,sqlite3_bind_text的第四个参数是长度,-1 表示自动算到结尾;第五个参数是析构行为,SQLITE_STATIC表示字符串在 step 前不会被释放,SQLITE_TRANSIENT表示 SQLite 自己拷贝一份。参数上,如果绑定的字符串是临时缓冲区,必须用SQLITE_TRANSIENT,否则 step 时可能读到已释放内存。sqlite3_reset把语句恢复到 prepare 后的状态,可以重新绑定再执行,省去重复编译。
3.2 事务的边界:一次事务多少条合适
事务把多次写操作合并成一次磁盘同步,这是性能提升的关键。但事务不是越大越好。我一般按 1000 到 5000 条一批提交。太大内存里要缓存回滚日志,异常断电恢复时间长;太小磁盘同步次数多,提升有限。
// 分批提交的写法 const int BATCH = 2000; sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); for (int i = 0; i < total; ++i) { // bind + step if ((i + 1) % BATCH == 0) { sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL); sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL); } } sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL);参数说明:BATCH取值跟磁盘类型有关。机械盘同步慢,可以取大一点;SSD 同步快,取小一点减少锁持有时间。如果采集线程和 UI 线程并发,事务持有写锁期间 UI 读会被阻塞,所以批量插入最好放在独立线程,并且每批之间让出时间片。
3.3 WAL 模式:读写并发下的后悔药
默认的 rollback journal 模式下,写会阻塞读。上位机场景里,采集线程在写、UI 线程在查,很容易卡界面。开启 WAL 模式后,读写可以并发,写不阻塞读,读不阻塞写。
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL); sqlite3_exec(db, "PRAGMA synchronous=NORMAL;", NULL, NULL, NULL);逻辑说明:WAL 模式会生成-wal和-shm两个附属文件,数据库目录需要可写。synchronous=NORMAL在 WAL 下是安全的,只在 checkpoint 时同步,性能比 FULL 好很多。参数上,synchronous有 OFF、NORMAL、FULL、EXTRA 四档,OFF 最快但断电可能损坏,NORMAL 在 WAL 下推荐,FULL 最稳但慢。注意 WAL 模式在共享目录(比如网络盘)上不能用,会报错。
注意:WAL 文件在程序异常退出后可能残留,下次打开会自动恢复。如果恢复失败,检查目录权限和磁盘空间。
4. 避坑与排查:VC 下 sqlite 最常见的五类翻车
4.1 中文乱码:不是数据库的错
现象:插入的中文查出来是乱码,或者查询条件带中文匹配不到。原因:VC 工程默认用多字节字符集,而 SQLite 内部按 UTF-8 存储。你传进去的 GBK 字节被当成 UTF-8 解释。解决:统一转成 UTF-8 再绑定。用MultiByteToWideChar转宽字符,再WideCharToMultiByte转 UTF-8。
// GBK -> UTF-8 char* GbkToUtf8(const char* gbk) { int wlen = MultiByteToWideChar(CP_ACP, 0, gbk, -1, NULL, 0); wchar_t* wbuf = new wchar_t[wlen]; MultiByteToWideChar(CP_ACP, 0, gbk, -1, wbuf, wlen); int ulen = WideCharToMultiByte(CP_UTF8, 0, wbuf, -1, NULL, 0, NULL, NULL); char* ubuf = new char[ulen]; WideCharToMultiByte(CP_UTF8, 0, wbuf, -1, ubuf, ulen, NULL, NULL); delete[] wbuf; return ubuf; // 调用方负责 delete[] }参数说明:CP_ACP是当前系统 ANSI 代码页,中文系统就是 GBK。如果工程已经用 Unicode 字符集,直接WideCharToMultiByte转 UTF-8 即可。绑定的时候用SQLITE_TRANSIENT,让 SQLite 拷贝一份,避免缓冲区释放后数据失效。
4.2 数据库文件被锁:谁没关句柄
现象:程序运行中想用外部工具打开 db 文件,提示被占用;或者第二次sqlite3_open同一个文件失败。原因:SQLite 默认在打开期间持有文件锁,而且如果有语句没 finalize,连接不会真正关闭。解决:确保每个sqlite3_prepare_v2都有对应的sqlite3_finalize,每个sqlite3_open都有对应的sqlite3_close。用 RAII 封装是个好习惯。
class DbGuard { public: DbGuard(sqlite3* db) : db_(db) {} ~DbGuard() { if (db_) sqlite3_close(db_); } private: sqlite3* db_; };如果确实需要外部工具同时访问,开 WAL 模式能缓解,但写的时候仍然会锁。彻底方案是程序退出时关闭连接,或者用只读方式打开。
4.3 内存泄漏:prepare 了不 finalize
现象:程序跑久了内存持续上涨,任务管理器里看到句柄数也在涨。原因:sqlite3_prepare_v2创建的语句对象不释放,SQLite 内部会缓存编译结果,长期不 finalize 就是泄漏。解决:每个 prepare 配一个 finalize,异常分支也要保证执行。用sqlite3_next_stmt可以遍历未释放的语句,调试时打印出来定位。
// 调试用:列出所有未 finalize 的语句 sqlite3_stmt* s = sqlite3_next_stmt(db, NULL); while (s) { printf("unfinalized: %s\n", sqlite3_sql(s)); s = sqlite3_next_stmt(db, s); }4.4 多线程崩溃:一个连接不能跨线程随便用
现象:采集线程和 UI 线程共用一个sqlite3*,偶尔崩溃在sqlite3_step里。原因:即使开了SQLITE_THREADSAFE=1,同一个连接也不能被多个线程同时使用,串行化模式只是保证 API 调用是线程安全的,不保证逻辑上的并发使用。解决:每个线程用自己的连接,或者用临界区把数据库操作包起来。我一般给数据库封装一个类,内部用CRITICAL_SECTION保护所有公开方法。
class Db { public: void Exec(const char* sql) { EnterCriticalSection(&cs_); sqlite3_exec(db_, sql, NULL, NULL, NULL); LeaveCriticalSection(&cs_); } private: sqlite3* db_; CRITICAL_SECTION cs_; };参数说明:临界区粒度要控制好,不要把整个批量插入都包进去,否则 UI 线程等太久。可以在批量插入的每批之间释放锁。
4.5 编译报错:找不到 sqlite3.h 或链接错误
现象:fatal error C1083: 无法打开包括文件: "sqlite3.h"或者LNK2019: 无法解析的外部符号 sqlite3_open。原因:头文件路径没加,或者静态库没链接。解决:项目属性里附加包含目录加上 sqlite3.h 所在目录,链接器输入里加上 sqlite3.lib。如果用源码集成,确认sqlite3.c在编译列表里。另一个常见坑是 C/C++ 混合编译,sqlite3.h 没有extern "C"保护,C++ 工程里包含会名字修饰不匹配。解决:包含时加extern "C"。
extern "C" { #include "sqlite3.h" }5. 进阶技巧:用 sqlite3 的扩展能力把查询和备份做扎实
5.1 自定义函数:把业务逻辑下推到 SQL 层
SQLite 允许注册自定义函数,在 SQL 里直接调用 C 函数。比如报警等级需要根据数值动态计算,可以注册一个level_name函数:
static void levelName(sqlite3_context* ctx, int argc, sqlite3_value** argv) { int level = sqlite3_value_int(argv[0]); const char* name = "unknown"; switch (level) { case 0: name = "info"; break; case 1: name = "warn"; break; case 2: name = "error"; break; } sqlite3_result_text(ctx, name, -1, SQLITE_STATIC); } // 注册 sqlite3_create_function(db, "level_name", 1, SQLITE_UTF8, NULL, levelName, NULL, NULL);逻辑说明:sqlite3_create_function的第二个参数是 SQL 里用的函数名,第三个是参数个数,第四个是编码。注册后可以SELECT level_name(level) FROM alarm;。参数上,sqlite3_result_text的最后一个参数用SQLITE_STATIC表示返回的字符串是静态存储,不需要 SQLite 拷贝;如果是动态分配的,用SQLITE_TRANSIENT让 SQLite 拷贝后自己释放。
5.2 在线备份:不停止写入也能备份
SQLite 提供在线备份 API,可以在数据库运行期间把内容复制到另一个文件,不需要停采集线程。这对工控场景很实用,定时备份不影响生产。
sqlite3* pBackup = NULL; sqlite3_open("backup.db", &pBackup); sqlite3_backup* bk = sqlite3_backup_init(pBackup, "main", db, "main"); if (bk) { // 每次复制 100 页,循环直到完成 int rc; do { rc = sqlite3_backup_step(bk, 100); if (rc == SQLITE_BUSY || rc == SQLITE_LOCKED) { Sleep(10); // 让出时间片,等锁释放 } } while (rc == SQLITE_OK || rc == SQLITE_BUSY || rc == SQLITE_LOCKED); sqlite3_backup_finish(bk); } sqlite3_close(pBackup);参数说明:sqlite3_backup_step的第二个参数是每次复制的页数,-1 表示一次复制完。分步复制的好处是每步之间可以处理锁冲突,不会长时间阻塞写操作。SQLITE_BUSY和SQLITE_LOCKED时 Sleep 一下重试,直到返回SQLITE_DONE。备份完成后记得sqlite3_backup_finish,否则目标库可能不完整。
5.3 用 EXPLAIN 看查询计划:索引有没有生效
数据量到几十万行后,查询变慢,第一反应是加索引。但索引有没有被用上,不能靠猜。用EXPLAIN QUERY PLAN看:
EXPLAIN QUERY PLAN SELECT * FROM alarm WHERE code = 'E001';输出里如果看到SCAN TABLE alarm,说明全表扫描,索引没生效。看到SEARCH TABLE alarm USING INDEX idx_code,说明走了索引。常见索引不生效的原因:查询条件对列做了函数运算、类型不匹配、或者统计信息过期。用ANALYZE;更新统计信息后再看。
我一般会在建表后对常用查询列建索引,但不过度建。每个索引都会拖慢插入速度,报警表插入频繁,索引控制在两三个以内。如果查询模式复杂,考虑用覆盖索引,把查询涉及的列都放进索引里,避免回表。
5.4 一个我踩过的坑:DATETIME 默认值不是你想的那样
建表时用DEFAULT CURRENT_TIMESTAMP,存进去的是 UTC 时间,不是本地时间。上位机显示的时候差 8 小时,查了半天以为是时区配置问题。解决:要么在插入时显式传本地时间字符串,要么查询时用datetime(ts, 'localtime')转换。我现在的习惯是插入时用sqlite3_bind_text绑本地时间,格式YYYY-MM-DD HH:MM:SS,查询直接比较字符串,简单可控。
// 获取本地时间字符串 SYSTEMTIME st; GetLocalTime(&st); char ts[32]; sprintf_s(ts, "%04d-%02d-%02d %02d:%02d:%02d", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); sqlite3_bind_text(stmt, 3, ts, -1, SQLITE_TRANSIENT);这个习惯帮我省了很多跨时区部署的麻烦。数据库里存什么格式,显示层怎么格式化,最好在项目初期就定死,不然后期改起来到处是坑。希望帮到你。
本文还有配套的精品资源,点击获取