SQLite 在小型项目里的并发体验
很多人一提起 SQLite,印象还停留在"单机玩具、只能一个连接"。但事实是,只要开了 WAL 模式,它的并发能力在中小规模项目里完全够用——读写不互相阻塞,性能也比想象中好。这篇记一下我这几年踩过的坑和最终的配置套路。
WAL 是一切的起点
默认的 rollback journal 模式下,只要有写操作,整个数据库就加锁,读也只能等着。换成 WAL(Write-Ahead Logging)之后,写操作先写到一个独立的 -wal 文件,读操作走主库,两者并行不冲突。这是 SQLite 能支撑并发的前提。
-- 只需要一句
PRAGMA journal_mode=WAL;
执行一次即可,这个设置会持久化到数据库文件本身(不是连接级别的)。改完之后你会发现同目录下多了 .db-wal 和 .db-shm 两个文件,这是正常的,别手贱删掉。
BUSY 和那讨厌的 "database is locked"
开了 WAL,读和写不冲突了,但写和写之间还是串行的——同一时刻只能有一个写事务。如果两个连接几乎同时想写,后到的那个会拿到 SQLITE_BUSY 错误,在 Go 里就是那句让人头疼的 database is locked。
解决思路有两个,配合用最稳:
- 设置 busy_timeout:让后到的写连接先等一会儿,而不是立刻报错。
- 应用层串行化写入:在 Go 里用一个
sync.Mutex(或 channel)把写操作保护起来。
// 连接字符串里带上 busy_timeout(毫秒)
db, err := sql.Open("sqlite3",
"app.db?_journal_mode=WAL&_busy_timeout=5000")
5000 毫秒是个比较稳妥的值——大多数情况下另一个写事务很快就会结束,等几秒比直接报错强得多。
那个容易被忽略的设置:_txlock
go-sqlite3 驱动默认的 _txlock=immediate 其实挺关键。它让事务在 BEGIN 时就立刻尝试拿写锁,而不是等到第一条写语句才拿(deferred 模式)。区别在于:immediate 模式下,"锁竞争"发生在事务开始时,失败可以立刻重试;deferred 模式下,可能事务跑了一半才发现拿不到锁,回滚成本更高。
如果你的事务里只有读没有写,用 deferred 反而更好(不占写锁)。混合场景我一般直接默认 immediate,简单不易错。
Go 里的连接池配置
这是另一个坑。很多人习惯性地 db.SetMaxOpenConns(0)(无上限),在 MySQL/Postgres 里没问题,但在 SQLite 里是灾难——因为 SQLite 的锁是文件级的,连接越多、冲突越频繁。推荐这样配:
// SQLite 推荐的连接池配置
db.SetMaxOpenConns(1) // 写连接只用 1 个,天然串行
// 读可以多开,但需要用独立的只读连接池
更优雅的做法是开两个 *sql.DB:一个写库(MaxOpenConns=1),一个读库(用 ?mode=ro 只读打开,可以多连接)。这样写串行、读并发,结构清晰。
什么时候还是该换 Postgres
说了这么多 SQLite 的好,也得承认它的边界。这几条里中了任何一条,就该认真考虑换 Postgres 了:
- 并发写入量持续较高(比如每秒几十次以上的写)。
- 需要复杂的事务隔离、行级锁、MVCC 的高级特性。
- 有多台机器需要同时访问同一个库(SQLite 不支持网络访问)。
但对单机部署、读多写少的小工具——比如我这个博客的访问统计、一个团队内部的配置中心——SQLite 配上 WAL,真的是又省心又够用。别因为"它听起来简单"就轻易换上更重的方案。