SQLite WAL 模式调优实战:多读一写并发原理、busy_timeout 与 synchronous 参数取舍与备份坑

别小看你站点的那个 .db 文件:SQLite 其实可以扛住高并发读

很多独立站长在做项目时,一提到数据库就条件反射地上 MySQL。但如果你的站点访问量是每天几千到几万 PV,数据模型也不复杂(文章、评论、标签、访问日志这类),那么用一个 SQLite 文件往往比维护一台 MySQL 更省心:没有了额外的守护进程、没有了连接池配置、备份就是 cp 一个文件。问题是,很多人对 SQLite 的印象停留在「一写入就锁全库,并发一高就 database is locked」。这个印象在默认配置下是对的,但只要你正确开启 WAL 模式并调好几个参数,SQLite 的并发能力会有一个质的飞跃。

这篇文章我会从原理讲到实操:WAL 到底改了什么、为什么它能同时支持「多读一写」、busy_timeout 和 synchronous 该怎么配合、以及如何验证你的设置真的生效了。最后给出一份可以直接抄走的初始化配置和备份注意点。全程针对「单机小型内容站/API 服务」这个场景。

先理解默认模式为什么慢:回滚日志的代价

SQLite 默认使用 rollback journal(回滚日志) 模式。它的写入流程大致是:先把要修改的页面的原始内容写到一个 -journal 文件里,再修改主数据库文件,最后删除 journal。这个设计保证了原子性(崩了就回滚),但代价是:写操作期间会独占整个数据库的写锁和读锁。也就是说,一个事务在写,其他所有连接(包括只想读的)都得排队等着。在 Web 场景里,读请求远多于写请求,这种「写阻塞读」直接导致了那个经典的报错——database is locked。

WAL(Write-Ahead Logging,预写式日志)模式的思路完全不同:修改不再写回主库文件,而是先追加到一个 -wal 文件里,读操作则通过主库 + WAL 合并出最新视图。这样一来:

  • 读不阻塞写:读连接读的是自己的快照,写连接往 WAL 追加,两者互不干扰。
  • 写也不阻塞读:正在读的事务不受新写入影响。
  • 但还是只有一个写者:同一时刻只允许一个写事务。多个写请求依然要串行,只是它们之间靠 busy 机制等待,而不是把读者也一起卡死。

一句话结论:WAL 把「多读多写全串行」变成了「多读并发 + 单写排队」。对绝大多数读多写少的网站应用,这就够了。

开启 WAL:一行 PRAGMA,但别只写这一行

开启 WAL 本身非常简单,执行一次即可(它是持久属性,写进数据库头,重启后保留):

PRAGMA journal_mode = WAL;

返回结果是 wal 就代表成功。但如果你想真正发挥 WAL 的威力,还得同时设置下面几个参数。这些是连接级别的(每次建立连接都要重设),和 journal_mode 的持久性不同,这点非常容易踩坑。

PRAGMA journal_mode = WAL;       -- 持久,只需设一次
PRAGMA synchronous = NORMAL;      -- 连接级,每次连接都设
PRAGMA busy_timeout = 5000;       -- 连接级,单位毫秒
PRAGMA foreign_keys = ON;         -- 连接级,按需
PRAGMA cache_size = -20000;       -- 连接级,负值表示 KB

逐个解释:

synchronous = NORMAL

这是 WAL 模式下的性能关键。默认是 FULL,意思是每次事务提交都要 fsync,保证断电不丢数据,但速度慢。在 WAL 下,NORMAL 只保证「WAL 文件的数据在提交时不 fsync,但操作系统崩溃不丢库一致性,只有掉电可能丢失最后几个已提交事务」。对于网站这种「偶尔丢最后一条评论无所谓」的场景,NORMAL 是官方推荐值和性能甜点。如果你做的是订单、账务,那就老老实实保持 FULL。

busy_timeout = 5000

当写者发现锁被别人占着,默认行为是立刻返回 SQLITE_BUSY(就是那个 database is locked)。设置 busy_timeout 后,SQLite 会在超时时间内不断重试,而不是立刻报错。5000 毫秒对 Web 请求足够了。这一条是消灭报错最有效的一招,务必设。

cache_size = -20000

负值单位是 KB,所以 -20000 是 20MB 的页缓存。默认只有 2MB,稍微复杂的查询就要频繁读盘。给到 20-50MB 能显著减少磁盘 IO。你的机器内存越大,可以给得越大(但别超过可用内存的三分之一)。

用 Python 示例把配置落到代码里

最容易被忽略的一点:上面除 journal_mode 外的参数都是按连接生效的。如果你用连接池,每一条新连接都要执行一遍。下面是一个典型的一次性初始化函数,供参考:

import sqlite3

def connect(db_path):
    conn = sqlite3.connect(db_path, timeout=5, isolation_level=None)
    conn.execute("PRAGMA journal_mode = WAL;")
    conn.execute("PRAGMA synchronous = NORMAL;")
    conn.execute("PRAGMA busy_timeout = 5000;")
    conn.execute("PRAGMA cache_size = -20000;")
    conn.execute("PRAGMA foreign_keys = ON;")
    return conn

