第26章:数据库连接池的C语言实现:池化思想、线程安全队列、连接复用、超时机制

说实话,连接池这东西,我刚开始做嵌入式数据库时根本没当回事。那时候觉得,每次查询都新建一个连接,用完就关掉,多简单啊。直到有一次,我在一个高并发的数据采集项目里,系统频繁崩溃——原因就是频繁创建和销毁连接,把系统资源耗尽了。嗯,从那以后,我才真正重视起连接池来。

这一章,我就带你手写一个轻量级的数据库连接池。核心就四个点:池化思想线程安全队列连接复用超时机制。说白了,就是让连接像水池里的水一样,随取随用,用完了还回去,别浪费。

26.1 池化思想:为什么需要连接池?

你想想看,每次数据库操作都经历“建立连接→执行SQL→关闭连接”这个过程,开销有多大?三次握手、身份认证、内存分配……这些操作在嵌入式设备上尤其昂贵。我曾在ARM Cortex-M4的板子上测试过,一次完整的连接建立要消耗约5ms,而实际SQL执行可能只需要0.1ms。这比例,太离谱了。

池化思想的核心就是:预先创建一批连接,放在池子里,谁用谁取,用完归还。这样就把“创建连接”的开销平摊到了系统启动时,运行时几乎零成本。

核心要点:

  • 连接池大小 = 最大并发数 + 缓冲数(我个人习惯留20%余量)
  • 连接复用 ≠ 连接永远不关,空闲太久还是要回收的
  • 池化不是万能的,连接数太多反而会拖垮数据库

26.2 整体架构:一张图看懂连接池

先给你看个整体结构,心里有个底。我画了张图,把连接池的核心模块和流程都标出来了。

数据库连接池核心架构图 应用线程 Thread 1..N 获取连接 连接池管理器 初始化 / 扩容 / 回收 空闲连接队列(线程安全) conn1 | conn2 | conn3 | ... | connN 取出 忙碌连接 正在使用 归还连接 超时检查器 定时扫描空闲队列 数据库服务器 MySQL / SQLite / 自定义 实际连接

这张图里,空闲连接队列是核心。所有线程都从这里取连接,用完再放回去。超时检查器会定期扫描,把那些闲置太久的连接关掉,避免资源浪费。

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 超时机制:别让线程死等

超时机制分两种:

  1. 获取连接超时:线程等了一段时间还没拿到连接,就返回错误。
  2. 连接空闲超时:连接在池子里闲置太久,就关掉它,释放资源。

第一种超时,我们在 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 + 后台清理线程

嗯,连接池这东西,说白了就是空间换时间。多花点内存,换来的是系统稳定性和响应速度。我在好几个嵌入式项目里都用过这套实现,效果都不错。你如果自己动手写一遍,会发现其实没那么复杂——关键是把线程安全和超时处理好,其他的都是细节。


公众号:蓝海资料掘金营,微信deep3321