云盘客户端同步(一):本地操作日志与同步协议

1 分钟阅读
·

云盘客户端将新增、修改、移动和删除记录为可重放的本地操作日志,并通过游标与服务端日志比对,确定上传、拉取和确认的同步顺序。

桌面云盘客户端最容易先想到的做法,是隔一段时间扫描一次同步目录,再把本地文件树和服务端文件树做比较。这个办法能找出一些差异,却很难回答几个实际问题:一个文件先改名又移动,服务端应该看到哪两个操作;本地删除后又在相同路径创建了新文件,两个对象如何区分;请求在网络超时后,到底是服务端没有收到,还是已经执行成功但响应丢了。

同步时更可靠的基础对象是操作记录。客户端把用户在本地做过的新增、修改、移动、重命名、删除写入本地日志。服务端也为已接受的目录变更追加日志。客户端带着上次处理到的服务端游标提交本地操作,再拉取游标之后的服务端日志。这样即使进程退出或请求重试,仍然能知道哪些操作尚未确认,哪些远端变更尚未应用。

假设用户在笔记本离线修改了 计划.txt,与此同时另一台电脑把所在目录改了名字。客户端需要分别判断:本地有多少尚未提交的操作,服务端从上个游标之后新增了什么操作,以及这些操作在当前版本下能否应用。仅比较两棵文件树无法保留这些操作的先后关系,也不能准确恢复中断前的进度。

本文只讨论文件和目录的元数据同步顺序,包括名称、父目录、删除标记和版本。文件内容的上传下载、鉴权和权限校验由其他模块处理。内容传输完成后,客户端可以用内容摘要或内容版本引用生成相应的元数据操作。

本地监听事件不能直接当作同步操作

文件系统通知通常只说明某个路径发生过变化。不同系统的通知粒度不同,保存一个文件可能产生多次修改事件,移动可能表现为旧路径删除和新路径创建。监听层应该尽量收集原始信息,但不应把每条通知直接发给服务端。

客户端需要有一层归并逻辑,将短时间内的事件整理成稳定操作。例如用户新建 草稿.txt 后连续保存五次,可以保存一条 create 操作,内容引用指向最后一次保存的版本。若某个尚未上传的新文件被删除,可以取消对应的 create 操作,不必把创建和删除都发送到服务端。

操作分为五类:

  • create:创建文件或目录,带上对象类型、名称和父目录 ID。
  • update:更新文件的内容引用或其他可修改元数据。
  • move:改变对象的父目录 ID。
  • rename:改变对象名称。
  • delete:为对象记录删除操作,服务端保留对象 ID 和删除版本,不能只按路径删除。

路径适合展示给用户,不适合用作对象身份。/项目/计划.txt 被移动或重命名后路径会变化;文件删除后,同一路径还可以被另一个新文件占用。因此客户端首次发现一个对象时就要保存稳定的 fileId。本文采用客户端创建 ID 的协议:新建文件或目录时,客户端生成随机且全局唯一的 fileId,将它与 create 操作、本地索引和后续依赖操作一起持久化。服务端接受创建后永久沿用该 ID,不分配替代 ID,也不维护临时 ID 映射。服务端需要校验该 ID 尚未被其他对象占用,并将重复提交同一 operationId 返回为首次结果。

一条本地操作的结构可以写成这样:

function makeOperation(input) {
  if (input.type === "create" && !input.entryType) {
    throw new Error("create requires entryType");
  }
  if (!input.fileId) {
    throw new Error("operation requires client-generated fileId");
  }

  return {
    operationId: input.deviceId + ":" + input.sequence,
    deviceId: input.deviceId,
    sequence: input.sequence,
    type: input.type,
    entryType: input.type === "create" ? input.entryType : null,
    fileId: input.fileId,
    parentId: input.parentId || null,
    name: input.name || null,
    originalName: input.type === "rename" || input.type === "move"
      ? input.originalName || null : null,
    originalParentId: input.type === "rename" || input.type === "move"
      ? input.originalParentId || null : null,
    baseVersion: input.baseVersion || 0,
    contentHash: input.contentHash || null,
    createdAt: Date.now(),
    state: "pending"
  };
}

type 表示这条日志要执行的操作,entryType 表示被创建对象是 "file" 还是 "folder"。只有 create 操作使用 entryType,并且创建时必须提供它,不能用 type 同时表示这两个概念。创建操作的 fileId 必须在写入本地索引和操作日志前生成;moverenameupdatedelete 及依赖该对象的后续操作都引用这个同一个 ID。

