第26章:数据库连接池的C语言实现:池化思想、线程安全队列、连接复用、超时机制
说实话,连接池这东西,我刚开始做嵌入式数据库时根本没当回事。那时候觉得,每次查询都新建一个连接,用完就关掉,多简单啊。直到有一次,我在一个高并发的数据采集项目里,系统频繁崩溃——原因就是频繁创建和销毁连接,把系统资源耗尽了。嗯,从那以后,我才真正重视起连接池来。
这一章,我就带你手写一个轻量级的数据库连接池。核心就四个点:池化思想、线程安全队列、连接复用、超时机制。说白了,就是让连接像水池里的水一样,随取随用,用完了还回去,别浪费。
26.1 池化思想:为什么需要连接池?
你想想看,每次数据库操作都经历“建立连接→执行SQL→关闭连接”这个过程,开销有多大?三次握手、身份认证、内存分配……这些操作在嵌入式设备上尤其昂贵。我曾在ARM Cortex-M4的板子上测试过,一次完整的连接建立要消耗约5ms,而实际SQL执行可能只需要0.1ms。这比例,太离谱了。
池化思想的核心就是:预先创建一批连接,放在池子里,谁用谁取,用完归还。这样就把“创建连接”的开销平摊到了系统启动时,运行时几乎零成本。
核心要点:
- 连接池大小 = 最大并发数 + 缓冲数(我个人习惯留20%余量)
- 连接复用 ≠ 连接永远不关,空闲太久还是要回收的
- 池化不是万能的,连接数太多反而会拖垮数据库
26.2 整体架构:一张图看懂连接池
先给你看个整体结构,心里有个底。我画了张图,把连接池的核心模块和流程都标出来了。
这张图里,空闲连接队列是核心。所有线程都从这里取连接,用完再放回去。超时检查器会定期扫描,把那些闲置太久的连接关掉,避免资源浪费。
26.3 线程安全队列:多线程下怎么保证不出乱子?
多线程同时取连接,如果队列不是线程安全的,就会出现两个线程拿到同一个连接的情况。我在项目中遇到过这种bug,查了两天才定位到——两个线程同时操作了同一个数据库句柄,数据全乱套了。
解决方案其实不复杂:互斥锁 + 条件变量。取连接时加锁,队列空了就等待条件变量;归还连接时加锁,然后通知等待的线程。
来看代码实现:
// 连接池节点结构
typedef struct ConnNode {
DBConnection* conn; // 实际的数据库连接
time_t create_time; // 创建时间戳
time_t last_used; // 最后使用时间
struct ConnNode* next; // 链表指针
} ConnNode;
// 线程安全队列
typedef struct {
ConnNode* head; // 队首
ConnNode* tail; // 队尾
int count; // 当前连接数
int max_count; // 最大连接数
pthread_mutex_t mutex; // 互斥锁
pthread_cond_t cond; // 条件变量
} SafeQueue;
// 初始化队列
void queue_init(SafeQueue* q, int max) {
q->head = q->tail = NULL;
q->count = 0;
q->max_count = max;
pthread_mutex_init(&q->mutex, NULL);
pthread_cond_init(&q->cond, NULL);
}
// 从队列取连接(带超时)
DBConnection* queue_pop(SafeQueue* q, int timeout_ms) {
pthread_mutex_lock(&q->mutex);
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += timeout_ms / 1000;
ts.tv_nsec += (timeout_ms % 1000) * 1000000;
// 队列为空时等待,直到超时或被唤醒
while (q->head == NULL) {
int ret = pthread_cond_timedwait(&q->cond, &q->mutex, &ts);
if (ret == ETIMEDOUT) {
pthread_mutex_unlock(&q->mutex);
return NULL; // 超时返回NULL
}
}
// 取出队首连接
ConnNode* node = q->head;
q->head = node->next;
if (q->head == NULL) q->tail = NULL;
q->count--;
pthread_mutex_unlock(&q->mutex);
return node->conn;
}
// 归还连接到队列
void queue_push(SafeQueue* q, DBConnection* conn) {
ConnNode* node = malloc(sizeof(ConnNode));
node->conn = conn;
node->last_used = time(NULL);
node->next = NULL;
pthread_mutex_lock(&q->mutex);
if (q->tail == NULL) {
q->head = q->tail = node;
} else {
q->tail->next = node;
q->tail = node;
}
q->count++;
// 通知等待的线程
pthread_cond_signal(&q->cond);
pthread_mutex_unlock(&q->mutex);
}
个人经验:这里用 pthread_cond_timedwait 而不是 pthread_cond_wait,就是为了实现超时机制。我曾经只用 pthread_cond_wait,结果某个线程获取连接时数据库挂了,那个线程就永远卡在那里。加上超时后,至少能优雅地返回错误。
26.4 连接复用:怎么判断连接还能不能用?
连接复用不是简单地把连接放回队列就完事了。你想想看,如果数据库服务器重启了,或者网络断过,那些旧连接其实已经失效了。直接复用的话,SQL执行就会报错。
我常用的做法是:在取出连接时做一次轻量级的心跳检测。比如执行一个 SELECT 1,如果失败就重建连接。
// 检查连接是否有效
int conn_is_alive(DBConnection* conn) {
// 执行一个最简单的查询
int ret = db_execute(conn, "SELECT 1");
if (ret != 0) {
// 连接已失效,需要重建
return 0;
}
return 1;
}
// 从连接池获取连接(带自动修复)
DBConnection* pool_get_connection(ConnectionPool* pool, int timeout_ms) {
DBConnection* conn = queue_pop(&pool->idle_queue, timeout_ms);
if (conn == NULL) {
return NULL; // 超时
}
// 检查连接是否有效
if (!conn_is_alive(conn)) {
// 连接失效,重建一个
db_close(conn);
conn = db_connect(pool->config);
if (conn == NULL) {
return NULL; // 重建失败
}
}
return conn;
}
注意:心跳检测虽然好,但别太频繁。我见过有人每次取连接都做检测,结果数据库被查询请求打满了。建议设置一个 last_used 时间戳,只有闲置超过一定时间(比如30秒)的连接才做检测。
26.5 超时机制:别让线程死等
超时机制分两种:
- 获取连接超时:线程等了一段时间还没拿到连接,就返回错误。
- 连接空闲超时:连接在池子里闲置太久,就关掉它,释放资源。
第一种超时,我们在 queue_pop 里已经用 pthread_cond_timedwait 实现了。第二种超时,需要一个后台线程定期扫描:
// 后台线程:定期清理空闲超时的连接
void* cleanup_thread(void* arg) {
ConnectionPool* pool = (ConnectionPool*)arg;
while (pool->running) {
sleep(10); // 每10秒扫描一次
pthread_mutex_lock(&pool->idle_queue.mutex);
ConnNode* prev = NULL;
ConnNode* curr = pool->idle_queue.head;
time_t now = time(NULL);
while (curr != NULL) {
// 如果连接空闲超过60秒,就关掉它
if (now - curr->last_used > 60) {
ConnNode* to_free = curr;
if (prev == NULL) {
pool->idle_queue.head = curr->next;
} else {
prev->next = curr->next;
}
curr = curr->next;
// 关闭实际连接
db_close(to_free->conn);
free(to_free);
pool->idle_queue.count--;
} else {
prev = curr;
curr = curr->next;
}
}
pthread_mutex_unlock(&pool->idle_queue.mutex);
}
return NULL;
}
避坑指南:我曾经把空闲超时设得太短(10秒),结果系统负载一高,连接频繁创建销毁,性能反而更差了。后来我改成60秒,并且只在连接数超过最小值时才清理。记住:超时时间要跟业务特点匹配。如果是高频短连接业务,超时设短点;如果是长连接业务,超时设长点甚至不设。
26.6 完整连接池的初始化与销毁
最后,把上面这些模块拼起来,就是一个完整的连接池了。初始化时预先创建一批连接,销毁时把所有连接都关掉:
// 连接池主结构
typedef struct {
SafeQueue idle_queue; // 空闲连接队列
DBConfig config; // 数据库配置
int min_conns; // 最小连接数
int max_conns; // 最大连接数
pthread_t cleanup_tid; // 清理线程ID
volatile int running; // 运行标志
} ConnectionPool;
// 初始化连接池
int pool_init(ConnectionPool* pool, DBConfig* config,
int min, int max) {
pool->config = *config;
pool->min_conns = min;
pool->max_conns = max;
pool->running = 1;
queue_init(&pool->idle_queue, max);
// 预先创建最小连接数
for (int i = 0; i < min; i++) {
DBConnection* conn = db_connect(config);
if (conn == NULL) {
return -1; // 初始化失败
}
queue_push(&pool->idle_queue, conn);
}
// 启动清理线程
pthread_create(&pool->cleanup_tid, NULL, cleanup_thread, pool);
return 0;
}
// 销毁连接池
void pool_destroy(ConnectionPool* pool) {
pool->running = 0;
pthread_join(pool->cleanup_tid, NULL);
// 关闭所有连接
while (pool->idle_queue.head != NULL) {
ConnNode* node = pool->idle_queue.head;
pool->idle_queue.head = node->next;
db_close(node->conn);
free(node);
}
pthread_mutex_destroy(&pool->idle_queue.mutex);
pthread_cond_destroy(&pool->idle_queue.cond);
}
26.7 总结:连接池的四个关键点
| 模块 | 核心思想 | 实现要点 |
|---|---|---|
| 池化思想 | 预先创建,重复使用 | 最小连接数、最大连接数、动态扩容 |
| 线程安全队列 | 互斥锁 + 条件变量 | 取连接时等待,归还时通知 |
| 连接复用 | 心跳检测 + 自动重建 | SELECT 1 检测,失效时重建 |
| 超时机制 | 获取超时 + 空闲超时 | timedwait + 后台清理线程 |
嗯,连接池这东西,说白了就是空间换时间。多花点内存,换来的是系统稳定性和响应速度。我在好几个嵌入式项目里都用过这套实现,效果都不错。你如果自己动手写一遍,会发现其实没那么复杂——关键是把线程安全和超时处理好,其他的都是细节。