上一篇从整体上介绍了 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,例如 INSERT、UPDATE 和 DELETE,可以使用同步直接执行接口:
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归还连接。