operationId 在一台设备上必须唯一。客户端首次注册设备时生成并持久化稳定的 deviceId;每次新增操作时,在写入操作记录的同一事务中递增并保存 sequence。重启后继续使用该设备标识和下一个序号。重装或需要把本机视为新设备时,生成新的 UUID 作为 deviceId,不能复用旧设备标识而将序号重新从零开始。服务端需要保存每个 operationId 的首次处理结果,保存时间至少覆盖客户端允许的合法重试窗口。

sequence 用于表示同一设备产生操作的稳定顺序,也用于构造 operationId。它不能代替服务端日志顺序。baseVersion 表示用户进行修改时看到的对象版本。例如本地编辑基于文件版本 12,提交时仍携带 12;如果服务端当前已经是版本 13,服务端可以据此拒绝覆盖并返回冲突信息。冲突的用户处理放到本系列第三篇。

renamemove 除了目标 nameparentId,还记录操作生成时的 originalNameoriginalParentId,取自当时的本地索引。这两个字段用于并发变更比对:收到版本冲突响应时,客户端把服务端当前条目与它们比较,判断名称或父目录是否也被另一端修改过;只是内容版本落后的操作可以基于新版本重新提交,不必交给用户。对应的前置检查在第三篇展开。这两个字段仅用于客户端收到冲突响应后的本地比对,不随同步请求发送,因此上面的请求示例中没有出现。

contentHash 只是内容模块产出的摘要或内容版本引用。同步协议不根据这个字段传输字节,也不负责校验分块。

本地数据库以追加方式保存操作。监听层和归并层生成操作后,先将它写入事务日志,再允许同步线程读取它。服务端确认前,记录都不能因为一次请求结束而删除。收到接受结果后的记录可以标为 confirmed,但在对应服务端日志被客户端消费前仍不能彻底删除。客户端可以保留完整操作记录,也可以在清理后持久化独立的“已消费 operationId”记录;后者必须能让客户端识别同一操作的回传日志,且保存时间至少覆盖服务端日志保留窗口或恢复流程所需的期限。

服务端日志和两个进度

服务端接受目录变更后,为变更追加一条全局有序日志。日志可以使用递增序号,也可以使用不可回退的游标。本文假定服务端日志条目中的 cursor 单调递增:

{
  cursor: 81042,
  operationId: "mac-7f2:183",
  type: "rename",
  fileId: "file-91",
  parentId: "folder-3",
  name: "计划-五月.txt",
  version: 13,
  sourceDeviceId: "mac-7f2"
}

客户端要保存两个互不替代的进度。

第一个是 remoteCursor,表示客户端已经连续完整地处理到哪一条服务端日志。游标之前的日志必须已经写入本地索引;涉及内容更新时,下载任务或内容缺失标记也必须已经登记。对于本机发起的操作,对应日志还必须已经完成确认或去重记录。它不表示本地操作都上传完成。

第二个是本地操作的确认状态。某条 pending 操作收到服务端接受结果后变成 confirmed,被拒绝且需要用户处理时变成 conflictfailed。即使本地操作已经确认,客户端也仍要消费服务端日志中的对应条目。若服务端延迟回传已接收操作,客户端必须从旧游标继续请求,直到收到并消费该条日志,不能因为 accepted 结果跳过相关日志后推进游标。

对象版本和游标解决的是不同问题。版本用于判断某项操作基于什么状态创建,游标用于限定客户端已经消费的服务端日志范围。一个批次里可以有多个对象的版本变化,但只会有一段连续的服务端日志游标。

服务端日志有保留窗口。如果客户端离线时间超过这个窗口,服务端应明确返回游标失效,客户端随后请求目录快照,比较本地索引并重新建立游标。重新校准和长时间离线恢复将在下一篇展开。

同步批次的请求和响应

一次元数据同步请求应携带客户端已经消费的游标、本地待提交操作和设备信息。客户端版本用于服务端兼容协议字段,设备 ID 用于诊断和回传来源识别。

var request = {
  deviceId: "mac-7f2",
  clientVersion: "1.6.0",
  remoteCursor: 81020,
  operations: [
    {
      operationId: "mac-7f2:183",
      type: "rename",
      fileId: "file-91",
      parentId: "folder-3",
      name: "计划-五月.txt",
      baseVersion: 12,
      contentHash: "e3b0c442..."
    }
  ]
};

