同步查询会阻塞当前线程,只有 SQL 执行完成并返回结果后,当前线程才能继续向下运行。
在一些服务器程序中,业务线程可能只需要提交数据库操作,并不希望一直等待。例如:
接收客户端请求
↓
提交数据库更新任务
↓
继续处理其他客户端请求
↓
后台线程执行SQL
这种情况下,可以把 SQL 封装成任务,放入线程安全队列,再由数据库工作线程从队列中取出并执行。
本文重点介绍异步连接池中的三个核心部分:
SQLOperation:统一描述不同类型的SQL任务
ProducerConsumerQueue:保存等待执行的任务
DatabaseWorker:后台获取并执行SQL任务
一、异步数据库连接池的运行方式
同步查询的基本流程是:
业务线程
↓
获取同步连接
↓
执行SQL
↓
等待MySQL返回结果
↓
继续执行后续代码
异步查询则将任务的提交与执行分开:
业务线程
↓
创建SQL任务
↓
将任务放入队列
↓
继续处理其他业务
后台工作线程
↓
从队列中取出任务
↓
绑定数据库连接
↓
执行SQL
↓
保存查询结果
异步连接池中的多个连接会共享同一个任务队列:
SQL任务队列
task1 → task2 → task3
↑
业务线程不断提交任务
↓
┌────────────────┼────────────────┐
↓ ↓ ↓
DatabaseWorker1 DatabaseWorker2 DatabaseWorker3
↓ ↓ ↓
MySQL连接1 MySQL连接2 MySQL连接3
每一个异步 MySQL 连接都绑定一个独立的 DatabaseWorker:
MySQLConnection::MySQLConnection(
ProducerConsumerQueue<SQLOperation*>* queue,
MySQLConnectionInfo& connInfo)
: m_queue(queue),
m_Mysql(nullptr),
m_connectionInfo(connInfo),
m_connectionFlags(CONNECTION_ASYNC)
{
/*
* 当前异步连接创建一个专属工作线程。
*
* 工作线程固定使用this所表示的MySQL连接,
* 但所有异步连接共享同一个SQL任务队列。
*/
m_worker = std::make_unique<DatabaseWorker>(
m_queue,
this
);
}
假设创建了 8 个异步连接,就会同时创建 8 个后台工作线程。
这些工作线程共同竞争队列中的任务,但每个工作线程只使用自己绑定的数据库连接:
工作线程1固定使用连接1
工作线程2固定使用连接2
工作线程3固定使用连接3
......
因此,异步连接不需要像同步连接一样,反复调用 GetFreeConnection() 查找空闲连接。
任务由哪个工作线程取走,就由该线程绑定的 MySQL 连接执行。
二、SQLOperation 如何统一表示 SQL 任务
异步任务可能有多种类型,例如:
普通字符串SQL
预处理语句
异步查询
数据库事务
批量查询
连接保活任务
如果工作线程分别判断每一种任务类型,代码会非常复杂。
因此,可以定义一个统一的任务基类:
class SQLOperation
{
public:
SQLOperation()
: m_conn(nullptr)
{
}
virtual ~SQLOperation() = default;
/*
* 工作线程统一调用call()执行任务。
*
* call内部再调用子类重写的Execute()。
*/
virtual int call()
{
Execute();
return 0;
}
/*
* 不同SQL任务需要实现自己的Execute()。
*/
virtual bool Execute() = 0;
/*
* 在任务执行前,为任务绑定MySQL连接。
*/
virtual void SetConnection(MySQLConnection* connection)
{
m_conn = connection;
}
protected:
// 当前任务最终使用的数据库连接
MySQLConnection* m_conn;
private:
// SQL任务不能被复制
SQLOperation(const SQLOperation&) = delete;
SQLOperation& operator=(const SQLOperation&) = delete;
};
SQLOperation 是一个抽象基类,因为它包含纯虚函数:
virtual bool Execute() = 0;
工作线程不需要知道任务具体是什么类型,只需要统一执行:
operation->SetConnection(_connection);
operation->call();
其中:
operation->SetConnection(_connection);
负责给任务绑定当前工作线程所管理的数据库连接。
下面的代码:
operation->call();
最终会调用具体任务类重写的 Execute()。
例如,普通 SQL 使用 BasicStatementTask 表示:
class BasicStatementTask : public SQLOperation
{
public:
/*
* sql:需要执行的SQL字符串
* async:是否需要返回查询结果
*/
BasicStatementTask(
const char* sql,
bool async = false
);
~BasicStatementTask() override;
// 执行当前SQL任务
bool Execute() override;
// 获取异步查询结果对应的future
QueryResultFuture GetFuture() const
{
return m_result->get_future();
}
private:
const char* m_sql; // 保存SQL字符串
bool m_has_result; // 是否需要返回结果
QueryResultPromise* m_result; // 用于传递异步查询结果
};
构造函数会复制 SQL 字符串:
BasicStatementTask::BasicStatementTask(
const char* sql,
bool async)
: m_result(nullptr)
{
/*
* 使用strdup复制SQL。
*
* 不能只保存调用者传入的指针,
* 因为任务真正执行时,原字符串可能已经失效。
*/
m_sql = strdup(sql);
// async为true表示这是一个需要返回结果的异步查询
m_has_result = async;
if (async)
{
// 创建promise,后台线程通过它保存查询结果
m_result = new QueryResultPromise();
}
}
之所以不能直接这样保存:
m_sql = sql;
是因为任务放入队列后,不一定立即执行。
例如:
void SubmitTask()
{
std::string sql = "UPDATE actor SET first_name='Tom'";
SakilaDatabase.Execute(sql.c_str());
// 函数结束后,sql对象被销毁
}
如果任务只保存 sql.c_str() 返回的地址,那么后台线程真正执行时,这个地址可能已经失效。
使用:
m_sql = strdup(sql);
会为 SQL 单独申请一块内存并复制内容。
任务销毁时再释放:
BasicStatementTask::~BasicStatementTask()
{
// 释放strdup申请的SQL字符串
free((void*)m_sql);
// 如果创建了promise,同时释放promise对象
if (m_has_result && m_result != nullptr)
{
delete m_result;
}
}
任务的执行逻辑如下:
bool BasicStatementTask::Execute()
{
/*
* 需要返回结果时,执行查询操作。
*/
if (m_has_result)
{
ResultSet* result = m_conn->Query(m_sql);
/*
* 查询失败或结果为空,
* 将一个空结果写入promise。
*/
if (result == nullptr ||
result->GetRowCount() == 0 ||
!result->NextRow())
{
delete result;
m_result->set_value(
QueryResult(nullptr)
);
return false;
}
/*
* 查询成功,将结果写入promise。
*
* 与该promise关联的future随后会变为就绪状态。
*/
m_result->set_value(
QueryResult(result)
);
return true;
}
/*
* 不需要返回结果时,
* 直接执行INSERT、UPDATE或DELETE等SQL。
*/
return m_conn->Execute(m_sql);
}
因此,同一个 BasicStatementTask 可以表示两种任务:
BasicStatementTask(sql, false)
↓
只执行SQL,不返回结果
BasicStatementTask(sql, true)
↓
执行查询,并通过promise返回结果
三、生产者---消费者队列如何保存任务
业务线程负责创建并提交任务,可以看作生产者。
数据库工作线程负责获取并执行任务,可以看作消费者。
因此,这里使用一个生产者---消费者队列:
template <typename T>
class ProducerConsumerQueue
{
private:
// 保护任务队列
std::mutex _queueLock;
// 保存等待执行的任务
std::queue<T> _queue;
// 控制工作线程等待和唤醒
std::condition_variable _condition;
// 队列是否已经停止运行
std::atomic<bool> _shutdown;
public:
ProducerConsumerQueue()
: _shutdown(false)
{
}
};
1. 提交任务
任务入队使用 Push():
void Push(const T& value)
{
/*
* 多个业务线程可能同时提交任务,
* 因此修改队列前需要加锁。
*/
std::lock_guard<std::mutex> lock(_queueLock);
// 将任务放入队尾
_queue.push(value);
/*
* 新任务到来后,
* 唤醒一个正在等待的工作线程。
*/
_condition.notify_one();
}
执行过程为:
业务线程创建任务
↓
获取_queueLock
↓
任务进入队尾
↓
释放_queueLock
↓
notify_one唤醒一个工作线程
由于使用了:
std::lock_guard<std::mutex>
当 Push() 函数结束时,互斥锁会自动释放,不需要手动调用 unlock()。
2. 等待并获取任务
工作线程使用 WaitAndPop() 获取任务:
void WaitAndPop(T& value)
{
/*
* unique_lock允许condition_variable
* 在等待期间自动释放和重新获得互斥锁。
*/
std::unique_lock<std::mutex> lock(_queueLock);
/*
* 队列为空并且没有关闭时,
* 当前工作线程进入等待状态。
*/
while (_queue.empty() && !_shutdown)
{
_condition.wait(lock);
}
/*
* 队列关闭,或者被唤醒后仍然没有任务,
* 直接返回。
*/
if (_queue.empty() || _shutdown)
{
return;
}
// 取出队头任务
value = _queue.front();
// 从队列中删除该任务
_queue.pop();
}
当队列为空时:
_condition.wait(lock);
会完成两个操作:
自动释放_queueLock
↓
让当前工作线程进入休眠
如果等待期间不释放互斥锁,生产者就无法进入 Push() 添加任务,程序会发生死锁。
生产者调用:
_condition.notify_one();
以后,一个工作线程被唤醒。
被唤醒的线程会重新获得 _queueLock,然后继续检查队列状态。
这里使用 while 而不是 if:
while (_queue.empty() && !_shutdown)
是因为条件变量可能出现虚假唤醒。
即使线程醒来,也不代表队列中一定存在任务,因此必须重新检查条件。
3. 关闭任务队列
关闭队列时调用:
void Cancel()
{
std::unique_lock<std::mutex> lock(_queueLock);
/*
* 清理尚未被工作线程执行的任务。
*/
while (!_queue.empty())
{
T& value = _queue.front();
// 如果T是指针,则delete该任务对象
DeleteQueuedObject(value);
_queue.pop();
}
// 设置队列关闭标志
_shutdown = true;
// 唤醒所有正在等待任务的工作线程
_condition.notify_all();
}
由于当前队列保存的是:
SQLOperation*
所以队列关闭时,需要释放剩余任务:
delete operation;
代码通过 std::enable_if 判断队列元素是否为指针:
template <typename E = T>
typename std::enable_if<
std::is_pointer<E>::value
>::type
DeleteQueuedObject(E& object)
{
delete object;
}
如果队列保存的不是指针,则什么也不做:
template <typename E = T>
typename std::enable_if<
!std::is_pointer<E>::value
>::type
DeleteQueuedObject(const E&)
{
}
这意味着任务提交到队列后,任务对象的所有权也交给了队列和工作线程:
任务尚未执行:
由队列负责管理
任务已经取出:
由工作线程负责管理
队列关闭:
队列删除尚未执行的任务
任务执行完成:
工作线程删除任务
因此,调用者将任务放入队列后,不能再手动 delete:
BasicStatementTask* task =
new BasicStatementTask(sql);
Enqueue(task);
// 错误:任务所有权已经交给队列
// delete task;
四、DatabaseWorker 如何执行异步任务
每一个异步连接都会创建一个 DatabaseWorker:
class DatabaseWorker
{
public:
DatabaseWorker(
ProducerConsumerQueue<SQLOperation*>* queue,
MySQLConnection* connection
);
~DatabaseWorker();
private:
// 所有异步工作线程共享的任务队列
ProducerConsumerQueue<SQLOperation*>* _queue;
// 当前工作线程专属的MySQL连接
MySQLConnection* _connection;
// 工作线程入口函数
void WorkerThread();
// 真正运行的C++线程
std::thread _workerThread;
// 控制工作线程退出
std::atomic<bool> _cancelationToken;
// 禁止复制DatabaseWorker
DatabaseWorker(const DatabaseWorker&) = delete;
DatabaseWorker& operator=(
const DatabaseWorker&) = delete;
};
构造函数启动工作线程:
DatabaseWorker::DatabaseWorker(
ProducerConsumerQueue<SQLOperation*>* newQueue,
MySQLConnection* connection)
{
_connection = connection;
_queue = newQueue;
// 初始状态不退出
_cancelationToken = false;
/*
* 创建后台线程。
*
* 线程从DatabaseWorker::WorkerThread开始运行,
* this表示将当前对象传递给成员函数。
*/
_workerThread = std::thread(
&DatabaseWorker::WorkerThread,
this
);
}
工作线程的核心循环如下:
void DatabaseWorker::WorkerThread()
{
if (_queue == nullptr)
{
return;
}
for (;;)
{
SQLOperation* operation = nullptr;
/*
* 从共享任务队列获取一个任务。
*
* 队列为空时,当前工作线程会进入休眠,
* 不会持续占用CPU。
*/
_queue->WaitAndPop(operation);
/*
* 线程池正在关闭,或者没有取得任务,
* 结束工作线程。
*/
if (_cancelationToken || operation == nullptr)
{
return;
}
/*
* 将当前工作线程专属的MySQL连接
* 交给SQL任务。
*/
operation->SetConnection(_connection);
// 执行具体SQL任务
operation->call();
/*
* 任务执行完成后释放任务对象。
*/
delete operation;
}
}
完整过程可以表示为:
WaitAndPop等待任务
↓
取得一个SQLOperation
↓
SetConnection绑定数据库连接
↓
call调用任务
↓
Execute执行具体SQL
↓
delete释放任务
↓
继续等待下一个任务
工作线程不需要判断当前任务是普通 SQL、预处理语句还是事务。
这是因为所有任务都继承自:
SQLOperation
并且都实现了自己的:
Execute()
这种设计利用了 C++ 的多态。
工作线程只依赖统一接口:
operation->call();
至于真正执行什么操作,由具体任务对象决定。
关闭工作线程时:
DatabaseWorker::~DatabaseWorker()
{
// 通知工作线程退出
_cancelationToken = true;
/*
* 关闭共享任务队列,
* 清理尚未执行的任务,并唤醒等待线程。
*/
_queue->Cancel();
/*
* 等待后台线程彻底结束。
*
* 如果不join,DatabaseWorker对象销毁后,
* 后台线程可能继续访问已经失效的this指针。
*/
if (_workerThread.joinable())
{
_workerThread.join();
}
}
这里的销毁顺序非常重要:
设置退出标志
↓
关闭任务队列
↓
唤醒等待线程
↓
工作线程退出
↓
join等待线程结束
↓
DatabaseWorker完成销毁
如果只设置:
_cancelationToken = true;
但工作线程正在:
_queue->WaitAndPop(operation);
中休眠,它可能无法及时醒来。
因此,还需要:
_queue->Cancel();
通过 notify_all() 唤醒等待线程。
五、异步 Execute 和 AsyncQuery 的完整流程
连接池通过 Enqueue() 统一提交任务:
template <class T>
void DatabaseWorkerPool<T>::Enqueue(
SQLOperation* operation)
{
// 将SQL任务放入共享任务队列
_queue->Push(operation);
}
1. 不需要返回结果的异步操作
例如异步执行 INSERT、UPDATE 或 DELETE:
template <class T>
void DatabaseWorkerPool<T>::Execute(
const char* sql)
{
// SQL为空时不执行
if (Trinity::IsFormatEmptyOrNull(sql))
{
return;
}
/*
* 创建一个不返回查询结果的SQL任务。
*
* BasicStatementTask的async参数默认为false。
*/
BasicStatementTask* task =
new BasicStatementTask(sql);
// 将任务交给异步工作线程
Enqueue(task);
}
调用方式如下:
SakilaDatabase.Execute(
"UPDATE actor "
"SET first_name='Tom' "
"WHERE actor_id=1"
);
调用 Execute() 后,当前业务线程只负责把任务放入队列,不等待 SQL 执行完成。
运行过程为:
业务线程调用Execute
↓
创建BasicStatementTask
↓
任务进入队列
↓
Execute立即返回
↓
某个DatabaseWorker取得任务
↓
通过自己的MySQL连接执行SQL
↓
删除任务对象
2. 需要查询结果的异步操作
需要返回结果时,使用:
template <class T>
QueryCallback DatabaseWorkerPool<T>::AsyncQuery(
const char* sql)
{
/*
* 第二个参数为true,
* 表示该任务需要返回查询结果。
*/
BasicStatementTask* task =
new BasicStatementTask(sql, true);
/*
* 必须先取得future,再将任务放入队列。
*
* 任务入队后可能立即被工作线程执行并删除,
* 如果先入队再调用GetFuture,
* task指针可能已经失效。
*/
QueryResultFuture result =
task->GetFuture();
// 将任务交给后台工作线程
Enqueue(task);
/*
* 使用QueryCallback封装future,
* 上层代码可以在查询完成后处理结果。
*/
return QueryCallback(
std::move(result)
);
}
这里最值得注意的是执行顺序:
QueryResultFuture result = task->GetFuture();
Enqueue(task);
不能轻易写成:
Enqueue(task);
// 危险:任务可能已经执行完成并被删除
QueryResultFuture result = task->GetFuture();
因为异步工作线程可能非常快。
任务一旦进入队列,就可能立即被取走、执行并删除。因此,必须在移交任务所有权之前取得 future。
异步查询的使用示例如下:
#include "AsyncCallbackProcessor.h"
#include <atomic>
#include <chrono>
#include <thread>
int main()
{
MySQL::Library_Init();
DatabaseLoader loader;
loader.AddDatabase(
SakilaDatabase,
"127.0.0.1;3306;root;123456;sakila",
8,
2
);
if (!loader.Load())
{
MySQL::Library_End();
return 1;
}
// 用于保存并处理尚未完成的异步回调
AsyncCallbackProcessor<QueryCallback> processor;
// 标记当前异步查询是否已经处理完成
std::atomic<bool> finished = false;
/*
* 提交异步查询,并注册结果处理函数。
*/
processor.AddCallback(
SakilaDatabase.AsyncQuery(
"SELECT actor_id, first_name, last_name "
"FROM actor WHERE actor_id=1"
).WithCallback(
[&finished](QueryResult result)
{
if (!result)
{
TC_LOG_ERROR(
"",
"异步查询没有返回数据"
);
finished = true;
return;
}
TC_LOG_INFO(
"",
"actor_id=%u, name=%s %s",
(*result)[0].GetUInt32(),
(*result)[1].GetString(),
(*result)[2].GetString()
);
finished = true;
}
)
);
/*
* AsyncQuery只负责让后台线程执行SQL。
*
* 回调不会自动在当前线程中执行,
* 需要定期检查future是否已经就绪。
*/
while (!finished)
{
processor.ProcessReadyCallbacks();
// 避免循环检查占满CPU
std::this_thread::sleep_for(
std::chrono::milliseconds(20)
);
}
SakilaDatabase.Close();
MySQL::Library_End();
return 0;
}
整个异步查询流程如下:
业务线程调用AsyncQuery
↓
创建BasicStatementTask
↓
从promise取得future
↓
任务进入生产者---消费者队列
↓
AsyncQuery返回QueryCallback
↓
后台DatabaseWorker获取任务
↓
为任务绑定MySQL连接
↓
执行Query
↓
通过promise保存查询结果
↓
future变为ready状态
↓
ProcessReadyCallbacks检测到结果
↓
执行用户注册的回调函数
使用异步连接池时,需要注意以下问题:
1. Execute只是将任务放入队列,不代表SQL已经执行完成;
2. 异步任务提交后,不能再手动删除任务对象;
3. 工作线程执行完成后会自动delete任务;
4. 队列关闭时,尚未执行的任务会被直接清理;
5. AsyncQuery需要先取得future,再将任务放入队列;
6. 回调函数不会自动执行,需要定期调用
ProcessReadyCallbacks();
7. 关闭连接池前,应停止提交新的异步任务;
8. 当前Cancel属于取消式关闭,不能保证队列中
尚未执行的SQL全部完成。
异步数据库执行机制的核心可以总结为:
业务线程只负责创建并提交SQL任务;
所有异步连接共享一个线程安全任务队列;
每个异步连接拥有一个专属DatabaseWorker;
工作线程从队列中竞争任务并绑定自己的连接;
任务通过多态调用各自的Execute函数;
无结果任务执行后直接结束;
有结果任务通过promise和future传递查询结果。