数据库访问:SQLite跨平台封装、ODBC抽象、连接池设计
数据库访问这块,说实话是跨平台开发里最绕不开的坑。你想想看,Windows上用ODBC,Linux上可能用libpq或者sqlite3,macOS又一套。每次换平台就要重写数据库层?那也太累了。
我个人习惯是,不管底层用什么数据库,上层接口必须统一。今天咱们就聊聊怎么把SQLite、ODBC这些乱七八糟的接口,封装成一套干净、好用的C++接口。顺便把连接池也带上——毕竟高并发场景下,频繁创建连接是真的扛不住。
核心思路:抽象一个统一的数据库接口层,底层用适配器模式对接SQLite、ODBC等具体实现。上层业务代码只跟接口打交道,换数据库就像换插座一样简单。
1. SQLite跨平台封装
SQLite本身是C接口,函数名都是sqlite3_xxx这种风格。直接裸用的话,代码里到处都是sqlite3_open、sqlite3_exec、sqlite3_close,看着就头疼。
我建议封装成RAII风格的类。构造时打开数据库,析构时自动关闭。这样就算中间抛异常,资源也不会泄漏。
class SQLiteDatabase {
public:
explicit SQLiteDatabase(const std::string& path) {
int rc = sqlite3_open(path.c_str(), &db_);
if (rc != SQLITE_OK) {
throw std::runtime_error("Failed to open database: " +
std::string(sqlite3_errmsg(db_)));
}
}
~SQLiteDatabase() {
if (db_) {
sqlite3_close(db_);
}
}
// 禁止拷贝,允许移动
SQLiteDatabase(const SQLiteDatabase&) = delete;
SQLiteDatabase& operator=(const SQLiteDatabase&) = delete;
bool execute(const std::string& sql) {
char* errMsg = nullptr;
int rc = sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &errMsg);
if (rc != SQLITE_OK) {
std::string err = errMsg ? errMsg : "unknown error";
sqlite3_free(errMsg);
throw std::runtime_error("SQL error: " + err);
}
return true;
}
private:
sqlite3* db_ = nullptr;
};
小技巧:SQLite的sqlite3_exec回调函数可以用来处理查询结果。但我个人更推荐用sqlite3_prepare_v2 + sqlite3_step的方式,可控性更强,也方便做参数绑定。
我在项目中遇到过一个问题:多线程同时写SQLite,结果频繁报SQLITE_BUSY。后来发现SQLite默认是单线程模式。解决办法是编译时加上SQLITE_THREADSAFE=1,或者用sqlite3_config设置多线程模式。
2. ODBC抽象层
ODBC是Windows上的老牌数据库接口,但说实话,它的API设计得挺反人类的。一堆SQLAllocHandle、SQLConnect、SQLExecDirect,而且错误处理特别繁琐——你得先调用SQLGetDiagRec才能拿到错误信息。
我封装ODBC的思路是:把那些繁琐的句柄管理藏起来,对外暴露跟SQLite类似的接口。这样上层代码根本不用关心底层是SQLite还是ODBC。
class OdbcDatabase {
public:
explicit OdbcDatabase(const std::string& connectionString) {
// 分配环境句柄
SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &env_);
SQLSetEnvAttr(env_, SQL_ATTR_ODBC_VERSION, (SQLPOINTER)SQL_OV_ODBC3, 0);
// 分配连接句柄
SQLAllocHandle(SQL_HANDLE_DBC, env_, &conn_);
// 连接数据库
SQLCHAR* outConnStr = nullptr;
SQLSMALLINT outLen = 0;
SQLRETURN ret = SQLDriverConnect(conn_, nullptr,
(SQLCHAR*)connectionString.c_str(), SQL_NTS,
outConnStr, 0, &outLen, SQL_DRIVER_COMPLETE);
if (!SQL_SUCCEEDED(ret)) {
throw std::runtime_error("ODBC connection failed");
}
}
~OdbcDatabase() {
if (conn_) SQLDisconnect(conn_);
if (conn_) SQLFreeHandle(SQL_HANDLE_DBC, conn_);
if (env_) SQLFreeHandle(SQL_HANDLE_ENV, env_);
}
bool execute(const std::string& sql) {
SQLHANDLE stmt = nullptr;
SQLAllocHandle(SQL_HANDLE_STMT, conn_, &stmt);
SQLRETURN ret = SQLExecDirect(stmt, (SQLCHAR*)sql.c_str(), SQL_NTS);
if (!SQL_SUCCEEDED(ret)) {
// 这里可以提取错误信息
SQLFreeHandle(SQL_HANDLE_STMT, stmt);
return false;
}
SQLFreeHandle(SQL_HANDLE_STMT, stmt);
return true;
}
private:
SQLHENV env_ = nullptr;
SQLHDBC conn_ = nullptr;
};
注意:ODBC的句柄释放顺序很重要。必须先释放语句句柄,再释放连接句柄,最后释放环境句柄。顺序错了会内存泄漏。我曾经因为这个bug排查了一整天……
3. 统一抽象接口
有了SQLite和ODBC的具体实现,接下来就是抽象一个基类。所有数据库操作都通过这个基类进行。
class IDatabase {
public:
virtual ~IDatabase() = default;
virtual bool execute(const std::string& sql) = 0;
virtual std::shared_ptr<IResultSet> query(const std::string& sql) = 0;
virtual bool beginTransaction() = 0;
virtual bool commit() = 0;
virtual bool rollback() = 0;
};
// 工厂函数
std::unique_ptr<IDatabase> createDatabase(const std::string& type,
const std::string& connectionInfo) {
if (type == "sqlite") {
return std::make_unique<SQLiteDatabase>(connectionInfo);
} else if (type == "odbc") {
return std::make_unique<OdbcDatabase>(connectionInfo);
}
throw std::invalid_argument("Unsupported database type: " + type);
}
你看,上层代码只需要调用createDatabase("sqlite", "test.db")或者createDatabase("odbc", "DSN=MyDB"),完全不用管底层实现。这就是抽象的魅力。
4. 连接池设计
连接池这东西,说白了就是提前创建一批数据库连接,用的时候拿一个,用完还回去。避免每次请求都创建新连接——那开销太大了。
我设计连接池时,主要考虑三个点:
- 线程安全:多个线程同时取连接,不能乱套
- 连接复用:用完的连接要能回到池子里
- 超时机制:等不到连接不能死等,得有个超时
class ConnectionPool {
public:
ConnectionPool(const std::string& type,
const std::string& connInfo,
size_t minSize,
size_t maxSize)
: type_(type), connInfo_(connInfo),
minSize_(minSize), maxSize_(maxSize) {
// 初始化最小连接数
for (size_t i = 0; i < minSize_; ++i) {
pool_.push(createConnection());
}
}
std::shared_ptr<IDatabase> acquire() {
std::unique_lock<std::mutex> lock(mutex_);
// 如果池子为空且没达到上限,创建新连接
if (pool_.empty() && total_ < maxSize_) {
auto conn = createConnection();
total_++;
return conn;
}
// 等待可用连接(带超时)
if (pool_.empty()) {
if (!cond_.wait_for(lock, std::chrono::seconds(5),
[this]{ return !pool_.empty(); })) {
throw std::runtime_error("Acquire connection timeout");
}
}
auto conn = pool_.front();
pool_.pop();
return conn;
}
void release(std::shared_ptr<IDatabase> conn) {
std::lock_guard<std::mutex> lock(mutex_);
pool_.push(conn);
cond_.notify_one();
}
private:
std::shared_ptr<IDatabase> createConnection() {
return createDatabase(type_, connInfo_);
}
std::string type_;
std::string connInfo_;
size_t minSize_;
size_t maxSize_;
size_t total_ = 0;
std::queue<std::shared_ptr<IDatabase>> pool_;
std::mutex mutex_;
std::condition_variable cond_;
};
经验之谈:连接池的大小不是越大越好。我见过有人把连接池设成1000,结果数据库服务器直接挂了。一般建议根据业务峰值来算,比如QPS是1000,每个查询耗时10ms,那10个连接就够用了。留点余量,设成20~30比较稳妥。
5. 整体架构图
下面这张图展示了整个数据库访问层的架构。从最上层的业务代码,到中间的抽象接口,再到底层的具体实现,层层隔离,互不干扰。
6. 避坑指南
做数据库封装这些年,我踩过的坑真不少。挑几个典型的说说:
- 连接泄漏:曾经有一次,某个线程拿了连接没归还,结果连接池慢慢被掏空,整个服务挂了。后来我加了个RAII包装器,析构时自动归还连接,再也没出过这问题。
- 事务嵌套:SQLite不支持嵌套事务,但ODBC支持。如果你在抽象层里做了事务嵌套,换到SQLite就会报错。我的解决办法是:在抽象层里用计数器模拟嵌套事务,只有最外层才真正提交或回滚。
- 字符编码:SQLite默认UTF-8,ODBC可能是UTF-16。如果你在Windows上混用,字符串会乱码。我习惯在抽象层统一转成UTF-8,底层再按需转换。
重要提醒:连接池里的连接要定期做心跳检测。数据库服务器可能因为网络波动或重启导致连接断开,但客户端不知道。我一般每隔30秒发一条SELECT 1,如果失败就重建连接。
好了,数据库访问这块就聊到这儿。核心就是:抽象接口 + 适配器模式 + 连接池。把这三点吃透了,不管以后换什么数据库,你都能从容应对。