服务端先按操作 ID 查询是否已经处理过同一条操作。已处理过时返回第一次处理的结果,不再执行一次重命名或创建。对于 create,服务端使用请求中的客户端 fileId 创建对象;该 ID 已被另一对象占用时拒绝操作。未处理的其他操作则校验对象存在性、基础版本和目录约束,接受后写入对象数据和服务端日志。响应包含每条操作的接受结果、拒绝结果、从请求游标之后读到的服务端日志和 nextCursoraccepted.fileId 始终等于请求操作的 fileId,协议中不存在 clientFileIdserverFileId 的映射字段。

var response = {
  accepted: [
    { operationId: "mac-7f2:183", fileId: "file-91", version: 13 }
  ],
  rejected: [
    {
      operationId: "mac-7f2:184",
      reason: "version_conflict",
      retryable: false,
      currentVersion: 14,
      currentEntry: {
        fileId: "file-91",
        parentId: "folder-3",
        name: "计划-六月.txt"
      },
      conflict: { type: "content_and_name" }
    }
  ],
  changes: [
    {
      cursor: 81021,
      operationId: "mac-7f2:183",
      type: "rename",
      fileId: "file-91",
      parentId: "folder-3",
      name: "计划-五月.txt",
      version: 13,
      sourceDeviceId: "mac-7f2"
    }
  ],
  nextCursor: 81021
};

每个 rejected 条目都要带 operationIdreasonretryablereason 使用预先定义的枚举,例如 version_conflictnot_foundparent_deletedinvalid_namepermission_denied。服务端在可提供时返回 currentVersioncurrentEntryconflict 信息,客户端据此展示和处理失败。version_conflictrejected 中的一类,只有这种拒绝会进入冲突处理;其他不可重试拒绝通常标记为 failed,可重试拒绝保留为 pending 并记录原因。

nextCursor 是服务端对本次响应交付范围的声明:它表示本次 changes 已按游标连续完整交付的最后一个游标,不表示客户端已经应用这些日志。changes 为空时,nextCursor 必须等于请求中的 remoteCursor。服务端不能把尚未随响应交付的日志,或仅因本批操作已接受而尚未回传的日志计入 nextCursor。客户端要校验变化列表从请求游标开始连续,不能接受中间缺号后仍跳到更大的 nextCursor。只有在同一事务内完成远端索引更新、下载任务或内容缺失标记登记,以及本机操作的确认或去重处理后,客户端才可以将自己的 remoteCursor 推进到这个 nextCursor

服务端可以在响应中包含本批刚接受的操作,也可以让客户端在下一次拉取时看到它们。前一种方式减少一次请求,后者也可行。无论采用哪种方式,客户端都必须按服务端 cursor 顺序消费日志,不能根据本机时间或网络响应到达顺序决定应用顺序。

同步循环需要优先保证落盘顺序。applyRemoteChange 的边界是更新本地元数据索引,并为内容更新登记下载任务或内容缺失标记;实际字节下载可以在事务提交后异步执行。登记任务不能晚于游标推进。客户端必须以 sourceDeviceId === state.deviceId 显式判定自设备日志。自设备日志只用于确认本地操作、更新服务端版本和持久化已消费记录,不能调用文件系统落地逻辑。若来源是本机但本地操作和已消费记录都找不到,当前批次不能应用,也不能推进 remoteCursor。客户端应持久化恢复任务并停止普通同步;它不能把该日志当作其他设备变更写入文件系统。只有来自其他设备的变更才调用文件系统落地逻辑。

function syncOnce(store, api, callback) {
  var state = store.readSyncState();
  var operations = store.listReadyOperations(100);

  api.sync({
    deviceId: state.deviceId,
    clientVersion: state.clientVersion,
    remoteCursor: state.remoteCursor,
    operations: operations
  }, function (err, result) {
    if (err) return callback(err);

    var changes = result.changes.slice().sort(function (a, b) {
      return a.cursor - b.cursor;
    });

    if (!isContiguous(state.remoteCursor, changes, result.nextCursor)) {
      return callback(new Error("non-contiguous server changes"));
    }

    var missingSelfChange = findMissingSelfChange(store, state, changes);
    if (missingSelfChange) {
      return blockForSelfOperationRecovery(store, state, missingSelfChange,
        callback);
    }

    store.transaction(function (tx) {
      result.accepted.forEach(function (item) {
        tx.markOperation(item.operationId, "confirmed", item.version);
      });

      result.rejected.forEach(function (item) {
        var operationState = item.reason === "version_conflict" ? "conflict" :
          (item.retryable ? "pending" : "failed");
        tx.markOperation(item.operationId, operationState, item.currentVersion);
        // 错误记录同时保存当前条目和冲突信息,作为诊断上下文。
        tx.saveOperationError(item.operationId, item.reason, item.retryable, {
          currentEntry: item.currentEntry || null,
          conflict: item.conflict || null
        });
      });

      changes.forEach(function (change) {
        var isSelfDevice = change.sourceDeviceId === state.deviceId;

        if (isSelfDevice) {
          // 进入这个分支前已经验证本地日志或已消费记录存在。
          if (tx.hasOperation(change.operationId)) {
            tx.confirmOperation(change.operationId, change.version);
            tx.updateEntryVersion(change.fileId, change.version);
            tx.markOperationLogConsumed(change.operationId, change.cursor);
          } else {
            tx.updateEntryVersion(change.fileId, change.version);
          }
          return;
        }

        tx.applyRemoteChange(change);
      });

      // 此处事务已经完成索引更新、下载任务登记及自操作处理。
      tx.saveRemoteCursor(result.nextCursor);
    }, callback);
  });
}

