跳转到内容

离线数据同步与冲突解决

发布于 更新于

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 在租约有效时无法同时提交同一操作;在端点公布的保留窗口内,其幂等约定会处理请求执行时间超过租约的 剩余情况。

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 前置条件另行保护服务器资源,避免过期写入。

本次修订按 RFC 9110 表述 If-Match 条件为假时可选的响应,并把幂等保留期限相关表述限定为 Internet-Draft 对生命周期管理与过期策略的要求。