【C++】手写 MySQL 连接池(二):同步连接的获取、加锁与释放

上一篇从整体上介绍了 MySQL 连接池的组成,并简单区分了同步连接和异步连接。

同步查询的特点是:

调用线程获取一个数据库连接,直接执行 SQL,并等待数据库返回结果后再继续运行。

当多个线程同时执行同步查询时,连接池必须保证同一个 MySQL 连接不会被多个线程同时使用。因此,同步连接的核心问题是:

复制代码
如何判断连接是否空闲?
如何避免多个线程同时占用同一个连接?
使用完成后,如何将连接重新交还给连接池?

本文重点分析 LockIfReady()GetFreeConnection()Query()Unlock() 之间的配合过程。

一、为什么一个 MySQL 连接不能被多个线程同时使用

连接池中保存着多个数据库连接:

复制代码
同步连接池
├── connection0
├── connection1
├── connection2
└── connection3

假设线程 A 和线程 B 同时操作 connection0

复制代码
线程A:发送 SELECT 查询
             ↓
线程B:发送 UPDATE 查询
             ↓
两个线程同时操作同一个 MySQL 连接

这可能造成以下问题:

  • SQL 请求和返回结果相互干扰;
  • 一个线程读取到另一个线程的查询结果;
  • MySQL 连接内部状态被破坏;
  • 出现数据竞争或程序崩溃;
  • 事务中的多条语句被其他线程插入。

因此,在连接对象中需要增加一把互斥锁:

复制代码
class MySQLConnection
{
private:
    // MySQL底层连接句柄
    MySQLHandle* m_Mysql;

    // 保护当前数据库连接,避免被多个线程同时使用
    std::mutex m_Mutex;
};

这里的互斥锁不是保护整个连接池,而是每一个数据库连接都有自己独立的锁:

复制代码
connection0 → mutex0
connection1 → mutex1
connection2 → mutex2
connection3 → mutex3

这样可以实现:

复制代码
线程A使用connection0
线程B使用connection1
线程C使用connection2

多个线程可以同时执行数据库操作,只要它们使用的不是同一个连接。

如果只给整个连接池设置一把锁,那么即使池中有多个连接,同一时刻也只能有一个线程执行 SQL,这样连接池就失去了并发处理的意义。


二、LockIfReady 和 Unlock 如何管理连接状态

连接对象提供了两个与锁有关的接口:

复制代码
class MySQLConnection
{
protected:
    // 尝试获取当前连接的使用权
    bool LockIfReady();

    // 使用完成后释放连接
    void Unlock();

private:
    std::mutex m_Mutex;
};

它们的实现如下:

复制代码
bool MySQLConnection::LockIfReady()
{
    /*
     * try_lock不会一直阻塞当前线程。
     *
     * 如果连接空闲:
     *     加锁成功,返回true。
     *
     * 如果连接正在被其他线程使用:
     *     加锁失败,立即返回false。
     */
    return m_Mutex.try_lock();
}

void MySQLConnection::Unlock()
{
    /*
     * 当前线程已经完成数据库操作,
     * 释放互斥锁,让其他线程可以继续使用该连接。
     */
    m_Mutex.unlock();
}

try_lock() 和普通 lock() 的区别是:

复制代码
lock():
连接被占用时,当前线程阻塞在这把锁上,直到锁被释放。

try_lock():
连接被占用时立即返回false,当前线程可以检查其他连接。

连接池中存在多个同步连接,因此更适合使用 try_lock()

假设当前状态如下:

复制代码
connection0:正在被线程A使用
connection1:正在被线程B使用
connection2:空闲
connection3:空闲

线程 C 检查连接时:

复制代码
检查connection0
    ↓
try_lock失败
    ↓
检查connection1
    ↓
try_lock失败
    ↓
检查connection2
    ↓
try_lock成功
    ↓
线程C使用connection2

这样,线程 C 不需要一直等待 connection0,而是可以继续寻找其他空闲连接。

连接的状态并没有使用单独的布尔变量表示:

复制代码
bool isUsing;

而是直接由互斥锁状态表示:

复制代码
互斥锁可以成功获取:连接空闲
互斥锁获取失败:连接正在使用

获取连接和归还连接的基本规则是:

复制代码
// 获取连接的使用权
if (connection->LockIfReady()) {

    // 在加锁期间执行数据库操作
    connection->Query(sql);

    // 使用完成后必须解锁
    connection->Unlock();
}

