小项目别急着上 Postgres:SQLite 开 WAL 足够撑很久
默认模式的问题
SQLite 默认用 rollback journal(回滚日志)。写事务进行时,它会给整个数据库文件加排他锁,这期间所有读操作都得排队。单机脚本感觉不到,一旦有个 Web 服务在跑,就会出现「写入时页面卡住几百毫秒」的现象。
WAL(Write-Ahead Logging)把这个问题从根上改了:写操作追加到独立的 -wal 文件,读操作继续读主数据库文件的旧快照,读写互不阻塞。
开启只需要一句:
PRAGMA journal_mode=WAL;
这个设置是持久化的,写进数据库文件头,不用每次连接都设。但其他 pragma 是连接级的,每次连接都要重新执行。
配套的三个 pragma
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA busy_timeout=5000;
PRAGMA foreign_keys=ON;
synchronous=NORMAL 是 WAL 模式下推荐的取值。默认的 FULL 意味着每次提交都要 fsync,写入吞吐受限;NORMAL 在 WAL 下只保证「操作系统崩溃不丢数据」,只有断电这种极端情况可能丢最后几个事务。对绝大多数业务,这个权衡是合理的,吞吐能提升好几倍。
busy_timeout 经常被忘。没有它,遇到锁冲突时 SQLite 会立刻返回 SQLITE_BUSY,而不是等待。设成 5000 表示最多等 5 秒,能把大部分瞬时冲突吃掉。
foreign_keys 默认是关闭的,这一点很多人不知道——外键约束写了但根本没生效。
并发上限到底在哪
这是最需要说清楚的一点。WAL 带来的是「多读一写」:
· 读操作可以无限并发(各自读自己的快照);
· 写操作任意时刻只能有一个,第二个写事务会拿到 SQLITE_BUSY,靠 busy_timeout 排队等待。
所以真正的问题不是「能扛多少 QPS」,而是「单个写事务要多久」。如果你的写事务是 2ms 的 INSERT,理论上限约 500 写/秒;如果某个事务写了 5000 行花了 300ms,这 300ms 里所有写请求都在排队。
实践中的经验值:
· 读多写少(博客、CMS、配置中心、爬虫入库)→ SQLite 完全够用,日 PV 十万级毫无压力;
· 写多且事务短(计数器、日志、队列)→ 也能用,但要把批量写合并成事务;
· 有长时间写事务(大批量导入、复杂聚合更新)→ 会明显阻塞其他写,这时候该考虑换库了。
必须换数据库的几个信号
不要凭感觉换,看这几个硬指标:
多机部署。SQLite 是单机文件,多个进程只能在同一台机器的同一文件系统上共享。一旦要上两台机器做负载均衡,就必须换。
长时间写事务超过 100ms。这不是 SQLite 的问题,是它不适合这种负载模式。
需要细粒度权限控制。SQLite 只有文件权限,没有用户和角色。
数据量超过几百 GB 且需要频繁范围扫描。SQLite 的 B-tree 单机性能很好,但没有并行查询,大表分析会慢。
除了这四条,其余大多数「我们需要 Postgres」的判断,其实是没调 pragma。
一个容易被忽略的运维点
WAL 模式下会多出 -wal 和 -shm 两个文件。备份时如果只拷 .db,会丢掉还没 checkpoint 的最新数据,备份等于残缺。
正确做法是用 SQLite 自带的在线备份命令:
sqlite3 app.db ".backup 'backup.db'"
或者 SQL 里执行 VACUUM INTO 'backup.db'(3.27+),它会把当前数据整理成一份干净的新库,不需要停服务。
另外注意 -wal 文件在某些情况下会持续增长(长事务持有读快照时无法 checkpoint)。如果看到 -wal 涨到几百 MB 不下,八成是有个连接开了读事务一直没提交。
用 Node 时的具体建议
better-sqlite3 是同步 API,看起来反直觉,但在 SQLite 这个场景反而是优势:同步调用不会被事件循环打断,事务边界更清晰,性能也比异步封装好。前提是每个查询都足够快——SQLite 的本地读通常是微秒级,不构成阻塞。
node:sqlite(Node 22+ 内置)也可以,API 更简单,但目前功能还不如 better-sqlite3 完整。
事务一定要显式包:
const insertMany = db.transaction((rows) => {
const stmt = db.prepare('INSERT INTO items(name, ts) VALUES (?, ?)');
for (const r of rows) stmt.run(r.name, r.ts);
});
insertMany(rows); // 1000 行一次提交,比逐条快两个数量级
逐条 INSERT 不包事务,每行都要 fsync 一次,1000 行能跑出 3 秒。包上事务就是 20 毫秒。这个差距不是 SQLite 慢,是用法不对。
结论
SQLite 不是「玩具数据库」,它是被严重低估的生产级选择。开 WAL、设 busy_timeout、合批写、用 .backup 做备份,这四件事做完,它能覆盖掉相当大一部分小型项目的需求——而且零运维成本,这在个人项目和边缘部署里是巨大优势。
真到了要换的时候,你也已经知道该换的原因了,而不是因为「听说 SQLite 不能用于生产」。
- 点赞
- 收藏
- 关注作者
评论(0)