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 模式,一条线串起来:

SQLite 多线程并发访问知识体系 线程模式选择 连接池管理 锁机制 单线程 / 多线程 / 序列化 sqlite3_config() 设置 编译时 SQLITE_THREADSAFE 预创建 / 借还 / 扩容 pthread_mutex + cond 避免频繁 open/close 读写锁 / 互斥锁 pthread_rwlock_t 读读不互斥,读写互斥 WAL 模式:写不阻塞读,读不阻塞写 PRAGMA journal_mode=WAL;

16.6 实战建议——怎么选?

说了这么多,到底怎么用?我总结几条经验:

  • 单线程场景: 用单线程模式,性能最高。别折腾别的。
  • 多线程只读: 多线程模式 + 连接池 + 读写锁。读操作几乎不冲突。
  • 多线程读写都有: 序列化模式 + 连接池 + WAL 模式。这是最稳的组合。
  • 高并发写入: 考虑分库分表,别让 SQLite 扛。它毕竟不是 PostgreSQL。

最后一个小建议: 上线前一定要做压力测试。我见过太多项目,开发环境跑得好好的,一上线并发一高就崩。用 sqlite3_threadsafety() 检查当前编译是否支持多线程,别等到线上才发现。

嗯,多线程并发这块就聊到这儿。记住:SQLite 本身不慢,慢的是你不会用。选对模式、搭好池子、开 WAL,并发能力直接上一个台阶。


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