一个离线优先的笔记应用,用户积累了数月的本地数据。某天打开应用,发现所有数据消失了——没有任何错误提示,没有任何用户操作。开发者检查代码,没有发现任何删除数据的逻辑。这不是bug,这是浏览器的存储驱逐机制在"正常工作"。

更令人困惑的是,同样的应用在Chrome上数据完好无损,在Safari上却频繁丢失。用navigator.storage.estimate()查询配额,返回的数字在不同设备上差异巨大。这些现象背后,是浏览器存储配额管理系统的复杂设计——一个大多数开发者知之甚少,却直接影响用户体验的核心机制。

两种截然不同的限制逻辑

浏览器存储配额的复杂性,首先体现在不同存储API采用了完全不同的限制策略。

Web Storage的硬性边界

localStoragesessionStorage的规则非常简单且统一:每个origin各5MB,合计10MB。这是一个硬性限制,由W3C Web Storage规范定义。当尝试写入超过限制的数据时,浏览器会抛出QuotaExceededError异常。

try {
    localStorage.setItem('key', largeData);
} catch (e) {
    if (e.name === 'QuotaExceededError') {
        // 存储已满,需要清理
    }
}

这个限制的初衷是防止恶意网站占用过多用户磁盘空间。但在现代Web应用中,5MB往往捉襟见肘——一张高分辨率图片就可能超过这个限制。

动态配额的计算艺术

IndexedDB、Cache API和Origin Private File System(OPFS)采用了完全不同的策略:动态配额。配额不再是固定的字节数,而是基于设备磁盘空间的百分比计算。

这种设计的核心考量是防止指纹识别。如果每个网站都有固定的配额(比如100MB),恶意网站可以通过测量实际可用空间来推断用户的磁盘大小,从而形成设备指纹。动态配额使得这个数字在不同设备上呈现不同值,有效阻止了这种攻击。

但这也带来了一个令人困惑的现象:navigator.storage.estimate()返回的配额可能远超当前磁盘剩余空间。因为配额是基于总磁盘空间而非可用空间计算的。

三大浏览器的配额策略差异

不同浏览器对动态配额的实现存在显著差异,这直接影响了跨浏览器应用的行为一致性。

Chrome/Chromium:慷慨但有限制

Chrome的配额策略最为慷慨:

  • 浏览器全局上限:磁盘总空间的80%
  • 单个origin上限:磁盘总空间的60%

例如,在一个1TB磁盘的设备上,单个网站理论上可以存储高达600GB的数据。但Chrome 66引入了一个重要变化:将最小保留空间从1%改为固定的1GB。这意味着在小容量设备上,实际可用配额会比理论值低。

Chrome对隐私模式(Incognito)的处理更为严格。在这种模式下,origin配额被限制为总磁盘空间的约5%。这是为了防止隐私模式下的网站数据长期占用用户设备空间。

Firefox:分层配额体系

Firefox采用了一个相对复杂的分层结构:

  • 全局限制:浏览器最多使用50%的磁盘空间
  • 群组限制:同一eTLD+1下的所有origin(如example.com、www.example.com、foo.bar.example.com)共享2GB配额
  • 持久化存储:如果获得persistent storage权限,单个origin可使用高达50%的磁盘空间(上限8TiB)

Firefox的群组限制设计是为了防止单一实体通过创建多个子域名来绕过配额限制。这种设计在多租户SaaS应用中可能产生影响——不同租户如果使用同一域名的不同路径,会共享同一配额池。

Safari:从保守到相对开放

Safari的存储策略经历了显著演变。在Safari 17.0之前,Safari采用逐步询问机制:初始配额1GB,超过后每次增加200MB都需要用户确认。

从Safari 17.0(iOS 17、macOS Sonoma)开始,Apple简化了这一机制:

场景 Origin配额 全局配额
浏览器应用 磁盘空间的60% 磁盘空间的80%
嵌入WebView的应用 磁盘空间的15% 磁盘空间的20%
已安装的PWA 同浏览器应用 同浏览器应用

对于跨域iframe,Safari分配单独的配额,约为父页面配额的10%。这是为了防止第三方脚本滥用存储空间。

存储驱逐:数据的隐形杀手

配额限制只是问题的一面。更让开发者头疼的是存储驱逐(eviction)——浏览器在特定条件下主动删除网站数据。

Best-effort与Persistent两种模式

浏览器存储分为两种模式:

Best-effort模式(默认):数据"尽力保存",但在存储压力下可能被删除。这是所有origin的默认状态。

Persistent模式:数据只有在用户明确操作时才会被删除(通过浏览器设置或清除数据功能)。需要主动申请,浏览器会根据启发式规则决定是否批准。

// 申请持久化存储
if (navigator.storage && navigator.storage.persist) {
    const isPersisted = await navigator.storage.persist();
    console.log(`持久化存储${isPersisted ? '已批准' : '被拒绝'}`);
}

// 检查当前状态
const isPersisted = await navigator.storage.persisted();

Firefox会向用户显示确认对话框,而Chrome和Safari则使用启发式算法自动决定。Chrome的启发式规则考虑因素包括:

  • 网站是否被添加到书签
  • 网站是否拥有通知权限
  • 网站是否被添加到主屏幕(PWA)
  • 用户与网站的互动频率

LRU驱逐算法

当设备存储空间不足时,浏览器会触发存储驱逐。核心算法是LRU(Least Recently Used):优先删除最久未被使用的origin数据。

