状态仅 3.2 万条,整存落盘就比增量写慢 88 倍:离线优先「整存」的实测复盘
2026/8/29 4:59:54 网站建设 项目流程

你只改了一个字段,点下「保存」,界面却卡了一下——而本地根本没有网络请求。离线优先教程里最常见的那句建议是「把状态序列化后写进本地存储」,于是很多人就真的在每次变更时把整份状态JSON.stringify后整体重写。状态一大,这个「简单可靠」的习惯就开始反噬:保存耗时随状态规模线性增长,写入放大更是离谱。本文用一次可复现的微基准,把「整存快照」和「增量 append / WAL(写前日志)」两种落盘策略摆到同一台秤上称一遍。

背景:离线优先把「本地落盘」推到了主路径上

2026 年本地优先(local-first)已经从极客架构变成很多产品的基础要求。多方资料都提到,离线优先 / PWA 类应用的留存显著更高,而前提是「数据主副本在设备本地,云只是备份与同步的通道」。客户端存储栈也从localStorage(同步、阻塞主线程、5MB 上限)演进到 IndexedDB、OPFS,以及通过 WASM 跑进浏览器的 SQLite。

但存储引擎只是下半身,上半身是「怎么写」。大量离线优先入门文只说「把你的状态保存到本地」,开发者顺手实现的往往是:每次变更都把整份状态序列化、整体覆盖写盘。2026 年几篇本地优先架构文章其实已经点名了这个坑——有文章明确建议「把每个用户动作作为不可变事件(写前日志)持久化,而不是只存最终状态」,也有文章把「同步变更日志而非当前状态」列为事件溯源的核心。问题来了:整存和增量,到底差多少?

解剖:两种落盘策略差在哪

把「保存一次」拆开看,两种策略的 CPU 与 I/O 形状完全不同:

// 策略 A:整存快照——每次保存都序列化整份状态后覆盖写盘 function saveSnapshot(state) { fs.writeFileSync(path, JSON.stringify(state)); // O(状态规模) } // 策略 B:增量 append / WAL——每次只追加一条本次操作 function saveDelta(op) { fs.appendFileSync(logPath, JSON.stringify(op) + "\n"); // O(1) }

策略 A 的成本随当前状态规模K线性增长:你改了一个字段,也得把K条记录全部重新序列化、全部重新写一遍。策略 B 的成本与你改了什么无关,永远只写那一条操作。

这里有个容易误解的点:阻塞主线程的不是磁盘 I/O(小文件下几乎可忽略),而是JSON.stringify的 CPU 成本。即便你改用异步 API(IndexedDB、async写盘),序列化这道 CPU 工序依然存在,只是被挪到了 Worker / 微任务里——它不会凭空消失,只是不再卡你这一帧。所以下面的「每次保存耗时」测的就是这道序列化 + 写的真实墙钟,对同步 / 异步两种写法都成立。

实证:一次 Node v22 微基准

模型:状态是长度为K的记录数组,每条约 100 字节;一次「保存」只改其中 1 个字段。M = 500次保存,取每次保存的中位墙钟与累计写入字节。策略 A 每次整体writeFileSync(JSON.stringify(state));策略 B 每次appendFileSync一行操作。环境:Node v22.22.2,纯内置、零依赖。

图1:横轴为记录数(对数刻度),纵轴为每次保存的中位墙钟(ms)。整存曲线随规模线性爬升,增量曲线几乎贴着 0 轴。

结果(节选):

记录数 K整存中位 (ms)增量中位 (ms)倍数500 次累计写入
5000.310.112.7×32 MB
2,0000.760.126.2×106 MB
8,0002.550.1516.9×406 MB
16,0005.140.1244.4×815 MB
32,0009.990.1188.5×1.64 GB

整存每次保存耗时与状态规模严格线性相关(拟合≈0.15 + 0.000312·Kms,R²≈1.00):3.2 万条时已到 9.99ms,约为增量的 88 倍。按这条实测直线外推,约 5.3 万条击穿 16.6ms 单帧预算,约 16 万条越过 50ms 感知阈值——而增量全程 <0.17ms,没有穿越点。

实证续:被忽略的写放大

单次耗时之外,更隐蔽的是写入放大。整存每次都把整份文件重写一遍,500 次保存的累计写入就是500 × 单份大小;增量每次只追加一行。到 3.2 万条时:

图2:整存 500 次累计重写 1.64 GB,增量仅 47 KB,相差约 3.4 万倍(对数刻度)。

整存累计重写1.64 GB,增量仅47 KB,相差约3.4 万倍。这笔账记在 SSD 磨损、笔记本电池、以及「每次保存都在烧 I/O」上。

需要诚实补一句:这是会话内的写入量,不是稳态磁盘占用。增量方案要能复原,必须保留一份「基准快照」(和整存那份一样大),再加上一小段日志——所以稳态占用两者大致持平。真正省下来的是「每次保存的代价」和「会话累计 I/O」,而不是你的硬盘总量。

局限:增量写不是银弹,也有自己的另一面

增量 / WAL 把「保存」变便宜了,但把成本挪到了加载与回放:打开应用时你得把日志里的每条操作重放一遍,回放成本随编辑次数M线性增长,而非随状态规模K。本次基准里 500 条操作的回放仅 0.37ms,看起来无感;但一个长期使用的本地应用,M会远大于K——日志越滚越长,冷启动回放就会反超。

图3:整存保存成本随状态规模 K 增长,增量保存恒定但回放随编辑数 M 增长;周期压实让两者都可控。

所以纯增量不可取,正解是周期压实(compaction):保留一份基准快照,平时只追加增量,每隔若干次保存或体积阈值就把「当前状态」重新落为新的基准并截断旧日志。这正是前面提到的写前日志 / 事件溯源工程实践。另外,本基准用的是约 100 字节的小记录;真实笔记的正文、附件元信息更大,穿越点会来得更早,线性斜率更陡。

结论与下一步

一句话方法论:非极小状态下,离线优先应用应该「存增量而非整存」——基准快照 + 追加日志 + 周期压实,而不是每次变更都整体序列化重写。状态只有几百条时,整存简单够用;一旦状态开始长大,把「整存」换成「基准 + 增量 + 周期压实」,就能让保存耗时恒定在亚毫秒级,并砍掉几个数量级的无效写入。

开源地址

  • 矩阵门户:https://github.com/wangzifan396-wzf/WB
  • 单文件工具聚合器:https://github.com/wangzifan396-wzf/nano-workbench
  • GitHub 组织主页:https://github.com/wangzifan396-wzf

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询