第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 锁管理器设计:精细化的并发控制
锁管理器是数据库并发控制的“交通警察”。它决定哪个事务能拿锁,哪个事务要等待,以及如何避免死锁。
我个人习惯把锁管理器分成三层:
- 锁表层:维护所有锁的哈希表,key是资源ID,value是锁队列
- 锁队列层:每个资源对应一个等待队列,记录等待的事务
- 死锁检测层:定期检查等待图,发现环就回滚代价最小的事务
来看锁表的核心数据结构:
// 锁请求类型
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 整体架构一览
这三个模块是怎么配合的?我画了张图,你看一眼就明白了:
从这张图能看出来,工作线程先通过连接池拿到数据库连接,然后事务控制层决定能看到哪些数据版本,最后锁管理器处理真正的资源冲突。三层各司其职,缺一不可。
最后提醒一句:别把锁粒度搞得太粗。我见过有人图省事,整个表一把锁,结果并发度直接归零。锁粒度要精细到行级别,甚至页级别,才能发挥多线程的真正威力。
公众号:蓝海资料掘金营,微信deep3321