3. 数据库的创建与连接管理
好,咱们今天聊点实在的。数据库的创建和连接管理,说白了就是你怎么让程序跟数据库文件「搭上线」,并且保证这条线稳定、高效、不出幺蛾子。我做了这么多年嵌入式数据库开发,发现很多新手甚至老手都在这一步栽过跟头。今天我把我的经验掰开揉碎了讲给你听。
3.1 创建与打开数据库文件
在嵌入式数据库里,比如 SQLite,一个数据库就是一个文件。你调用 sqlite3_open(),它就会帮你搞定两件事:如果文件不存在,就创建它;如果存在,就打开它。听起来简单吧?但坑就在细节里。
核心函数原型:
int sqlite3_open(
const char *filename, // 数据库文件路径
sqlite3 **ppDb // 输出参数,返回数据库句柄
);
我个人习惯在调用 sqlite3_open() 之后,立刻检查返回值。不是等于 SQLITE_OK 就完事了,还要看看 sqlite3_errcode() 和 sqlite3_errmsg()。为什么?因为有时候文件路径权限不对,或者磁盘满了,返回值可能不是 SQLITE_OK,但也不是典型的错误码。你想想看,如果程序跑在生产环境上突然打不开数据库,你连日志都没有,那得多抓狂。
我的小技巧: 打开数据库后,立刻执行一次 PRAGMA schema_version;。如果返回 0,说明这是一个全新的空数据库。如果返回非零,说明是已有数据库。这个技巧帮我在项目里快速区分「首次初始化」和「正常启动」两种场景。
3.2 设置编码
编码问题,嗯,这里要注意。嵌入式数据库的编码设置直接影响你存进去的中文、日文、特殊符号能不能正确读写。SQLite 默认编码是 UTF-8,但很多老项目或者 Windows 环境会用 UTF-16。
我曾经在一个物联网项目里遇到过:设备上报的数据里包含中文传感器名称,结果因为数据库编码没设对,读出来全是乱码。排查了整整两天,最后发现是 sqlite3_open16() 和 sqlite3_open() 混用了。
设置编码的推荐做法:
// 打开数据库后立即设置编码
sqlite3_exec(db, "PRAGMA encoding = 'UTF-8';", NULL, NULL, &zErrMsg);
// 或者设置 UTF-16
sqlite3_exec(db, "PRAGMA encoding = 'UTF-16le';", NULL, NULL, &zErrMsg);
我个人建议统一用 UTF-8。原因很简单:跨平台兼容性好,C 语言字符串处理方便,而且大部分网络协议也是 UTF-8。你非要用 UTF-16 的话,记得所有字符串接口都要用 sqlite3_bind_text16() 和 sqlite3_column_text16(),别混了。
| 编码类型 | 适用场景 | 注意事项 |
|---|---|---|
| UTF-8 | 跨平台、网络传输、Linux/Unix | 字符串长度按字节计算,中文占3字节 |
| UTF-16le | Windows 原生、某些嵌入式系统 | 注意字节序,大端小端要匹配 |
| UTF-16be | 某些网络协议、特定硬件 | 用得少,不推荐 |
3.3 连接池思想
说到连接池,很多嵌入式开发者会觉得:「我就一个数据库文件,要什么连接池?」其实不然。你想想看,如果你的程序是多线程的,每个线程都频繁打开关闭数据库,那性能损耗可不小。每次 sqlite3_open() 都要做文件 I/O、初始化内部结构,开销挺大的。
连接池的思想很简单:预先创建一批连接,用的时候拿一个,用完还回去。在嵌入式环境里,我通常实现一个轻量级的连接池,不需要像 Java 的 DBCP 那么复杂。
轻量级连接池核心结构:
typedef struct {
sqlite3 *conn; // 数据库连接
int in_use; // 是否被占用
pthread_mutex_t mutex; // 连接级锁
} DbConn;
typedef struct {
DbConn *conns; // 连接数组
int total; // 总连接数
int available; // 可用连接数
pthread_mutex_t pool_mutex;
} ConnPool;
我建议连接池的大小设置为 CPU 核心数 + 1。为什么?因为数据库操作通常是 I/O 密集型的,多一两个连接可以充分利用等待时间。但别设太多,嵌入式设备的资源有限,连接多了反而增加上下文切换的开销。
避坑指南: 连接池里的每个连接,在创建时都要单独设置 PRAGMA journal_mode=WAL;。WAL 模式允许多个读连接和一个写连接并发工作,性能提升非常明显。我曾经在一个数据采集项目里,把连接池从 1 个连接扩展到 4 个,并启用 WAL 模式,写入吞吐量提升了 3 倍。
3.4 错误处理与日志
错误处理,这是我觉得最值得花功夫的地方。很多人的代码里只有 if (rc != SQLITE_OK) return -1;,然后就没有然后了。出了问题根本不知道错在哪。
我自己的做法是:封装一个统一的错误处理宏,把错误码、错误信息、文件名、行号全部记录下来。
我的错误处理宏:
#define DB_CHECK(db, rc, msg) do { \
if ((rc) != SQLITE_OK && (rc) != SQLITE_ROW && (rc) != SQLITE_DONE) { \
fprintf(stderr, "[ERROR] %s:%d - %s - rc=%d, err=%s\n", \
__FILE__, __LINE__, (msg), (rc), sqlite3_errmsg(db)); \
/* 这里可以写日志文件 */ \
log_write(LOG_LEVEL_ERROR, "%s:%d - %s - rc=%d, err=%s", \
__FILE__, __LINE__, (msg), (rc), sqlite3_errmsg(db)); \
} \
} while(0)
你看,这样每次调用数据库操作后,只要一行 DB_CHECK(db, rc, "insert sensor data");,就能把完整的错误信息记录下来。我遇到过最离谱的一次,是磁盘坏道导致数据库文件损坏,sqlite3_exec() 返回 SQLITE_CORRUPT。如果没有详细的日志,你根本想不到是硬件问题。
重要提醒: 错误处理里不要只打印错误就完事。对于 SQLITE_BUSY(数据库忙)和 SQLITE_LOCKED(表被锁),应该实现重试机制。我一般重试 3 次,每次等待 100 毫秒。如果 3 次后还失败,再记录错误并返回。
日志方面,我建议分三个级别:
- ERROR:数据库操作失败,需要人工介入。比如文件损坏、权限错误。
- WARN:操作重试成功、连接池耗尽后等待、编码转换警告。这些不影响功能,但值得关注。
- INFO:数据库打开/关闭、连接池初始化、PRAGMA 设置。用于追踪程序运行状态。
嗯,说到日志,我还想提一句:日志文件别跟数据库文件放在同一个目录。万一数据库文件所在的磁盘满了,日志还能写进去,帮你定位问题。这个教训是我用一次线上事故换来的。
3.5 知识体系总览
下面这张图是我自己整理的,把本章的核心知识点串起来了。你可以把它当作一个检查清单,看看自己有没有遗漏什么。
好了,这一章的内容就这些。创建和打开数据库是基本功,编码设置决定了你能存什么数据,连接池思想帮你提升性能,错误处理和日志则是你排查问题的最后一道防线。把这四点吃透了,你的嵌入式数据库程序就能跑得稳、跑得快、出了问题还能快速定位。