状态仅 3.2 万条,整存落盘就比增量写慢 88 倍:离线优先「整存」的实测复盘 📅 发布时间:2026/8/29 5:00:20 👁 浏览次数: 你只改了一个字段点下「保存」界面却卡了一下——而本地根本没有网络请求。离线优先教程里最常见的那句建议是「把状态序列化后写进本地存储」于是很多人就真的在每次变更时把整份状态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 成本。即便你改用异步 APIIndexedDB、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 MB2,0000.760.126.2×106 MB8,0002.550.1516.9×406 MB16,0005.140.1244.4×815 MB32,0009.990.1188.5×1.64 GB整存每次保存耗时与状态规模严格线性相关拟合≈0.15 0.000312·KmsR²≈1.003.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-workbenchGitHub 组织主页https://github.com/wangzifan396-wzf