浏览器存储如何悄悄破坏用户数据以及如何防止这种情况
浏览器存储可能会在无错误提示的情况下损坏用户数据:更新丢失、快照过时、架构变化以及设备共享问题。能够保持稳定的localStorage使用模式。
来源: The Browser Storage Mistake That Quietly Corrupts User Data
浏览器存储的使用体验异常简单。使用 JSON.stringify 对对象进行序列化,再用 JSON.parse 读取,这样用户的偏好设置、半填的表单、筛选条件或草稿就能在下次刷新后依然存在。无需架构设计,无需网络请求,也无需后端迁移。短短几行JavaScript代码就能让应用在几分钟内具备持久化特性。
这种便捷性容易让人产生一个危险的假设:认为存储在浏览器中的任何数据都可以当作小型本地数据库来使用。
问题通常始于一个庞大的状态对象。应用会从 localStorage 中加载整个对象,在内存中修改某个属性后再将整个对象写回。每一步看起来都很合理——JSON格式有效,写入操作成功,屏幕也显示了预期的内容,似乎没有任何问题。
一旦应用程序的另一个部分持有该对象的过时副本,问题就会出现。可能是另一个标签页更改了某个设置,也可能是后台任务正在编辑草稿,又或者应用程序的旧版本以另一种方式解读该对象。也有可能是在用户已经修改了某些内容之后,某个组件又重新加载了旧数据。由于每个写入器都会保存完整的快照,因此只需修改一个字段,就有可能让其他所有字段中的旧值重新出现。
无需出现语法错误,也不会抛出存储异常。每个写入器都可以持有完全有效的数据,却仍可能抹除另一个写入器所做的有效更新。
存储 API 本身并无过错。问题在于人们期望一个简单的字符串存储系统——即最后写入的内容会覆盖之前的内容——能够像理解记录、字段、事务、版本、所有权以及并发访问那样工作。但实际上它根本不了解这些概念,只是简单地记住上次分配给某个键的字符串内容。
无辜的读-改-写操作
典型的实现方式如下:
function updatePreferences(changes) {
const saved = localStorage.getItem("user-settings");
const settings = saved ? JSON.parse(saved) : {};
const nextSettings = {
...settings,
...changes,
};
localStorage.setItem(
"user-settings",
JSON.stringify(nextSettings)
);
}
只要有一个标签页且应用仅有一个当前版本,它就可以运行多年。每次更新都会读取最新值,合并更改后再写入结果。这种方式简短易读,通常足以满足基本偏好设置的需求。
风险出现在读取与写入之间存在时间间隔,或应用在内存中保留该对象的副本时。
假设有两个标签页加载了相同的设置:
{
"theme": "light",
"fontSize": 16,
"sidebarCollapsed": false
}
第一个标签页会将主题切换为深色。第二个标签页会将字体大小设置为十八号。如果每个标签页都编辑自己内存中的副本并保存整个对象,那么最后保存的那个就会覆盖另一个的更改。
第一个标签页可能会存储如下内容:
{
"theme": "dark",
"fontSize": 16,
"sidebarCollapsed": false
}
稍后,仍保留着旧快照的第二个标签页会存储以下内容:
{
"theme": "light",
"fontSize": 18,
"sidebarCollapsed": false
}
字体大小的变化依然存在,而主题则悄悄恢复为浅色模式。
这就是更新丢失导致的故障。最终生成的对象并未出现格式错误,每个字段也都包含有效值。问题在于某次有效的更新消失了,且没有任何冲突发生的迹象。
这类漏洞在开发阶段很难被发现,因为开发者通常一次只测试一个标签页、一台设备以及一个操作。而真实用户则会让应用长时间运行,复制标签页、恢复会话、切换窗口,从而访问那些内存中的状态比当前存储的状态更旧的内容。
存储API无法区分某个标签页仅改变了主题、另一个仅改变了字体大小。它看到的只是两个完整的字符串,因此以最后一个字符串为准。
快照并非当前状态
更严重的错误是认为从存储中加载的对象会一直与存储保持连接。事实并非如此。JSON.parse仅会在某一时刻创建一个表示存储中字符串的内存值,之后它就只是快照而已。此时另一个标签页可能会更新该键值,某个组件也可能会替换它,或是更新的版本可能会迁移该数据,而内存中的副本对此一无所知。
状态管理会让情况更加糟糕。应用在启动时可能会从localStorage加载状态数据,之后每次数据变更后都会保存整个状态。多个组件会在这个已加载的快照上操作,而与此同时其他地方却在修改同一个键值。
延迟保存扩大了数据不一致的可能。许多应用会采用防抖机制来控制持久化操作,因此不会在每次按键时都执行localStorage.setItem。用户修改某个字段后,保存操作会在几百毫秒后触发,而闭包会保存该时刻的状态对象。在保存操作执行之前,另一个标签页或组件可能已经存储了更新后的数据。当回调最终执行时,它会把之前保存的旧数据覆盖在新的数据之上。
防抖机制虽然提升了性能,却影响了数据一致性,因为没人规定该如何处理那些过时的数据快照。
异步初始化也会产生同样的现象。组件会使用默认值进行渲染,用户可以立即进行交互,而存储的状态则通过框架的效应或抽象机制稍后才会出现。随后,数据同步步骤会将当前状态替换为之前保存的快照。在用户看来,他们的更改似乎在刚做出不久后就又被撤销了。
这类错误看起来似乎毫无规律,因为涉及的每个值都是有效的。真正的问题在于时间顺序的优先级:是应该以较早加载的值为准,还是以用户稍后的操作为准,又或是以其他上下文写入的内容为准?如果没有任何规则规定,那就由最后执行的赋值操作来决定最终结果。
无事务的持久化
localStorage通常被描述为同步的键值存储方式。这没错,但它所带来的信任感可能超出了该API的实际能力。
一次 setItem 调用会在当前上下文中同步替换一个值。这种原子性仅适用于单次调用。若读取、修改和写入操作分多步进行,则在不同标签页或工作进程之间并不构成事务。不存在比较并替换、行锁定、版本冲突、字段级合并或回滚机制。
从概念上讲,操作顺序依然是:
const current = read();
const next = modify(current);
write(next);
另一个上下文可以对不同的快照执行自己的操作序列。每次读写操作都能成功,但最终的结果会导致数据丢失。
将不同类别拆分为独立的键可以减少冲突。存储在某个键下的主题无法覆盖另一个键下的无关草稿。这虽然比保留一个巨大的状态对象要好,但仍无法解决所有的并发问题,因为两个编写者仍可能为同一个草稿或偏好设置记录发生冲突。
当localStorage中的键值发生变化时,浏览器还会触发一个storage事件,通知其他文档这一变动。这有助于实现同步,但并非真正的事务机制。一旦其他标签页接收到该事件,它可能已经基于过时的数据采取了操作,或者已经安排了写入操作来恢复旧版本的数据。该事件仅会报告有新的写入操作发生,应用程序仍需自行决定如何将这些新数据与未保存的本地更改进行协调。
在很多实现中,情况会变得十分复杂。开发者需要添加监听器、合并对象、更新存储数据,并防止循环写入,很快他们就在一个看似比完整数据层更简单的API之上构建出了一个部分同步系统。
以上内容并不意味着localStorage就不适合存储应用数据。只是说明你所需的保障措施必须与API所提供的功能相匹配。对于那些有明确所有者、且允许被覆盖的小型数据来说,它确实是个不错的选择。但当多个主体独立编辑同一份共享的结构性数据时,它的稳定性就会下降。
一个大的JSON数据块会带来广泛的影响
将所有状态数据保存在同一个键下很方便,因为数据还原只需要一次读取和解析操作。但这也意味着每次更新都会重写所有数据。
只需调整侧边栏设置,就会重写与该数据块相关的所有内容:未完成的草稿、认证元数据、近期搜索记录、已忽略的横幅以及缓存的表单值。如果作者在这些内容中保留了过时的数据,那么此次无关的编辑就会把旧值恢复过来。捆绑到同一键值中的主题越多,就越有可能出现某位作者未看到其他人的修改的情况。
迁移过程也会受到影响。新版本可能会重新设计对象的某一部分,而保留其余部分。如果迁移不完整,应用程序可能会生成一个混合了不同版本字段的对象,而旧版本标签页则可能继续保留其原有的结构,从而实际上抵消迁移的效果。
按所有权和生命周期划分存储可以减少这些冲突。主题偏好设置、临时草稿、已忽略的通知以及缓存条目无需共享同一版本、过期策略或写入路径。独立的键能避免某一方面的变更影响到另一方面。
划分方式也不应过于机械化。为每个微小的属性都创建一个键会变得难以管理,而且当多个值需要同时更改时仍可能产生矛盾的组合。合理的划分标准是那些拥有唯一所有者、单一结构以及统一更新规则的状态数据。那些必须作为一个整体保持一致的值可以归为一类,而因无关原因发生变化的值,共享快照除了增加暴露风险外并无益处。
可以把键视为存储一条完整的记录,它不应替代应用程序的全部内存。
模式漂移使旧数据变成新的腐败数据
浏览器数据的使用寿命往往超出预期。用户可能在数十次版本更新后仍使用同一配置文件,而旧版本中存储的数值可能在数月后再次出现。因此,即便没人设计过,浏览器存储也存在着某种模式。
假设某个旧版本存储了一个布尔值:
{
"notifications": true
}
较新的版本用一个更丰富的对象取而代之:
{
"notifications": {
"email": true,
"push": false
}
}
那些不检查旧值也不进行迁移就直接假设新数据结构的代码可能会出错。更隐蔽的问题是,这类代码可能会使用默认值,之后再将这些默认值覆盖到已存储的记录中,从而丢弃用户之前选择的设置。
字段名称变更也会引发类似问题。遗留的 darkMode 标志可能会与更新后的 theme 值共存。应用程序的某个部分会读取新字段,而旧版本的标签页则会保留旧的数据结构,导致用户的偏好设置在不同会话之间发生不可预测的变化。
TypeScript 无法解决这些问题。编译器虽然能够描述当前期望的数据结构,但 JSON.parse 返回的却是过去未知代码生成的运行时数据。类型断言虽然能让编译器不再报错,却使得这种数据不匹配更难被发现:
const settings =
JSON.parse(savedValue) as CurrentSettings;
那行代码并不会将旧存储格式转换为当前的模式,它只是告诉 TypeScript 可以信任这些数据。
更安全的代码会将持久化存储的值视为外部输入,对它们进行解析、验证结构、检查版本,并有意识地迁移已知的旧格式。如果某个值无法安全解析,应用程序应选择丢弃它、保留以备恢复,或向用户询问。
在数据旁边存储版本号可以让格式转换更加清晰明了:
{
"version": 3,
"data": {
"theme": "dark"
}
}
版本号本身无法执行迁移操作,但它能告知代码哪些假设仍然成立。
解析成功并不等同于安全
开发者通常会将JSON.parse封装在try/catch结构中,并以受保护的方式调用存储层。这样虽能处理格式错误的JSON,有一定帮助,但语法有效性仅仅是首要要求而已。
解析后的对象可能缺少必需字段、包含不合理的值、遵循过时的架构,或是通过开发者工具或扩展程序被手动修改过。即便值的类型正确,其含义也可能出错。在合法的 JSON 中,-500 这样的页面大小数值也是有效的;"ultraviolet" 这样的主题名称也是合法的字符串;缺少用于关联文档的 ID 的草稿同样属于合法对象。但由于浏览器仅能存储字符串且不了解你的领域规则,这些对象都不应被自动视为应用程序的状态。
可靠的数据还原机制会利用架构库、小型手动编写的解析器或精心设计的构造函数,来验证应用程序允许接受的数据。关键在于,这种验证过程能生成已知的内部模型,而非将原始解析数据直接传递给应用程序。
验证机制也是默认值适用的场合。缺失的可选设置可以安全地使用默认值;而未保存的草稿若缺少标识符,则意味着该草稿无法附加到任何文档中,也不应自动恢复。将这两种情况都视为无害的缺失,会带来一种虚假的安心感,从而掩盖数据损坏的问题。备用方案不应掩盖不确定性。如果应用程序无法证明存储的数据属于当前用户、对应的架构及记录,恢复这些数据可能带来的危害大于直接忽略它们。
身份信息应纳入存储协议中
共享浏览器会带来另一个错误假设,即存储空间属于当前登录的用户。实际上,存储空间属于浏览器的来源地址。
想象在同一台机器上,有一个人登出,另一个人登录。除非应用程序清除这些数据或为它们划分命名空间,否则剩余的草稿、过滤器、偏好设置或缓存记录仍会留存。诸如 draft、recent-items 或 dashboard-settings 这样的通用键可能会导致第一个人的数据出现在第二个人的会话中。即便其中没有敏感信息,这也属于数据损坏,因为有效的数值被错误地关联到了错误的用户身上。
登出时清除所有存储数据虽然有一定帮助,但手段较为粗暴。它可能会删除设备层面的合法偏好设置,比如语言或无障碍设置;而且如果在当前标签页仍保留内存快照的情况下于其他标签页登出,此方法也可能失效。
经过精心设计的数据分类方式会将数据分为用户专属、会话专属、设备专属以及公共数据。属于某用户的记录可以在键中或包装对象中携带该用户的ID,而在接收数据之前,通过验证机制可确认存储的所有者与当前登录的身份一致。
草稿通常还需要文档或资源的ID。当有多个记录处于打开状态时,全局恢复“最新草稿”存在风险,因为如果键中没有指定哪个对象拥有该内容,那么一个记录中的内容就可能会出现在另一个记录中。命名空间设计不仅仅是为了整洁,更是为了防止有效数据被放入错误的上下文中。
加密无法修复数据损坏问题
关于浏览器存储风险的讨论往往聚焦于安全性问题。敏感信息绝不能随意存放在浏览器可访问的存储空间中,尤其是那些可能被注入脚本读取的信息。这确实是个值得关注的问题,但与数据一致性并非同一类问题。
对存储的数据进行加密并不能防止多个标签页互相覆盖数据。它既不提供事务处理功能,也不具备版本检查、所有权管理或数据迁移机制。某个应用可以完美地加密过时的数据快照,同时也能轻易删除更新后的数据快照。有些团队会试图通过编码、混淆处理或加密来解决实际上属于设计层面的问题。这些措施在某些威胁模型下或许能提升数据保密性,但却依然无法保证更新过程的可靠性。
同样地,从 localStorage 转向 IndexedDB 可以获得更大的存储容量、结构化的存储方式、索引功能以及事务处理能力。但它本身并不能生成正确的应用逻辑——事务必须涵盖正确的记录,更新仍需遵循数据所有权规则,而架构升级也需要精心设计。该技术提供了更强大的基础功能,应用必须在明确的一致性模型下使用这些功能。
从数据所有权入手
与其纠结该选择哪种存储 API,不如先明确数据的所有者,以及如何处理其他写入者所做的更改。
普通的偏好设置通常采用“最后写入者胜出”的原则。如果两个标签页设置了不同的主题,保留最新设置可能正是所需,不过如果可见界面需要立即更新,应用仍应监听跨标签页的变更。
未完成的草稿则是另一回事。在这种“最后写入者胜出”的模式下,用户可能会因此付出实际的工作成本,因此该记录需要包含修订编号、时间戳、文档标识以及冲突处理策略。在覆盖原有内容之前,应用程序可以比较不同版本,保留两个冲突的版本,或者提醒用户有其他标签页正在编辑同一份草稿。
对于需要在多台设备间共享的重要记录,浏览器存储根本不应作为权威数据源。它仅可作为离线缓存或临时工作副本,而服务器则应保存权威版本并处理冲突。
正确的设计方案取决于数据的价值以及修改它的人数。将显示偏好设置与法律表格、长篇文章草稿或离线财务记录相比较,其重要性显然存在差异。若将所有数据都视为以任意键值存储的字符串,就会忽视丢失某次更新所带来的损失。
单一的写入路径也有帮助。当任何组件都可以随时读取、合并并替换同一个键值时,就没人能去审查一致性规则了。一个存储模块可以集中处理验证、迁移、同步以及冲突解决等工作。这并不需要围绕三个偏好设置构建复杂的存储层,只需防止持久化代码过度扩展,避免出现没人能判断哪个快照是当前版本的情况。
保留数据,切勿擅自重新解读
最糟糕的存储错误并不会以QuotaExceededError或JSON解析失败的形式显现出来。这类问题显而易见,可以记录、测试并处理。真正具有破坏性的是那些虽然写入成功,却保存了本应有效数据错误版本的情景。
试想这样一种情况:一个过时的标签页只修改了一个字段,却又恢复了五个旧字段;一次延迟的回调覆盖了更新的草稿;一个旧版本在保存数据时使用了过时的架构;或者新的登录操作继承了上一个账户的状态。从存储API的角度来看,这些操作似乎都成功完成了。但数据损坏的问题之所以出现,是因为该应用从未定义过版本控制、所有权规则或冲突处理规则。
当浏览器的存储功能能够满足特定需求时,它便显得十分有用:保存用户偏好设置、缓存非关键数据、支持离线操作、恢复未完成的工作。然而,将某些内容持久化存储会使得短暂存在的状态变成长期有效的输入数据,因此需要像对待服务器或数据库中的数据那样加以妥善管理。
在实践中,这意味着要检查返回的内容、为其添加版本号、将无关记录分开存储、在需要明确所有权时标注用户和资源的身份,以及避免为微小的修改而重新生成庞大的快照。这也意味着要慎重决定是否采用“最后写入者胜出”的原则,防止其无意中成为既定规则。
浏览器会忠实保留它收到的最后一条数据。确保最后写入数据的人有权决定哪些内容应当保留,则需要由你来决定。
相关阅读
- Agent Memory Snapshots, Skills, Decay and Branch Scoping — 为编程智能体设计的嵌入式向量数据库内存层的四项扩展功能:可合并的便携式快照、技能激活功能、带冲突检测的衰减机制,以及按分支划分的内存管理。