function findMissingSelfChange(store, state, changes) {
  for (var i = 0; i < changes.length; i++) {
    var change = changes[i];
    if (change.sourceDeviceId === state.deviceId &&
        !store.hasOperationOrConsumedRecord(change.operationId)) {
      return change;
    }
  }
  return null;
}

function blockForSelfOperationRecovery(store, state, change, callback) {
  store.transaction(function (tx) {
    tx.recordSelfOperationAnomaly(change.operationId, change.cursor,
      "self operation is missing from local log and consumed records");
    tx.createRecoveryTask({
      type: "reconcile_missing_self_operation",
      operationId: change.operationId,
      fileId: change.fileId,
      cursor: change.cursor,
      expectedVersion: change.version
    });
    tx.blockSync("missing_self_operation", change.cursor);
    // 不写 remoteCursor,也不写这批 accepted、rejected 或 changes 的结果。
  }, function (err) {
    if (err) return callback(err);
    callback(new Error("sync blocked: self operation recovery required"));
  });
}

function isContiguous(remoteCursor, changes, nextCursor) {
  if (changes.length === 0) return nextCursor === remoteCursor;
  if (changes[0].cursor !== remoteCursor + 1) return false;

  for (var i = 1; i < changes.length; i++) {
    if (changes[i].cursor !== changes[i - 1].cursor + 1) return false;
  }

  return changes[changes.length - 1].cursor === nextCursor;
}

上述连续性校验假定游标是连续递增序号。若服务端游标是不可比较的令牌,响应还需要提供可验证的前继关系,客户端按该关系检查完整区间。无论游标采用何种编码,客户端都只在同一事务已经处理完整连续区间后,才把 remoteCursor 更新为服务端返回的 nextCursor。进程在事务前退出时,下次会从旧游标重新读取同一批日志;进程在事务后退出时,游标已经对应完成的本地状态。自设备操作通过本地操作记录或持久化的已消费 operationId 记录去重;其他设备的重复变更按 fileId 和版本幂等处理。

自设备日志缺少本地操作和已消费记录表示本地日志、清理策略或索引可能损坏。普通同步必须保持 blocked,并从旧 remoteCursor 恢复,不能把该条日志视为已消费,也不能为它临时补一条已消费记录。恢复任务应请求服务端目录快照及目标对象的当前版本,重建或校验本地元数据索引,再逐条重新核对仍未确认的本地操作及其依赖。只有恢复事务已经写入完整索引、下载任务或内容缺失标记、剩余本地操作状态和已消费记录,且能证明该服务端快照覆盖缺失日志的 cursor,才解除阻塞并将 remoteCursor 更新到快照游标。恢复失败时保留阻塞状态,供用户导出诊断信息或重建本地副本。

请求超时不等于服务端没有执行。客户端不得新建一个操作来重试,而应以原来的 operationId 重发原记录。服务端以 operationId 返回已有结果,客户端再根据确认结果更新本地状态。这就是操作提交的幂等条件。

同步引擎写文件时要处理事件回流

远端变更落到本地文件系统后,文件系统监听器仍然会收到创建、修改或重命名事件。监听器通常不能从这些通知直接得到 fileIdoperationId。如果它把事件当作用户操作重新写入本地日志,客户端会把刚从服务端收到的变更又上传一遍,轻则制造无效日志,重则产生循环修改。