每一次成功的 LockIfReady(),都必须对应一次 Unlock()

否则,连接会一直处于加锁状态,其他线程以后都无法再使用它。


三、GetFreeConnection 如何查找空闲连接

同步连接保存在:

复制代码
_connections[IDX_SYNCH]

其中每一个元素都是一个连接对象:

复制代码
std::array<
    std::vector<std::unique_ptr<T>>,
    IDX_SIZE
> _connections;

获取空闲同步连接的函数如下:

复制代码
template <class T>
T* DatabaseWorkerPool<T>::GetFreeConnection()
{
    // 当前检查位置
    uint8 i = 0;

    // 获取同步连接总数
    auto const numConnections =
        _connections[IDX_SYNCH].size();

    T* connection = nullptr;

    /*
     * 不断寻找空闲连接。
     *
     * 如果所有连接都正在使用,
     * 当前线程会继续循环检查,
     * 直到某个连接被释放。
     */
    for (;;)
    {
        /*
         * 通过取模让下标在连接数组中循环:
         *
         * 0、1、2、3、0、1、2、3......
         */
        connection =
            _connections[IDX_SYNCH]
                        [++i % numConnections]
                        .get();

        /*
         * 尝试锁住当前连接。
         *
         * 成功:说明连接空闲,结束循环;
         * 失败:说明连接被占用,继续检查下一个。
         */
        if (connection->LockIfReady())
            break;
    }

    /*
     * 返回时连接仍然处于加锁状态。
     *
     * 调用者执行完SQL后,
     * 必须调用connection->Unlock()。
     */
    return connection;
}

假设同步连接池中有 3 个连接:

复制代码
connection0:占用
connection1:占用
connection2:空闲

查找过程为:

复制代码
GetFreeConnection()
        ↓
检查connection1
        ↓
LockIfReady()返回false
        ↓
检查connection2
        ↓
LockIfReady()返回true
        ↓
返回connection2

返回连接时,connection2 已经被当前线程加锁:

复制代码
connection2
    ↓
当前线程独占使用
    ↓
其他线程无法同时获得该连接

这也是代码注释中特别强调的内容:

复制代码
/*
 * 获取到的连接已经加锁。
 * 操作完成后必须调用Unlock(),
 * 否则可能造成连接永久占用。
 */
T* connection = GetFreeConnection();

需要注意,当前实现采用的是循环检查方式:

复制代码
for (;;)
{
    // 不断检查连接
}

如果所有同步连接都处于繁忙状态,当前线程会一直循环,这种方式属于忙等待。

它的优点是代码简单,连接很快释放时能够立即获取;缺点是等待时间较长时会持续占用 CPU。

更完善的连接池可以使用条件变量或信号量:

复制代码
没有空闲连接
    ↓
当前线程进入休眠
    ↓
某个线程归还连接
    ↓
唤醒一个等待线程

不过在理解当前连接池时,可以先记住:

GetFreeConnection() 会循环检查同步连接,直到通过 try_lock() 成功锁住其中一个连接。


四、同步查询如何获取并归还连接

同步查询接口如下:

复制代码
QueryResult Query(
    char const* sql,
    T* connection = nullptr
);

实现代码如下:

复制代码
template <class T>
QueryResult DatabaseWorkerPool<T>::Query(
    char const* sql,
    T* connection)
{
    /*
     * 如果调用者没有指定连接,
     * 就从同步连接池获取一个空闲连接。
     *
     * GetFreeConnection返回的连接已经加锁。
     */
    if (connection == nullptr)
        connection = GetFreeConnection();

    /*
     * 当前线程直接执行SQL,并等待查询完成。
     */
    ResultSet* result = connection->Query(sql);

    /*
     * SQL已经执行完成,
     * 立即归还数据库连接。
     *
     * 后续对ResultSet的读取不再长期占用连接。
     */
    connection->Unlock();

    /*
     * 查询失败、结果为空,或者无法定位到第一行,
     * 都返回一个空的QueryResult。
     */
    if (result == nullptr ||
        result->GetRowCount() == 0 ||
        !result->NextRow())
    {
        delete result;
        return QueryResult(nullptr);
    }

    /*
     * 返回使用智能指针管理的查询结果。
     */
    return QueryResult(result);
}

整个同步查询流程为:

复制代码
调用Query(sql)
        ↓
调用GetFreeConnection()
        ↓
