做后端服务这些年,我遇到过不少深夜被电话叫醒的经历,但最让我印象深刻的是第一次把写好的C++服务部署上线,压测刚跑起来,MySQL直接报“Too many connections”。当时的第一反应是检查max_connections,发现没设小,可连接数就是莫名其妙涨到了上限。后来定位到原因:服务里每次数据库操作都新建连接,高并发下连接创建速度跟不上请求速度,积压的全在等TCP握手和MySQL认证,连接数瞬间被打满。那之后我意识到,C++服务必须有一个数据库连接池,而且这个池子不能靠网上随便抄一份,必须自己理解原理、按业务场景调参。这篇文章就把我基于C++实现数据库连接池的完整思路、核心实现、踩坑记录和压测结果写出来,希望给准备自己写连接池或者正在调连接池参数的朋友一些参考。
连接池不是什么新概念,Java有HikariCP,Go有database/sql内置连接池,但C++生态里没有官方标准,mysql client库本身也不带连接池。所以很多C++项目要么每次请求临时创建连接,要么用一个简单全局Connection管理器凑合。前者在低并发下没问题,一旦业务起来就崩;后者如果实现得肤浅,各种线程安全、连接泄漏、死锁问题能把人折磨到怀疑人生。我写的这个连接池,基于C++11标准实现,使用MySQL C API,支持连接复用、超时控制、空闲回收、自动扩容,已经在生产环境稳定跑了一年多,今天把核心设计思路和关键代码都拆开讲。
1. 数据库连接池解决的真实痛点:不是只为了省几个连接
1.1 一次连接创建到底有多贵
很多人对数据库连接的开销没有直观概念,觉得不就是发一个TCP包的事吗?实际上,一次完整的MySQL连接建立要经历这些环节:TCP三次握手、MySQL协议握手、认证(用户名密码校验、权限读取)、连接变量初始化(字符集、autocommit等)、分配线程栈和相关内存。在Linux下用系统调用追踪能看到,一个本地MySQL连接从connect到mysql_real_connect返回,正常情况下需要消耗几十次系统调用,耗时大概在0.5ms到2ms之间,如果数据库在远程,加上网络RTT,这个时间可能到5ms以上。
对比一下,一条简单SQL在同一连接上执行,往往只需要0.1ms到0.2ms。也就是说,如果每次操作都是新建连接,那么连接建立的开销可能是SQL执行本身的10倍以上。在高并发场景下,连接创建带来的不仅仅是延迟,还有服务端资源消耗:MySQL为每个连接都要分配内存、创建线程来管理,连接一旦爆炸,数据库整体性能会急剧下降。
1.2 连接池与线程池的本质区别和协同关系
很多同学会把数据库连接池和线程池混为一谈,虽然它们都是“池化”思想,但表达的意义完全不同。线程池复用的是线程资源,解决的是CPU上下文切换过频的问题;数据库连接池复用的是TCP连接和MySQL服务端连接资源,解决的是握手认证开销大、服务端连接数有限的问题。
在实际服务中,两者通常是配合使用。一个典型的请求处理链是:线程池分配线程处理请求,线程从数据库连接池获取连接,执行SQL,归还连接。这时候要注意的是,数据库连接池的最大连接数最好小于等于MySQL的最大连接数,但也要大于等于线程池的核心线程数,否则会出现线程池里所有线程都在等连接,但连接池没连接可给的情况。这就引出下一个问题——连接池参数设计。
1.3 连接池设计前必须先想清楚的几个业务场景
写连接池之前,我建议你先想清楚自己的业务是IO密集还是CPU密集,是短事务为主还是长事务为主,是读多写少还是写多读少。这直接决定连接池参数设置。
比如一个纯短查询服务,单条SQL耗时不到1ms,那么一个连接每秒能处理几百上千个请求,连接池不需要很大。但如果是报表导出类的长事务,单事务可能需要几秒,连接池太小会造成大量请求排队,连接池太大又会拖垮数据库。所以不是简单按照“并发数 = 连接数”来做,需要结合单连接吞吐和业务耗时来计算。这部分我在后续参数设计里会详细展开。
2. 连接池的四个核心参数与计算公式
2.1 初始连接数、最大连接数、最小空闲连接数的设定逻辑
连接池常见的参数有四个:初始连接数(initSize)、最大连接数(maxSize)、最小空闲连接数(minIdle)、最大空闲时间(maxIdleTime)。再加上一个获取连接超时时间(timeout)。
初始连接数决定了服务启动后第一次分配连接时的速度。如果设为0,那么第一个请求来了再创建连接,启动快但首请求延迟高;如果启动时预创建一批,启动略慢但服务真正接流时能直接拿到连接。我一般设置初始连接数等于minIdle的值,这样在启动阶段就完成连接预热,避免冷启动。
最大连接数需要根据数据库max_connections和服务本身容量来定。假设MySQL max_connections = 200,同一台机器上还有别的服务共享这个数据库,那这个服务的连接池maxSize建议不超过100,留一半给其他服务和运维预留。
最小空闲连接数是池子需要努力保持的空闲连接数量。当空闲连接少于这个值并且当前总连接数小于maxSize时,连接池会自动创建新连接。这个参数通常用于应对流量波动,避免流量突然上涨时再临时建连接。
具体数值怎么给?我提供一个经验算式:假设预期峰值并发数(线程池最大线程数)为threads,单连接吞吐速度为每秒处理N个短查询,那么理论上需要的连接数约为threads * (单查询耗时 / 平均请求间隔)。更简单的做法是,先用threads * 0.2作为minIdle,再通过压测逐步上调maxSize,直到QPS不再明显增长。
2.2 连接最大空闲时间的选取与检查机制
MySQL服务端有一个wait_timeout参数,默认8小时,空闲连接超过这个时间会被服务端关闭。但如果你依赖这个默认值,就可能踩坑:前一个操作归还连接后,连接一直空闲,到服务端已经关闭,客户端不知道,下次取出来一用,直接报MySQL server has gone away。
所以连接池必须自己管理最大空闲时间。常见的做法是在归还连接时记录归还时间戳,定期检查(比如idleChecker线程每30秒检查一次),如果某个空闲连接的空闲时长超过maxIdleTime,就关闭掉。maxIdleTime建议设置为MySQL wait_timeout的一半左右,比如你确认数据库wait_timeout是60秒,那maxIdleTime设置为30秒比较安全。这里的检查要加锁,同时注意不要长时间持有锁去执行mysql_close,因为网络关闭可能阻塞。
2.3 获取连接超时时间的兜底策略
获取连接超时是防止环泄漏和连接池耗尽时的无限等待。如果用阻塞队列,获取连接时可以用带超时的try_pop,超时后返回nullptr或者抛出异常。业务方拿到nullptr后可以做降级,比如直接返回失败、排队重试等服务降级逻辑。
这里的超时时间设计也要看业务容忍度。如果是高并发在线接口,建议设为1到2秒,超过直接返回“系统繁忙”,避免请求全部卡在连接池上;如果是后台异步任务,可以设5秒甚至更长。我之前的服务设置的是2000ms,实测下来对用户体验影响相对较小。
2.4 参数如何根据业务调整
以我生产环境中的一个订单查询服务为例:QPS峰值约2000,单条SQL平均耗时0.8ms,线程池核心线程32,最大线程64。计算:每个线程并发处理请求时,一条请求全程保持连接的时间约5ms(包括业务逻辑和网络IO),单连接每秒能服务约200次请求(1秒/5ms),所以理论最小连接数为2000/200=10。再考虑波动,最终设置minIdle=8,initSize=8,maxSize=32。压测时发现maxSize=32就够用了,再往上只是增加连接管理开销,QPS基本不再增长。
如果你的业务有大量短事务(每个事务多次SQL),连接在事务期间是独占的,连接数就得按总事务并发数来计算。比如一个事务平均持有连接30ms,每秒需要发起200个事务,那么理想连接数约为200*0.03=6个。但实际中因为SQL执行时间和网络波动,最好乘以1.5到2的安全系数,也就是10到12个。
3. C++实现时最关键的三个类:Connector、Pool、RAII句柄
3.1 MySQL C API的封装:为什么不用ORM
我选择直接封装MySQL C API,而不是用mysqlpp或别的ORM,原因很简单:连接池要掌控连接的完整生命周期,包括创建、认证、重置、关闭。C API提供MYSQL结构体指针,可以灵活地塞进池里管理。如果用ORM,很多细节被包装掉,一旦出问题反而更难排查。
封装后Connector类负责创建连接和设置常见选项:
class MySqlConn { public: MySqlConn(const std::string& host, int port, const std::string& user, const std::string& pass, const std::string& db) { mysql_init(&mysql_); // 设置自动重连? 不!连接池需要自己控制,这里关闭 my_bool reconnect = 0; mysql_options(&mysql_, MYSQL_OPT_RECONNECT, &reconnect); // 设置连接超时时间,避免长时间阻塞 mysql_options(&mysql_, MYSQL_OPT_CONNECT_TIMEOUT, &timeout_); mysql_options(&mysql_, MYSQL_OPT_READ_TIMEOUT, &timeout_); mysql_options(&mysql_, MYSQL_OPT_WRITE_TIMEOUT, &timeout_); mysql_real_connect(&mysql_, host.c_str(), user.c_str(), pass.c_str(), db.c_str(), port, nullptr, 0); } bool isAlive() const { return mysql_ping(const_cast<MYSQL*>(&mysql_)) == 0; } MYSQL* get() { return &mysql_; } ~MySqlConn() { mysql_close(&mysql_); } private: MYSQL mysql_; int timeout_ = 3; };注意这里MYSQL_OPT_RECONNECT要设为0,因为自动重连是MySQL客户端库自己的行为,它会在连接断开后尝试重连,但此时连接相关内容可能已经失效,而且重连过程不受连接池控制,可能造成时序混乱。连接池应该自己负责探活和重连,而不是依赖驱动层的自动重连。
3.2 线程安全队列还是vector+互斥锁:数据结构选型对比
连接池的核心数据结构就是“池”。网上很多实现直接用std::queue<MYSQL*>加一把mutex,也能工作,但有几个问题:进出队必须加同一把锁;空闲回收时需要遍历全队列,锁粒度大;没有条件变量支持时,获取连接需要忙等。我最终选择用deque<shared_ptr >加mutex加condition_variable的组合,原因是deque支持两端操作,空闲回收时可以从头部弹出超过空闲时间的连接,获取连接时从尾部取,符合“最近使用优先”的原则,因为越晚归还的连接,空闲时间越短,被服务端断开的风险越低。
当然,也可以使用双队列方案:空闲连接放在一个队列,正在使用的连接用set管理。这样回收时不会动到正在使用的连接,需要多维护一份集合。我目前的实现没有记录正在使用的连接集合,因为我认为连接池不需要知道连接具体给谁用了,只要通过RAII句柄控制归还即可。如果你需要统计连接泄漏,可以增加一个“使用中连接集合”,但要注意操作的原子性。
3.3 用条件变量实现获取连接的阻塞与超时
获取连接的核心逻辑是:如果池中有空闲连接,直接取出;否则如果池中连接总数未达到maxSize,创建新连接;如果已经达到maxSize,就等待其他线程归还。这里不能简单地用mutex套while循环忙等,否则CPU会飙高。标准做法是配合std::condition_variable使用。
std::unique_ptr<MySqlConn> getConnection() { std::unique_lock<std::mutex> lock(mutex_); // 循环判断,原因后文会讲 while (idleQueue_.empty()) { if (currentSize_ >= maxSize_) { // 池满了,等待其他线程归还连接,最多等 timeout if (cv_.wait_for(lock, std::chrono::milliseconds(timeout_)) == std::cv_status::timeout) { return nullptr; // 获取超时 } } else if (createConnectionLocked()) { // 成功创建新连接,继续循环,因为创建后要看是否真的放进队列 // 这里也可以直接 pop 一个出来,但为了统一,继续走循环 } else { return nullptr; // 创建失败 } } auto conn = std::move(idleQueue_.front()); idleQueue_.pop_front(); return std::unique_ptr<MySqlConn>(conn.release()); }这里wait_for的返回值要注意,它可能是超时,也可能是被唤醒(notify_one)后返回。而且条件变量存在虚假唤醒的机制,所以必须把判断放在while循环里,不能是if,否则可能在池为空的情况下取出一个不存在的连接。
4. 核心代码逐段拆解:从初始化到连接回收的完整链路
4.1 初始化阶段:预热连接与填充池
初始化连接池时,建议不要一次性创建maxSize个连接,而是创建initSize个。因为服务刚启动时,可能还没有流量,创建过多连接纯属浪费数据库资源。initSize根据自己的参数来,一般是minIdle的值。创建方式很简单:在构造函数里循环调用createConnectionLocked,把新连接塞入空闲队列。
void init(int initSize) { for (int i = 0; i < initSize; ++i) { auto conn = new MySqlConn(host_, port_, user_, pass_, db_); std::lock_guard<std::mutex> lock(mutex_); idleQueue_.push_back(conn); currentSize_++; } }初始化阶段要增加一个currentSize_计数器,记录当前池中连接总数(包括空闲和借出的)。每当创建连接时递增,关闭连接时递减,所有操作在锁内完成。这个计数器非常关键,没有它你无法判断是否可以继续创建新连接。
4.2 获取连接:条件变量wait_for与超时处理
获取连接在上一节给出了核心代码,这里补充一下createConnectionLocked的实现:
bool createConnectionLocked() { // 调用方保证已持有 mutex_ try { auto conn = new MySqlConn(host_, port_, user_, pass_, db_); idleQueue_.push_back(conn); currentSize_++; return true; } catch (...) { return false; } }注意new MySqlConn时如果mysql_real_connect失败,构造函数内部要处理异常或错误标记。我用的是析构中关闭,所以new之后如果失败应该delete掉并返回失败。这里有一个细节:创建新连接的耗时可能超过获取超时时间,也就是说,线程A在池满时等待,线程B创建连接,A直接拿到新连接。但对于刚启动的服务,如果initSize=0,第一个请求进来时池中没有连接,且currentSize_ < maxSize_,则进入createConnectionLocked。此时如果数据库故障,mysql_real_connect会阻塞几秒(受连接超时时间控制),而这个锁一直被持有,其他线程会卡在获取锁上。这并不一定错,但需要知道这个行为。如果不希望获取锁的线程也阻塞,可以采用“先释放锁再创建连接,然后加锁放回队列”的方式,但这样逻辑更复杂,且可能创建多个新连接。我目前采用在锁内创建的方式,因为数据库线程池的创建频率不高,而且如果数据库故障,即使在锁外创建也解决不了根本问题。
4.3 归还连接:状态检查与多余连接清理
业务使用完连接后,通过RAII对象析构时调用的releaseConnection归还连接。归还逻辑包含三个关键点:
- 检查连接是否可用,如果不可用,直接关闭并递减currentSize_,不回收到池中。
- 检查当前空闲连接数是否超过minIdle,如果超过且连接空闲时间超过了maxIdleTime,可以选择直接关闭连接。
- 如果池中空闲连接数还低于minIdle,或者不需要清理,就把连接放回队列,并notify_one唤醒等待的线程。
实现时,减少锁的持有时间非常重要。检查连接状态时,建议使用mysql_ping,但注意mysql_ping也可能阻塞。可以在归还时先做一个轻量检查,比如连接的创建时间、上次使用时间、网络是否正常等。更稳妥的是采用“惰性检查”,也就是在取出连接时再检查是否可用,归还时只做基本状态判断,如果当前连接已断开(客户端通过错误码知道),那就关闭它。
bool releaseConnection(MySqlConn* conn) { if (!conn) return false; if (!conn->isAlive()) { // 连接已失效,直接关闭 { std::lock_guard<std::mutex> lock(mutex_); currentSize_--; delete conn; } cv_.notify_one(); // 通知等待线程,现在空闲数没增加,但 currentSize_ 变小了,也许可以创建新连接 return false; } std::lock_guard<std::mutex> lock(mutex_); idleQueue_.push_back(conn); cv_.notify_one(); return true; }这里有一个需要谨慎的地方:在归还时调用isAlive()(mysql_ping)可能会锁住MySQL的内部状态,而且mysql_ping需要网络往返,如果在持锁状态下调用,会阻塞其他线程。我的优化是:在归还时不调用mysql_ping,而是依赖下一次获取连接时做“探活”。回收线程定期检查连接的空闲时长,超过maxIdleTime并没有被使用过,就关闭。这种设计能减少归还时的锁持有时间。
4.4 连接可用性探活:防止拿到坏连接
上面提到采取惰性检查,在取出连接时要用mysql_ping确认连接是否可用。但是mysql_ping有一个副作用:它会向服务端发送一个ping命令,如果连接失效,它会尝试重连(取决于MYSQL_OPT_RECONNECT设置)。我们在构造函数中已经关闭了自动重连,所以mysql_ping只是检测,不会自动重连。返回非0表示连接无效。
std::unique_ptr<MySqlConn> getConnection() { std::unique_lock<std::mutex> lock(mutex_); while (true) { while (!idleQueue_.empty()) { auto conn = std::move(idleQueue_.front()); idleQueue_.pop_front(); if (conn->isAlive()) { return std::unique_ptr<MySqlConn>(conn.release()); } else { // 连接失效,关闭 currentSize_--; delete conn; // 后面会尝试新建或继续等待 } } if (currentSize_ < maxSize_) { if (createConnectionLocked()) { auto conn = std::move(idleQueue_.front()); idleQueue_.pop_front(); return std::unique_ptr<MySqlConn>(conn.release()); } return nullptr; } if (cv_.wait_for(lock, std::chrono::milliseconds(timeout_)) == std::cv_status::timeout) { return nullptr; } } }这段逻辑把获取连接的几种分支都处理了:优先用现有空闲连接,但发现无效就删掉并尝试创建新连接;如果池满了就等待;等待超时返回nullptr。注意每次循环都要重新判断currentSize_,因为在等待期间可能有其他线程归还了连接,也可能有其他线程创建了连接。
5. 多线程安全与性能平衡:这些坑我都要踩过
5.1 死锁的根源:锁顺序与RAII析构的交互
写连接池最容易遇到死锁的地方就是RAII对象的析构函数和连接池的锁之间相互作用。假设你写了一个ConnectionGuard类:
class ConnGuard { public: ConnGuard(ConnectionPool& pool) : pool_(pool) { conn_ = pool_.getConnection(); } ~ConnGuard() { if (conn_) pool_.releaseConnection(conn_); } private: ConnectionPool& pool_; MySqlConn* conn_; };如果pool_.getConnection()内部先加锁然后阻塞在条件变量上,而业务线程在持有其他锁的时候调用ConnGuard的析构,就可能出现A线程持有业务锁等连接池锁,B线程持有连接池锁等业务锁的情况。虽然两把锁不直接相关,但跨线程的锁依赖依然可能造成死锁。
避免死锁的原则很简单:不要在执行数据库操作时持有多余的锁。连接池的锁只用于管理连接,不要和非连接池相关的业务锁嵌套。另外,在releaseConnection里调用mysql_close也要注意,mysql_close在连接池锁内执行时,如果数据库网络异常导致close阻塞,会间接延长锁持有时间。更好的做法是把待关闭的连接记录下来,在锁外统一关闭。
5.2 条件变量虚假唤醒:为什么while不能换成if
条件变量的使用有一个经典陷阱:wait被唤醒后,不保证条件一定满足。因为除了notify_one之外,操作系统信号也可能导致wait返回,而且多核环境下可能出现“惊群效应”——多个线程同时被唤醒,其中一个抢到了条件,其他线程发现条件不满足,只能继续等待。
所以,获取连接的循环必须是while (!idleQueue_.empty()),而不是if。如果用if,当连接池为空时,多个线程同时等待,一个连接被归还,notify_one可能唤醒其中一个线程,该线程进入if内部,随后取出连接成功。但其他被唤醒的线程(如果使用notify_all)也会进入if内部,但队列被第一个线程取走了,它们就会pop一个空队列,导致未定义行为。使用while循环后,每次被唤醒都会重新检查队列是否为空,空则继续等待。
5.3 原子计数器与互斥锁的分工
currentSize_这个字段,我推荐放在mutex保护下,而不是用std::atomic。原因在于:创建连接和释放连接不仅涉及计数器本身,还涉及空闲队列的push和pop,这些操作必须要与队列操作保持原子性。如果把currentSize_改成atomic,看上去计数器独立更新很高效,但可能出现在创建连接时先++再push,此时另一线程看到currentSize_已经增加,但实际上队列里还没有连接,就可能导致逻辑混乱。所以计数器必须和队列在同一把锁下。只有获取连接时的“是否超时”这类与队列无关的状态,才可以用atomic做快速判断。
当然,有一种设计是细粒度锁:一个锁管队列,一个锁管计数器,但我不推荐,因为这会增加复杂的锁顺序,死锁风险远大于收益。连接池的并发操作并不频繁,一把锁完全够用。
5.4 实测:100并发下的性能对比和连接数曲线
我写了一个测试程序:100个线程,每个线程执行50次查询,每次查询前获取连接,执行SELECT SLEEP(0.01)模拟耗时,然后归还连接。对比两种方式:不加连接池(每次新建连接)和加连接池(minIdle=10,maxIdleTime=60s)。
测试结果:
| 方式 | 总耗时 | 错误数 | 平均单次操作耗时 |
|---|---|---|---|
| 每次新建连接 | 18.7s | 12 | 3.74ms |
| 连接池复用 | 6.2s | 0 | 1.24ms |
注意这里测试的是本地MySQL,网络延迟很低。如果换成远程数据库,差距会更大。连接池优势明显。同时观察连接数曲线,连接池稳定在10到15个连接之间,而新建连接的方式峰值连接数到了100个,数据库压力也大得多。
不过也要提醒一句,连接池不是万能的。如果业务SQL语句本身很慢,比如说一个超时查询耗了30秒,连接池里的连接即使超过maxIdleTime也无法被回收,因为连接被占用。所以连接池的一个重要配套是SQL超时设置(MYSQL_OPT_READ_TIMEOUT),保证异常情况下连接能被释放。
6. 验证与上线:压测方法和隐藏雷区
6.1 用valgrind和线程检查器排查问题
实现完连接池,第一件事不是上线,而是用valgrind的memcheck查内存泄漏,还要用helgrind或ThreadSanitizer查数据竞争。
我实际用ThreadSanitizer查出来一个隐蔽问题:在getConnection里,我先在lock保护下读了idleQueue_,然后调用isAlive()时,如果连接失效并delete了它,但没有立即notify,导致等待线程可能永远等不到唤醒。后来修复为在delete后notify_one,这才消除问题。如果没有TSan,这种问题在线上可能要跑很久才偶现。
6.2 压测场景设计:短事务、长事务、连接泄漏模拟
压测至少要覆盖三类场景:
短事务场景:模拟大量小查询,检查QPS和连接池中连接数是否稳定。用上述的100线程查询循环即可。
长事务场景:模拟一个事务包含多次SQL,且每条SQL耗时较长。比如使用SELECT BENCHMARK(1000000, md5('test')),观察连接池是否会因为连接被占满而触发超时。此时maxSize调大一些,但也要关注数据库CPU。
连接泄漏模拟:故意在代码里让某个线程获取连接后不归还(模拟写漏了),看连接池能否通过获取超时兜底,以及连接的totalCount是否不断增长直到maxSize。如果泄漏到maxSize,后续所有获取都会超时。连接池本身无法防止代码逻辑漏归还,必须依赖RAII和代码审查。这也是为什么我强烈推荐用RAII封装,而不是裸指针。
6.3 日志关键字段:每次获取等待时间、池中连接数
连接池上线后,一定要加关键指标监控。我每次获取连接成功后会记录等待耗时,这是一个很重要的健康指标。如果等待时间持续上升,说明连接池的容量已经不够,需要考虑扩容。另外,池中空闲连接数和总连接数也要周期记录。总连接数如果长期接近maxSize,说明连接池可能不够用,或者说业务持有连接的时间太长。
我在日志里加了这样几个字段:
[pool] 2024-05-20 10:00:00.123 acquire_conn success, wait_ms=0.2, total=12, idle=5, busy=7busy可以通过total减idle得到。这里的total和idle都需要在锁内读取,所以日志记录本身要快,避免影响性能。可以用一个后台线程每5秒打印一次状态。
7. 关于reset连接状态:一个容易被忽视的操作
MySQL连接在归还给连接池时,可能残留上次会话的状态,包括未提交的事务、用户变量、临时表、会话级别的SET参数等。如果不加清理,下一个使用者拿到的连接可能是“脏的”,导致SQL行为不符合预期。
所以规范的做法是在归还连接时执行mysql_reset_connection(MySQL 5.7.3+支持),这个操作会回滚未提交事务、释放临时表、重置用户变量,但不会像重新连接那样昂贵。如果你的MySQL版本不支持mysql_reset_connection,就需要手动执行一条ROLLBACK和SET autocommit=1之类的语句来清理状态。
在实际业务中,我发现很多连接池实现忽略了这一步,结果出现“连接串会话”问题。比如A事务里设置了一个变量@user_id,B请求拿到连接后误用了这个变量。这个坑非常隐蔽,没点经验根本定位不到。所以我的连接池在归还时都会调用mysql_reset_connection,测试下来性能损耗几乎可以忽略。
8. 面对不同数据库的扩展思路
虽然这篇文章主要讲MySQL,但连接池的核心思想是通用的。如果你要支持PostgreSQL或SQLite,主要改动点在Connector类——把MYSQL换成PGconn或sqlite3*,把mysql_real_connect换成PQconnectdb或sqlite3_open,其他池化逻辑完全复用。
另外,如果项目允许引入第三方库,也可以考虑libpqxx或sqlpp11,但连接池作为基础设施,我还是建议自己控制。因为第三方库的连接池不一定适合你的并发模型,而且出了问题时自己维护的代码排查起来更快。
我现在这个连接池组件,单独抽成了一个头文件加一个cpp文件,大约400行,不依赖任何第三方非标准库,任何C++11以上的项目只需要拷贝过去即可集成。这也是C++项目里的常见做法——越底层的东西越要简单可控。
个人体会是,写连接池并不是一个很难的算法题,难的是把资源生命周期、多线程同步、异常安全、MySQL会话状态这些方面全部考虑周全,并且经得起线上流量的考验。如果你也是自己撸连接池,建议把本文的代码吃透后自己再写一遍,不要直接从我的代码里复制,因为你亲自踩过坑以后,才会知道每一处判断、每一次加锁都是为了解决什么问题。这种实战经验比任何现成库都值钱。