
它比你想的能扛,也比你想的更容易被用错
很多人对 SQLite 的印象还停留在「手机 App 里的本地缓存」。实际上,它每天在数以十亿计的设备上承担着真实业务:大部分网站的会话数据、大量桌面软件的项目文件、不少中小型 SaaS 的主库,底层都是它。单个文件、零运维、事务完整,这些特性在小规模场景下比任何服务型数据库都省事。
但「能当生产库」和「直接当生产库用」是两件事。默认配置下的 SQLite 有明确的并发上限,而它的备份方式和 MySQL 完全不同——直接 cp 一个正在写入的库文件,可能得到一个损坏的副本。本文给出一份可以直接落地的配置,并列出四个真实踩过的坑。
1. 先判断你的负载在不在射程内
SQLite 的并发模型很简单:任意时刻只允许一个写者,读者可以有多个。这不是配置问题,是文件锁的物理限制,改不了。所以判断标准不是「访问量大不大」,而是「写操作密不密」。
- 适合:读多写少,写入可以串行化,单机部署,日写入量在几十万行以内
- 适合:边缘节点、内部工具、单租户应用、需要随应用一起打包分发的场景
- 不适合:持续高频写入(如每秒数百次 INSERT),多台机器同时写同一个库
- 不适合:库文件放在 NFS、SMB 等网络文件系统上——WAL 依赖共享内存,跨网络会直接失效
一个粗略的经验值:写事务每秒个位数到几十次,SQLite 完全无压力;一旦写请求开始排队,P99 延迟会以肉眼可见的方式塌陷,因为每个写者都在等同一把锁。
2. 四个 PRAGMA,决定它稳不稳
SQLite 的默认值偏向「兼容与安全」,不是偏向「生产吞吐」。下面四条是必设项,前三条建议写进连接初始化,第四条按库设置一次即可。
PRAGMA journal_mode = WAL; -- 持久化设置,写一次即可
PRAGMA synchronous = NORMAL; -- WAL 下的推荐值
PRAGMA busy_timeout = 5000; -- 连接级,单位毫秒
PRAGMA foreign_keys = ON; -- 连接级,默认关闭,必须手动开
journal_mode=WAL 是最关键的一条。回滚日志模式下,写操作会阻塞读;WAL 模式下读写互不阻塞,只有写与写之间互斥。这一条通常能带来数量级的并发改善。
synchronous=NORMAL 在 WAL 模式下是安全的:断电最多丢失最近若干个已提交事务,但不会损坏数据库。如果设成 FULL,每次提交都要 fsync,写入吞吐会掉一个档次;设成 OFF 则是拿完整性换速度,不建议。
busy_timeout 解决的是「锁等待直接报错」的问题。默认值是 0,意味着一旦遇到锁就立刻返回 SQLITE_BUSY。设成 5000 后,连接会自己等最多五秒,绝大多数瞬时竞争都能被消化掉。这一条不设,线上会频繁出现莫名其妙的写失败。
3. 写事务要短,批量写要包起来
WAL 解决了读写互斥,但写者之间仍然串行。所以真正的优化方向只有一个:让每次持锁的时间尽可能短。两条原则。
第一,不要在事务里做网络请求或文件 IO。一个事务里夹了一次 HTTP 调用,锁就被占了几百毫秒,后面所有写者一起排队。正确做法是先在外面把数据准备好,再开事务写入。
第二,批量插入必须显式包在一个事务里。SQLite 默认每条语句自动提交,一千条 INSERT 就是一千次 fsync。包进一个事务后,通常能快两个数量级。
BEGIN IMMEDIATE;
-- 一批 INSERT / UPDATE
COMMIT;
注意用 BEGIN IMMEDIATE 而不是裸 BEGIN。裸 BEGIN 是延迟事务,先拿读锁、写的时候再升级,升级失败会直接返回 SQLITE_BUSY 且 busy_timeout 对它无效;IMMEDIATE 一开始就拿写锁,把等待交给 busy_timeout 处理,行为可预期得多。
4. 备份:不要 cp,用 backup 或 VACUUM INTO
这是最容易出事的一环。数据库正在写入时,磁盘上的 .db 文件处于中间状态,直接复制可能拿到一个页级不一致的文件,而且往往要等到恢复那天才发现。
正确做法有三种,按场景选。
| 方式 | 命令 | 适用场景 | 注意 |
|---|---|---|---|
| 在线备份 | sqlite3 app.db “.backup ‘/backup/app.db'” | 库在线、需要一致性快照 | 内部用 backup API,可安全并发 |
| 导出压缩库 | sqlite3 app.db “VACUUM INTO ‘/backup/app.db'” | 想要一个紧凑的副本 | 目标文件必须不存在 |
| 停写后复制 | 先 checkpoint,再 cp | 能接受短暂停机 | 必须同时处理 -wal 与 -shm |
日常最省心的是第一种。它不需要停写,也不需要停读,备份过程中数据库照常提供服务,代价只是一点额外的 IO。
如果确实要走「停写后复制」这条路,顺序必须是:先执行 PRAGMA wal_checkpoint(TRUNCATE) 把 WAL 内容合并回主库,再复制 .db 文件,同时确认目录里没有残留的 -wal 文件。只复制 .db 而漏掉 -wal,等于丢掉了最近的写入。
5. WAL 文件会一直长,除非你管它
WAL 模式下会多出两个文件:app.db-wal 和 app.db-shm。前者是预写日志,后者是共享内存索引。正常情况下,WAL 会在达到阈值时自动 checkpoint,把内容写回主库并复用空间。
问题出在长连接上。只要还有一个读事务没结束,checkpoint 就无法推进,WAL 会一直增长。一个忘了提交的读事务,能让 WAL 涨到几个 GB。所以两件事要做:
- 设置 PRAGMA wal_autocheckpoint = 1000(页数,约 4MB),保持默认值通常够用
- 监控 -wal 文件大小,超过阈值就告警,这往往意味着有连接泄漏
另外,进程退出时如果还有连接没关闭,-shm 文件可能残留。它不是数据,删掉无害,但残留往往说明你的连接池没有正确回收,值得顺手查一下。
6. 四个踩过的坑
坑一:多进程写入没设 busy_timeout。表现是高峰期随机报 database is locked,日志里毫无规律。根因就是默认 timeout 为 0,锁一撞就失败。加上 5000 毫秒后彻底消失。
坑二:外键约束默认是关的。很多人以为写了 FOREIGN KEY 就生效,实际必须每个连接执行 PRAGMA foreign_keys=ON。漏了这一条,脏数据会在几个月后才暴露。
坑三:把库放在容器挂载的网络盘上。开发环境用 Docker volume 没问题,一旦换成 NFS 挂载,WAL 立刻报 disk I/O error。原因是共享内存映射跨主机不可用。SQLite 要求本地文件系统,这条没有折中方案。
坑四:用 ORM 的默认连接池。不少 ORM 默认开十几个连接,每个连接各自持锁排队,反而比单连接更慢。SQLite 场景下,写连接一个就够,读连接按 CPU 核数给,多余的连接只会增加锁竞争。
写在最后
SQLite 的价值不在性能数字,而在它把「数据库」这件事的运维成本压到了接近零:没有端口、没有账号、没有主从、没有版本升级窗口,备份就是一个文件。对一个只有几个人的项目来说,这份简单本身就是可靠性。
但它不是无条件的。写并发是硬上限,网络文件系统是硬禁区,备份方式必须换。把这三件事想清楚,剩下的配置照着上面抄就行。真正的风险从来不是选错数据库,而是用默认配置把它当成 MySQL 使。
你现在手上的项目,写操作大概是什么量级?也许它一直不需要那台云数据库。

评论 (0)