NeDB凭什么扛住崩溃?崩溃安全写入源码剖析:临时文件+fsync+原子rename
【免费下载链接】nedbThe JavaScript Database, for Node.js, nw.js, electron and the browser项目地址: https://gitcode.com/gh_mirrors/ne/nedb
NeDB 是一款纯 JavaScript 写的嵌入式数据库,用于 Node.js、Electron 和浏览器场景,没有任何二进制依赖。它的崩溃安全写入机制——临时文件 + fsync + 原子 rename——保证即使断电或进程被强杀,数据一条不丢、不会出现半截损坏的文件,整套方案藏在不到 150 行代码里,非常值得精读。
一、为什么普通写文件会在崩溃时丢数据?
调用 Node.js 的fs.writeFile时,数据其实先进操作系统页缓存,由内核决定何时真正落盘。如果此时断电或进程被杀:
- 页缓存丢失 → 文件内容残缺
- 更糟的是:直接覆盖原文件写了一半 → 旧版本也被毁掉
所以崩溃安全写入必须回答三个问题:
- 写新版本时,如何保证原文件不被弄坏?→临时文件
- 如何保证数据真的在盘上,而不是只躺在缓存?→fsync
- 如何一次性干净地"切换"新旧版本?→原子 rename
NeDB 在 lib/storage.js 里把这三个问题全部回答了。
二、先看全景:NeDB 的持久化模型
日常增删改走的是append-only模型:数据文件从中间不动,新内容一律追加到文件末尾。lib/persistence.js 的persistNewState里,更新和删除也是以"新状态"的形式追加一行记录,追加是单次写入,快且原子性天然。
只有整文件重写(压缩 / compaction)时才走崩溃安全写入通道,触发时机有三种:
- 每次
loadDatabase时,由 lib/persistence.js 的persistCachedDatabase全量重写 - 手动调用
persistence.compactDatafile() - 通过
setAutocompactionInterval设置自动压缩间隔(源码强制最小 5 秒)
💡 为什么这么设计?因为压缩是低频操作,为它付出"先写临时文件 + 两次 fsync"的代价是划算的;高频操作走追加,性能拉满。
三、核心剖析:crashSafeWriteFile 的 6 步流水线
lib/storage.js 的crashSafeWriteFile是崩溃安全的灵魂,用async.waterfall串联 6 步:
| 步骤 | 动作 | 目的 |
|---|---|---|
| 1 | fsync 数据文件所在目录 | 保证临时文件的目录项真正落盘 |
| 2 | 若原文件存在,先 fsync 原文件 | 让盘上的旧版本"做实"再动手 |
| 3 | writeFile写入filename~临时文件 | 新内容完整写一份,不碰原文件 |
| 4 | fsync 临时文件 | 把新内容刷到物理磁盘 |
| 5 | rename(临时文件, 原文件名) | 原子替换,一步到位 |
| 6 | 再次 fsync 目录 | 让 rename 后的目录项持久化 |
3.1 第一块拼图:~临时文件
临时文件名固定为filename + '~'(见 lib/storage.js)。由于从不直接覆写原文件,磁盘上任何时刻只可能是两种状态之一:
- 原文件 =完整的旧版本
- 临时文件 = 可能不完整的新版本
NeDB 甚至在构造时就禁止用户数据库文件名以~结尾,防止与崩溃备份文件冲突,见 lib/persistence.js 的显式报错。
3.2 第二块拼图:fsync 把页缓存真正刷到盘
lib/storage.js 的flushToStorage先fs.open拿到文件句柄,再调fs.fsync(fd)——这是数据离开"可能丢失区"的唯一时刻。两个细节值得注意:
- 目录也要 fsync:Linux 上目录项(文件名到 inode 的映射)同样被缓存,不刷新的话,重启后可能"找回"的是旧文件名
- Windows 豁免:Windows 无法对目录 fsync,lib/storage.js 对 win32/win64 直接跳过目录刷新,源码注释坦承这是权衡——除了首次加载时恰好崩溃这种极小概率场景,不会造成 100% 数据丢失
3.3 第三块拼图:原子 rename
POSIX 系统上rename是原子操作:读者眼里原文件要么是完整旧版、要么是完整新版,绝不存在"半新半旧"。NeDB 的storage.rename就是 lib/storage.js 里对fs.rename的直通封装。
✅ 三步合起来就是:写到一边 → 刷盘做实 → 原子切换 → 刷新目录项。断电切在任何一步,你拿到的要么是可用的旧版,要么是完整的新版。
四、崩溃之后:ensureDatafileIntegrity 自动善后
如果进程恰好死在"临时文件写了一半",下次启动时磁盘上会残留:完好的原文件 + 半个临时文件。lib/storage.js 的ensureDatafileIntegrity负责三分法善后:
- 原文件存在→ 写入成功过,直接放行
- 原文件与临时文件都不存在→ 全新数据库,创建空数据文件
- 原文件丢失但临时文件在→ 说明这次写入"差临门一脚",把临时文件 rename 回正式文件名,救回数据
⚠️ 注意:这一步被放在loadDatabase流水线的最前端执行(见 lib/persistence.js),意味着用户永远看不到残留的~文件——数据库自己会打扫战场。
五、真实验证:一个故意杀死进程的测试
作者没有停留在纸上谈兵。test/persistence.test.js 里有一个端到端的"崩溃演习":
- 写入一个约 150KB、500 条记录的数据文件
- 在子进程中加载数据库,而 test_lac/loadAndCrash.test.js 提前猴补丁改写了
fs.writeFile:临时文件刚写满 5000 字节就process.exit(1),模拟硬崩溃 - 崩溃后断言:原文件长度分毫未动,临时文件恰好停在 5000 字节
- 重新加载数据库:500 条记录一条不少,临时文件被自动清理,文件系统恢复干净
这套测试证明了一句话:整文件重写过程中的任何时刻崩溃,旧数据零丢失。
六、几个值得记住的工程细节
- 追加写并不每次 fsync(为性能),最坏情况只是最后一行被截断。而 lib/persistence.js 的
treatRawData重放时会跳过损坏行,初始计数corruptItems = -1正是为"文件末尾正常空行"留的容错(lib/persistence.js)——追加式设计与容错读取互为兜底 - 损坏率熔断:损坏行占比超过
corruptAlertThreshold(默认 10%)时,NeDB 拒绝启动(lib/persistence.js),防止用错反序列化钩子导致静默吞数据 - 序列化钩子必须成对:只配
afterSerialization或beforeDeserialization其中一个,NeDB 直接拒绝启动(lib/persistence.js),还会用随机字符串验证两个钩子互为逆操作 - 纯内存模式全跳过:
inMemoryOnly会在每个持久化方法开头直接短路返回
七、一句话总结
| 崩溃风险点 | NeDB 的解法 | 源码位置 |
|---|---|---|
| 原文件被写坏 | 新内容先完整写入~临时文件 | lib/storage.js |
| 数据停留在页缓存 | 文件 + 目录双 fsync | lib/storage.js |
| 新旧版本切换瞬间崩溃 | POSIX 原子 rename | lib/storage.js |
| 崩溃后残留临时文件 | ensureDatafileIntegrity三分法善后 | lib/storage.js |
| 方案是否真有效 | 写 5000 字节即杀进程的端到端测试 | test/persistence.test.js |
这套设计的精妙之处,在于它没有发明任何新东西——临时文件、fsync、rename 全是操作系统最基础的积木,拼图的顺序和完整性才是关键。对于任何想写文件型存储的工程师,lib/storage.js 里这条 6 步流水线都可以直接当作参考实现来抄作业。
【免费下载链接】nedbThe JavaScript Database, for Node.js, nw.js, electron and the browser项目地址: https://gitcode.com/gh_mirrors/ne/nedb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考