第24章 多线程在数据库系统中的应用:连接池实现、事务并发控制、锁管理器设计

数据库系统,说白了就是多线程的集大成者。我早年做中间件开发时,第一次被线上数据库连接打满搞到凌晨三点,从那以后我就明白——不懂多线程,就别碰数据库内核。今天咱们就聊聊连接池、事务并发和锁管理器这三个核心模块。

24.1 连接池实现:别让线程等连接

连接池这东西,我习惯叫它“线程的加油站”。每个工作线程需要访问数据库时,从池子里拿一个连接,用完还回去。省去了反复创建销毁的开销。

核心设计要点:

  • 预创建固定数量的连接,避免动态创建的性能抖动
  • 使用信号量或条件变量控制连接获取的等待与唤醒
  • 连接空闲超时自动回收,防止“僵尸连接”

来看一个简化版的连接池实现:

// 连接池结构体
typedef struct {
    sql_conn_t **conns;        // 连接数组
    int total;                 // 总连接数
    int *available;            // 可用标记数组
    pthread_mutex_t mutex;     // 保护池状态
    pthread_cond_t cond;       // 等待条件
    int wait_count;            // 等待线程数
} conn_pool_t;

// 获取连接
sql_conn_t *pool_get(conn_pool_t *pool) {
    pthread_mutex_lock(&pool->mutex);
    
    // 循环查找可用连接
    while (1) {
        for (int i = 0; i < pool->total; i++) {
            if (pool->available[i]) {
                pool->available[i] = 0;
                pthread_mutex_unlock(&pool->mutex);
                return pool->conns[i];
            }
        }
        // 没有可用连接,等待
        pool->wait_count++;
        pthread_cond_wait(&pool->cond, &pool->mutex);
        pool->wait_count--;
    }
}

// 归还连接
void pool_put(conn_pool_t *pool, sql_conn_t *conn) {
    pthread_mutex_lock(&pool->mutex);
    for (int i = 0; i < pool->total; i++) {
        if (pool->conns[i] == conn) {
            pool->available[i] = 1;
            break;
        }
    }
    // 唤醒一个等待线程
    if (pool->wait_count > 0) {
        pthread_cond_signal(&pool->cond);
    }
    pthread_mutex_unlock(&pool->mutex);
}

避坑指南:我曾经在连接池里用了一个全局锁,结果高并发下所有线程都在抢锁,性能直接腰斩。后来改成无锁队列+每个连接独立状态标记,吞吐量翻了三倍。

24.2 事务并发控制:ACID的守护者

事务并发控制,说白了就是让多个事务同时跑,但结果跟串行执行一样。我参与过一个金融系统,每天几百万笔交易,并发控制要是出问题,账都对不上。

常见的并发问题就那几种:

问题类型 描述 后果
脏读 读到未提交的数据 数据不一致
不可重复读 同一事务内两次读结果不同 逻辑错误
幻读 范围查询时出现新行 结果集不一致
丢失更新 两个事务同时修改同一行 数据覆盖

解决这些问题的经典方案是MVCC(多版本并发控制)。每个事务看到的是数据的一个快照版本,写操作创建新版本,读操作读旧版本。嗯,这里要注意——MVCC不是银弹,它需要配合锁机制才能完全避免异常。

// 简化的MVCC行结构
typedef struct mvcc_row {
    int row_id;
    int value;
    int begin_ts;   // 创建该版本的事务ID
    int end_ts;     // 失效该版本的事务ID
    struct mvcc_row *next;  // 版本链
} mvcc_row_t;

// 事务读取:只读 begin_ts <= my_ts < end_ts 的版本
int mvcc_read(mvcc_row_t *head, int my_ts) {
    mvcc_row_t *cur = head;
    while (cur) {
        if (cur->begin_ts <= my_ts && 
            (cur->end_ts > my_ts || cur->end_ts == 0)) {
            return cur->value;
        }
        cur = cur->next;
    }
    return -1; // 未找到
}

注意:MVCC的版本链不能无限增长。我见过一个系统因为长事务一直不提交,版本链越积越长,每次读都要遍历几十个版本,性能惨不忍睹。一定要设置事务超时和版本回收机制。

24.3 锁管理器设计:精细化的并发控制

锁管理器是数据库并发控制的“交通警察”。它决定哪个事务能拿锁,哪个事务要等待,以及如何避免死锁。

我个人习惯把锁管理器分成三层:

  1. 锁表层:维护所有锁的哈希表,key是资源ID,value是锁队列
  2. 锁队列层:每个资源对应一个等待队列,记录等待的事务
  3. 死锁检测层:定期检查等待图,发现环就回滚代价最小的事务

来看锁表的核心数据结构:

// 锁请求类型
typedef enum { LOCK_SHARED, LOCK_EXCLUSIVE } lock_mode_t;

// 锁队列节点
typedef struct lock_node {
    int tx_id;                // 事务ID
    lock_mode_t mode;         // 锁模式
    int granted;              // 是否已授予
    struct lock_node *next;   // 队列指针
} lock_node_t;

// 锁表条目
typedef struct lock_entry {
    int resource_id;          // 资源ID
    lock_node_t *queue;       // 等待/持有队列
    pthread_mutex_t mutex;    // 保护该条目
    pthread_cond_t cond;      // 条件变量
} lock_entry_t;

// 锁管理器
typedef struct lock_manager {
    lock_entry_t *table;      // 锁表
    int table_size;           // 表大小
    pthread_t deadlock_thread; // 死锁检测线程
} lock_manager_t;

锁授予的逻辑其实不复杂:

  • 如果是共享锁请求,且队列中没有排他锁等待,直接授予
  • 如果是排他锁请求,必须等队列中所有锁释放
  • 锁释放时,唤醒队列中所有可以授予的等待者

个人经验:死锁检测别做得太频繁。我曾经把检测间隔设成100ms,结果死锁没抓到几个,CPU全耗在遍历等待图上了。后来改成1秒检测一次,加上超时回滚兜底,效果反而更好。

24.4 整体架构一览

这三个模块是怎么配合的?我画了张图,你看一眼就明白了:

数据库多线程并发控制架构 工作线程池(多个线程并发执行事务) 连接池(管理数据库连接,线程安全获取/归还) 事务并发控制(MVCC + 隔离级别) 锁管理器(锁表 + 等待队列 + 死锁检测) 磁盘存储 / Buffer Pool 读不阻塞读 写不阻塞读(MVCC) 锁升级、锁超时 死锁回滚

从这张图能看出来,工作线程先通过连接池拿到数据库连接,然后事务控制层决定能看到哪些数据版本,最后锁管理器处理真正的资源冲突。三层各司其职,缺一不可。

最后提醒一句:别把锁粒度搞得太粗。我见过有人图省事,整个表一把锁,结果并发度直接归零。锁粒度要精细到行级别,甚至页级别,才能发挥多线程的真正威力。


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