- 数据同步
【免费下载链接】remotely-save
Sync notes between local and cloud with smart conflict: S3 (Amazon S3/Cloudflare R2/Backblaze B2/...), Dropbox, webdav (NextCloud/InfiniCLOUD/Synology/...), OneDrive, Google Drive (GDrive), Box, pCloud, Yandex Disk, Koofr, Azure Blob Storage.
导读
本文档深入剖析开源仓库remotely-save(Obsidian 双向云同步插件)中新一代同步算法 V3 的完整设计:它通过引入"本地上次成功同步历史"这一第五输入源,实现了真正的删除检测(true deletion detection)、可配置的删除保护、双向 / 增量推送 / 增量拉取等多方向同步,并以一张张决策表(decision table)穷举本地与远端文件的所有状态组合。读完本文,你将理解 V3 的五个输入源分别是什么、双向与增量模式的决策分支编号(如 branch 09/10/22–39)各自对应什么操作,以及这些设计如何在仓库源码(尤其是 pro/src/sync.ts)中落地。
V3 的设计背景与定位
Sync Algorithm V3 设计文档起草于 2024-01-17(Drafted on 20240117),其定位是"一个绝对更好的同步算法"(an absolutely better sync algorithm),核心改进点在于两点:
- 更好地追踪删除(Better for tracking deletions):V2 依赖本地删除/重命名历史与远端删除历史,V3 则把"上次成功同步时的状态"固化为一个独立输入源,从而能判断"某侧文件消失到底是删除还是从未存在过";
- 更好地支持子分支(Better for subbranching):即决策树被拆分成更多细分分支,以便在不同同步方向(双向、仅推送、仅拉取、推送+删除、拉取+删除)下给出差异化、更安全的动作。
根据 docs/sync_algorithm/README.md 的目录结构,V1、V2、V3 三个算法版本是逐代演进的关系:V1 只有三个输入源(本地文件、远端文件、本地删除/重命名历史),V2 增加了远端删除历史成为四个输入源,V3 则在 docs/sync_algorithm/v2/README.md 的基础上进一步加入"上次成功同步历史",形成五个输入源。
算法来源与许可说明
设计文档明确声明,V3 本质上是对以下开源算法的组合改造,且这四个上游项目均以 MIT License 发布,因此不存在许可争议:
- Algorithm V2(本仓库自身的上一代算法);
- syncrclone(Jwink3101);
- rsinc(ConorWilliams);
- rclone 的 bisync 方案中的部分思想。
在源码层面,pro/src/sync.ts 的第 536–540 行注释也印证了这一点:"Heavy lifting. Basically follow the sync algorithm of https://github.com/Jwink3101/syncrclone",同时指出"Also deal with syncDirection which makes it more complicated"——即 V3 相比 syncrclone 额外处理了同步方向维度,这正是下述多张决策表的由来。
五个输入源与运行阶段
V3 拥有五个输入源:
| 编号 | 输入源 | 说明 |
|---|---|---|
| 1 | local all files | 本地全部文件(Obsidian 可直接通过其 API 提供) |
| 2 | remote all files | 远端全部文件(部分服务直接提供 API,部分服务需要插件递归扫描目录) |
| 3 | local previous succeeded sync history | 本地"上次成功同步"的历史记录 |
| 4 | local deletions | 本地删除记录 |
| 5 | remote deletions | 远端删除记录 |
运行阶段分为两次:
- 初始化运行(Init run):一次性"消费"远端删除记录(remote deletions),把历史数据转换为本地上次成功同步历史(local previous succeeded sync history)。也就是说,首次同步时旧算法遗留的远端删除历史被吸收进新算法的历史基准中,之后不再依赖它。
- 后续运行(Later runs):仅使用第 1、2、3 个输入源(本地文件、远端文件、上次成功同步历史)。这是 V3 与 V2 最关键的结构差异:删除检测不再依赖"删除历史"这条易失、易被伪造的记录,而是通过"当前状态 vs 上次成功同步状态"的差分来推断。
在实现上,prevSync(上次成功同步历史)以数据库记录的形式存储。从源码看,pro/src/sync.ts 中通过getAllPrevSyncRecordsByVaultAndProfile/upsertPrevSyncRecordByVaultAndProfile/clearPrevSyncRecordByVaultAndProfile(这些函数定义于 src/localdb.ts)按vaultRandomID + profileID维度读写每条路径的上次同步记录,dispatchOperationToActualV3在每个决策分支执行完毕后都会同步更新或清除对应记录,从而保证"上次成功同步状态"始终准确反映最新一次同步结果。
决策表:V3 的核心机制
V3 的核心是一系列"双向表"(bidirectional table),设计文档将其描述为基于 syncrclone 与 rsinc 修改而来;增量推送/增量拉取专用表又是在双向表基础上进一步修改。表中单元格的数字即代码中的决策分支编号(decision branch),??表示该组合在相应模式下不会出现或暂未定义分支。
在阅读表格前需要明确行/列含义:
- 行 = 本地状态:
local unchanged(本地未变)、local modified(本地已修改)、local deleted(本地已删除)、local created(本地新建); - 列 = 远端状态:
remote unchanged(远端未变)、remote modified(远端已修改)、remote deleted(远端已删除)、remote created(远端新建)。
双向同步(Bidirectional)
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|---|---|---|---|---|
| local unchanged | (02/21) do nothing | (09) pull | (07) delete local | (??) conflict |
| local modified | (10) push | (16/17/18/19/20) conflict | (08) push | (??) conflict |
| local deleted | (04) delete remote | (05) pull | (01) clean history | (03) pull |
| local created | (??) conflict | (??) conflict | (06) push | (11/12/13/14/15) conflict |
双向模式的代表性决策:
- branch 02/21 do nothing:本地与远端都未变(或内容完全相同),无需操作;
- branch 09 pull:本地未变但远端被修改,说明远端发生了新修改,拉取远端版本;
- branch 07 delete local:本地未变而远端被删除,说明删除发生在远端,同步删除本地;
- branch 10 push:本地已修改而远端未变,推送本地版本;
- branch 16/17/18/19/20 conflict:两侧都发生了修改(或两侧同时新建),进入冲突处理——具体选哪个分支取决于冲突策略(见下文"冲突策略与分支细分");
- branch 08 push:本地已修改且远端被删除,本地修改晚于远端删除,以本地为准推送;
- branch 04 delete remote:本地已删除而远端未变,删除远端;
- branch 05 pull:本地已删除但远端被修改,远端修改晚于本地删除,以远端为准拉取;
- branch 01 clean history:两侧都已删除,只剩历史记录,清理该条同步历史;
- branch 03 pull:本地已删除且远端新建,说明远端重建了文件,拉取;
- branch 06 push:本地新建而远端已删除,推送本地新文件;
- branch 11/12/13/14/15 conflict:两侧同时新建,进入冲突处理。
增量仅推送(Incremental push)
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|---|---|---|---|---|
| local unchanged | (02/21) do nothing | (26) conflict push | (32) conflict push | (??) conflict |
| local modified | (10) push | (25) conflict push | (08) push | (??) conflict |
| local deleted | (29) conflict do nothing | (30) conflict do nothing | (01) clean history | (28) conflict do nothing |
| local created | (??) conflict | (??) conflict | (06) push | (23) conflict push |
仅推送模式只允许把本地变更上传到远端,凡涉及"应拉取或应删除"的动作一律改写为冲突语义:
- branch 26 conflict push:本地未变但远端被修改,按理应拉取,但仅推送模式下以本地为准强制推送;
- branch 32 conflict push:本地未变而远端被删除,按理应删除本地,但仅推送模式下以本地为准重新推送;
- branch 25 conflict push:两侧都修改,仅推送模式下保留本地并推送;
- branch 29/30 conflict do nothing:本地已删除而远端未变/被修改,按理应删除远端或拉取,但仅推送模式下既不删除远端也不拉取,什么都不做(防止误删远端数据);
- branch 28 conflict do nothing:本地已删除而远端新建,什么也不做;
- branch 23 conflict push:两侧同时新建,仅推送模式下保留本地并推送。
增量仅拉取(Incremental pull)
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|---|---|---|---|---|
| local unchanged | (02/21) do nothing | (09) pull | (33) conflict do nothing | (??) conflict |
| local modified | (27) conflict pull | (24) conflict pull | (34) conflict do nothing | (??) conflict |
| local deleted | (35) conflict pull | (05) pull | (01) clean history | (03) pull |
| local created | (??) conflict | (??) conflict | (31) conflict do nothing | (22) conflict pull |
仅拉取模式只允许把远端变更下载到本地:
- branch 33 conflict do nothing:本地未变而远端被删除,按理应删除本地,但仅拉取模式下不删除本地;
- branch 27/24 conflict pull:本地已修改而远端未变/已修改,按理应推送或冲突处理,仅拉取模式下强制以远端为准拉取;
- branch 34 conflict do nothing:本地已修改而远端被删除,什么也不做;
- branch 35 conflict pull:本地已删除而远端未变,按理应删除远端,仅拉取模式下以远端为准拉取回来;
- branch 31 conflict do nothing:本地新建而远端被删除,什么也不做;
- branch 22 conflict pull:两侧同时新建,仅拉取模式下保留远端并拉取。
增量推送+删除(Incremental push and delete,diff decisionBranch: 38)
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|---|---|---|---|---|
| local unchanged | (02/21) do nothing | (26) conflict push | (32) conflict push | (??) conflict |
| local modified | (10) push | (25) conflict push | (08) push | (??) conflict |
| local deleted | (38) delete remote | (30) conflict do nothing | (01) clean history | (28) conflict do nothing |
| local created | (??) conflict | (??) conflict | (06) push | (23) conflict push |
与"仅推送"相比,唯一的差异是local deleted × remote unchanged从 branch 29(conflict do nothing)变为branch 38 delete remote:推送+删除模式允许把本地删除传播到远端。
增量拉取+删除(Incremental pull and delete,diff decisionBranch: 39)
| local\remote | remote unchanged | remote modified | remote deleted | remote created |
|---|---|---|---|---|
| local unchanged | (02/21) do nothing | (09) pull | (39) delete local | (??) conflict |
| local modified | (27) conflict pull | (24) conflict pull | (34) conflict do nothing | (??) conflict |
| local deleted | (35) conflict pull | (05) pull | (01) clean history | (03) pull |
| local created | (??) conflict | (??) conflict | (31) conflict do nothing | (22) conflict pull |
与"仅拉取"相比,唯一的差异是local unchanged × remote deleted从 branch 33(conflict do nothing)变为branch 39 delete local:拉取+删除模式允许把远端删除传播到本地。
决策分支在源码中的落地
设计文档中的数字分支与 pro/src/sync.ts 的getSyncPlanInplace函数一一对应。该函数对每个key依次考察local、remote、prevSync三方状态,并按表格逻辑打上decisionBranch与decision(如"remote_is_modified_then_pull"、"local_is_created_then_push"、"conflict_modified_then_keep_local")。以下是几个典型分支的源码对照:
- branch 2 / 21(equal / do nothing):当
local.mtimeCli === remote.mtimeCli || local.mtimeCli === remote.mtimeSvr且local.sizeEnc === remote.sizeEnc时判定为完全相同(见源码约第 776–787 行); - branch 9(remote is modified then pull):
localEqualPrevSync && !remoteEqualPrevSync时,若同步方向非"仅推送"则拉取远端(第 798–824 行); - branch 10(local is modified then push):
!localEqualPrevSync && remoteEqualPrevSync时,若同步方向非"仅拉取"则推送本地(第 825–851 行); - branch 26 / 27:上述两种"单侧修改"情形在仅推送/仅拉取方向下被改写为冲突语义(第 807–816 行与第 834–843 行);
- branch 25 / 24:两侧都修改且都存在
prevSync时,仅推送模式取 branch 25(keep local),仅拉取模式取 branch 24(keep remote)(第 916–978 行); - branch 38 / 39:见前述源码第 1028–1031 行(
incremental_push_and_delete_only下local_is_deleted_thus_also_delete_remote)与第 1116–1130 行(incremental_pull_and_delete_only下remote_is_deleted_thus_also_delete_local); - branch 22 / 23:两侧同时新建(
prevSync === undefined)时,仅拉取取 branch 22(keep remote),仅推送取 branch 23(keep local)(第 854–915 行)。
值得注意的源码细节:decisionBranch编号在实现中还扩展到了 100 以上(文件夹分支 101–140)以及 301/302(smart conflict 合并分支),说明设计文档中的表格主要描绘文件决策,文件夹与智能合并被编码在更高编号的分支里。
冲突策略与分支细分
双向表中冲突单元格标注的16/17/18/19/20(两侧修改)与11/12/13/14/15(两侧新建)正是冲突策略的分流点。从 src/baseTypes.ts 第 233–236 行可知冲突策略类型为:
export type ConflictActionType = | "keep_newer" // 保留较新者 | "keep_larger" // 保留较大者 | "smart_conflict"; // 智能冲突(Pro 功能)源码中的映射关系(第 856–956 行)为:
keep_newer:比较local.mtime与remote.mtime,较新者胜出 → branch 11/12(新建)或 16/17(修改);keep_larger:比较local.sizeEnc与remote.sizeEnc,较大者胜出 → branch 13/14(新建)或 18/19(修改);smart_conflict:尝试把两侧内容合并(branch 301/302),若不可合并则复制为两个文件(对应 pro/src/conflictLogic.ts 中的mergeFile与tryDuplicateFile)。
同步方向与设置项
双向 / 增量方向由设置项syncDirection控制,其类型定义位于 src/baseTypes.ts 第 131–136 行:
export type SyncDirectionType = | "bidirectional" | "incremental_pull_only" | "incremental_push_only" | "incremental_pull_and_delete_only" | "incremental_push_and_delete_only";恰好对应设计文档中的五张决策表。此外,与算法相关的设置还包括conflictAction(冲突策略)、skipSizeLargerThan(忽略超过该尺寸的文件,见 docs/sync_algorithm/sync_ignoring_large_files.md)、protectModifyPercentage(删除保护阈值)、syncConfigDir/syncBookmarks/syncUnderscoreItems(是否同步配置目录、书签、下划线开头的特殊项)以及ignorePaths/onlyAllowPaths过滤列表。
功能清单:Must have 与 Nice to have
设计文档将 V3 的目标功能分为两档:
必须实现(Must have)
- 真正的删除检测(true deletion detection);
- 删除保护(deletion protection,可阻塞,带设置项开关);
- 从旧算法的事务迁移(transaction from the old algorithm);
- 用户警告提示——新算法要求所有客户端全部升级到新版本(文档特意标注"deliberately corrupt the metadata file??"即通过作废旧元数据文件的方式强制升级);
- 过滤器(filters,对应
ignorePaths/onlyAllowPaths); - 冲突警告(conflict warning);
- 部分同步(partial sync,即按需同步部分文件)。
锦上添花(Nice to have)
- 真实的时间与哈希(true time and hash);
- 冲突重命名(conflict rename,即"两边都保留并改名")。
上述清单与 docs/sync_algorithm/v3/intro.md 中的面向用户的功能核对表基本吻合(如sync conflict: keep newer、keep larger已完成,keep both and rename、show warning未完成;deletion: true deletion status computation、meta data: no remote meta data any more、sync direction: incremental push only / pull only、deletion protection: warning based on the threshold均已完成)。
删除保护(Deletion Protection)的实现侧证
"警告基于阈值"(warning based on the threshold)这一删除保护机制在源码中体现为protectModifyPercentage设置项,并与mixedEntityMappings["/$@meta"]中记录的protectModifyPercentage、syncDirection、triggerSource等现场信息配合,用于在大量删除发生前向用户发出警告。从 pro/src/sync.ts 第 1200–1225 行可以看到,每次生成同步计划时都会写入一份/$@meta元数据,记录服务类型、并发度、是否加密、同步方向、冲突策略、删除保护阈值等,这份元数据正是算法判断"本次变更规模是否异常"的依据之一。
升级通知与用户确认
"新算法需要所有客户端更新"的警告在插件端由 src/syncAlgoV3Notice.ts 中的SyncAlgoV3Modal弹窗实现。该弹窗要求用户同时勾选两个确认框后才允许继续:
- 手动备份确认(
syncalgov3_checkbox_manual_backup,对应manualBackup字段); - 所有设备已升级确认(
syncalgov3_checkbox_requiremultidevupdate,对应requireUpdateAllDev字段)。
只有当两个复选框都被勾选时,"同意"按钮才解除禁用。同意后调用saveAgreeToUseNewSyncAlgorithm()并把设置项agreeToUseSyncV3(见 src/baseTypes.ts 第 174 行)置为 true,同时按用户设置触发自动同步、初始化同步或保存即同步;若拒绝,则插件直接unload()卸载。
目录(文件夹)的同步规则
V3 设计文档本身聚焦文件级决策表,但仓库中 docs/sync_algorithm/v2/README.md 对目录处理的规则在 V3 中依然成立(V3 源码 pro/src/sync.ts 第 575–767 行以key.endsWith("/")区分文件夹,且文件夹不看 mtime/size,只判断存在性,分支编号 101–140)。V2 文档给出的文件夹规则可视为 V3 目录语义的补充说明:
- 先生成所有文件的同步计划。只要有文件存在,其所有父目录都应存在——本地缺则本地递归创建,远端缺则远端递归创建;
- 一个目录可删除,当且仅当:它出现在远端删除历史中,且它本身为空或其所有子目录都可删除。
文档还给出了三个典型示例:
- 设备 1 删除某目录并同步 → 设备 2 新建同名目录并同步 → 该目录在设备 2 上再次被删除;
- 设备 1 删除某目录并同步 → 设备 2 新建同名目录并放入新文件后同步 → 由于存在新文件,该目录在设备 2 上被保留;
- 设备 1 删除某目录并同步 → 设备 2 未触碰该目录 → 该目录及其未改动的子文件在设备 2 上被删除。
这一"目录可删除性"判定在 V3 源码中由keptFolder集合实现:任何被判定为"应保留"的文件夹都会把其父目录递归加入keptFolder,最终确保不会因删除父目录而误删子内容(见 pro/src/sync.ts 第 560、578–621 行)。
从 V2 到 V3 的演进要点
对比 docs/sync_algorithm/v2/README.md 可以更清楚地看到 V3 的改进:
- 输入源从 4 个变为 5 个:V2 的四源为本地文件、远端文件、本地删除/重命名历史、远端删除历史;V3 用"上次成功同步历史"替代了对删除历史的持续依赖,仅初始化运行时消费一次远端删除历史;
- 判定基准从"最大时间戳"变为"差分状态":V2 的核心理念是收集 mtime/删除时间四个时间戳并"尊重最大时间戳及其对应操作";V3 则通过
local、remote、prevSync三者是否两两相等来推断谁被修改、谁被删除,从而避免"时间戳倒流"导致误判; - 同步方向成为一等公民:V2 只有双向语义,V3 增加了仅推送、仅拉取、推送+删除、拉取+删除四类方向,并以独立决策表定义每类方向下冲突单元格的行为;
- 删除保护显式化:通过
protectModifyPercentage阈值与警告机制,防止"一侧清空/大规模删除"被当作正常变更传播到另一侧; - 元数据不再上云:V3 的
prevSync历史存于本地数据库,远端不再存放同步元数据文件(intro.md 中meta data: no remote meta data any more已勾选),这也意味着加密模式下的同步无需在远端维护额外元数据。
总结
Sync Algorithm V3 是 Remotely Save 项目同步内核的一次系统性重构:它把"上次成功同步历史"作为第五输入源,用五张决策表穷举本地与远端的全部状态组合,并通过decisionBranch编号(1–39 覆盖文件决策,101–140 覆盖文件夹决策,301/302 覆盖智能合并)把每种组合映射为可执行动作。双向、增量推送、增量拉取、推送+删除、拉取+删除五种方向各自拥有独立的冲突语义,配合keep_newer/keep_larger/smart_conflict三种冲突策略与protectModifyPercentage删除保护阈值,在"不丢数据"与"正确传播变更"之间取得了平衡。对于希望深入源码的读者,推荐按以下顺序阅读仓库文件:设计文档 → 面向用户的功能核对表 → V2 对照文档 → 核心实现 pro/src/sync.ts(重点看getSyncPlanInplace与dispatchOperationToActualV3)→ 冲突策略 pro/src/conflictLogic.ts → 升级弹窗 src/syncAlgoV3Notice.ts。
- 数据同步
【免费下载链接】remotely-save
Sync notes between local and cloud with smart conflict: S3 (Amazon S3/Cloudflare R2/Backblaze B2/...), Dropbox, webdav (NextCloud/InfiniCLOUD/Synology/...), OneDrive, Google Drive (GDrive), Box, pCloud, Yandex Disk, Koofr, Azure Blob Storage.
相关推荐
Remotely Save 同步算法 V3 深度解析:基于决策分支表的健壮双向同步与删除追踪设计
Remotely Save 同步算法 V3 深度解析:基于决策分支表的健壮双向同步与删除追踪设计 导读 本文围绕 Remotely Save 插件(Obsidi
数据同步Remotely Save 同步算法 V3 详解:删除追踪、冲突处理与迁移机制
Remotely Save 同步算法 V3 详解:删除追踪、冲突处理与迁移机制 本文基于 Remotely Save 官方文档 docs/sync_algori
数据同步Remotely Save 同步算法 V2 解析:四大数据源、四时间戳决策表与文件夹递归删除规则
Remotely Save 同步算法 V2 解析:四大数据源、四时间戳决策表与文件夹递归删除规则 本文以 Remotely Save(Obsidian 本地库与
数据同步
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考