SQLite 在个人项目里的取舍
单文件、零运维,是 SQLite 最吸引人的地方。但它并不是“小型 MySQL”,并发写入、备份方式和迁移策略都需要提前想清楚。
个人项目的数据量通常不大,但部署环境往往很朴素:一台小服务器,一个 Node.js 进程,没有专门的数据库运维。 这种场景下 SQLite 很合适。不过用了一段时间后,我发现真正需要注意的不是 SQL 语法,而是几项边界条件。
一、先判断写入模式
SQLite 的写操作是串行的。读可以并发,但同一时刻只有一个写事务。对于“读多写少”的个人项目,这通常不是问题; 如果每个请求都要写、而且并发不低,就要谨慎。
- 单进程、低频写入:非常适合。
- 多进程同时写同一个文件:容易遇到
database is locked。 - 需要多台机器水平扩展:不适合,应该换客户端/服务端数据库。
如果确实有多进程读取需求,可以开启 WAL 模式,让读写互不阻塞。但写入仍然只有一个,不能把它当成并发写数据库。
二、几个值得一开始就设置的参数
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
WAL 模式对读写混合更友好;busy_timeout 让进程在锁冲突时等待而不是立刻报错;
外键约束默认是关闭的,需要每个连接显式打开。最后这一条很容易被忽略,导致约束看起来写了却没生效。
synchronous = NORMAL 是在性能和安全性之间的常见折中。如果数据非常关键,可以使用
FULL,代价是写入更慢。
三、事务要包住整组写入
批量插入或“先查再写”的逻辑,应该放进同一个事务。否则中间失败会留下不完整的数据, 并发时也可能出现检查通过但写入冲突的情况。
const insertMany = db.transaction((rows) => {
const stmt = db.prepare(
'INSERT INTO note (title, body, created_at) VALUES (?, ?, ?)'
);
for (const row of rows) {
stmt.run(row.title, row.body, Date.now());
}
});
insertMany(rows);
better-sqlite3 的 transaction() 会把函数包进事务,异常时自动回滚。
同步 API 在这里反而让代码更直观:不用处理回调,也不容易漏掉 await。
四、迁移要可重复执行
我习惯用一个简单的迁移表记录已经执行过的版本,启动时按顺序补齐。每个迁移只做一件事,并且尽量幂等。
CREATE TABLE IF NOT EXISTS schema_migration (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
-- 迁移 1
ALTER TABLE note ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0;
加字段通常比改字段安全。需要重命名或删除时,先确认旧版本代码是否还会读取; 如果服务可能回滚,最好分两次发布,先兼容新旧两种结构。
五、备份不要直接复制正在写入的文件
在 WAL 模式下,数据可能同时存在于主文件和 -wal 文件里。直接 cp 一个正在写入的
数据库,得到的副本可能不一致。更稳妥的方式是使用 SQLite 自带的备份命令。
sqlite3 app.db ".backup '/backup/app-20261105.db'"
# 或者生成一个整理过的副本
sqlite3 app.db "VACUUM INTO '/backup/app-20261105.db'"
备份完成后要验证:打开副本,执行一次 PRAGMA integrity_check,再查一下关键表的行数。
只看到文件存在并不代表备份可用。
六、什么时候该换掉它
出现下面这些信号时,我会考虑迁移到服务端数据库:写入并发明显上升、需要多个应用实例共享数据、 需要细粒度的权限控制,或者备份窗口已经影响到正常服务。提前识别比事后补救容易得多。
SQLite 的优势是简单,不是“什么都能扛”。把边界想清楚,它就是一个非常省心的选择。