这里的"最近使用"有两个判断标准:

  1. 最后一次用户交互时间
  2. 最后一次存储操作时间

需要注意的是,驱逐是以origin为单位进行的。当一个origin被选中驱逐时,该origin下的所有存储数据(IndexedDB、Cache、localStorage等)会被一次性删除。浏览器不会只删除部分数据,因为这样可能导致应用状态不一致。

Safari的7天规则

Safari的Intelligent Tracking Prevention(ITP)引入了一个独特的驱逐策略:7天不活跃自动清除

如果一个网站连续7天没有用户交互(点击、触摸等),Safari会删除该网站的所有脚本可写存储,包括:

  • localStorage
  • sessionStorage
  • IndexedDB
  • Cache API
  • Service Worker注册
  • Cookies(仅限JavaScript设置的)

这个规则有几个重要的例外:

  • 已安装的PWA:添加到主屏幕的Web应用不受此限制
  • 服务器设置的Cookies:通过HTTP头部设置的Cookies不受影响
  • 用户已授予持久化权限的origin:通过navigator.storage.persist()获得批准的origin

2020年3月,Apple宣布这一政策时引发了开发者社区的强烈反响。对于离线优先应用来说,这意味着如果用户一周不打开应用,所有本地数据都会消失——这几乎违背了离线应用的设计初衷。

查询与监控:Storage API的正确使用

W3C Storage Standard提供了一套API让开发者能够查询和管理存储状态。

查询配额使用情况

if (navigator.storage && navigator.storage.estimate) {
    const estimate = await navigator.storage.estimate();
    
    console.log(`已使用: ${estimate.usage} 字节`);
    console.log(`配额上限: ${estimate.quota} 字节`);
    console.log(`使用率: ${(estimate.usage / estimate.quota * 100).toFixed(2)}%`);
}

需要注意的是,estimate()返回的是估算值,不是精确值。出于隐私考虑,浏览器会对返回值进行模糊处理。此外,跨域资源(如CDN上的资源被缓存到Cache Storage)的体积可能会被额外填充,以防止通过存储大小推断用户行为。

开发者工具中的配额模拟

Chrome DevTools从版本88开始支持存储配额模拟功能。在Application面板的Storage部分,勾选"Simulate custom storage quota"即可指定一个模拟的配额上限。这对于测试存储压力场景非常有用。

QuotaExceededError的正确处理

当写入操作超过配额时,IndexedDB和Cache API会抛出QuotaExceededError异常。正确处理这个异常是生产环境必备的防御性编程:

// IndexedDB事务处理
const transaction = db.transaction(['store'], 'readwrite');
transaction.onabort = (event) => {
    const error = event.target.error;
    if (error.name === 'QuotaExceededError') {
        // 清理旧数据后重试
        cleanupOldData().then(() => retryOperation());
    }
};

// Cache API处理
try {
    const cache = await caches.open('my-cache');
    await cache.put(request, response);
} catch (error) {
    if (error.name === 'QuotaExceededError') {
        await clearOldCaches();
        await cache.put(request, response); // 重试
    }
}

Storage Buckets:精细化的存储管理

传统的存储驱逐以origin为单位,这意味着关键数据和可丢弃数据会被一起删除。W3C正在推进的Storage Buckets API旨在解决这个问题。

// 创建一个持久化的存储桶
const bucket = await navigator.storageBuckets.open('critical-data', {
    durability: 'strict',    // 高持久性
    persist: true            // 申请持久化
});

// 在该桶中存储数据
const cache = await buckets.caches.open('user-settings');

通过将数据分类存储到不同的bucket,开发者可以更精细地控制数据的生命周期。当存储压力来临时,浏览器可以优先驱逐标记为"可丢弃"的bucket,而保留关键数据。

Storage Buckets API目前仍处于实验阶段,Chrome 122+已开始支持。

最佳实践总结

  1. 选择正确的存储API:简单键值对用localStorage(注意5MB限制),复杂数据用IndexedDB,HTTP缓存用Cache API,大文件用OPFS。

  2. 始终处理QuotaExceededError:不要假设存储永远不会满。在生产环境中实现优雅降级策略。

  3. 监控存储使用情况:定期调用navigator.storage.estimate(),在接近配额上限时主动清理。

  4. 为关键数据申请持久化:使用navigator.storage.persist()保护重要数据,但要做好申请被拒绝的准备。

  5. Safari用户的特殊考量:提醒Safari用户至少每周打开一次应用以避免ITP清除,或引导用户将应用添加到主屏幕。

  6. 实现数据备份机制:本地存储从来不是可靠的长期保存方案。关键数据应同步到服务端。

技术演进的隐忧

浏览器存储配额系统的设计,体现了Web平台在安全性、隐私保护和开发者便利性之间的艰难平衡。动态配额防止了指纹识别,LRU驱逐保护了用户磁盘空间,ITP规则限制了跨站跟踪。但每一条保护性规则,都可能成为开发者意想不到的"坑"。

更深层的问题是:浏览器存储的可靠性边界到底在哪里?对于离线优先应用,用户数据的安全性无法得到保证——这不是实现bug,而是设计限制。开发者需要清醒认识到,浏览器存储本质上是缓存,而非存储。任何关键数据都应该有服务端备份。

当Web应用越来越接近原生应用的体验预期时,浏览器存储配额机制的局限性也变得越发明显。Storage Buckets API代表了向正确方向迈出的一步,但在Web平台真正实现可靠的数据持久化之前,开发者仍需在便利性和可靠性之间做出权衡。


参考资料