IndexedDB实战:浏览器端离线存储与同步方案

29 阅读1分钟

IndexedDB实战:浏览器端离线存储与同步方案

上个月我们组接了个需求:给一个外勤巡检系统做离线支持。巡检员在信号差的工地拍照、填表单,数据先存本地,等有网了再同步上去。听起来不复杂对吧?localStorage 存一下不就完了?

我一开始也是这么想的。

这时候就不得不请出 IndexedDB 了。这东西 API 丑得让人想哭,但在浏览器端做离线存储,它几乎是唯一的正经选择。

离线数据模型设计:别偷懒,状态机是必须的

数据能存下来只是第一步。真正让我掉头发的是:怎么设计数据模型,才能在离线和在线之间无缝切换?

每条记录都要带同步状态

这个原则我们是踩了坑之后才确立的。一开始我们就存原始业务数据,等网络恢复了遍历一遍全量上传。结果发现:已经同步过的数据又传了一遍,同步失败的数据没有重试机制,用户改了已同步的数据不知道该怎么处理。

后来给每条记录加了 syncStatus 字段,整个世界清净了。

const record = {
  id: crypto.randomUUID(),       // 客户端生成 UUID,避免跟服务端 ID 冲突
  title: '5号楼电梯年检',          // 业务字段
  photos: [],                    // 存的是 Blob 引用,实际图片存在单独的 object store
  result: 'passed',
  syncStatus: 'pending',         // pending → syncing → synced / failed
  syncAttempts: 0,               // 重试次数,用于指数退避
  lastSyncError: null,           // 最近一次失败原因,方便排查
  localUpdatedAt: Date.now(),    // 本地最后修改时间
  serverUpdatedAt: null          // 服务端确认时间
}

这里有个容易忽略的细节:idcrypto.randomUUID() 在客户端生成,而不是等服务端返回自增 ID。原因是离线状态下你拿不到服务端 ID,如果用临时 ID 后续还得做一次 ID 映射,非常麻烦。

syncStatus 这个字段其实是个状态机:

  创建/修改
     ↓
  pending ──触发同步──→ syncing ──成功──→ synced
                          │                  ↑
                          失败               │
                          ↓                  │
                        failed ──重试──→ syncing

为什么要用状态机而不是简单的布尔值 isSynced: true/false?因为布尔值无法表达"正在同步中"这个中间态。如果用户在同步过程中又改了数据,你需要知道当前这条记录是"正在传"还是"还没传"。布尔值做不到。我们早期用布尔值时出过一个 bug:同步请求还在飞,用户又改了表单,改完 isSynced 被设回 false,紧接着之前的请求返回成功又把它设成 true,导致用户的最新修改永远没同步上去。状态机彻底解决了这个问题。

同步策略:三种方案,各有各的坑

离线数据攒够了,网络恢复了,怎么把数据送上去?这里有三种常见思路。

方案二:Background Sync API

这是我个人觉得设计得最优雅的方案。通过 Service Worker 注册一个同步任务,浏览器会在"合适的时机"自动触发,哪怕用户关掉了页面。主线程只需要把数据写入 IndexedDB 然后注册一个 sync tag,剩下的交给 Service Worker 在后台完成:

// 主线程:保存数据并注册同步任务
async function saveAndSync(record) {
  const db = await openDB('InspectionDB', 1)
  await db.put('reports', record)
  const registration = await navigator.serviceWorker.ready
  await registration.sync.register('sync-reports')
}

// service-worker.js:监听 sync 事件,执行实际同步
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-reports') {
    event.waitUntil(doSync()) // 浏览器会等 doSync() 完成
  }
})

doSync() 的内部逻辑和方案一基本一样:从 IndexedDB 捞 pending 记录,逐条推送。

这个方案的坑在于兼容性。截至 2026 年 3 月,Background Sync 基本只有 Chromium 系浏览器(Chrome、Edge)支持,Firefox 和 Safari 都没实现。如果你的用户群里有大量 iOS 用户,这个方案等于白搭。

我们的做法是把 Background Sync 当增强手段:支持就用,不支持就降级到方案一的 online 事件监听。

方案三:定时轮询 + 手动触发

最不"优雅"但最稳的方案。封装一个 SyncScheduler 类,每 30 秒检查一次有没有待同步记录,同时监听 online 事件做即时触发。核心就三件事:定时器轮询、网络恢复即时触发、明确离线时跳过。

class SyncScheduler {
  constructor(db, interval = 30000) {
    this.db = db
    this.interval = interval
    this.timer = null
  }

  start() {
    this.timer = setInterval(() => this.trySync(), this.interval)
    window.addEventListener('online', () => this.trySync())
  }

  async trySync() {
    if (!navigator.onLine) return
    const pending = await this.db.getAllFromIndex('reports', 'by_status', 'pending')
    if (pending.length === 0) return
    // 逐条同步,逻辑同方案一
  }
}

这里 trySync 先检查 navigator.onLine 再查库,避免离线时做无意义的 IndexedDB 查询。stop() 方法用于页面卸载时清理定时器,防止内存泄漏。这个方案没什么花哨的技巧,但在生产环境里反而最让人放心,用户也喜欢看到一个"同步"按钮——给他们确定感。

三个方案我们最终是混着用的:Background Sync 做第一优先级,online 事件做第二优先级,定时轮询做兜底。手动同步按钮作为用户最后的"救命稻草"。

冲突处理:离线同步绕不过的硬骨头

两个巡检员同时离线编辑了同一条报告,回到有网的时候同步上去,服务端收到两个不同版本,听谁的?

策略一:Last Write Wins(最后写入胜出)

