16、多线程与并发访问:SQLite线程模式、连接池实现、读写锁、WAL模式
多线程并发访问数据库,这话题我太熟了。早些年做嵌入式项目,单线程跑得挺欢,一上多线程就各种崩溃。SQLite 默认配置其实挺保守的,你得主动告诉它:「嘿,我要上多线程了!」
今天咱们就把这块掰开揉碎讲清楚。线程模式怎么选、连接池怎么搭、读写锁怎么用、WAL 模式到底香不香——一个一个来。
16.1 SQLite 的三种线程模式
SQLite 编译时有个开关叫 SQLITE_THREADSAFE,取值 0、1、2。但咱们更常用的是运行时设置,通过 sqlite3_config() 来切换。
| 模式 | 宏定义 | 说明 |
|---|---|---|
| 单线程 | SQLITE_CONFIG_SINGLETHREAD | 所有互斥锁禁用,性能最高,但只能单线程用 |
| 多线程 | SQLITE_CONFIG_MULTITHREAD | 连接对象可跨线程传递,但同一时刻只能一个线程用 |
| 序列化 | SQLITE_CONFIG_SERIALIZED | 完全线程安全,内部自动加锁,性能有损耗 |
我个人的习惯: 如果只是读多写少,用多线程模式 + 外部自己控制锁。如果写操作频繁,直接上序列化模式省心。但别盲目选序列化——我在项目中遇到过,开序列化后写入性能掉了将近 30%。
设置代码很简单:
// 在 sqlite3_open() 之前调用
sqlite3_config(SQLITE_CONFIG_SERIALIZED);
// 或者用多线程模式
sqlite3_config(SQLITE_CONFIG_MULTITHREAD);
注意: sqlite3_config() 必须在任何数据库连接打开之前调用。一旦调用了 sqlite3_open(),再改配置就无效了。
16.2 连接池实现——别频繁开关连接
你想想看,每次请求都 sqlite3_open() 再 sqlite3_close(),这开销有多大?文件锁、内存分配、日志初始化……一套下来几十毫秒就没了。
连接池说白了就是预创建一批连接,用的时候借,用完还。我做过一个 IoT 数据采集项目,设备每秒上报 200 条数据,没连接池的时候 CPU 占用 80%,加上连接池直接降到 30%。
一个简易连接池的核心结构:
typedef struct {
sqlite3 **conns; // 连接数组
int *available; // 可用标记
int total; // 总连接数
pthread_mutex_t lock; // 互斥锁
pthread_cond_t cond; // 条件变量
} ConnectionPool;
// 初始化连接池
ConnectionPool* pool_create(int size, const char *db_path) {
ConnectionPool *pool = malloc(sizeof(ConnectionPool));
pool->total = size;
pool->conns = malloc(size * sizeof(sqlite3*));
pool->available = malloc(size * sizeof(int));
pthread_mutex_init(&pool->lock, NULL);
pthread_cond_init(&pool->cond, NULL);
for (int i = 0; i < size; i++) {
sqlite3_open(db_path, &pool->conns[i]);
pool->available[i] = 1; // 初始全部可用
}
return pool;
}
// 借连接
sqlite3* pool_borrow(ConnectionPool *pool) {
pthread_mutex_lock(&pool->lock);
while (1) {
for (int i = 0; i < pool->total; i++) {
if (pool->available[i]) {
pool->available[i] = 0;
pthread_mutex_unlock(&pool->lock);
return pool->conns[i];
}
}
// 没有可用连接,等待
pthread_cond_wait(&pool->cond, &pool->lock);
}
}
// 还连接
void pool_return(ConnectionPool *pool, sqlite3 *conn) {
pthread_mutex_lock(&pool->lock);
for (int i = 0; i < pool->total; i++) {
if (pool->conns[i] == conn) {
pool->available[i] = 1;
break;
}
}
pthread_cond_signal(&pool->cond);
pthread_mutex_unlock(&pool->lock);
}
小技巧: 连接池大小别拍脑袋定。我一般按「峰值并发数 × 1.2」来算。比如峰值 50 个线程同时访问,池子就开 60 个连接。多了浪费内存,少了线程排队。
16.3 读写锁——读读不互斥,读写才互斥
SQLite 默认的锁机制是:任何写操作都会阻塞所有读操作。这在读多写少的场景下简直是灾难。
读写锁(pthread_rwlock_t)就解决了这个问题:多个线程可以同时读,只有写的时候才独占。说白了就是「读读不打架,读写才打架」。
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
void read_data() {
pthread_rwlock_rdlock(&rwlock);
// 执行 SELECT 查询
pthread_rwlock_unlock(&rwlock);
}
void write_data() {
pthread_rwlock_wrlock(&rwlock);
// 执行 INSERT/UPDATE/DELETE
pthread_rwlock_unlock(&rwlock);
}
我曾经踩过一个坑: 读写锁 + 事务混用。在写锁内开启事务,结果事务没提交锁也没释放,其他读线程全卡死了。记住:锁的范围要小于事务范围,或者干脆用 WAL 模式替代手动读写锁。
16.4 WAL 模式——并发读写的终极方案
WAL(Write-Ahead Logging)模式,说白了就是「先写日志,再写数据库」。写入的时候不阻塞读取,读取的时候也不阻塞写入。这才是真正的并发读写。
开启 WAL 模式就一行代码:
sqlite3_exec(db, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL);
WAL 模式下,写入操作先追加到 WAL 文件,读取操作从数据库文件和 WAL 文件共同读取。等 WAL 文件大到一定程度,或者主动触发 checkpoint,才会合并到主数据库。
WAL 模式 vs 传统回滚日志模式:
| 特性 | 回滚日志模式 | WAL 模式 |
|---|---|---|
| 读写并发 | 写阻塞读 | 写不阻塞读 |
| 读读并发 | 支持 | 支持 |
| 写入性能 | 一般 | 更好(顺序写) |
| 读取性能 | 正常 | 略差(需查 WAL) |
| 崩溃恢复 | 回滚未完成事务 | 重放 WAL 日志 |
注意: WAL 模式在 NFS 网络文件系统上可能出问题。我有个项目部署在 NAS 上,WAL 文件经常损坏。后来改成回滚日志模式才稳定。所以嵌入式环境用 WAL 没问题,网络存储要谨慎。
16.5 知识体系总览
下面这张图把多线程并发的核心脉络画出来了。从线程模式到底层锁机制,再到 WAL 模式,一条线串起来:
16.6 实战建议——怎么选?
说了这么多,到底怎么用?我总结几条经验:
- 单线程场景: 用单线程模式,性能最高。别折腾别的。
- 多线程只读: 多线程模式 + 连接池 + 读写锁。读操作几乎不冲突。
- 多线程读写都有: 序列化模式 + 连接池 + WAL 模式。这是最稳的组合。
- 高并发写入: 考虑分库分表,别让 SQLite 扛。它毕竟不是 PostgreSQL。
最后一个小建议: 上线前一定要做压力测试。我见过太多项目,开发环境跑得好好的,一上线并发一高就崩。用 sqlite3_threadsafety() 检查当前编译是否支持多线程,别等到线上才发现。
嗯,多线程并发这块就聊到这儿。记住:SQLite 本身不慢,慢的是你不会用。选对模式、搭好池子、开 WAL,并发能力直接上一个台阶。
公众号:蓝海资料掘金营,微信deep3321