剖析 MySQL 数据库连接池(C++版)
目录
☀️0. 前言
🌤️1. 数据库连接池概述
⛅1.1 服务器与数据库交互
⛅1.2 MySQL 数据库网络模型
⛅1.3 MySQL 连接驱动安装
⛅1.4 同步(synchronous)连接池与异步(asynchronous)连接池
⛅1.5 同步连接池和异步连接池的使用场景
⛅1.6 实现同步连接池与异步连接池
🌥️1.6.1 实现同步连接池
🌥️1.6.2 实现异步连接池
🌤️2. 实现数据库连接池
⛅2.1 初始化数据库连接池连接信息
⛅2.2 SQL 字符串与预处理的 SQL 语句
⛅2.3 函数接口封装和调用关系
🌤️3. 结束语
☀️0. 前言
上次给大家介绍了线程池的实现,这次来介绍下 Linux 下数据库连接池的实现,以大家最为熟悉的 MySQL 为例,因为连接池总体实现还是较为复杂的,本次以一个开源框架的数据库连接池部分为例进行讲解。
🌤️1. 数据库连接池概述
⛅1.1 服务器与数据库交互
首先,服务器与数据库的交互是请求响应模式,通常所使用的是 TCP 长连接,而 TCP 连接需要三次握手和四次挥手,并且每次连接都需要验证账号密码等,所以连接资源属于耗时资源,可以用连接池来复用连接。
⛅1.2 MySQL 数据库网络模型
这里来简单介绍以下 MySQL 数据库的网络模型:
主线程使用 IO 多路复用中的 select 来监听 listenfd,如果 listenfd 上触发了可读事件,就说明有客户端来连接,就为他分配一个连接线程,同一个连接上,如果收到多个请求,该连接线程是串行执行的。如果对 IO 多路复用部分不熟悉的同学,可以看看我网络专栏的部分文章,这里你或许会疑惑为什么 MySQL 用的是 select,而不是性能更高的 epoll?主要原因是 select 支持跨平台,epoll 只是 linux 下的,并且 MySQL 规定最高只能监听128个文件描述符,在这种小数量下,select 其实和epoll 性能是差不多的。
所以当我们创建多条连接时,每条连接 MySQL 都会分配一个线程,可以充分释放 MySQL 执行SQL 语句的性能,那是不是创建的连接越多越好呢?显然不是的,看过线程池的同学应该明白,一般数量控制在 CPU 核心数比较合适,创建的太多反而会降低性能。
⛅1.3 MySQL 连接驱动安装
如果要用 C/C++ 实现数据库连接池,首先要安装 libmysqlclient-dev 驱动,这个官方提供的驱动使用了阻塞 IO,具体这个阻塞的 IO 要怎么理解呢?以最常见的 query() 函数为例:首先将 sql 通过MySQL 协议打包,然后服务器调用 send() 或 write(),然后调用 rend() 或 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 博客不久,可能写的也不是很好,如果有任何疑问或者发现我有哪里写的不对的地方,欢迎大家留言告诉我!我都会一一改正的。
如果觉得文章对你有帮助的话,还请点赞,关注,收藏支持小占!