最简单——谁的时间戳新,就用谁的。服务端拿 incoming.localUpdatedAt 和已有记录比较,新的覆盖旧的,旧的直接拒绝。实现成本几乎为零。

function handleSync(incoming) {
  const existing = db.findById(incoming.id)
  if (!existing || incoming.localUpdatedAt > existing.localUpdatedAt) {
    db.save(incoming)
    return { status: 'accepted' }
  }
  return { status: 'rejected', reason: 'stale' }
}

问题也很明显:先提交的人的修改会被静默覆盖掉,他完全不知道自己的数据被人"踩"了。对于巡检系统这种场景,一条报告被覆盖可能意味着安全隐患被忽略。所以这个策略只适合数据覆盖后果不严重的场景,比如草稿自动保存。

策略二:服务端仲裁 + 冲突提示

同步的时候带上一个版本号。如果服务端发现版本号对不上,就拒绝写入,把冲突抛给客户端让用户自己决定。客户端在请求体里带上 expectedVersion(即"我修改时基于的版本号"),服务端比对后如果版本不匹配,就返回冲突状态和最新的服务端数据:

async function syncRecord(record) {
  const res = await fetch('/api/reports/sync', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...record, expectedVersion: record.version })
  })
  const result = await res.json()
  if (result.conflict) {
    await db.put('reports', {
      ...record, syncStatus: 'conflict', serverVersion: result.serverData
    })
    showConflictResolver(record, result.serverData) // 弹窗让用户对比选择
  }
}

拿到冲突后,客户端把 syncStatus 设为 conflict,同时把服务端版本缓存到 serverVersion 字段。然后弹一个对比界面,左右两栏分别展示"我的版本"和"服务端版本",让用户逐字段选择保留哪个。用户选完后生成一个合并版本,带着新版本号重新提交。这套流程对用户有一定打扰,但对于巡检报告这类数据准确性要求高的场景,宁可多问一句也不能静默丢数据。

策略三:CRDT(无冲突数据类型)

CRDT 的思路是从数据结构层面消除冲突:每个客户端的操作都设计成可交换、可结合的,合并时不需要协调。举个最简单的例子——G-Counter(增长计数器)。假设要统计某个巡检点的检查次数,两个巡检员 A 和 B 各自离线期间分别检查了 3 次和 2 次:

A 的本地计数器: { A: 3, B: 0 }
B 的本地计数器: { A: 0, B: 2 }

合并规则:对每个节点取 max → { A: 3, B: 2 } → 总数 = 5

不管 A 和 B 的数据以什么顺序到达服务端,合并结果都是 5,不需要冲突处理。这对计数器、集合添加这类操作确实很优雅。

但问题在于,我们的巡检表单是"一个表单一堆字段"——检查结果、备注、整改意见、签名……这些字段是整体覆盖式更新,不是可累加的操作。你没法对"备注从'正常'改成'有裂缝'"和"备注从'正常'改成'需复检'"做 max 合并,因为文本字段没有偏序关系。要让 CRDT 处理这种任意字段的表单编辑,你得把每个字段拆成独立的 Last-Writer-Wins Register,再组合成一个 Map CRDT,实现复杂度直接起飞,而且最终效果和策略二的逐字段冲突对比差不多,还不如让用户自己选。

生产环境的架构拼图

跑了三个月之后整个离线同步系统的架构大致是这样的:

┌──────────────────────────────────────────────────┐
│                  用户操作层                        │
│   表单提交 / 拍照上传 / 列表查看                    │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│               离线数据管理层                       │
│  CRUD API(idb) + 状态机管理 + 配额监控/清理        │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│                IndexedDB                          │
│  reports 表 + attachments 表                      │
└────────────────────┬─────────────────────────────┘
                     │
┌────────────────────▼─────────────────────────────┐
│              同步调度层                            │
│  Background Sync → online 事件 → 定时轮询         │
│  并发控制(3路) + 指数退避重试                      │
└────────────────────┬─────────────────────────────┘
                     │
              ┌──────▼──────┐
              │  服务端 API  │
              │ 版本号校验   │
              │ 冲突检测     │
              └─────────────┘

跑了三个月,稳定服务了 200 多个巡检员的日常使用。期间收集到的数据:| 指标 | 数值 | |------|------| | 单用户日均离线记录 | 1530 条 | | 平均单条记录大小(含图片引用) | 28KB(图片 Blob 另算) | | 单用户 IndexedDB 平均占用 | 45MB | | 同步成功率(首次尝试) | 94.7% | | 重试后最终同步成功率 | 99.6% | | 冲突发生率 | 0.3%(大部分是同一巡检点被两人同时检查) | | 清理后平均释放空间 | 每周约 12MB/用户 |

剩下 0.4% 同步始终失败的,基本都是网络极端不稳导致的超时,最后靠用户手动点同步按钮解决。

如果你也在做类似的离线功能,附一下我们实际的迭代过程供参考:

阶段时间方案触发升级的事件
V1第1~2周idb + online 事件 + Last Write Wins能跑通基本流程
V2第3周加入定时轮询兜底发现工地 WiFi 频繁假在线,online 事件不触发同步
V3第5周加入版本号冲突检测两个巡检员覆盖了同一条报告,甲方投诉数据丢失
V4第7周加入 Background Sync + 降级策略用户反馈关掉页面后数据没同步,第二天才发现
V5第9周加入配额监控和自动清理一个巡检员的手机浏览器 IndexedDB 爆了

总共花了大约两个月从最简单的 V1 演进到当前的混合方案。每次升级都是被线上问题推着走的,没有一次是"提前设计"出来的。