数据库访问:SQLite跨平台封装、ODBC抽象、连接池设计

数据库访问这块,说实话是跨平台开发里最绕不开的坑。你想想看,Windows上用ODBC,Linux上可能用libpq或者sqlite3,macOS又一套。每次换平台就要重写数据库层?那也太累了。

我个人习惯是,不管底层用什么数据库,上层接口必须统一。今天咱们就聊聊怎么把SQLite、ODBC这些乱七八糟的接口,封装成一套干净、好用的C++接口。顺便把连接池也带上——毕竟高并发场景下,频繁创建连接是真的扛不住。

核心思路:抽象一个统一的数据库接口层,底层用适配器模式对接SQLite、ODBC等具体实现。上层业务代码只跟接口打交道,换数据库就像换插座一样简单。

1. SQLite跨平台封装

SQLite本身是C接口,函数名都是sqlite3_xxx这种风格。直接裸用的话,代码里到处都是sqlite3_opensqlite3_execsqlite3_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设计得挺反人类的。一堆SQLAllocHandleSQLConnectSQLExecDirect,而且错误处理特别繁琐——你得先调用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. 整体架构图

下面这张图展示了整个数据库访问层的架构。从最上层的业务代码,到中间的抽象接口,再到底层的具体实现,层层隔离,互不干扰。

数据库访问层架构图 业务代码 (Business Logic) IDatabase 抽象接口 适配器层 (Adapter) SQLite 实现 ODBC 实现 其他数据库实现 连接池 (Connection Pool)

6. 避坑指南

做数据库封装这些年,我踩过的坑真不少。挑几个典型的说说:

  • 连接泄漏:曾经有一次,某个线程拿了连接没归还,结果连接池慢慢被掏空,整个服务挂了。后来我加了个RAII包装器,析构时自动归还连接,再也没出过这问题。
  • 事务嵌套:SQLite不支持嵌套事务,但ODBC支持。如果你在抽象层里做了事务嵌套,换到SQLite就会报错。我的解决办法是:在抽象层里用计数器模拟嵌套事务,只有最外层才真正提交或回滚。
  • 字符编码:SQLite默认UTF-8,ODBC可能是UTF-16。如果你在Windows上混用,字符串会乱码。我习惯在抽象层统一转成UTF-8,底层再按需转换。

重要提醒:连接池里的连接要定期做心跳检测。数据库服务器可能因为网络波动或重启导致连接断开,但客户端不知道。我一般每隔30秒发一条SELECT 1,如果失败就重建连接。

好了,数据库访问这块就聊到这儿。核心就是:抽象接口 + 适配器模式 + 连接池。把这三点吃透了,不管以后换什么数据库,你都能从容应对。


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