数据存储 约 8 分钟阅读

SQLite 在个人项目里的取舍

单文件、零运维,是 SQLite 最吸引人的地方。但它并不是“小型 MySQL”,并发写入、备份方式和迁移策略都需要提前想清楚。

个人项目的数据量通常不大,但部署环境往往很朴素:一台小服务器,一个 Node.js 进程,没有专门的数据库运维。 这种场景下 SQLite 很合适。不过用了一段时间后,我发现真正需要注意的不是 SQL 语法,而是几项边界条件。

一、先判断写入模式

SQLite 的写操作是串行的。读可以并发,但同一时刻只有一个写事务。对于“读多写少”的个人项目,这通常不是问题; 如果每个请求都要写、而且并发不低,就要谨慎。

如果确实有多进程读取需求,可以开启 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 的优势是简单,不是“什么都能扛”。把边界想清楚,它就是一个非常省心的选择。
← 返回文章列表 个人软件技术分享