循环检查同步连接
        ↓
LockIfReady成功
        ↓
获得一个已加锁连接
        ↓
执行connection->Query(sql)
        ↓
获取查询结果
        ↓
调用connection->Unlock()
        ↓
连接重新变为空闲状态
        ↓
返回QueryResult

底层连接执行查询时,会继续调用:

复制代码
ResultSet* MySQLConnection::Query(char const* sql)
{
    if (sql == nullptr)
        return nullptr;

    MySQLResult* result = nullptr;
    MySQLField* fields = nullptr;
    uint64 rowCount = 0;
    uint32 fieldCount = 0;

    /*
     * 调用MySQL底层API执行SQL,
     * 并获取结果、字段和行数。
     */
    if (!_Query(
            sql,
            &result,
            &fields,
            &rowCount,
            &fieldCount))
    {
        return nullptr;
    }

    // 将MySQL原始结果封装成ResultSet
    return new ResultSet(
        result,
        fields,
        rowCount,
        fieldCount
    );
}

真正发送 SQL 的位置是:

复制代码
bool MySQLConnection::_Query(
    char const* sql,
    MySQLResult** result,
    MySQLField** fields,
    uint64* rowCount,
    uint32* fieldCount)
{
    // 当前连接不存在,无法执行查询
    if (m_Mysql == nullptr)
        return false;

    // 向MySQL服务器发送SQL
    if (mysql_query(m_Mysql, sql))
    {
        // 获取并记录错误信息
        uint32 errorCode = mysql_errno(m_Mysql);

        TC_LOG_ERROR(
            "sql.sql",
            "[%u] %s",
            errorCode,
            mysql_error(m_Mysql)
        );

        return false;
    }

    /*
     * mysql_store_result会读取完整查询结果,
     * 并将结果保存在客户端内存中。
     */
    *result = reinterpret_cast<MySQLResult*>(
        mysql_store_result(m_Mysql)
    );

    // 获取结果行数
    *rowCount = mysql_affected_rows(m_Mysql);

    // 获取字段数量
    *fieldCount = mysql_field_count(m_Mysql);

    if (*result == nullptr || *rowCount == 0)
        return false;

    // 获取字段信息
    *fields = reinterpret_cast<MySQLField*>(
        mysql_fetch_fields(*result)
    );

    return true;
}

这里使用了:

复制代码
mysql_store_result(m_Mysql);

它会将完整结果集读取到客户端内存中。

因此,SQL 执行完成后,可以先调用:

复制代码
connection->Unlock();

将连接归还给连接池,再由上层代码慢慢读取 QueryResult

如果一直等到上层处理完所有查询结果才释放连接,连接会被占用更长时间,降低连接池的利用率。

对于不需要返回结果的 SQL,例如 INSERTUPDATEDELETE,可以使用同步直接执行接口:

复制代码
template <class T>
void DatabaseWorkerPool<T>::DirectExecute(
    char const* sql)
{
    if (sql == nullptr || *sql == '\0')
        return;

    // 获取一个已经加锁的同步连接
    T* connection = GetFreeConnection();

    // 当前线程直接执行SQL
    connection->Execute(sql);

    // 执行完成后归还连接
    connection->Unlock();
}

因此:

复制代码
Query():
执行需要返回结果集的SQL。

DirectExecute():
执行不需要返回结果集的SQL。

五、多线程查询示例与注意事项

下面通过三个业务线程同时查询数据库,观察同步连接池的使用过程:

复制代码
#include <thread>

#include "DatabaseEnv.h"
#include "DatabaseLoader.h"
#include "Implementation/SakilaDatabase.h"
#include "MySQLThreading.h"
#include "Log.h"

