MySQL数据库连接池实现详解(C++版)
MySQL数据库连接池实现详解(C++版)
本文将介绍MySQL数据库连接池的实现方法,包括同步和异步连接池的原理和实现细节。通过本文,读者可以深入了解数据库连接池的工作机制,并掌握其在实际项目中的应用方法。
0. 前言
上一篇文章介绍了线程池的实现,这次将介绍Linux下数据库连接池的实现,以MySQL为例。由于连接池的实现较为复杂,本次将以一个开源框架的数据库连接池部分为例进行讲解。
1. 数据库连接池概述
1.1 服务器与数据库交互
服务器与数据库的交互采用请求响应模式,通常使用TCP长连接。TCP连接需要三次握手和四次挥手,每次连接都需要验证账号密码等,因此连接资源属于耗时资源,适合使用连接池来复用连接。
1.2 MySQL数据库网络模型
MySQL数据库的网络模型如下:
- 主线程使用IO多路复用中的select来监听listenfd
- 当listenfd上触发可读事件时,说明有客户端来连接,就为他分配一个连接线程
- 同一个连接上,如果收到多个请求,该连接线程是串行执行的
这里可能会有疑问:为什么MySQL用的是select,而不是性能更高的epoll?主要原因是select支持跨平台,而epoll只是Linux下的。另外,MySQL规定最高只能监听128个文件描述符,在这种小数量下,select和epoll的性能是差不多的。
所以当我们创建多条连接时,每条连接MySQL都会分配一个线程,可以充分释放MySQL执行SQL语句的性能。但是,创建的连接数量并不是越多越好,一般数量控制在CPU核心数比较合适,创建的太多反而会降低性能。
1.3 MySQL连接驱动安装
如果要用C/C++实现数据库连接池,首先要安装libmysqlclient-dev驱动。这个官方提供的驱动使用了阻塞IO,具体来说:
- 首先将sql通过MySQL协议打包
- 然后服务器调用send()或write()
- 然后调用recv()会阻塞线程等待MySQL的返回
- 最后确定是完整回应包后进行协议解析并将结果返回
协议打包和解析都是MySQL驱动帮我们完成的,所以可以很明显看到这是个耗时操作,一般主线程在处理业务逻辑需要和数据库交互的时候,这种阻塞的耗时操作都需要优化。
1.4 同步(synchronous)连接池与异步(asynchronous)连接池
同步和异步的概念不好理解,经常要和阻塞和非阻塞搞混。这里以后面要实现的两个接口为例:
QueryResult Query(char const* sql, T* connection = nullptr);
QueryCallback AsyncQuery(char const* sql);
第一个Query()是同步连接池的接口,另一个AsyncQuery()是异步连接池的接口。同步和异步的区别就在于执行一条SQL语句后,怎么拿到数据库的返回值:
- 在同步连接池中,Query()一返回,就可以拿到数据库的返回值
- 在异步连接池中,执行SQL并不是发生在AsyncQuery()中,AsyncQuery()返回的结果也不是数据库的返回值,当前的职责是在其他接口中实现的
这里要区分阻塞与同步,非阻塞与异步的区别:
- 阻塞是指的线程或者协程,在等待某个事件时无法继续工作
- 同步是指的任务按顺序执行,一个完成后才能执行下一个
- 非阻塞和异步也是同样的区别,阻塞是实现同步的一种方式,但不是唯一的
给大家看一下两个接口的具体实现函数:
QueryResult DatabaseWorkerPool<T>::Query(char const* sql, T* connection /*= nullptr*/)
{
if (!connection)
connection = GetFreeConnection();
ResultSet* result = connection->Query(sql);
connection->Unlock();
if (!result || !result->GetRowCount() || !result->NextRow())
{
delete result;
return QueryResult(nullptr);
}
return QueryResult(result);
}
其中Query(char const* sql, T* connection /= nullptr/)调用了Query(char const* sql),Query(char const* sql)又调用了_Query(const char* sql, MySQLResult pResult, MySQLField pFields, uint64* pRowCount, uint32* pFieldCount),_Query(const char* sql, MySQLResult pResult, MySQLField pFields, uint64* pRowCount, uint32* pFieldCount)中调用了mysql_query()这个数据库提供的阻塞函数。所以该函数最终返回的结果就是数据库的返回值。
再来看看异步连接的实现:
QueryCallback DatabaseWorkerPool<T>::AsyncQuery(char const* sql)
{
BasicStatementTask* task = new BasicStatementTask(sql, true);
// Store future result before enqueueing - task might get already processed and deleted before returning from this method
QueryResultFuture result = task->GetFuture();
Enqueue(task);
return QueryCallback(std::move(result));
}
可以看到是把任务放到了一个队列中,实际上是交给了线程池来做,不阻塞当前线程,去阻塞线程池中的一个线程来获取数据库的返回值。
1.5 同步连接池和异步连接池的使用场景
异步连接池的性能要高于同步连接池,那什么时候适合使用同步连接池呢?
- 在服务器初始化阶段,因为这一阶段许多操作是线性、依赖关系明确的,如初始化数据库连接、加载配置、启动服务等,这些操作通常需要按照严格的顺序执行,确保前面的步骤完成后,后续步骤才能安全进行。如果使用异步机制,虽然可以提升并发性能,但可能会引入复杂的错误处理逻辑,增加调试的难度
- 所以服务器启动过程的可靠性优先于性能,并且短暂的初始化过程中也没有大量并发需求,而同步正好可以让任务顺序执行,适合这一阶段
- 除此之外,在处理业务时都适合用异步连接池
1.6 实现同步连接池与异步连接池
1.6.1 实现同步连接池
示意图如上所示,开启多个线程加快服务器初始化,加锁的目的是为了不让多个线程同时使用一个连接,同时是否上锁也是该连接是否空闲的标志。获取连接的方式是round robin(轮询算法),依次查看连接是否空闲,发现空闲连接就上锁,与数据库进行交互,阻塞该线程等待数据库返回,返回之后释放锁。
1.6.2 实现异步连接池
示意图如上所示,基于线程池(对线程池不熟悉的同学,可以去看我另一篇实现线程池的文章)实现,用户请求push进SQL任务执行队列,让线程池中的线程去取任务并与数据库交互,从而实现不阻塞主线程,去阻塞线程池中的线程,与传统的线程池不同的是,一般线程池中线程会因为任务队列为空而阻塞,异步连接池中的线程阻塞除了上述原因外,还可能是为了等待数据库返回而阻塞。线程池中的连接是线程安全的,每个连接都和特定的线程一一绑定,而具体的任务与连接是无关的。
2. 实现数据库连接池
代码来自于一个名为TrinityCore的项目,该项目是一个开源的MMORPG服务端模拟器,重点来分析该项目中数据库连接池的设计,主要剖析的主文件是DatabaseWorkerPool.h、DatabaseWorkerPool.cpp。
首先要明确一个概念,MySQL中很多数据库,一个连接池对应着一个库,如果需要访问两个库,就需要两个连接池,以此类推。
2.1 初始化数据库连接池连接信息
相关函数和类定义如下:
// 设置数据库连接信息,包括数据库连接字符串和异步、同步线程的数量
void SetConnectionInfo(std::string const& infoString, uint8 const asyncThreads, uint8 const synchThreads);
void DatabaseWorkerPool<T>::SetConnectionInfo(std::string const& infoString, uint8 const asyncThreads, uint8 const synchThreads)
{
_connectionInfo = std::make_unique<MySQLConnectionInfo>(infoString);
_async_threads = asyncThreads;
_synch_threads = synchThreads;
}
struct TC_DATABASE_API MySQLConnectionInfo
{
explicit MySQLConnectionInfo(std::string const& infoString);
std::string user;
std::string password;
std::string database;
std::string host;
std::string port_or_socket;
std::string ssl;
};
可以看到首先初始化数据库连接信息(包括用户名、密码、哪个库等),然后设置同步和异步的线程数量。
2.2 SQL字符串与预处理的SQL语句
void Execute(char const* sql); // 简单字符串,如“select * from table;”
void Execute(PreparedStatement<T>* stmt); // 预处理好的SQL语句
这里简单的说一下预处理,预处理是指在执行SQL查询之前,将SQL语句的结构进行预编译和缓存,以便多次执行,提高效率并增强安全性。具体来说,预处理可以提高执行效率,对于重复执行的SQL语句,预处理能够提高执行效率。SQL语句只需要编译一次,以后每次执行时只需要传递参数并执行,节省了重新编译的开销。且预处理可以防止SQL注入,预处理可以有效防止SQL注入攻击。因为用户提供的输入是作为参数绑定到预编译的SQL语句中的,攻击者无法通过输入恶意的SQL代码来篡改查询逻辑。例如,如果使用预处理,攻击者不能通过输入
1 OR 1=1
来破坏查询逻辑,因为这会被视为一个普通的字符串参数,而不是SQL代码。
2.3 函数接口封装和调用关系
上图为总体的核心的函数调用关系,大家只要对线程池有充分的理解,就可以很容易看懂这里面的调用关系。源代码比较多,但是核心的部分就在这里面,代码虽多但很好理解,完整代码我放在了数据库连接池源代码,感兴趣的同学可以去自己阅读以下
3. 结束语
本文较为简单的阐述了数据库连接池的实现方法,本人目前还是个在校生,还比较小白,也刚刚开始写CSDN博客不久,可能写的也不是很好,如果有任何疑问或者发现我有哪里写的不对的地方,欢迎大家留言告诉我!我都会一一改正的。