同步引擎在执行文件系统操作前,应登记一次性确认记录,至少包括预期路径、预期版本或内容摘要、写入时间和关联的 fileIdoperationId。监听器收到事件后先按路径索引找到候选记录,再核验路径、版本或摘要是否符合预期。匹配时消费该确认记录,只更新监听器的观察状态,不生成待上传操作。

确认记录需要有合理的有效期,但不能在超时后把事件静默忽略。未匹配记录、核验失败的事件,以及过期后才到达的事件,都回到归并层,由归并层根据当前路径、索引和内容摘要决定是否生成用户操作。这样可以处理延迟通知,也不会长期屏蔽用户在同一路径上的真实修改。

仅用“同步期间暂停监听”通常不够。文件系统通知可能延迟到写入结束之后,暂停期间用户也可能真的编辑文件。确认记录应精确描述预期结果,并在事件到达时完成核验。

目录依赖决定提交顺序

操作日志是追加记录,不表示任意顺序都能提交。同步线程取出待提交操作时,需要先构造显式依赖图,再按稳定规则拓扑排序。

  • 创建父目录的操作依赖于其上级目录已存在。
  • 在新目录中创建文件或子目录、移动对象到新目录的操作,依赖目标目录的创建操作。
  • 同一 fileId 的操作按 sequence 建立前后依赖。例如先重命名后移动,服务端必须按这个顺序处理两条记录。
  • 删除目录时需要定义明确策略。若协议把删除目录定义为递归删除,只提交父目录删除并由服务端生成受影响对象的日志;若目录和子项逐项删除,则子项删除依赖于父目录删除之前完成。

listReadyOperations 不能简单按创建时间取前 100 条。它先从本地索引和未确认日志建立依赖图,以 sequenceoperationId 作为稳定的同级排序规则,进行拓扑排序后再截取批次。依赖指向已确认操作时,索引必须已经反映该操作的结果;依赖同时位于当前批次时,服务端必须承诺按请求中的排序在同一请求内原子处理。本文采用后一种约定:服务端要么按整个拓扑序接受并写入日志,要么返回能够指出未满足依赖的拒绝结果,不能任意并发执行批次内操作。

function listReadyOperations(log, index, limit) {
  var graph = buildDependencyGraph(log.pending(), index);
  var ordered = stableTopologicalSort(graph, function (a, b) {
    return a.sequence - b.sequence ||
      (a.operationId < b.operationId ? -1 : 1);
  });

  return takeDependencyClosedPrefix(ordered, graph, index, limit);
}

本地索引只能过滤显然无法执行的操作,不能替代服务端校验。另一台设备可能在客户端提交前删除了目标目录,服务端仍需根据当前版本决定接受、拒绝或返回冲突。

用户看到的状态来自日志结果

客户端界面不应把“HTTP 请求成功”直接显示为“已同步”。请求成功可能只表示服务器返回了响应,批次中仍可能有冲突条目或尚未应用的远端日志。文件列表和同步面板可以根据操作记录聚合出几种状态:

状态 判断依据
待同步 对象存在 pending 操作,尚未开始提交或等待依赖。
同步中 操作已经被同步线程取出,等待本次请求结果。
已同步 本地相关操作已确认,且本地索引已消费到对应服务端日志。
需要处理 操作被服务端拒绝、基础版本冲突,或本地无法应用远端变更。

状态需要关联具体的操作记录,不能只挂在网络连接上。离线时,用户仍可以继续修改文件,界面应显示待同步;网络恢复后,已有待同步操作会进入同步中;某一项冲突不应把整个同步目录都标成失败。

诊断信息也应保存在本地。定位某台设备的同步问题时,至少需要操作 ID、设备 ID、文件 ID、基础版本、服务端游标、请求时间和失败原因。没有这些信息,只看到“同步失败”很难判断是网络超时、对象已删除,还是同一操作已经被服务端处理。

本文的范围

本地操作日志保存了用户在本机做过什么,服务端游标定义了客户端已经连续应用到哪一段服务端日志。两者分别处理提交恢复和拉取恢复。基础版本、稳定文件 ID 和操作 ID 让服务端能够判断对象身份、版本边界和重试请求。

还有几种情况没有在这里展开:文件内容上传到一半断开,客户端重启后如何继续;服务端日志已经超过保留窗口,如何重新校准;本地和远端基于不同版本修改同一文件时,如何保护用户内容。这些问题需要在日志协议之上继续保存内容传输进度和冲突记录。下一篇先处理增量传输、断点恢复和游标失效后的重新校准。


481 字 · 59 段落
ximing

Written by ximingFollow onGitHub

相关文章