很多人第一次接触 SQLite,是从一个只有几张表的小工具开始的:不需要安装数据库服务,只要打开一个文件就能读写。也正因为这种“轻”,SQLite 常被误解成只能做演示、缓存或临时数据。可一旦它被放进博客、桌面软件、边缘设备和内部系统,真正棘手的问题马上出现:程序被强制结束时,刚写入的数据还在吗?服务器突然断电会不会把数据库变成乱码?打开 WAL 后,为什么目录里多了两个文件?备份时只复制主数据库,究竟算不算完整?
这些问题表面上都叫“数据安全”,背后却对应不同机制。理解它们,不只是为了记住几条 PRAGMA,而是为了建立一套从事务、操作系统到存储设备的完整心智模型。只有知道一次 COMMIT 穿过了哪些层,才能根据业务代价选择性能、可靠性与运维复杂度之间的平衡。
一、先把“安全”拆成三个问题
讨论数据库是否可靠时,至少要区分三个概念。
原子性回答的是:一组修改会不会只完成一半。假设一次下单同时写入订单、扣减库存、记录流水,正确结果只能是“全部成功”或“全部撤销”,不能留下半张订单。
持久性回答的是:数据库已经告诉应用“提交成功”之后,修改是否能经受故障。这里还要继续追问故障类型:只是应用进程崩溃,还是整个操作系统死机,抑或机器突然掉电?三者穿透的缓存层级不同,要求也不同。
可恢复性回答的是:即使数据库文件仍然完整,遭遇误删、磁盘损坏、恶意加密或错误升级后,能不能恢复到可接受的时间点。事务日志不能替代备份;一份从未实际恢复过的备份,也只能算“希望”,不能算能力。
因此,“事务返回成功”“文件没有损坏”和“业务数据一定找得回来”是三个不同承诺。可靠系统必须分别设计,不能用其中一个替代另外两个。
二、一次 COMMIT 到底经过了什么
应用执行 SQL 时,数据通常不会直接从代码跳进磁盘介质。一次写入大致会经过 SQLite 的页缓存、操作系统的文件缓存、文件系统以及存储设备自己的缓存。某一层报告“写入完成”,有时只表示数据已经交给下一层,并不等于电子信号已经稳定落在非易失介质上。
这也是为什么数据库需要同步写入操作。SQLite 会在关键阶段请求操作系统把特定内容真正向下刷新,并依赖文件系统提供的顺序保证。如果省略这些同步,提交延迟会显著降低,但机器掉电时,最近写入的页可能消失,甚至出现“新数据页已经落盘、保护它的日志却还没落盘”的危险顺序。
另一方面,同步调用也不是魔法。廉价硬件、异常驱动或配置不当的磁盘控制器可能对刷新请求给出过早确认。数据库能正确使用操作系统提供的能力,却无法替损坏的存储设备兑现承诺。因此真正严肃的可靠性设计还包括健康监控、冗余、备份和恢复演练。
三、回滚日志:先保存旧世界,再修改新世界
SQLite 传统的原子提交机制是 rollback journal,也就是回滚日志。它的核心思想很直观:修改主数据库之前,先把将被覆盖的旧页面复制到日志中。
一个经过简化的事务过程可以理解为:
- 创建回滚日志,并写入即将被修改的原始页面;
- 确保日志达到所需的持久化级别;
- 修改主数据库中的页面;
- 刷新主数据库;
- 删除、截断或标记日志,表示事务完成。
如果程序在中途崩溃,下次打开数据库时,SQLite 会识别尚未正常收尾的“热日志”,用其中的旧页面恢复主文件。只要提交边界的写入顺序得到保证,外界最终看到的就是事务之前或事务之后的状态,而不是随机混合的中间态。
这种模式稳健、容易理解,但写事务既要记录旧页面,又要更新主文件;写入期间,读写之间也更容易互相阻塞。对读多写少的应用,它完全可以工作;而当应用希望读请求不被正常写事务长时间挡住时,WAL 往往更合适。
四、WAL:不急着改主文件,先把新世界追加在后面
WAL 是 Write-Ahead Logging 的缩写。在 WAL 模式下,事务不会立刻把修改覆盖到主数据库,而是把新的页面版本追加写入 数据库名-wal 文件。提交记录进入 WAL 后,这个事务就可以对新的读取者可见。主数据库稍后再通过 checkpoint,也就是检查点过程,吸收 WAL 中已经提交的页面。
这带来一个关键优势:读取者可以继续基于自己的快照读取主文件与 WAL 中相应版本,写入者则顺序追加新内容。正常情况下,读和写不再像回滚日志模式那样频繁互相阻塞。顺序追加对存储也通常更友好。
但 WAL 不是“开启后并发无限”。SQLite 仍然只有一个写入者能在同一时刻推进写事务。如果代码在事务中调用外部接口、处理大文件或等待用户输入,其他写入就只能排队。WAL 提升的是读写协作方式,不会把单机文件数据库变成多主分布式数据库。
此外,-wal 不是可以随手删除的临时垃圾。在尚未完成 checkpoint 时,它包含数据库当前状态的一部分。旁边的 -shm 文件用于协调连接和索引 WAL。数据库正常打开时直接删除这些伴随文件,可能造成数据丢失或连接异常。
WAL 也可能持续变大。最常见的原因不是“写入太快”,而是某个长时间读取事务一直保留旧快照,使 checkpoint 无法回收它仍可能访问的页面。于是,监控 WAL 大小的同时,还要寻找长事务、遗忘关闭的游标和迟迟不结束的请求。
五、synchronous 决定你愿意为哪种故障付费
PRAGMA synchronous 经常被粗暴地解释为“安全开关”,其实它更像故障模型与延迟预算之间的合同。
FULL会在关键提交点执行更严格的同步。在 WAL 模式中,它通常意味着每次提交都额外确保 WAL 达到持久化要求,适合不能接受已确认事务在掉电后消失的场景,代价是更多同步等待。NORMAL保留防止数据库结构损坏所需的重要顺序和检查点同步,但减少每次事务的强制刷新。在 WAL 模式下,它通常能很好地抵抗应用崩溃;机器突然掉电时,最近已返回成功的少量事务仍可能回退。对于内容站点、可重建索引和多数单机应用,这是常见的性能与安全折中。OFF大幅减少保护,适合可随时重建的数据、导入中间产物或明确的性能实验,不应因为“压测数字更漂亮”就直接用于重要生产数据。
选择时不要问“哪个模式最快”,而要问:“如果界面已经显示保存成功,随后机房立即断电,丢掉最后几次修改是否可接受?”如果答案是否定的,就应为更严格的同步成本买单。如果数据可以从源文件、消息队列或上游接口重放,NORMAL 可能更经济。
还要注意,进程崩溃与断电不是一回事。进程退出通常不会清空操作系统缓存,掉电却可能让多层易失缓存同时消失。测试只用 kill 结束进程,无法完整证明断电持久性。
六、一组适合 Node.js 单机应用的起点
以 better-sqlite3 为例,一个读多写少的博客或内部工具可以从下面的配置开始:
import Database from 'better-sqlite3';
const db = new Database('app.sqlite');
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
这里没有所谓“万能最佳值”。如果业务承诺每一次成功写入都必须抵抗突然断电,可以把 synchronous 调整为 FULL,然后用真实磁盘和真实事务大小测量高分位延迟。
busy_timeout 也不会增加数据库的写并行度。它只是让连接遇到短暂锁竞争时先等待,而不是立刻返回 SQLITE_BUSY。真正有效的优化是缩短写事务:先在事务外完成网络请求与复杂计算,再用一个紧凑事务写入已经准备好的数据。
const savePost = db.transaction((post) => {
insertPost.run(post);
insertAuditLog.run({
action: 'create_post',
target: post.slug,
});
});
把需要共同成功的写入包进同一事务,远比在每条语句外层堆叠重试更重要。重试如果没有幂等设计,还可能把一次请求执行两遍。像文章 slug、支付请求号或导入批次号这样的业务唯一键,应由数据库的 UNIQUE 约束守住,而不是只靠应用先查询再插入。约束是最后一道并发防线。
七、备份时为什么不能只复制一个文件
开启 WAL 后,直接复制 app.sqlite 可能得到一个缺少最近事务的旧快照,因为新页面还在 app.sqlite-wal 中。更危险的是,在文件不断变化时分别复制主文件和 WAL,两个副本可能不属于同一个时间点。文件都在,不代表组合一定一致。
优先选择 SQLite 的在线备份能力,让数据库在持续提供服务时生成一致快照。better-sqlite3 提供了对应接口:
const target = `backups/app-${Date.now()}.sqlite`;
await db.backup(target);
另一种适合运维快照的方式是 VACUUM INTO,它会创建一个紧凑的新数据库文件,但需要评估额外磁盘空间、执行时间和版本支持。无论采用哪种方式,快照完成后都应复制到故障域之外;与生产数据库放在同一块磁盘、同一台机器上的“备份”,只能应对少量误操作,无法应对整盘损坏或主机丢失。
一个可执行的最低标准是:
- 定期生成一致快照,并记录开始时间、结束时间、大小和校验值;
- 至少保留一份异机或对象存储副本,并设置合理的历史版本;
- 在隔离环境打开备份,执行
PRAGMA quick_check或更完整的integrity_check; - 真正启动应用,验证关键页面、记录数量和业务约束;
- 记录恢复耗时,确认它小于业务允许的 RTO,并确认可接受的 RPO。
RPO 表示最多能承受丢失多长时间的数据,RTO 表示故障后多久必须恢复服务。没有这两个数字,“每天备份一次”很难判断是否足够:对个人博客也许合适,对高频订单系统可能完全不够。
八、可靠性要靠可观测性,而不是靠相信默认值
SQLite 几乎不需要日常管理,不等于完全不需要监控。至少应关注以下信号:
- 主数据库、WAL 与备份文件的大小,以及所在磁盘的剩余空间;
- 写事务耗时的 P50、P95 和 P99,而不只是平均值;
SQLITE_BUSY、I/O 错误、只读文件系统和磁盘已满错误的次数;- checkpoint 是否持续成功,WAL 是否长期只增不减;
- 最近一次成功备份与最近一次成功恢复演练的时间。
平均延迟尤其容易骗人。大多数提交可能只要几毫秒,但周期性的 checkpoint、磁盘抖动或备份竞争会让少量请求突然变慢,而用户感受到的往往正是尾部延迟。把错误与延迟按时间画出来,通常比盲目修改参数更快找到问题。
定期运行一致性检查也有价值,但不要在生产高峰机械执行最重的检查。更稳妥的做法是在恢复出来的备份副本上完整检查,生产库只安排经过评估的轻量检测。这样既验证备份可用性,又避免为了“检查安全”制造新的性能事故。
九、做一次可控的故障演练
可靠性不能只靠阅读文档证明。可以在测试环境搭建一个小实验:持续写入带校验值和业务唯一键的记录,同时进行读取;随机在事务前、事务中和事务后终止进程;重新启动后运行一致性检查,并验证“每个已完成业务要么完整存在,要么完整不存在”。随后再测试磁盘空间耗尽、备份目录不可写、文件被意外改成只读等场景。
演练时不要用“自增 ID 必须连续”作为正确性标准。事务回滚和失败重试本来就可能留下编号空洞。更可靠的业务不变量包括:每篇文章的 slug 唯一、每个订单的明细金额之和等于总额、每条审计记录都能指向存在的对象。数据库完整性检查只能发现页结构等底层问题,业务不变量需要应用自己验证。
如果要评估真实断电边界,应在专门硬件和可丢弃数据上进行,并明确存储控制器、文件系统与挂载选项。不要在生产机器上拔电,也不要把一次进程强杀实验包装成完整的电源故障证明。
十、什么时候应该离开 SQLite
SQLite 的优势来自“数据库与应用位于同一台机器,事务由一个嵌入式引擎协调”。如果多个独立应用节点需要同时写同一份数据库,试图把文件放到普通网络共享目录,往往会把简单问题变成锁语义、网络分区和缓存一致性问题。此时,使用为网络访问设计的数据库服务通常更稳妥。
当系统需要大量并发写入、跨地域高可用、细粒度账号权限、独立扩缩容或成熟的集中审计时,也应认真评估 PostgreSQL 等服务型数据库。反过来,如果应用是单节点部署、以读取为主、数据规模适中,并且重视简单备份与低运维成本,SQLite 往往不是“将就”,而是更少故障部件的主动选择。
选型的关键不是数据有多少,而是写入来自哪里、故障域有多大、允许丢多少数据、多久必须恢复。一个几百 GB 的只读或单写者应用可能仍适合 SQLite;一个只有几十 MB、却要求多节点同时写入的系统反而不适合。
结语:可靠不是某个参数,而是一条完整证据链
打开 WAL 可以改善读写协作,设置 synchronous 可以定义提交面对断电时的边界,事务和约束可以保护业务一致性,在线备份可以生成可恢复快照,监控与演练则负责证明这些机制确实工作。任何单独一项都不能包办“数据绝不丢失”。
真正可靠的 SQLite 应用会形成一条证据链:知道每次提交承诺什么;知道 WAL 和 checkpoint 正在怎样运行;知道备份位于哪里;知道最近一次恢复花了多久;也知道在业务增长到什么条件时应该迁移。SQLite 的可贵之处不只是把数据库装进一个文件,而是让一套严肃的事务系统以很低的复杂度进入应用。简单并不等于脆弱——未经验证的简单才是。