前端知库

前端离线同步怎样处理重试和数据冲突?

用离线编辑草稿说明本地状态、待同步操作和服务端版本的关系,区分可重试失败与冲突,并说明幂等、合并、清理和用户决策边界。

参考答案

简答

离线同步的做法是:本地先写入(IndexedDB),并记录一个待同步操作队列;联网后按顺序重放。每个操作带唯一 ID,服务端据此实现幂等,避免重试导致重复写入。网络错误、5xx 可以退避重试;409 冲突或版本不匹配不能盲目重试,要通过合并策略或让用户选择来解决。同步成功后再清理队列。

离线笔记或表单编辑器不能把“保存成功”定义成一次 fetch 返回 200。用户可能在地铁里编辑,网络恢复后同时打开了另一台设备;本地内容需要先保存,上传失败要能重试,服务端版本变化还要避免静默覆盖别人的修改。

离线同步至少包含三份事实:本地当前文档、尚未发送的操作或变更,以及服务端已经确认的版本。只保存一个 isSynced 布尔值,无法解释“本地有新编辑但上一次请求正在重试”这种真实状态。

本地保存和同步队列是不同层次

编辑输入时,应先把草稿写入 IndexedDB 等异步存储,保证刷新或页面被回收后仍有恢复机会。同步队列记录待发送的业务变更,不能只在内存数组里保存,否则标签页关闭就会丢掉待同步内容。

一个待同步记录至少要能回答:属于哪个用户和文档、基于哪个服务端版本、客户端操作 ID 是什么、载荷是什么、当前尝试次数和最后错误是什么。敏感内容仍要按应用安全策略保护,不能因为放进本地数据库就自动加密。

JavaScript
const change = {
  id: crypto.randomUUID(),
  documentId: 'note-42',
  baseVersion: 7,
  patch: { title: '新的标题' },
  createdAt: Date.now(),
  attempts: 0,
};

id 是这次客户端操作的幂等标识,不是服务端文档版本。客户端重试时应复用同一个操作 ID,不能每次重试都生成新 ID,否则服务端可能把同一编辑当成多次写入。

网络恢复不等于可以立刻发送全部队列

navigator.onLine 只是一个提示,不能证明目标 API 已经可访问。网络恢复、页面重新可见或后台同步事件触发后,仍要通过实际请求确认。多个标签页同时开始同步时,还需要使用 Web Locks、服务端幂等或其他协调方式,避免重复消费同一队列。

发送队列时应限制并发和批量大小。暂时不可用的网络错误、超时和 5xx 可以使用有上限的指数退避;认证失效、参数错误和权限拒绝通常需要用户重新登录或修改数据,不能无限重试。队列达到上限时要停止接受更多数据、提示用户或导出草稿,不能让内存和磁盘无限增长。

后台同步 API 的支持范围和触发时机有限,不能把它当成“浏览器一定会在网络恢复后执行”的保证。即使没有 Background Sync,应用也可以在页面再次打开、获得焦点或用户点击“重试”时继续处理队列。

If-Match 和版本号用来发现冲突

假设本地基于服务端版本 7 修改标题,另一台设备已经把文档更新到版本 8。客户端直接发送覆盖请求,服务端如果无条件接受,后一台设备就会静默覆盖前一台的修改。

一种常见方案是提交 baseVersion 或 If-Match:

http
PATCH /api/documents/note-42 HTTP/1.1
If-Match: "version-7"
Idempotency-Key: 5d5b1e3a-...
Content-Type: application/json

{"title":"新的标题"}

如果服务端当前版本仍是 7,可以应用变更并返回新版本 8;如果已经变成 8,则返回冲突结果,例如 409 或 412,并提供当前服务端表示。客户端不能把这类响应当成普通网络失败自动重发同一个覆盖请求。

ETag、版本号和操作 ID 的职责不同:版本号或 ETag 检查“我基于哪份数据编辑”,幂等键识别“是不是同一次操作”。只使用其中一个,不能同时解决冲突和重复提交。

冲突解决要根据数据语义选择

简单文本可以让用户看到本地版本、服务端版本和差异,选择保留哪一份或手动合并。不要默认采用“最后写入者获胜”,除非业务明确接受覆盖。

结构化字段可以按字段合并,但字段之间可能存在约束。例如收货地址的城市和邮编不能由两台设备分别修改后随意拼接。集合、排序列表和计数器也各有冲突语义,不能只套一个深度合并函数。

富文本、绘图和协同编辑可能需要操作变换、CRDT 或服务端协同协议。把整个文档当成一个字符串反复覆盖,无法提供多人同时编辑的可靠体验。复杂合并算法还要限制输入大小和执行时间,避免恶意或异常数据拖垮页面。

成功、失败和不确定不能混成一个状态

服务端返回成功后,本地队列应在事务中标记为已确认,并更新对应操作的服务端版本。请求超时、连接断开或响应解析失败都可能使客户端无法确认结果:服务器可能已经应用操作,也可能没有收到。不能简单生成新操作再发一次,应按服务端幂等协议查询或重试原操作。幂等记录的保留期和同键不同载荷的拒绝规则也必须约定。

确认操作 A 时,用户可能已经在本地继续编辑出 B。不能用 A 的旧响应覆盖当前草稿,再把整个文档标成已同步;应只确认 A,保留 B 的修改,并根据新服务端版本安排后续提交。请求中的载荷与幂等键绑定后也不能原地改写,同键重试必须表示同一次操作。

本地草稿与对应待同步记录尽量在同一 IndexedDB 事务中落盘,避免草稿已保存却没有队列项,或队列包含用户看不到的内容。事务不能跨网络等待保持打开:先提交本地事务,执行网络请求,再用新的短事务校验版本并确认结果。浏览器存储仍可能遇到配额、清理和故障,应提供明确的保存状态与恢复路径。

用户退出账号时,应用要决定待同步数据是保留、加密导出、清理还是等待确认。不能把下一个账号登录到同一浏览器后继续显示上一个账号的离线队列。队列、缓存和当前会话要按用户或租户隔离,退出时清理或冻结对应数据。

页面恢复和多标签页也属于同步流程

页面从 bfcache 恢复、设备重新联网或另一个标签页完成同步后,应重新读取本地权威记录和服务端状态,而不是只依赖组件内存中的旧布尔值。BroadcastChannel 可以通知“某文档发生变化”,但它没有历史、确认和事务;收到通知后仍应按版本重新读取。

如果两个标签页同时消费同一条队列,需要短而明确的互斥范围。网络请求可以在锁外完成,拿到响应后再用版本检查和短事务提交结果,避免一把锁长时间等待网络或用户操作。没有 Web Locks 的环境可以依赖服务端幂等和数据库事务,但不能删除锁后继续做无保护的并发写入。

验收要覆盖真实故障顺序

测试不能只模拟一次断网后恢复。应覆盖编辑后立即关闭页面、请求已到达但响应丢失、重复重试、服务端版本冲突、多个标签页同时同步、账号退出、队列损坏、配额不足、旧客户端和新服务端协议不兼容等情况。

离线同步的目标是让数据状态可解释、可恢复、可由用户做决定。Service Worker、IndexedDB、重试和冲突算法只是工具;最终仍要由应用定义哪份数据是权威、哪些操作可重放、冲突怎样解决,以及用户怎样知道自己的修改是否真正保存。