int main()
{
    // 初始化MySQL客户端库
    MySQL::Library_Init();

    DatabaseLoader loader;

    /*
     * 创建数据库连接池:
     *
     * 8个异步连接;
     * 2个同步连接。
     */
    loader.AddDatabase(
        SakilaDatabase,
        "127.0.0.1;3306;root;123456;sakila",
        8,
        2
    );

    if (!loader.Load())
    {
        TC_LOG_ERROR(
            "",
            "数据库连接池创建失败"
        );

        MySQL::Library_End();
        return 1;
    }

    /*
     * 线程1执行同步查询。
     */
    std::thread thread1([]()
    {
        auto result = SakilaDatabase.Query(
            "SELECT actor_id, first_name, last_name "
            "FROM actor WHERE actor_id = 1"
        );

        if (!result)
        {
            TC_LOG_ERROR("", "线程1查询失败");
            return;
        }

        TC_LOG_INFO(
            "",
            "thread1: actor_id=%u, name=%s %s",
            (*result)[0].GetUInt32(),
            (*result)[1].GetString(),
            (*result)[2].GetString()
        );
    });

    /*
     * 线程2执行同步查询。
     */
    std::thread thread2([]()
    {
        auto result = SakilaDatabase.Query(
            "SELECT actor_id, first_name, last_name "
            "FROM actor WHERE actor_id = 2"
        );

        if (!result)
        {
            TC_LOG_ERROR("", "线程2查询失败");
            return;
        }

        TC_LOG_INFO(
            "",
            "thread2: actor_id=%u, name=%s %s",
            (*result)[0].GetUInt32(),
            (*result)[1].GetString(),
            (*result)[2].GetString()
        );
    });

    /*
     * 线程3同样执行同步查询。
     *
     * 当前只有2个同步连接。
     * 如果前两个连接都在使用,
     * 线程3会在GetFreeConnection中继续等待。
     */
    std::thread thread3([]()
    {
        auto result = SakilaDatabase.Query(
            "SELECT actor_id, first_name, last_name "
            "FROM actor WHERE actor_id = 3"
        );

        if (!result)
        {
            TC_LOG_ERROR("", "线程3查询失败");
            return;
        }

        TC_LOG_INFO(
            "",
            "thread3: actor_id=%u, name=%s %s",
            (*result)[0].GetUInt32(),
            (*result)[1].GetString(),
            (*result)[2].GetString()
        );
    });

    // 等待三个业务线程结束
    thread1.join();
    thread2.join();
    thread3.join();

    // 关闭连接池
    SakilaDatabase.Close();

    // 释放MySQL客户端库
    MySQL::Library_End();

    return 0;
}

假设线程 1 和线程 2 先获得两个同步连接:

复制代码
同步连接池:

connection0 → 线程1使用
connection1 → 线程2使用

线程3暂时没有可用连接

当线程 1 查询完成后:

复制代码
线程1调用Unlock()
        ↓
connection0恢复空闲
        ↓
线程3的LockIfReady成功
        ↓
线程3开始执行查询

整个连接复用过程为:

复制代码
线程1获取connection0
线程2获取connection1
线程3等待
        ↓
线程1执行完成并Unlock
        ↓
线程3获取connection0

使用当前同步连接池时,需要注意以下几点:

复制代码
1. 每次成功获取连接后都必须调用Unlock;

2. 同一个连接不能被多个线程同时使用;

3. 同步连接数量必须大于0;

4. 同步连接数量过少时,业务线程会等待空闲连接;

5. 当前GetFreeConnection采用循环检查,会产生忙等待;

6. 持有连接期间不要执行与数据库无关的耗时操作;

7. 关闭连接池前,应确保所有业务线程已经退出。

同步连接管理的核心可以总结为:

复制代码
每个MySQL连接拥有独立互斥锁;
LockIfReady通过try_lock判断连接是否空闲;
GetFreeConnection循环寻找可成功加锁的连接;
Query和DirectExecute在已加锁连接上执行SQL;
SQL执行完成后调用Unlock归还连接。

0voice · GitHub

相关推荐
Xzaveir18 小时前
不要用一个状态表示“号码已认证”:企业号码身份的四域模型
android·人工智能
zhangjin112019 小时前
AOSP下载
android
Mico1820 小时前
MySQL 8.0.35 GTID 主从复制搭建-基于GITD
android·mysql·adb
YXL1111YXL1 天前
LeakCanary 源码解析检测泄露工作机制(一)
android·leakcanary
且随疾风前行.1 天前
Android Binder 驱动 - 内核驱动层源码初探
android·网络·binder
额恩661 天前
AI 智能体从零搭建实战教程——扣子
android·rxjava·coze
hunterandroid1 天前
前台服务适配与线上排查:通知权限、启动限制和任务保活
android·前端
帅次1 天前
Android 高级工程师面试:Flutter 渲染与性能 近1年高频追问 20 题
android·flutter·面试·渲染·性能
糖果店的幽灵1 天前
【langgraph 从入门到精通graphApi 篇】Command 与动态流程控制
android·java·数据库·人工智能·langgraph