设计离线优先的数据同步系统
问题
如何设计一个在弱网或完全离线时仍可读写、恢复网络后能够安全同步,并支持多标签页和多设备协作的 Web 应用?
离线优先不是“缓存一个页面”,核心是把客户端设计成一个可以暂时独立工作的数据节点:
- 本地数据库是真正的读取入口,UI 不直接依赖网络响应;
- 写操作先形成带唯一 ID 和基础版本的 Outbox 变更日志,本地事务提交后立即更新 UI;
- 同步器在前台、网络恢复或后台同步机会出现时批量上传;
- 服务端按幂等键去重,返回权威版本和增量游标;
- 冲突不能统一“以后写覆盖”,要按业务选择拒绝、字段合并、操作转换或 CRDT;
- 还要处理 Schema 迁移、存储配额、退出登录清理、跨标签页选主和可观测性。
Service Worker 负责离线资源和后台机会,本地业务数据通常放 IndexedDB;不要把 Service Worker 当成永不退出的后台进程。
一、需求与边界
以离线笔记应用为例,核心需求包括:
- 已访问的笔记离线可读;
- 离线时可以新增、编辑、删除;
- 恢复网络后自动同步,不重复创建或覆盖新版本;
- 多标签页不会同时重复上传同一批操作;
- 多设备编辑发生冲突时不静默丢数据;
- 用户能够看到离线、同步中、冲突和失败状态。
非功能目标:
| 目标 | 示例指标 |
|---|---|
| 本地交互 | 写入本地数据库后 100ms 内反馈 |
| 数据耐久 | 页面刷新、崩溃后待同步操作不丢失 |
| 幂等 | 同一个操作重试任意次数只生效一次 |
| 恢复 | 网络恢复后在可控时间内完成增量同步 |
| 可解释 | 用户能识别未同步和冲突的数据 |
二、总体架构
数据职责
| 数据 | 存储位置 | 原因 |
|---|---|---|
| HTML、JS、CSS、图标 | Cache Storage | 按 Request/Response 缓存,适合 App Shell |
| 业务实体 | IndexedDB | 支持结构化数据、索引和事务 |
| 待上传操作 | IndexedDB Outbox | 页面关闭后仍可恢复 |
| 同步游标 | IndexedDB | 只拉取游标之后的增量 |
| 敏感权威数据 | 服务端 | 客户端存储不应成为安全边界 |
三、本地数据模型
type SyncStatus = 'pending' | 'syncing' | 'conflict' | 'failed';
interface LocalEntity<T> {
id: string;
data: T;
serverVersion: number;
updatedAt: number;
deleted?: boolean;
}
interface OutboxMutation<T = unknown> {
// 客户端生成的全局唯一幂等键,重试时保持不变
mutationId: string;
clientId: string;
sequence: number;
entityType: string;
entityId: string;
operation: 'create' | 'update' | 'delete';
baseVersion: number;
payload: T;
status: SyncStatus;
retryCount: number;
createdAt: number;
lastError?: string;
}
interface SyncCheckpoint {
scope: string;
cursor: string | null;
lastSyncedAt?: number;
}
mutationId 用于服务端幂等,baseVersion 用于发现并发修改,sequence 用于保持单客户端操作顺序。
四、本地写入链路
必须在同一个 IndexedDB 事务中写业务实体和 Outbox。否则可能出现 UI 已更新但没有待同步记录,或者操作已入队但本地数据没有变化。
async function updateNote(
db: IDBPDatabase<AppSchema>,
note: LocalEntity<Note>,
patch: Partial<Note>,
): Promise<void> {
const tx = db.transaction(['notes', 'outbox'], 'readwrite');
const now = Date.now();
const next: LocalEntity<Note> = {
...note,
data: { ...note.data, ...patch },
updatedAt: now,
};
const mutation: OutboxMutation<Partial<Note>> = {
mutationId: crypto.randomUUID(),
clientId: getStableClientId(),
sequence: await nextSequence(tx),
entityType: 'note',
entityId: note.id,
operation: 'update',
baseVersion: note.serverVersion,
payload: patch,
status: 'pending',
retryCount: 0,
createdAt: now,
};
await tx.objectStore('notes').put(next);
await tx.objectStore('outbox').add(mutation);
await tx.done;
}
五、同步协议
推荐一次同步包含两个阶段:
- Push:按客户端顺序上传待处理操作;
- Pull:使用服务端游标拉取已确认操作之后的增量变更。
interface SyncRequest {
clientId: string;
cursor: string | null;
mutations: OutboxMutation[];
}
interface MutationResult {
mutationId: string;
status: 'applied' | 'duplicate' | 'conflict' | 'rejected';
entity?: LocalEntity<unknown>;
reason?: string;
}
interface SyncResponse {
results: MutationResult[];
changes: LocalEntity<unknown>[];
nextCursor: string;
hasMore: boolean;
}
服务端处理原则:
- 以
mutationId建唯一约束,重复请求返回原结果; - 先校验租户、用户和实体权限,再处理操作;
- 对需要强一致的实体比较
baseVersion; - 应用变更和写增量日志尽量处于同一事务;
- 返回服务端时间、权威版本和稳定游标。
六、冲突解决策略
| 策略 | 适用场景 | 风险 |
|---|---|---|
| 服务端拒绝,用户手工合并 | 合同、配置等高价值数据 | 用户成本高 |
| Last Write Wins | 在线状态、低价值偏好 | 可能静默丢更新 |
| 字段级合并 | 不同字段独立修改 | 同字段仍会冲突 |
| 操作日志合并 | 计数器、集合增删 | 需要操作语义 |
| OT / CRDT | 多人实时文本和结构化协作 | 实现与调试成本高 |
设备时间可能错误,也可能被用户修改。Last Write Wins 应使用服务端接收时间或逻辑时钟,并且只用于允许丢失并发更新的数据。
发生冲突时应保留:本地版本、服务端版本、原始操作和冲突原因。不要为了“同步成功率”直接覆盖用户内容。
七、多标签页协调
多个标签页可能同时发现网络恢复并启动同步,可采用:
- Web Locks API 竞争
sync-owner锁; - BroadcastChannel 广播同步状态和本地变更;
- 不支持 Web Locks 时使用带租约的 IndexedDB 记录;
- 即使客户端已经选主,服务端仍必须保证幂等。
async function runAsSyncOwner(sync: () => Promise<void>): Promise<void> {
if (!navigator.locks) {
await sync(); // 服务端幂等是最终防线
return;
}
await navigator.locks.request('offline-sync', { ifAvailable: true }, async lock => {
if (!lock) return;
await sync();
});
}
八、失败、迁移和安全
重试分类
- 网络中断、
429、5xx:指数退避并加入随机抖动; 401:先恢复认证,不无限重试;403、业务校验失败:标记 rejected,提示用户处理;- 版本冲突:进入冲突流程,不当成普通失败重试。
Schema 迁移
- 数据库升级函数必须支持从任意仍受支持的旧版本迁移;
- 大迁移分批执行,记录迁移游标,避免阻塞启动;
- 新旧客户端并存时,服务端同步协议需要版本协商;
- 迁移前保留可恢复快照或能够重新从服务端构建本地数据。
安全与隐私
- 本地缓存属于用户设备上的副本,不存放不必要的敏感字段;
- 退出登录时按账号和租户范围清理数据;
- XSS 可以读取同源 IndexedDB,因此 CSP、输出编码和依赖治理仍然关键;
- 前端的租户字段和权限判断不能替代服务端授权;
- 对共享设备提供“不要保留离线数据”的产品选项。
九、可观测性与测试
推荐监控:
- Outbox 长度、最老操作等待时间、同步成功率;
- 冲突率、拒绝率、各错误类型重试次数;
- 本地数据库打开和迁移失败率;
- 存储使用量、配额不足和数据重建次数;
- 从网络恢复到同步完成的耗时。
关键测试包括断网、慢网、重复请求、乱序响应、崩溃恢复、多标签页竞争、设备时间错误、Schema 跨版本升级和服务端部分成功。
常见面试问题
Q1: 离线优先和普通缓存有什么区别?
答案:普通缓存主要减少读取延迟;离线优先还允许本地写入,并需要可靠记录操作、恢复同步、处理幂等和冲突,因此本质上是分布式数据同步问题。
Q2: 为什么不能只用 localStorage?
答案:localStorage 同步阻塞主线程、容量有限、只存字符串且没有事务,不适合结构化业务数据和可靠 Outbox。IndexedDB 提供异步访问、索引和多对象存储事务。
Q3: Background Sync 是否可靠?
答案:不能把它当成可靠任务调度器。浏览器支持、权限、系统策略和执行时机都有限制。应用应在启动、回到前台、网络恢复等多个机会主动同步,并允许用户手工重试。
Q4: 如何避免重复创建数据?
答案:客户端首次操作时生成稳定的 mutationId 和实体 ID,所有重试保持不变;服务端以幂等键建立唯一约束,并返回第一次处理的结果。
Q5: 客户端和服务端谁是最终权威?
答案:通常服务端是跨设备和权限意义上的权威;客户端是离线期间的临时事实源。产品也可以采用 local-first 模型,但仍需明确共享、备份、权限和冲突语义。
Q6: 离线删除如何设计?
答案:先写 Tombstone,而不是立即物理删除。同步确认且超过多设备增量窗口后再清理,否则旧设备可能把已经删除的数据重新上传回来。