注意 timeout=5 和 PRAGMA busy_timeout=5000 是两件事:前者是 Python 驱动层面对锁等待的处理,后者是 SQLite 引擎层的。都设上更保险。isolation_level=None 是把事务控制权交给自己(autocommit),方便手动 BEGIN。

验证当前连接的设置是否生效,直接查:

PRAGMA journal_mode;
PRAGMA synchronous;
PRAGMA busy_timeout;

WAL 带来的三个「副作用」,你必须提前知道

副作用一:多了 -wal 和 -shm 两个文件。 开启后数据目录会出现 xxx.db-wal 和 xxx.db-shm。这是正常的,别去手动删。-shm 是共享内存索引文件,用于协调多连接读取 WAL。你的备份不能再只 cp 那个 .db 文件了,否则会丢掉 WAL 里还没 checkpoint 的数据。这点下面单独讲。

副作用二:WAL 文件会一直增长。 正常情况下 SQLite 会在合适的时机做 checkpoint(把 WAL 内容合并回主库并截断 WAL)。但如果一直有长事务或持续的读连接,checkpoint 就做不干净,WAL 可能涨到几百 MB。你要主动管理 checkpoint:

PRAGMA wal_checkpoint(TRUNCATE);

可以定期(比如每小时)执行一次,或者在低峰期跑。返回三个数字(busy, log, checkpointed),如果第一个是 1,说明有连接占用导致没完成,可以再等等重试。想在无阻塞下自动 checkpoint,可以调 PRAGMA wal_autocheckpoint = 1000;(每 1000 页触发一次,默认也是 1000,一般不用动)。

副作用三:WAL 不适用于网络文件系统。 因为 -shm 依赖共享内存,WAL 模式要求数据库文件在本地磁盘。如果你的 .db 放在 NFS、SMB 或某些容器卷上,WAL 可能无法工作甚至损坏。放进本地 SSD 分区,别图省事放网络盘。

正确的备份方式:别再裸 cp 了

这是 WAL 模式下最容易出事的地方。用 cp xxx.db backup.db 备份一个有 WAL 的库,可能得到一份不一致的快照。三种安全做法,按推荐度排序:

  1. 用 SQLite 自带的在线备份命令(最推荐,热备、原子):
    sqlite3 source.db ".backup '/path/backup.db'"
    或用 Python 的 conn.backup(dest_conn)。它会在事务层面做一致性快照,不需要停服务。
  2. 用 VACUUM INTO(SQLite 3.27+):
    sqlite3 source.db "VACUUM INTO '/path/backup.db';"
    顺带整理碎片,输出的是干净的单文件库。
  3. 停机冷备:停掉应用,执行一次 checkpoint,然后同时 copy .db、-wal、-shm 三个文件。步骤多、易错,只在你没有热备手段时用。

我最推荐第 2 种,因为它一条命令、结果是个自洽的单文件、还顺带做了压缩整理,非常适合塞进 cron 定时备份。

什么时候 SQLite + WAL 就不够用了

诚实地说,WAL 不是银弹。以下情形你应该考虑换 MySQL/PostgreSQL:

  • 写并发极高:比如高频下单、秒杀。WAL 依然只有一个写者,写压力大时全在排队。经验阈值大概是持续写入超过每秒几百次事务就要警惕。
  • 需要多机写入:SQLite 是单机文件库,多台应用服务器同时写同一个文件库(哪怕放共享盘)会出问题。
  • 需要复杂的权限、复制、在线扩容:这些是服务型数据库的领域。

但只要你是单机、读多写少、数据量在几十 GB 以内,SQLite + WAL 的「零运维、单文件、备份简单」优势会压过一切负面印象。很多知名生产系统都在这个模式下跑得很稳。

一份可以直接抄走的排查清单

如果你已经开了 WAL 但还是遇到问题,按这个顺序查:

  1. PRAGMA journal_mode; 确认真的是 wal,而不是又被打回 delete。
  2. PRAGMA busy_timeout; 确认不是 0。为 0 就是没设上,报 database is locked 全是它的锅。
  3. 看目录里 -wal 文件多大。ls -lh *.db-wal,如果几百 MB 没降下去,说明 checkpoint 一直被堵,去找有没有忘记收尾的长事务/未关闭连接。
  4. 确认 .db 在本地盘,不在 NFS/SMB 上。
  5. 确认备份用的是 .backup 或 VACUUM INTO,而不是裸 cp。

把这五条过一遍,95% 的 SQLite 并发问题都能定位。SQLite 从来不是「玩具数据库」,它只是默认配置面向的是「极度保守的正确性」而非「Web 高并发」。理解了 WAL 是什么,你就能按自己的业务把这块保守的默认值调到合适的甜点区。

Last modification:October 10th, 2026 at 01:24 pm

Leave a Comment