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 知识体系总览

下面这张图是我自己整理的,把本章的核心知识点串起来了。你可以把它当作一个检查清单,看看自己有没有遗漏什么。

数据库创建与连接管理 · 知识体系 数据库连接管理 创建/打开数据库 sqlite3_open() 编码设置 PRAGMA encoding 连接池思想 复用连接 错误处理与日志 DB_CHECK宏 + 分级日志 文件路径 权限检查 UTF-8 UTF-16 线程安全 WAL模式 重试机制 分级日志 四个核心模块,每个模块都有对应的实践要点

好了,这一章的内容就这些。创建和打开数据库是基本功,编码设置决定了你能存什么数据,连接池思想帮你提升性能,错误处理和日志则是你排查问题的最后一道防线。把这四点吃透了,你的嵌入式数据库程序就能跑得稳、跑得快、出了问题还能快速定位。

公众号:蓝海资料掘金营,微信deep3321