后端工程

SQLite写入并发的三个坑

从锁粒度、WAL与连接管理三个角度,整理小项目里最容易遇到的SQLite写入失败,以及对应的排查顺序。

SQLite在小服务里非常好用,直到某天开始出现database is locked。这类问题通常不是SQLite不行,而是默认配置和实际写入模式不匹配。

坑一:把”轻量”理解成”可以随便并发写”

SQLite的写锁是库级的,同一时刻只允许一个写入者。多个请求同时写,后来的必须等busy_timeout,超时就抛错。这和行级锁的直觉完全不同。

排查顺序:

  1. 先看有没有长事务包裹了耗时逻辑(网络请求、循环处理写进了事务里);
  2. 再看写入是否集中在同一张表但分散在多个连接;
  3. 最后才考虑调参数。

把事务缩到最小,往往比调任何配置都有效。

# 反例:事务里做了不该做的事
with conn:
    row = conn.execute("SELECT ...").fetchone()
    result = call_remote_api(row)      # 网络耗时占着写锁
    conn.execute("UPDATE ...", (result,))

# 正解:先算完,再进事务
row = conn.execute("SELECT ...").fetchone()
result = call_remote_api(row)
with conn:
    conn.execute("UPDATE ...", (result,))

坑二:WAL不是万能开关

打开WAL能让读写不互相阻塞,但它有前提:底层文件系统必须支持可靠的共享内存与文件锁。

  • 网络文件系统、某些容器挂载卷、部分同步盘上,WAL可能不可用或直接报错;
  • WAL会带来-wal和-shm文件,备份时不能只拷主库文件;
  • 单机多进程且共享目录权限复杂时,启用前的验证成本比收益更高。

如果环境不支持WAL,老实的做法是串行化写入:在应用层用一个队列把写操作排队,单连接单写入者,问题立刻消失。对个人博客的评论量级,这完全够用。

// 用Promise链把写入串行化,避免并发写文件
let queue = Promise.resolve();
function enqueue(task) {
  queue = queue.then(task, task);
  return queue;
}

坑三:连接生命周期混乱

常见问题包括:每次请求新建连接却从不关闭、在多个模块里各自持有长连接、检查点上忘记PRAGMA busy_timeout。

建议固定下来:

  • 一个进程内共享一个写连接,读连接按需;
  • 显式设置busy_timeout(例如5000毫秒),别依赖默认值0;
  • 用上下文管理器保证异常时连接归还;
  • 所有写入走同一个入口,方便统一加重试与日志。

什么时候该换数据库

当出现下面任一信号,就该考虑PostgreSQL而不是继续调SQLite了:多机部署、写入QPS持续上百、需要跨服务事务、需要在线迁移与细粒度权限。

在那之前,单写入者 + 短事务 + 明确超时这三条,能解决绝大多数小项目的写入报错。

评论 0

评论保存在本站服务器,无需登录。带「作者」标识的是站主回复,请友善交流。

    评论内容至少2个字、最多1000字,按纯文本展示,不支持HTML。