【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

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui