一个待办清单应用,手机和平板同时登录同一个账号,数据走distributedDataObject同步。产品需求很朴素:手机改任务标题,平板改负责人,两边都希望对方的修改能合并过来,而不是谁把谁盖掉。

测试同学复现出来的现象很具体:两台设备都断网,手机把标题从“需求评审”改成“周五需求评审”,平板把负责人从“小王”改成“小李”。恢复网络,同步状态显示成功,change回调也触发了。然后你打开详情页,标题是新的,负责人还是“小王”;或者反过来,负责人对了,标题退回了旧的。两台设备看到的最终结果还不一定一样,取决于谁先同步完。

日志里没有任何异常,status事件显示online,change事件列出了变化的字段,一切看起来都在正常工作。问题不在同步有没有发生,在于distributedDataObject的冲突收敛策略只做整条记录级别的最后写入获胜(LWW),它不解析你改的是标题还是负责人。手机发过去的是一份“标题已改”的记录,平板发过去的是一份“负责人已改”的记录,系统比较的是两份记录的到达顺序,然后保留后到的那一份,整份覆盖。先到的那份修改,被安静地丢掉了。

LWW是记录级的,不是字段级的

先把这条边界钉死。distributedDataObject的同步模型是最终一致性,冲突处理采用LWW:当多个设备几乎同时修改同一个对象的同一个属性时,底层按消息到达顺序逐次覆盖,最终保留最后一条到达的修改。系统不解析用户数据内容,也不提供自定义冲突回调来接管覆盖逻辑。

这意味着“手机改标题”和“平板改负责人”在你的业务眼里是两次互不冲突的修改,但在同步层眼里,它们是同一个分布式对象上的两次属性写入。如果这两个属性在同一个对象上,而两次写入的到达顺序决定了最终整条记录保留哪一份,那么先到的那份修改里携带的“另一个字段”也会跟着被覆盖。你改的是A字段,丢的是B字段,因为LWW只认“这条记录最终长什么样”,不认“你这次到底动了哪个字段”。

一个很自然的补救思路是给每个字段单独建一个分布式对象,让title和owner各自独立同步。这确实能把冲突粒度降到字段级,但代价是对象数量膨胀,sessionId管理复杂,而且单个分布式对象的实例数量本身有上限。把整份文档塞成一个巨大对象,会把所有字段绑在同一条LWW记录上;拆得过细,又会在管理成本上翻车。官方文档对这条边界的描述是:复杂类型通常以根属性作为最小修改单元,单个分布式对象和实例数量都有上限。

时间戳靠不住,时钟漂移会把LWW的判断带偏

有人会想:LWW比的是时间戳,那我给每次修改带上updatedAt,同步之后按时间戳取更新的那一份,不就行了吗?

问题在于设备时钟并不保证一致。手机和平板的系统时间可能有几十毫秒甚至几秒的漂移,Date.now()在跨设备场景下不是一个可靠的单调时钟。两台设备几乎同时修改,A设备的updatedAt比B设备大,可能只是因为A的时钟快了,不是因为A的修改真的更晚。用时间戳单独做仲裁,在时钟漂移面前会做出错误的覆盖决策。

比时间戳更稳的是给每次修改附带编辑开始时看到的公共版本号(baseVersion)和本次实际修改的字段集合(changedFields)。baseVersion回答“你改的时候基于哪个版本”,changedFields回答“你到底动了哪些字段”。这两条信息加在一起,应用层才能判断两次修改是真的冲突(改了同一个字段),还是可以安全合并(改了不同字段)。

typescript

interface TaskPatch {
  recordId: string;
  baseVersion: number;          // 编辑开始时的版本
  changedFields: string[];      // 本次实际修改的字段
  deviceId: string;
  logicalClock: number;         // 每台设备自己的单调计数
  values: Record<string, unknown>;
}

logicalClock是设备本地的单调递增计数,不依赖系统时间。两台设备的logicalClock不能直接比较大小,但它可以和baseVersion配合,判断一次修改是否基于最新的公共基线。

业务层做三方合并,系统只负责传输

修正的核心思路不是去改LWW,而是在LWW覆盖发生之前,在应用层的change回调里完成一次显式的合并。系统负责把变化推过来,你负责决定接不接受、怎么接。

一个最小可用的结构是:本地维护当前记录的baseVersion和字段快照,收到远端change时,检查远端修改的changedFields和本地的changedFields有没有交集。没有交集,自动合并;有交集,不能静默覆盖,要么走业务规则仲裁,要么把冲突暴露给用户选择。

typescript

function mergePatches(local: TaskPatch, remote: TaskPatch): MergeResult {
  const conflictFields = local.changedFields.filter(f =>
    remote.changedFields.includes(f)
  );

  if (conflictFields.length === 0) {
    // 改的是不同字段,自动合并,两边修改都保留
    return {
      resolved: { ...local.values, ...remote.values },
      conflict: false,
      conflictFields: []
    };
  }

  // 改了同一个字段,不能悄悄覆盖
  return {
    resolved: { ...local.values, ...remote.values },
    conflict: true,
    conflictFields
  };
}

conflictFields为空是自动合并的条件,不为空则必须走下一步。这个判断只看字段集合有没有交集,不比较时间戳,天然避开了时钟漂移的问题。自动合并时,{ ...local.values, ...remote.values }把两边的修改合在一起,因为changedFields没有交集,不会出现同一个键被两个值争抢的情况。

有交集时的处理取决于业务。金额、库存这类字段,覆盖任何一端都是数据损失,应该让用户选择或者走分布式锁互斥修改。标题、备注这类字段,可以用logicalClock或业务优先级做仲裁,但仲裁结果要记入日志,不能静默发生。

自写回声会让页面短暂显示正确值,然后被旧回调覆盖

还有一个比字段丢失更隐蔽的现象:同步之后页面确实闪了一下正确的合并结果,然后几百毫秒内又被拉回了错误的值。

根因是本地保存也会触发一次change事件,远端同步又触发一次change,页面如果对两次事件都无差别地刷新,就可能出现“本地正确值被远端旧快照覆盖”。更糟的是,如果change监听是在setSessionId之后才注册的,setSessionId和监听之间的数据变化会直接漏掉,你连旧快照什么时候来的都看不到。正确的顺序是先注册change和status监听,再调setSessionId加入组网。

对于自写回声,页面侧需要在change回调里区分“这次变化是不是我自己刚提交的”。一个务实的做法是给每次本地提交打一个pendingPatchId,change事件携带的字段和pendingPatchId匹配时,跳过页面刷新,等远端确认之后再走正常的合并路径。不区分来源,页面就会在本地和远端之间来回跳。

排查顺序:先看冲突粒度,再看合并逻辑,最后看事件顺序

下次同步之后字段莫名其妙丢了的时候,按这个顺序走,比反复调同步参数快。

第一步,确认丢的字段和你改的字段是不是在同一个分布式对象上。如果是,问题不在“同步没发生”,在LWW只做记录级覆盖。把互不相关的字段拆到不同的对象,或者接受在应用层做合并。

第二步,检查change回调里有没有对changedFields做交集判断。没有交集判断的“直接接受远端值”,就是在让LWW替你做业务决策。补上字段级的合并逻辑,不同字段自动合并,同字段冲突显式处理。

第三步,确认change和status监听是在setSessionId之前注册的。顺序反了,第一次同步的变化会被漏掉,你看到的是合并后的结果,却看不到合并过程。

第四步,如果页面出现“短暂正确又被覆盖”,检查change回调有没有区分本地提交和远端同步。自写回声没有被过滤,页面就会被自己的提交和远端的旧快照来回拉扯。

distributedDataObject的同步是传输层的同步,它保证变化到达,但不保证业务上互不冲突的修改一定都被保留。LWW是记录级的,不是字段级的;时间戳在跨设备场景下不可靠;change事件需要应用层自己判断“这次变化该不该直接接受”。下一次手机改标题、平板改负责人、同步完了只剩一个的时候,先别怀疑同步没到——打开change回调看一眼,你有没有在合并之前,先问一句这两个修改到底改的是不是同一个字段。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