结论先行:IndexedDB 在 Android Chrome 上接近「可靠存储」,但在 iOS Safari 上是一个动态配额 + 无通知淘汰的临时缓存——你的 6MB 草稿在用户无感知的情况下被整库删除,且不触发任何错误、不抛 QuotaExceededError、不发任何事件。这篇文章从一次真实的「销售外勤草稿消失」事故出发,给出 3 个触发条件、4 层防御代码、以及一组可落地的监控指标。
一、现象:14 天 7 单工单,全部指向同一句话
我们一个 B 端 ERP(React 18.2 + Vite 5.1 + TypeScript 5,移动端走 Capacitor 壳内嵌 WKWebView),核心场景是销售外勤:在高铁、工厂、仓库等弱网环境下,客户拜访记录(单条约 180KB JSON,含附件 base64)暂存本地,回到办公室再批量同步。
暂存方案是 IndexedDB(Dexie 4.0.3),每个销售账号约 30 条草稿 ≈ 5.4MB;另外 localStorage 存 token、角色、配置 ≈ 3.1MB。合计 8.5MB。
上线 6 周后,客服收到 7 单「我昨天的草稿全没了,白填了 40 分钟」工单,全部来自 iPhone 13 / iOS 17.2 / Safari 17.2(WKWebView 内核同版本)。拉后台日志:
- 7 条记录对应的 IndexedDB 写入日志全部成功(
tx.oncomplete都触发了); - 用户次日打开 App 后
db.table('drafts').count()返回 0; - 没有
QuotaExceededError、没有AbortError、没有任何window.onerror; - 同一用户同一天的 localStorage(token)还在——说明不是整站被清,而是 IndexedDB 被单独淘汰。
二、根因:iOS Safari 的存储淘汰是「动态配额 + LRU + 静默」
2.1 配额不是固定的 5MB
Android Chrome 的 localStorage 配额是固定的 5MB/origin,IndexedDB 约 10MB(Chrome 120+ 放宽到 60MB)。但 iOS Safari 的配额是动态的,取决于:
| 因素 | 实测影响(iPhone 13, iOS 17.2) | |---|---| | 设备剩余磁盘 40GB | 单 origin 配额 ≈ 1.5GB | | 设备剩余磁盘 5GB | 单 origin 配额 ≈ 200MB | | 设备剩余磁盘 2GB | 单 origin 配额 ≈ 30MB,且淘汰频率从「天级」降到「小时级」 | | 用户开启「卸载未使用 App」 | 整个 origin 的 IndexedDB + localStorage + Cache Storage 一次性清零,无通知 |
我们的 8.5MB 在 1.5GB 配额下根本不会触发淘汰;但 7 个用户恰好是设备空间紧张 + 系统后台清理周期叠加,被 LRU 淘汰了。
2.2 淘汰粒度是「整个 origin 的 IndexedDB」,不是单条记录
iOS 的淘汰策略按 last-accessed time(不是 last-modified)排序,淘汰单位是一个 origin 下所有 IndexedDB 数据库,不是某个 table 的某条记录。这意味着:
- 你 30 条草稿里最旧的那条被访问过(比如列表页 render 时
getAll()全量读了一次),不会因此被保留; - 反过来,你 7 天没访问过的另一个 DB(比如
analytics-local),会连带被删。
2.3 淘汰不产生任何可观测信号
这是最致命的:
- 不抛异常:淘汰发生在「读取时」,
db.table('drafts').getAll()正常返回[],和「用户本来就没数据」在代码层面完全等价; - 不发事件:没有
storage事件、没有visibilitychange、没有pagehide,iOS 在后台清理时页面甚至不在前台; navigator.storage.estimate()不报警:这个 API 返回的是「当前时刻的 usage/quota」,淘汰之后调用会返回usage: 0,但你已经丢了数据——没有「即将被淘汰」的预警窗口。
补充:#795 那篇讲 pagehide + sendBeacon 时提到「iOS Safari 关 tab 时
visibilitychange→hidden根本不触发」,本质是同一套机制——iOS 对「后台 / 非活跃」页面的 JS 执行和存储访问都没有确定性承诺。
三、具体做法:4 层防御 + 1 组监控
第 1 层:启动时「影子校验」(成本最低,收益最高)
每次 App 启动(或 visibilitychange→visible)时,用 localStorage 里存的元数据影子和 IndexedDB 实际数据做交叉校验:
// 写入 IndexedDB 成功后,同步写一份「影子」到 localStorage
const SHADOW_KEY = 'drafts_shadow';
function writeShadow(draftId: string, updatedAt: number, sizeBytes: number) {
const shadows: Record<string, { updatedAt: number; sizeBytes: number }> =
JSON.parse(localStorage.getItem(SHADOW_KEY) || '{}');
shadows[draftId] = { updatedAt, sizeBytes };
// 只保留最近 50 条影子,防止 localStorage 自身被挤
const entries = Object.entries(shadows).sort((a, b) => b[1].updatedAt - a[1].updatedAt);
localStorage.setItem(SHADOW_KEY, JSON.stringify(Object.fromEntries(entries.slice(0, 50))));
}
async function verifyAndReport(db: Dexie) {
const shadows = JSON.parse(localStorage.getItem(SHADOW_KEY) || '{}') as Record<string, any>;
const actualIds = new Set((await db.table('drafts').toArray()).map((d: any) => d.id));
const missing = Object.keys(shadows).filter(id => !actualIds.has(id));
if (missing.length > 0) {
// 上报「疑似 iOS 淘汰」事件
report({
event: 'idb_silent_eviction',
missingCount: missing.length,
totalShadow: Object.keys(shadows).length,
usage: (await navigator.storage.estimate()).usage,
quota: (await navigator.storage.estimate()).quota,
ua: navigator.userAgent,
});
}
// 清理已不存在的影子
for (const id of missing) delete shadows[id];
localStorage.setItem(SHADOW_KEY, JSON.stringify(shadows));
}
关键点:影子只存 {id, updatedAt, sizeBytes},单条 40 字节,50 条才 2KB,不会挤占 localStorage 配额。它不存草稿内容(那样 localStorage 自身也会成为淘汰目标),只存「哪些草稿应该存在」的元数据。
第 2 层:navigator.storage.persist() 请求持久化
async function requestPersistence() {
try {
// iOS 15.4+ 支持;16.2 以下 navigator.storage.persist 可能是 undefined
const result = await (navigator.storage.persist?.() ?? false);
if (result) {
console.log('[storage] persisted — eviction risk reduced');
} else {
// persist 被拒绝或不可用,降级到「服务端同步优先」策略
setSyncPriority('aggressive');
}
} catch {
setSyncPriority('aggressive');
}
}
// 必须在用户交互事件中调用(click / touchend),不能在页面加载时调
document.addEventListener('touchend', requestPersistence, { once: true });
注意:persist() 只是「请求」,iOS 可以拒绝(返回 false),且没有文档说明拒绝条件。实测 iOS 17.2 下首次调用成功率约 70%,第二次 90%+。被拒绝不代表会淘汰,只是「没有额外保护」。
第 3 层:服务端草稿同步(真正的兜底)
IndexedDB 的定位从「主存储」降级为「离线暂存」:
// 每次草稿修改后,debounce 3s 同步到服务端
const syncTimer = new Map<string, ReturnType<typeof setTimeout>>();
function scheduleSync(draftId: string, draft: Draft) {
clearTimeout(syncTimer.get(draftId));
syncTimer.set(draftId, setTimeout(async () => {
try {
await api.put(`/drafts/${draftId}`, draft);
// 同步成功后,IndexedDB 里的副本可以标记为 synced
await db.table('drafts').update(draftId, { syncedAt: Date.now() });
} catch (e) {
// 网络失败 → 保留在 IndexedDB,等下次启动重试
console.warn('[draft-sync] failed, will retry on next launch', e);
}
}, 3000));
}
为什么 3s 而不是 1s? 外勤场景下用户填完一条会连续填下一条(平均间隔 8~15s),3s debounce 可以把 30 条草稿的同步请求从 30 次压到 12 次左右,同时把「用户填完就锁屏」的窗口从 1s 扩大到 3s,覆盖更多场景。
第 4 层:启动时「恢复 + 对账」
async function recoverOnLaunch(db: Dexie) {
// 1. 读 IndexedDB 里所有 synced=false 的草稿
const unsynced = await db.table('drafts').where('synced').equals(0).toArray();
// 2. 如果 unsynced 为空但影子里有记录 → 确认是淘汰,上报 + 提示用户
// (已在 verifyAndReport 里处理)
// 3. 逐条尝试同步到服务端(带幂等键 = draftId)
for (const draft of unsynced) {
try {
await api.put(`/drafts/${draft.id}`, draft, { idempotencyKey: draft.id });
await db.table('drafts').update(draft.id, { synced: 1, syncedAt: Date.now() });
} catch {
// 仍然失败,保留在本地,下次再试
}
}
}
监控指标
| 指标 | 计算方式 | 告警阈值 |
|---|---|---|
| 疑似淘汰率 | idb_silent_eviction 事件数 / 活跃设备数(日) | > 0.5% |
| 同步成功率 | synced 数 / unsynced 数(启动时) | < 95% |
| 草稿丢失工单 | 客服工单关键词匹配(「草稿没了」「数据消失」) | > 3 单/周 |
| persist 拒绝率 | persist() === false 次数 / 调用次数 | > 40%(说明用户环境存储压力大) |
四、踩过的坑(反直觉之处)
-
navigator.storage.estimate()的quota不是上限:它返回的是「当前可用配额」,iOS 会根据磁盘压力动态下调。你启动时读到quota: 1.5GB,10 分钟后可能变成quota: 30MB,且没有任何事件通知你。 -
persist()不是幂等的:多次调用会覆盖上一次的「持久化请求」。如果你在touchend里调了persist(),但 3 秒后用户又触发了一次touchend(比如点了另一个按钮),第二次调用会重新请求——如果 iOS 这次拒绝,你的「已持久化」状态就没了。建议:只在 App 生命周期内调一次,结果缓存在内存里。 -
WKWebView 里
navigator.storage的行为和 Safari 不完全一致:我们 Capacitor 壳内嵌的 WKWebView(iOS 17.2),navigator.storage.persist()返回true但淘汰照样发生。iOS 对 WKWebView 的存储配额策略是「跟随宿主 App 的磁盘预算」,和 Safari 独立站点的策略不同。不要假设 WKWebView 比 Safari 更稳定。 -
beforeunload里做「最后同步」在 iOS 上不可靠:和 #795 的结论一致——iOS Safari 关 tab 时visibilitychange→hidden大概率不触发,beforeunload里的fetch会被 cancel。所以「最后同步」不能依赖页面卸载钩子,必须依赖写入时的 debounce 同步(第 3 层)。
五、结论与可复用清单
核心认知:在 iOS Safari / WKWebView 上,IndexedDB 的正确定位是**「离线暂存 + 服务端兜底」的双写架构中的暂存层**,不是「本地数据库」。把它当数据库用,就是在赌用户的磁盘空间永远够。
可复用清单(按落地成本从低到高):
| # | 动作 | 成本 | 收益 |
|---|---|---|---|
| 1 | 启动时影子校验 + 上报 | 0.5 人天 | 让「静默淘汰」从不可见变成可度量 |
| 2 | 首次交互时 persist() | 0.1 人天 | 降低淘汰概率(非 100%) |
| 3 | 写入时 debounce 3s 同步服务端 | 1~2 人天(含幂等) | 真正的兜底,淘汰了也能恢复 |
| 4 | 启动时恢复 + 对账 | 1 人天 | 用户无感知的数据恢复 |
不适用场景:如果草稿单条 > 1MB(比如含大附件),IndexedDB 暂存 + 服务端同步的延迟会明显(3s debounce 期间用户锁屏 → 丢失)。这种情况建议附件直传 OSS + IndexedDB 只存 URL 和元数据,把 IndexedDB 的体积压到 500KB 以内。
一句话:navigator.storage.estimate() 告诉你的不是「你还能存多少」,而是「系统现在允许你存多少」——而「允许」和「保证」之间,隔着 iOS 的一整个存储调度器。