离线数据同步与冲突解决
发布于 更新于
IndexedDB 可以保存待提交的编辑,等网络恢复后再发送。服务器报告基础版本已过期时,继续保留编辑,交给用户处理。支持 Background Sync 时用它同步,否则在页面保持打开时重试。
浏览器与平台支持
链接到“浏览器与平台支持”一节IndexedDB 通过对象存储和事务保存浏览器端记录。MDN 把 Background Sync 标为有限可用,而且
它依赖安全上下文中的 Service Worker,因此离线写入不能只依赖它。示例会检测
SyncManager;如果它不存在,就使用窗口的 online 事件并立即尝试提交。
navigator.onLine 只是提示。MDN 指出浏览器和操作系统使用不同的判断方式,而且设备可能能
连接局域网却无法访问互联网。应由请求结果决定是否把编辑移出队列。
定义同步约定
链接到“定义同步约定”一节保存操作 ID、资源 URL、编辑后的文档,以及读取基础文档时收到的实体标签。提交时把该标签
放入 If-Match,并把操作 ID 作为端点的 Idempotency-Key。这是应用 API 的约定,不是
HTTP 本身提供的行为。示例采用已过期 IETF Internet-Draft 中仍属进行中工作的语义:一个键
只对应一种请求 payload;原请求完成后的重试会收到已保存的结果,并发重试会收到冲突响应。
RFC 9110 规定,当 If-Match 前置条件为假时,源服务器不得执行请求的方法。服务器可以返回
412 Precondition Failed;如果它确认改变状态的操作已经成功,也可以返回成功状态。
Internet-Draft 要求资源公布其幂等性规范,在适用时包含过期策略,并管理每个键的生命周期。 保留窗口应长于客户端的自动重试窗口,而客户端应在它过期前停止自动重试;过期后,应先读取 资源并确认操作是否完成,再创建新操作和新键。较长的窗口会为更多键、指纹和结果占用存储空间, 较短的窗口则会缩短可安全去重的重试时间。Internet-Draft 没有规定具体时长。
本示例在响应成功后删除队列操作。遇到 412 时保留操作并标记为 conflict,让界面加载
服务器版本,再由用户合并或替换。网络错误同样会保留操作,等待下次尝试。
页面和 Service Worker 共用一个 IndexedDB 发件箱。
把编辑加入队列并提交
链接到“把编辑加入队列并提交”一节存储模块创建一个对象存储,并提供 queueEdit() 和 flushOutbox()。服务器端点必须在
读取文档时返回 ETag,在更新时处理 If-Match,并按 Idempotency-Key 去重更新。
const database = new Promise((resolve, reject) => { const request = indexedDB.open('notes', 1); request.onupgradeneeded = () => { request.result.createObjectStore('outbox', { keyPath: 'id' }); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error);});
const LEASE_MS = 60_000;const RETRY_DELAY_MS = 5_000;
function transactionDone(transaction) { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onabort = transaction.onerror = () => reject(transaction.error); });}
async function claimNextOperation(owner) { const db = await database; const transaction = db.transaction('outbox', 'readwrite'); const store = transaction.objectStore('outbox'); const request = store.openCursor(); let claimed;
await new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (!cursor) return resolve();
const operation = cursor.value; const now = Date.now(); if ( operation.status === 'conflict' || operation.leaseUntil > now || operation.retryAfter > now ) { cursor.continue(); return; }
claimed = { ...operation, leaseOwner: owner, leaseUntil: now + LEASE_MS, }; const update = cursor.update(claimed); update.onsuccess = () => resolve(); update.onerror = () => reject(update.error); }; request.onerror = () => reject(request.error); }); await transactionDone(transaction); return claimed;}
async function finishOperation(id, owner, outcome) { const db = await database; const transaction = db.transaction('outbox', 'readwrite'); const store = transaction.objectStore('outbox'); const request = store.get(id); request.onsuccess = () => { const current = request.result; if (!current || current.leaseOwner !== owner) return;
if (outcome === 'accepted') { store.delete(id); return; }
store.put({ ...current, status: outcome === 'conflict' ? 'conflict' : current.status, leaseOwner: null, leaseUntil: 0, retryAfter: outcome === 'retry' ? Date.now() + RETRY_DELAY_MS : 0, }); }; await transactionDone(transaction);}
async function getNextAttemptAt() { const db = await database; const transaction = db.transaction('outbox', 'readonly'); const request = transaction.objectStore('outbox').openCursor(); let earliest = Infinity;
await new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (!cursor) return resolve(); const operation = cursor.value; const now = Date.now(); const readyAt = Math.max(operation.retryAfter ?? 0, operation.leaseUntil ?? 0); if (operation.status !== 'conflict') { earliest = Math.min(earliest, Math.max(now, readyAt)); } cursor.continue(); }; request.onerror = () => reject(request.error); }); await transactionDone(transaction); return Number.isFinite(earliest) ? earliest : null;}
export class OutboxRetryError extends Error { constructor(retryAt) { super('The outbox still has operations awaiting another attempt.'); this.name = 'OutboxRetryError'; this.retryAt = retryAt; }}
export async function flushOutbox() { const owner = crypto.randomUUID(); let operation; while ((operation = await claimNextOperation(owner))) { let response; try { response = await fetch(operation.url, { method: 'PUT', headers: { 'content-type': 'application/json', 'if-match': operation.etag, // The draft defines Idempotency-Key as a Structured Field String. 'idempotency-key': JSON.stringify(operation.id), }, body: JSON.stringify(operation.document), }); } catch { await finishOperation(operation.id, owner, 'retry'); continue; }
if (response.status === 412) { await finishOperation(operation.id, owner, 'conflict'); continue; } await finishOperation(operation.id, owner, response.ok ? 'accepted' : 'retry'); }
const retryAt = await getNextAttemptAt(); if (retryAt !== null) { throw new OutboxRetryError(retryAt); }}
export async function queueEdit(operation) { const db = await database; const transaction = db.transaction('outbox', 'readwrite'); const { leaseOwner, leaseUntil, retryAfter, ...queued } = operation; transaction.objectStore('outbox').put({ ...queued, status: operation.status ?? 'pending', }); await transactionDone(transaction);}本地编辑后调用 queueEdit({ id, url, document, etag })。每个逻辑操作使用一个稳定且唯一的
id,只有重试同一操作时才复用它。领取操作的事务会同时写入限时租约,因此页面和 Worker
在租约有效时无法同时提交同一操作;在端点公布的保留窗口内,其幂等约定会处理请求执行时间超过租约的
剩余情况。
检测 Background Sync 并回退
链接到“检测 Background Sync 并回退”一节API 存在时注册一次同步;否则保留 online 监听器,直到一次提交不再留下可重试操作,并按最早的
retryAt 在页面仍打开时安排下一次尝试。
import { OutboxRetryError, flushOutbox, queueEdit } from './outbox.js';
let retryTimer;let pageFlush;let flushAgain = false;
function stopPageRetry() { clearTimeout(retryTimer); retryTimer = undefined; window.removeEventListener('online', onOnline);}
function schedulePageRetry(retryAt) { clearTimeout(retryTimer); retryTimer = setTimeout(onOnline, Math.max(0, retryAt - Date.now()));}
async function flushFromPage() { if (pageFlush) { flushAgain = true; return pageFlush; } pageFlush = (async () => { do { flushAgain = false; try { await flushOutbox(); if (!flushAgain) stopPageRetry(); } catch (error) { if (!(error instanceof OutboxRetryError)) throw error; schedulePageRetry(error.retryAt); } } while (flushAgain); })().finally(() => { pageFlush = undefined; }); return pageFlush;}
function onOnline() { void flushFromPage().catch((error) => console.error('Outbox flush failed', error));}
async function requestFlush() { if ('serviceWorker' in navigator && 'SyncManager' in window) { try { const registration = await navigator.serviceWorker.ready; await registration.sync.register('flush-note-outbox'); return; } catch { // 继续执行页面重试路径。 } } window.addEventListener('online', onOnline); await flushFromPage();}
if ('indexedDB' in window) { await queueEdit({ id: crypto.randomUUID(), url: '/api/notes/42', document: { title: '装箱清单' }, etag: '"version-from-the-last-read"', }); await requestFlush();} else { showManualRetry('此浏览器无法在离线队列中保存编辑。');}Service Worker 使用同一个存储模块,并通过 waitUntil() 延长事件生命周期:
self.addEventListener('sync', (event) => { if (event.tag === 'flush-note-outbox') { event.waitUntil(flushOutbox()); }});Service Worker 和页面现在使用同一个持久队列。IndexedDB 会串行处理它们领取操作的事务,完成
请求时也只有仍持有租约的调用方才能修改记录。失败的请求会先释放租约,再让
flushOutbox() 拒绝。这会让 waitUntil() 报告同步失败;页面路径则等待到 retryAt,
避免不停地立即重试。
处理冲突
链接到“处理冲突”一节应用找到状态为 conflict 的操作后,获取服务器当前表示,并同时展示两个版本。随后,客户端
可以用新实体标签和 pending 状态把合并后的文档重新加入队列,也可以丢弃本地操作。不要仅
因为设备报告在线就删除队列编辑。
IndexedDB 规范规定,读写事务在其覆盖的对象存储内是原子的,因此页面和 Worker 的租约
领取操作是互斥的。租约会过期,避免上下文崩溃后永久阻塞队列;只要键仍在保留期内,端点的幂等
约定就会处理重复请求。超过公布的键过期时间后,应验证服务器状态,而不能假定重复请求仍然
安全。If-Match 前置条件另行保护服务器资源,避免过期写入。
更正(2026-10-20)
链接到“更正(2026-10-20)”一节本次修订按 RFC 9110 表述 If-Match 条件为假时可选的响应,并把幂等保留期限相关表述限定为
Internet-Draft 对生命周期管理与过期策略的要求。
另请参阅
链接到“另请参阅”一节- 离线策略
- Background Sync API 浏览器支持
- 存储参考
- Indexed Database API 3.0 (W3C)
- Background Synchronization API (MDN)
- HTTP Semantics: Preconditions (RFC Editor)
- The Idempotency-Key HTTP Header Field (IETF Internet-Draft,进行中的工作)