Netcatty 多设备 CRDT 同步:从“为什么会丢数据”到“怎样证明一定收敛” #2261
ryan-wong-coder
started this conversation in
Show and tell
Replies: 1 comment 1 reply
-
|
🎉 我学习到了很多,感谢你的贡献。 |
Beta Was this translation helpful? Give feedback.
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Netcatty 多设备 CRDT 同步:从“为什么会丢数据”到“怎样证明一定收敛”
本文面向普通开发者,介绍 CRDT 的基本原理,并讲解 Netcatty convergent sync v2 的具体实现。
阅读本文无需分布式系统基础。“半格”“偏序”“向量时钟”等概念,均结合多设备管理 SSH 主机的实际问题说明。文章先解释三件事:传统云同步为何覆盖修改,删除为何可能重新出现,Provider 的处理次序为何会改变结果。
厘清这些问题之后,再引入 CRDT。dot、version vector、MV-Register 和 tombstone 各有明确用途,并非孤立的术语。
后文还会说明这些概念在 Netcatty 代码、云文件协议、迁移流程、恢复机制、冲突界面和测试体系中的对应关系。
阅读方式
全文分为十个篇章,另外附有术语表和阅读检查。
第一篇梳理旧同步的问题;第二篇解释 CRDT 的基本原理;第三篇说明 Host、Settings 和分组的具体数据结构;第四至第七篇讨论严格校验、云协议、迁移和运行时;第八篇推演一次完整同步;第九、十篇列出代码地图、测试、设计取舍与常见问题。
只想了解功能缘由,读完前三篇即可。
准备审查或维护代码,可读至第七篇。
熟悉 CRDT 的读者,可直接阅读“第五篇:云文件协议”和“第七篇:真实同步运行时”。更紧凑的设计规范见:
相关开发记录:
第一篇:问题究竟出在哪里
1. 一个常见的使用场景
一名 Netcatty 用户需要管理几十台服务器。
他平时使用三台设备:
为了避免过度依赖单一云服务,他同时连接了 GitHub Gist 和 WebDAV。
MacBook 中有一台名为
production的主机:{ "id": "host-1", "name": "production", "hostname": "10.0.0.8", "port": 22, "username": "root" }这条主机记录经过加密后,会随完整快照上传至云端。
多设备同步看似只需四步:
难点在于:多个设备离线修改、多个 Provider 各自保存不同版本,删除与修改又可能同时发生。系统必须判断哪些写入已经覆盖旧值,哪些写入其实互不知情。
若采用“最后上传者胜出”,较早上传但尚未合入的修改便可能丢失。
按时间戳取较新值同样不可靠:设备时钟可能不准,时间较晚也不代表该设备见过另一项修改。
若一律本地优先,每台设备都会坚持自己的版本,最终结果仍取决于同步次序。
CRDT v2 专门处理这类判断问题。
介绍新方案之前,先看原有方案的工作方式。
2. 旧同步怎样工作:三方合并
Netcatty 原有同步使用三方合并。它与 Git 合并文件的基本思路相似。
每次合并需要三份快照:
base:上一次双方都确认过的共同版本;local:本设备现在的数据;remote:云端现在的数据。base好比改动发生前留下的一张照片。双方分别改动房间后,对照这张照片,便能判断哪些变化来自本地,哪些变化来自远端。
2.1 一个三方合并成功的例子
共同 base:
{ "name": "production", "port": 22 }本地完全没变,远端把端口改成了 2222:
系统很容易判断:只有远端发生了修改,因此采用远端。
反过来,只有本地修改时,就采用本地。
如果两边都删除同一个实体,也可以安全删除。
三方合并本身并无问题。在“一台设备、一个远端、一份可信 base”的范围内,它既实用,也容易理解。
对应代码在
domain/syncMerge.ts。2.2 三方合并依赖了哪些前提
这套判断依赖以下前提:
设备和 Provider 变多后,这些前提会逐渐失效。
这些前提会在下列场景中逐一失效。
3. 痛点一:修改不同字段,也会被当成同一个冲突
这是最直观的数据丢失问题。
3.1 发生了什么
MacBook 和公司电脑起初保存着同一个 Host:
{ "id": "host-1", "name": "production", "port": 22, "username": "root" }MacBook 离线修改名称:
{ "id": "host-1", "name": "production-eu", "port": 22, "username": "root" }公司电脑同时修改端口:
{ "id": "host-1", "name": "production", "port": 2222, "username": "root" }从人的角度看,这两个修改完全不冲突。
一个修改了
name,另一个修改了port。理想结果应该是:{ "id": "host-1", "name": "production-eu", "port": 2222, "username": "root" }3.2 为什么旧算法仍会冲突
旧三方合并把整个 Host 对象当作一个实体值。
它看到的是:
因此它只能得出“双方修改了同一个实体”。
旧实现选择本地实体作为 winner,并记录一次实体级冲突。假设本地是 MacBook,最终就可能保留
name修改,却把port: 2222覆盖回 22。3.3 这个问题不能靠更聪明的对象 diff 完全解决吗
可以改善,但还不够。
递归拆分对象、按字段执行三方合并,确实可以改善这种情况。旧 Settings 合并已有类似处理。
可问题不只是“粒度太粗”。多设备和多 Provider 场景还要求:
普通深度 diff 没有保存长期因果历史,因此只能缓解第一个症状,无法解决整个问题。
字段覆盖问题的结论
旧模型比较的是“整个对象是否变化”。新模型需要把实体拆成独立字段,每个字段保存自己的修改历史。
4. 痛点二:多个 Provider 的处理顺序会影响结果
合并次序造成的差异不如字段覆盖直观,却触及分布式同步的根本问题。
4.1 从两个分支变成四个分支
此时共有四份状态:
如果仍然使用三方合并,就需要把它们逐个折叠:
但 Provider 是并行下载的。今天可能 GitHub 最快,明天可能 WebDAV 最快。
如果合并函数不是交换、结合的,那么不同完成顺序就可能得到不同结果。
4.2 一个简化例子
假设三份状态对同一个字段给出不同值:
旧规则中,发生双方修改时偏向当前 local。
如果先合并 GitHub:
如果某个中间步骤把 WebDAV 结果当成新的 local,或者每个 Provider 使用不同 base,可能得到 C 或 B。
问题不在于某次恰好选中哪一个值,而在于算法无法保证:
一定等于:
4.3 固定 Provider 顺序为什么不是根治
可以规定永远按 GitHub、Google、OneDrive、WebDAV、S3 的顺序处理。
这会让单次实现更可复现,却不能解决:
固定顺序只是给某一条执行路径排序。它没有让数据本身携带足够信息,也没有让合并变成与顺序无关的运算。
合并顺序问题的结论
多 Provider 同步需要一种满足交换律、结合律和幂等性的合并。这样才能让网络顺序、重试和重复下载都不影响最终状态。
5. 痛点三:“没有这个数据”不等于“用户删除了它”
删除是快照同步中最容易被低估的问题。
5.1 同一个空缺,可能有完全不同的原因
云端快照里没有
host-1,可能表示:这些情况在 JSON 快照中都长成同一个样子:数组里没有那条记录。
5.2 Base 为什么只能暂时帮忙
如果有可信 base,可以比较:
此时可以判断“本地删除了 host-1”。
但如果设备长期离线,base 丢失、账号切换、缓存清理,或者另一个 Provider 没有相同 base,这个判断就失去了依据。
5.3 删除为什么会复活
场景如下:
host-1;host-1,云端新快照里不再包含它;host-1;这便是所谓“删除后复活”。
从使用者的角度看,已经删除的主机再次出现,很难与正常同步行为区分。
5.4 新模型需要什么
删除必须成为一条可复制、可比较的明确事实,而不是结构中的空白。
它需要回答:
CRDT 中的 tombstone 就是这张长期保留的“删除证明”。
6. 痛点四:删除和修改同时发生,谁应该赢
6.1 一个没有标准答案的问题
设备 A 删除
host-1。设备 B 在离线状态下,把
host-1.username从root改成admin。A 没看到 B 的修改,B 也没看到 A 的删除。
那么应该:
6.2 “删除永远优先”的代价
如果删除优先,B 的修改会消失。
有时这是合理的,但也可能是危险的。例如 B 修改的是新服务器地址或紧急修复后的登录用户,删除则是 A 在旧列表中误操作。
系统没有因果证据证明 A 看过 B 的修改,所以不能声称“A 的删除更晚,因此覆盖 B”。
6.3 “修改永远优先”的代价
如果修改优先,A 会看到自己删除的主机又回来。
这同样可能违背意图。特别是删除密钥、身份或敏感主机时,自动复活不是一个安全默认值。
6.4 为什么必须保留冲突
这里确有无法由系统自行判断的业务歧义。
算法能做的是:
CRDT 所谓“无冲突”,并非指用户意图永远一致,而是指合并次序不会使副本分叉,暂时无法判断的值也不会被静默丢弃。
7. 痛点五:Provider 返回“上传成功”,不代表状态真的保住了
7.1 典型的竞争写
设备 A 和 B 同时同步:
如果 Provider 只是覆盖同一个文件,S2 可能把 S1 覆盖掉。
A 收到的“上传成功”只能证明它的请求曾经写入,不能证明 S1 在本轮结束时仍存在。
7.2 为什么文件版本号也不够
版本号增加只能说明云文件产生了新修订。
它不能证明新修订包含 A 的全部修改。B 可能基于旧 S0 生成了一个更高版本号,却没有包含 S1。
7.3 新运行时必须怎样确认
上传后必须重新下载。
然后系统不能只比较文件 hash,而要检查回读状态是否包含本轮期望的全部因果写入。
如果包含,才算验证成功。
如果回读还包含另一个设备刚写入的新状态,就把它继续合并,再传播给其他 Provider。
如果回读缺少本轮写入,则本次 Provider 同步失败,不能假装已经收敛。
后文所述的 read–merge–write–verify,便用于处理这种竞争写入。
8. 新同步引擎需要满足的条件
据此,可先不考虑具体 CRDT 术语,把需求归纳如下。
一个可靠的多设备同步引擎应该做到:
8.1 不冲突的修改自动组合
修改不同实体、不同字段或不同 Settings 路径时,不需要用户介入。
8.2 真正并发的同字段值全部保留
系统可以选择一个临时展示值,但不能在没有因果证据时删除其他候选。
8.3 合并顺序不影响最终状态
无论先下载 GitHub 还是 WebDAV,无论某个状态重复到达多少次,最终结果必须相同。
8.4 删除不会被旧副本复活
删除需要长期、明确的因果记录。
8.5 用户解决冲突后,冲突不会再次出现
用户选择必须成为一条覆盖全部旧候选的新写入,而不是只保存在当前 UI。
8.6 旧客户端不会被粗暴切断
旧客户端至少还能读取普通快照。它写回旧格式时,新客户端要在有可信依据的情况下转换;没有依据时应阻断。
8.7 网络失败可以安全重试
重复下载、重复上传和应用重启不能创造新的逻辑修改。
8.8 无法证明安全时宁可停下
损坏 metadata、未知未来 schema、缺失可信 baseline、迁移期间发生新编辑,都应 fail closed。
这些条件与 CRDT 的适用范围相符。
第二篇:先建立 CRDT 的直觉
9. CRDT 的适用边界
CRDT 既不是云服务,也不是“最后写入者获胜”的另一种说法。它不负责网络传输,不要求中心服务器,也不保证业务上的意见永远一致。
它所规定的是一套数据结构和合并规则:多个副本各自修改之后,只要最终交换了相同的状态,就应得到相同结果。
Netcatty 仍需借助 GitHub、Drive、WebDAV 或 S3 存放文件,也仍需处理加密、重试和界面交互。CRDT 专门解决状态合并问题,使合并结果不受传输次序和重复次数影响。
10. 用“候选记录”理解 CRDT
可以把每个字段看作一组带来源的候选记录。
旧快照只保存当前值:
CRDT 除了保存 22,还记录:
合并两个副本时,系统汇总两边的候选记录。
只有新记录明确表明“已观察并覆盖某条旧记录”时,那条旧记录才可删除。
若两条记录互相都未观察到对方,则两者都要保留。
这构成了本实现的基本原则。
11. “最终收敛”是什么意思
设 A、B、C 三台设备最终都收到了同一批写入。
网络可能很混乱:
只要三者最终掌握相同的因果记录,便应得到相同状态。
这种性质叫强最终一致性。
11.1 交换律:先收到谁都一样
⊔表示 join,即 CRDT 合并。交换律解决消息顺序问题。
GitHub 先返回还是 WebDAV 先返回,不应影响结果。
11.2 结合律:怎样分批合并都一样
结合律解决分组和拓扑问题。
设备可以先在本地合并两个 Provider,也可以先接收另一台设备已经合并过的状态。
11.3 幂等性:重复收到不会重复生效
幂等性解决重试和重复投递。
同一个云文件下载两次,不应变成两次修改;应用重启后重新上传同一个 state,也不应制造冲突。
11.4 为什么这三条性质这么重要
网络通常无法保证消息仅投递一次且严格有序。
因此,合并规则本身应能容忍乱序、分批和重复传输。
CRDT 的价值正在于此。
12. 为什么选择 state-based CRDT
CRDT 有不同传播方式。
Operation-based CRDT 传播每个动作,例如:
这种方式通常需要可靠的操作日志、去重和投递机制。
State-based CRDT 传播当前完整状态。接收方只需把状态 join 进自己的状态。
Netcatty 的 Provider 接口天然是:
GitHub Gist、Google Drive、OneDrive、WebDAV 和 S3 没有共同的操作日志协议,也没有 Netcatty 中心服务器。
因此 state-based CRDT 更符合现有边界:
代价是状态文件需要携带更多因果 metadata。
13. 因果关系不同于时间顺序
理解 CRDT,首先要区分因果关系和钟表时间。
13.1 什么叫“因果上发生在后面”
设备 A 写入
port = 2222,上传。设备 B 下载到了这个值,然后写入
port = 2200。B 的写入明确看过 A 的写入,因此 B 可以覆盖 A。
这便构成了明确的因果关系。
13.2 什么叫“并发”
A 离线写
port = 2222。B 也离线写
port = 2200。两边都没看过对方。即使 A 的电脑显示 10:01,B 显示 10:02,也不能证明 B 是在知道 A 修改后作出的决定。
它们是并发写入。
13.3 为什么不能只看系统时间
设备时钟可能:
更重要的是,即使两个时钟完全准确,时间较晚也不代表它看过较早的写入。
所以,覆盖关系必须来自“我见过谁”的记录,而不是墙上时钟。
14. Dot:给每次写入一个不会混淆的编号
Netcatty 为每次有效写入分配一个 dot:
例如:
Dot 相当于设备为每次写入编排的连续编号。
macbook:7表示 MacBook 的第七次 CRDT 写入。Dot 本身不包含字段和值,只提供全局唯一身份,使其他候选能够准确声明:“我已经观察并覆盖
macbook:7。”14.1 为什么 counter 是设备全局的
如果每个字段各自从 1 开始计数,
name:1、port:1等身份需要携带更多结构,也更容易被错误复用。设备级单调 counter 让每次写入都有简单、稳定的身份。
14.2 Dot origin index 为什么还需要存在
只知道
macbook:7唯一还不够。系统还要证明它始终属于同一个 register。否则损坏状态可能让
macbook:7在一份副本中表示host-1.name,在另一份副本中表示host-2.port。因此 state 保存永久
dotOrigins:{ "macbook": { "7": "[\"entity-field\",\"hosts\",\"host-1\",\"name\"]" } }相同 dot 若出现在不同 register,校验立即失败。
15. Version Vector:副本掌握的设备进度
每个 state 保存:
{ "macbook": 7, "office-pc": 3 }它记录每台设备的已知进度:
向量 A 支配向量 B,表示 A 对每台设备的进度都不落后于 B。
这个判断在 Provider 写后验证中很重要。
如果本轮期望向量是:
{ "macbook": 7, "office-pc": 3 }回读远端是:
{ "macbook": 7, "office-pc": 4 }远端包含所有期望写入,还有一条新写入,因此它支配期望状态。
如果回读是:
{ "macbook": 6, "office-pc": 4 }它缺少
macbook:7,不能算验证成功。16. 精确 Context:这次写入到底看过谁
Version vector 描述整个副本的阅读进度,却不能直接用于某个字段的覆盖判断。
原因在于,同一设备的 counter 会交错分配给不同字段:
当
host-1.name写入 dot 3 时,它确实覆盖了这个字段的 dot 1。但 dot 2 属于另一个 Host 的另一个字段。不能因为 counter 已到 3,就声称
host-1.name覆盖了 dot 2。所以每个候选保存精确 context:
它只列出这个 register 中真正被观察和覆盖的 dots。
16.1 Dotted version vector 在这里怎样体现
代码中没有一个名为
DottedVersionVector的独立类型。它的语义由三部分组成:
dot:本次具体写入;context:本 register 的已观察历史;vector:整个副本的设备进度。字段间的覆盖关系由
dot + exact context决定。全局 vector 主要服务于副本进度和远端验证。
17. HLC:给并发候选排一个稳定顺序
Hybrid Logical Clock 结构为:
它结合物理时间和一个逻辑计数器。
系统时间正常前进时,采用新的物理时间,并把 logical 置 0;系统时间停滞或倒退时,则保留已有 wallTime,并将 logical 加一。
17.1 HLC 不负责决定覆盖关系
这里需要明确区分两种用途。
候选 A 是否覆盖候选 B,只看 A 的 context 是否包含 B 的 dot。
HLC 只在两个候选确实并发、系统又必须先显示一个值时,提供所有设备一致的排序。
因此时钟漂移可能改变冲突期间临时展示哪个候选,却不会删除另一个候选,也不会破坏最终收敛。
18. MV-Register:一个字段可以暂时拥有多个答案
MV 是 Multi-Value 的缩写。
普通寄存器只保存一个值:
MV-Register 保存所有仍未被因果覆盖的候选:
如果 B:3 与 C:1 互相没见过,它们都会留下。
如果之后 A 看到了两个值并选择
admin,A 会写一个新候选:此时 A:8 明确覆盖两个旧候选,register 才恢复成单值。
18.1 为什么临时 winner 不等于冲突解决
UI 为了正常显示,必须从多个候选中暂时选一个 winner。
但 winner 只是投影结果。
如果系统直接删除其他候选,就会退化成 Last-Writer-Wins,并再次丢数据。
只有创建带完整 context 的新 dot,冲突才算解决。
19. Tombstone:为删除保留因果记录
删除候选长这样:
{ "dot": { "deviceId": "macbook", "counter": 8 }, "context": [ { "deviceId": "macbook", "counter": 3 } ], "hlc": { "wallTime": 1780000000000, "logical": 0 }, "tombstone": true }它表达:
以后旧设备再带回
macbook:3,join 能看到 tombstone 已经覆盖它,因此旧值不会复活。19.1 为什么 tombstone 首版不自动清理
要安全删除 tombstone,系统必须证明所有可能再次出现的设备都已经见过它。
但 Netcatty 没有中心服务器维护设备租约,也不能确认一台半年没上线的笔记本永远不会回来。
如果只按“超过 90 天”清理,旧设备第 91 天上线就可能复活数据。
因此首版选择永久保留 tombstone。
这一取舍以较大的 metadata 为代价,优先保证删除不再复活。
第二篇要点
每个字段保存一组带来源的候选记录。每条记录有自己的 dot,并注明写入时已经观察过哪些旧记录。
只有被明确观察并覆盖的旧记录才能删除;互相未见的记录全部保留。
Version vector 记录整个副本的进度,HLC 只负责给并发候选稳定排序,tombstone 则让删除成为长期存在的明确事实。
第三篇:Netcatty 怎样把真实数据变成 CRDT
20. 一个 Host 不再是一个整体值
核心类型定义在
domain/convergentSync/types.ts。旧模型中的 Host 是一个完整 JSON 对象。
CRDT v2 会把它拆成:
20.1 Presence register 解决什么
它记录实体是否存在。
一个存在的实体不是简单地“出现在 map 中”,而是 presence 的当前候选为
true。删除实体时,系统向 presence 写入 tombstone。
因此实体即使在普通快照中不可见,其删除历史仍然存在于 CRDT state 中。
20.2 Position register 解决什么
Netcatty 的集合有顺序。
如果只按 ID 合并,设备 A 调整后的排序可能在另一台设备上丢失。
所以实体还有独立 position register。内容可以是数字或字符串,用于恢复稳定顺序。
当 position 相同,再用实体 ID 做确定性兜底,确保所有设备输出一致。
20.3 Field register 解决什么
每个顶层字段有自己的 MV-Register。
于是:
会落到两个不同 register,自然合并。
只有双方同时修改
name,而且互相没有看到对方,才会出现name字段冲突。20.4 为什么只拆顶层字段
理论上,可以把每个嵌套对象、数组元素甚至字符串字符都做成独立 CRDT。
但粒度越细,协议、迁移和 UI 就越复杂。
Netcatty 的目标是配置数据同步,不是多人实时编辑一篇文档。因此首版选择:
这让常见的“改名称”和“改端口”可以自动合并,同时把难以解释的数组并发编辑留给明确冲突。
21. 哪些集合属于普通实体
映射入口位于
domain/convergentSync/payload.ts。当前按稳定 ID 建模的集合有:
hostsidkeysididentitiesidproxyProfilesidsnippetsidnotesidportForwardingRulesidgroupConfigspath大部分实体天然有
id。groupConfigs使用path作为结构身份。进入 CRDT 时,path 决定它是哪一个实体;物化回普通SyncPayload时,再恢复原有结构。21.1 为什么 ID 不能作为普通字段修改
ID 决定 register 的地址。
如果允许把
host-1.id改成host-2,系统无法判断这是:所以 ID 是结构身份,不是 field register。要改变 ID,只能明确删除旧实体并创建新实体。
22. 字符串分组为什么也需要 Presence
customGroups、snippetPackages和noteGroups看起来只是字符串数组。但它们也有新增、删除和排序。
每个字符串自身就是稳定身份:
22.1 什么是 observed-remove
Observed-remove 可以翻译为“只删除自己确实观察到的新增”。
假设设备 A 从未见过分组
production。此时 A 本地执行一次“删除 production”,不应该创建一个能消灭未来新增的 tombstone。因为 A 并没有真的删除某个已知对象,它只是对一个不存在的对象执行了 no-op。
另一台设备 B 若同时新增
production,这个新增应该保留。反过来,如果 A 已经看过
production,再删除它,删除候选的 context 会包含已观察 add。旧设备以后带回那个 add,也无法让它复活。22.2 重复操作为什么不推进 counter
重复添加一个已经存在、没有冲突的分组,不产生新 dot。
重复删除一个已删除分组,也不产生新 dot。
未观察到对象时执行删除,同样是 no-op。
这是为了避免自动同步和重复 UI 操作不断制造无意义历史。
23. Settings 为什么按叶子路径拆分
Settings 是一个嵌套对象。
例如:
{ "theme": "dark", "terminalSettings": { "scrollback": 5000, "cursorBlink": true } }如果整个 Settings 只有一个 register,那么任何两项设置的并发修改都会冲突。
因此系统把叶子变成独立地址:
MacBook 修改主题、Windows 修改 scrollback,可以直接合并。
23.1 路径怎样编码
路径需要无歧义地序列化。
实现使用类似 JSON Pointer 的转义:
这样字段名本身含
~或/时也不会与路径分隔符混淆。23.2 数组为什么是原子值
数组的并发合并涉及插入位置、移动、重复元素身份和删除语义。
若要逐元素自动合并,需要 RGA、LSEQ 等 sequence CRDT,并且 UI 还要解释并发重排。
首版没有引入这些复杂度。
例如
customTerminalThemes这样的 Settings 数组,在 CRDT register 中作为一个完整 JSON 值。两台设备并发修改同一数组时,会保留两个候选并要求用户选择。24. Settings 父子结构冲突
这是 Settings 中最容易被忽略的边缘情况。
初始值:
{ "terminalSettings": { "scrollback": 5000 } }设备 A 把
terminalSettings整体改成字符串:{ "terminalSettings": "use-default" }设备 B 同时修改子路径:
{ "terminalSettings": { "scrollback": 10000 } }合并后不能同时保留:
因为一个 JSON 节点不能既是字符串,又有子属性。
24.1 Prefix-free 约束
活跃 Settings 叶子必须 prefix-free。
意思是:一个活跃路径不能同时是另一个活跃路径的祖先。
写入父路径时,系统会 tombstone 当前已观察到的后代。
写入子路径时,也会处理与它重叠的原子祖先。
若父、子变化并发发生,系统保留候选,并产生
setting-structure冲突。24.2 物化时怎样保持 JSON 合法
物化器按统一候选顺序,选择一组确定性的最大 prefix-free 路径。
没有被临时选中的结构候选仍保留在 CRDT 中。
UI 会把两个冲突路径显示为:
用户选择后,系统再写一个支配全部相关候选的新值。
25. 为什么修改字段时还要刷新 Presence
假设实体 presence 仍是旧的
true。设备 A 删除实体,只更新 presence 为 tombstone。
设备 B 同时修改
port,但如果 B 只更新 port register,不更新 presence,那么合并后可能出现:新 port 会被已删除实体隐藏,系统也难以向用户解释这是一次删除/修改竞争。
因此任何有效实体更新都会刷新
presence=true。并发场景变成:
presence 的 true 与 tombstone 并发,冲突清晰可见。
25.1 No-op 字段写入不能复活删除
这里需要一个细致边界。
如果 B 只是把 port 再次设置成当前已有的 22,没有真实变化,就不应该刷新 presence。
否则一次无意义的“保存”也可能与删除形成冲突,甚至让实体看起来重新出现。
所以只有字段确实变化、当前 register 有待解决冲突,或完整 upsert 明确恢复实体时,才产生新 dot 和 presence 写入。
26. 一次本地写入内部发生什么
所有 mutation 最终进入
domain/convergentSync/state.ts。领域层不直接调用
Date.now()。调用方把
now作为参数传入。这样同一输入可以在测试中得到完全可复现的状态。26.1 写入步骤
以修改
host-1.port为例,处理过程如下:macbook:9;macbook:9永久属于hosts/host-1/port;26.2 简化伪代码
删除的流程完全相同,只把
value换成tombstone: true。27. 为什么“值没变”通常不创建新 Dot
自动同步会频繁构建 payload。
如果每次都把当前值重新写入 CRDT,即使内容没有变化,也会:
因此无冲突、值相同的写入是 no-op。
27.1 一个重要例外
如果 register 有两个候选,而用户选择的值刚好等于当前临时 winner,仍然必须写新 dot。
因为:
不代表:
新 dot 的 context 必须包含所有候选,才能真正完成解决。
28. Register Join:合并两组候选记录
合并实现位于
domain/convergentSync/register.ts。合并过程分为四步。
28.1 第一步:找出相同 Dot
如果左右两边都有
macbook:9,它们应该描述完全相同的候选。实现会比较:
相同 dot 内容不同,说明状态损坏或 dot 被非法复用。系统抛出不变量错误,不随便选一边。
28.2 第二步:检查左侧独有候选
假设候选 L 只出现在左侧。
如果 L 的 dot 出现在右侧 register 的 causal context 中,说明右侧已经观察并覆盖 L。L 可以删除。
如果右侧 context 没有 L,就没有覆盖证据,L 必须保留。
28.3 第三步:对右侧做对称检查
右侧独有候选采用同样规则。
这个对称性是交换律的重要基础。
28.4 第四步:只留下因果最大候选
初步合并集合中,如果某候选的 dot 出现在另一个幸存候选的 context 中,前者已被后者覆盖。
最终只保留没有被其他候选因果支配的最大元素。
28.5 一个具体例子
左侧:
右侧:
右侧明确看过 A:1,所以合并后只剩 B:1。
再看并发例子:
A:2 和 B:1 都没看过对方,所以两个都保留。
29. 为什么 Join 可以证明收敛
可以先用直觉理解。
合并不会按到达时间随便删除候选。
它只做两件事:
证据相同,结果就相同。
重复提供同一证据,也不会制造新结果。
29.1 稍微形式化一点
把一个 register 看成“因果事件中的最大元素集合”。
如果候选 X 的 dot 出现在 Y.context 中,就记作:
表示 Y 覆盖 X。
并发候选之间没有大小关系。
Join 等价于:
集合并集满足交换律、结合律和幂等性。
在固定因果关系上取最大元素也是确定性的。
因此 register join 满足三条收敛性质。
整个 state 是大量 register 的组合:
每个分量都满足三条性质,组合状态也满足。
29.2 “旧候选数组被替换”为什么仍然是单调更新
从代码结构看,本地写入会把旧 candidates 数组替换成一个新候选,数组甚至可能变短。
但 CRDT 的“增长”指因果知识增长,不是 JSON 字节数只能变大。
旧候选的 dots 已进入新候选 context。新状态仍然证明它知道并覆盖旧状态,因此在 CRDT 偏序中是向上增长。
29.3 性质测试承担什么角色
文字证明容易遗漏实现细节。
仓库还使用
fast-check随机生成 2–20 个副本、离线分区、乱序和重复 join,直接验证:它不能替代数学推理,但可以持续检查代码修改是否破坏这些性质。
30. 从多候选状态生成普通快照
Netcatty 现有 UI 和 Vault 状态仍然消费普通
SyncPayload。所以 CRDT state 需要物化成一个确定性快照。
30.1 Winner 选择顺序
同一个 register 有多个候选时:
每台设备都执行相同规则,所以临时 winner 一致。
30.2 为什么值优先于并发删除
并发删除/修改时,系统没有证据判断谁应覆盖谁。
先显示普通值可以保留可恢复数据,同时 UI 会明确显示 presence 冲突。
这不意味着删除失效。删除候选仍在。
如果删除是因果上发生在修改之后,修改候选早已被 tombstone context 覆盖,最终只剩删除,不会进入“值优先”的并发排序。
30.3 Winner 不是 LWW
Last-Writer-Wins 会只保留 winner。
Netcatty 物化只选择一个展示值,其他并发候选仍在加密 envelope 和本地 replica 中。
这一区别决定了系统能否真正做到“不静默丢值”。
31. 用户解决冲突时发生什么
UI 传入两个稳定身份:
addressKey:冲突 register 的地址;candidateDot:用户选择的候选。领域函数找到候选后,不会直接删掉其他值。
它会创建一个新写入:
因此新写入支配全部旧候选。
接下来运行时:
相关代码:
domain/convergentSync/conflicts.tsinfrastructure/services/cloudSync/convergentSyncRuntimeMethods.tscomponents/cloud-sync/ConvergentSyncPanel.tsx第三篇要点
Netcatty 没有把整个 Vault 变成一个巨大的 CRDT 值。
它根据业务结构选择粒度:实体有 presence、position 和顶层字段 register;字符串集合使用 observed-remove entry;Settings 使用叶子路径;数组保持原子。
本地写入通过 dot 和 exact context 创建因果历史。Join 只删除被明确覆盖的候选。物化器提供稳定 winner,但冲突候选始终保留,直到用户选择产生新的支配写。
第四篇:状态怎样保证可验证、可序列化
32. 为什么 CRDT Metadata 不能“尽量解析”
普通偏好设置损坏时,应用有时可以忽略一个字段并使用默认值。
CRDT 因果 metadata 不能这样做。
如果一个 dot 被错误地认为已经覆盖另一个 dot,系统可能永久删除仍有价值的候选,并把这个错误传播到所有 Provider。
因此协议采取 fail closed:无法证明合法,就停止同步。
33. Canonicalization:同一状态只有一种标准写法
JavaScript 对象的插入顺序可能不同。
两个设备可以拥有完全相同的逻辑状态,却因为 map 构建顺序不同得到不同 JSON。
domain/convergentSync/serialization.ts会规范排序:这样做带来三个好处。
第一,同一逻辑状态在不同设备上序列化一致。
第二,测试、hash、调试和 benchmark 可复现。
第三,对象插入顺序不能伪造状态差异。
34. Hydrate 时检查哪些不变量
各项不变量的作用如下。
34.1 Schema 必须明确支持
当前只支持 schema 2。
遇到更高版本时,旧代码不能猜测新字段语义,因此阻断。
34.2 Counter 必须是安全正整数
负数、小数或超过 JavaScript 安全整数范围的 counter 无法提供可靠单调身份。
34.3 DotOrigins 必须覆盖完整设备前缀
若 vector 说 MacBook 已到 7,dotOrigins 必须解释 1 到 7 每个 counter 属于哪个 register。
中间缺少 4,说明因果历史不完整。
34.4 候选 Dot 必须被 Vector 覆盖
如果候选声称自己是
macbook:9,而 vector 只有 7,状态自相矛盾。34.5 Dot 不能跨 Register 重用
同一个 dot 永远只能对应一个 register ID。
34.6 Context 不能包含自己或未来
候选不能声称自己在写入前已经看过自己。
也不能声称
macbook:5看过同设备尚未产生的macbook:8。34.7 不能形成因果循环
如果 A 的 context 包含 B,B 的 context 又包含 A,就会出现“A 在 B 后面,同时 B 又在 A 后面”的不可能历史。
34.8 每个 Vector Counter 必须有见证
一个 counter 必须出现在当前候选或某个候选 context 中。
否则 vector 声称见过一段历史,实际状态却没有任何证据。
34.9 Presence 和 Position 有严格类型
Presence 只能是
true或 tombstone。Position 只能是字符串或数字。
这样物化逻辑不会遇到无法解释的结构值。
34.10 HLC 必须单调一致
候选 HLC 不能大于 state 保存的整体 HLC。
34.11 Settings 路径必须使用标准编码
同一逻辑路径不能有两种转义写法,否则它们可能被当作两个 register。
35. 为什么使用无原型 Record
实体 ID 和字段名来自用户数据。
某些字符串,例如
__proto__、constructor,在普通 JavaScript 对象上有特殊含义。CRDT map 使用无原型 record 和安全读写辅助函数,避免用户可控 ID 逃逸到对象原型链,或让序列化、遍历出现不一致。
相关辅助代码位于:
domain/convergentSync/record.tsdomain/convergentSync/json.ts第四篇要点
CRDT 正确性不只来自 join 公式,也来自严格数据边界。
如果 dot 可以复用、context 可以循环、vector 可以凭空增长,那么再漂亮的合并算法也没有意义。
因此 state 每次从存储或云端恢复时都要完整验证,再转成 canonical 表示。
第五篇:云文件协议为什么要保存两套视图
36. 云端文件的基本结构
Netcatty 的云文件外层仍然是:
meta是明文元数据。payload是 Base64 编码的 AES-GCM 密文。CRDT v2 没有改变“用户数据只在客户端解密”的安全模型。
36.1 明文中新增了什么
明文 metadata 只新增:
它告诉新客户端:这个文件包含 convergent sync v2 协议。
为什么要放在明文?
因为客户端需要在选择解析路径时知道协议代际,并且遇到未知未来版本时及时阻断。
36.2 明文中没有什么
以下内容均不进入明文 metadata:
它们全部位于 AES-256-GCM 加密 payload 内。
37. 加密模型保持不变
加密实现位于
infrastructure/services/EncryptionService.ts。大致流程是:
AES-GCM 不只隐藏内容,也验证密文完整性。
如果密文、IV 或认证标签被修改,解密会失败,而不是返回一个部分损坏的 JSON。
CRDT metadata 包含历史候选,可能比普通快照更加敏感,所以继续把它放在同一个认证加密边界内十分重要。
38. 为什么加密 Payload 里同时有 v1 快照和 CRDT Envelope
解密后的 payload 有两层视图。
第一层是普通
SyncPayload:{ "hosts": [], "keys": [], "snippets": [], "customGroups": [], "settings": {}, "syncedAt": 1780000000000 }第二层是:
{ "convergentSync": { "schemaVersion": 2, "encoding": "materialized-winner-v1", "state": {} } }两者是同一份数据的两种视图。
普通快照是“现在给用户看的资产表”。
CRDT envelope 是“为什么资产表得到这个结果的证据档案”。
38.1 为什么不能只保存 Envelope
旧版本 Netcatty 不认识 CRDT。
如果云文件只剩 envelope,旧客户端完全无法读取 Host、密钥和 Settings。
保留完整 v1 物化快照后,旧客户端仍能看到它认识的数据字段。JSON 中额外出现的
convergentSync对旧读取路径不会改变普通字段结构。38.2 为什么不能只保存普通快照
普通快照只能显示 winner。
它无法保存:
只保存快照,下一轮同步又会退回“谁的文件更新”问题。
所以两层都需要。
39. 为什么 Envelope 不把 Winner 完整复制一遍
如果每个 register 在 envelope 中再保存一次 winner,绝大部分用户数据会存两份。
例如 Host 名称
production:数据量和加密文件大小都会显著增加。
因此协议使用:
39.1 Materialized marker
如果选中候选的值与 v1 快照对应值相同,envelope 不保存该值,而是写:
{ "dot": { "deviceId": "macbook", "counter": 5 }, "context": [], "hlc": { "wallTime": 1780000000000, "logical": 0 }, "materialized": true }hydrate 时,系统去相邻 v1 快照的对应字段恢复值。
39.2 哪些值仍然必须内联
并发备选值必须内联,因为 v1 快照只包含 winner。
Tombstone 必须内联,因为普通快照无法表达删除证据。
Presence 和 position 这类结构 register 也保留内联值,让 envelope 结构可以自描述。
39.3 Hydrate 后为什么还要重新物化比较
假设 envelope 声称某个候选
materialized: true,但 v1 快照对应 Host 已经不存在。系统不能猜测值。
完整 hydrate 后,它会重新从 CRDT state 物化一份普通 payload,并要求它与云文件中的 v1 快照完全一致。
只要不一致,就说明:
此时同步阻断。
40. 一个简化的云文件示意
真实协议还包含更多字段,以下示例仅保留主要结构:
{ "meta": { "version": 18, "updatedAt": 1780000000000, "deviceId": "macbook", "algorithm": "AES-256-GCM", "syncSchemaVersion": 2, "iv": "...", "salt": "..." }, "payload": "<AES-GCM ciphertext>" }解密 payload 后:
{ "hosts": [ { "id": "host-1", "name": "production-eu", "port": 2222 } ], "keys": [], "snippets": [], "customGroups": [], "settings": { "theme": "dark" }, "syncedAt": 1780000000000, "convergentSync": { "schemaVersion": 2, "encoding": "materialized-winner-v1", "state": { "vector": { "macbook": 5, "office-pc": 3 }, "dotOrigins": {}, "hlc": { "wallTime": 1780000000000, "logical": 0 }, "collections": {}, "settings": {}, "stringCollections": {} } } }41. 旧客户端写回 v1 时会发生什么
读取兼容只是第一步。
旧客户端读取 v1 快照后,下一次上传会生成一个完全不含 envelope 的 v1 文件。
新客户端看到这种文件时,不能直接把它当成新的 CRDT 初始状态,也不能把所有缺失字段当成删除。
41.1 Trusted Provider Baseline
系统为每个 Provider 保存最后一次可信状态:
如果 GitHub 从上次可信 v1 快照变化为当前旧客户端快照,系统可以做字段 diff:
然后把差异转换成 synthetic CRDT writes。
41.2 为什么 Baseline 必须按 Provider 保存
GitHub 和 WebDAV 可能在不同时间被旧设备写入。
它们的上次可信状态不一定相同。
用 GitHub baseline 解释 WebDAV 快照,可能把正常旧值误判成删除或新增。
所以 baseline 是 provider-specific。
41.3 没有 Baseline 为什么必须阻断
没有 baseline,只看到当前 v1 快照,无法区分:
任何猜测都可能永久生成错误 tombstone。
因此系统提示升级旧设备或人工选择版本,而不是自动上传。
42. “字段省略”和“明确清空”必须区分
旧客户端可能根本没有某个新 optional field。
例如新版本支持
noteGroups,旧版本序列化时完全不写这个属性。如果把省略理解为删除,新版本设备同步一次后,所有 note group 都可能消失。
转换规则是:
undefined:继承 trusted baseline;[]:用户或客户端明确给出空集合,视为清空;{}:对相应对象语义视为显式空值;这条兼容规则虽小,却直接关系数据安全。
第五篇要点
云文件同时保存“当前可用快照”和“因果证据档案”。
普通 v1 快照让现有应用状态和旧客户端继续读取;加密 CRDT envelope 保存并发候选、删除和因果关系。
Winner 值通常通过
materialized: true从快照恢复,减少重复存储。旧客户端回写只有在存在 provider-specific trusted baseline 时才能安全转换。第六篇:第一次启用时为什么不能直接按下开关
43. 迁移不是简单的格式转换
把 v1 JSON 包进一个新字段很容易。
困难在于旧快照没有 dot、context 和 tombstone。
第一次创建 CRDT state,实质是在旧快照基础上建立初始因果历史。
如果当前世界本来就有未解决冲突、Provider 缺失或可疑大规模删除,直接初始化会把一个不确定快照包装成看似可信的因果状态。
以后所有设备都会继承这个错误起点。
所以迁移必须先审计,再确认。
44. 用户打开实验开关后发生什么
UI 不会立即把
enabled设为 true。它先执行准备阶段:
SyncPayload;预览包括:
纯计划位于
domain/convergentSync/migration.ts。带 I/O 的编排位于
application/convergentSyncMigration.ts。45. 只有 v1 数据时怎样迁移
45.1 选择起始来源
如果本地已有真实云实体,本地会参与旧 smart merge。
首次接入的新设备若没有任何 Host、Key、Snippet 等云实体,也没有可信 base,其首启 Settings 默认值不应阻止采用云端 vault。
例如新设备默认主题是 light,云端用户设置是 dark。不能因为两者不同就说“本地也有修改”。
所以新设备判断主要看云实体,而不是首启 Settings 默认值。
45.2 逐 Provider 使用可信三方合并
每个 v1 Provider 若有可信 base,就执行原有 smart merge。
若没有 base,只有当 Provider payload 与当前合并结果完全相同,才能安全接受。
只要内容不同,就无法知道差异来源,迁移阻断。
45.3 为什么迁移阶段不接受未解决冲突
CRDT 可以保存未来产生的字段冲突,但首次初始化不能把来源不明的旧三方冲突伪装成可靠并发历史。
旧 v1 没有足够证据为双方分配真实因果分支。
因此旧 smart merge 报告冲突时,迁移要求先处理,而不是自动创建 CRDT 候选。
45.4 Suspicious shrink
如果合并结果会一次删除大量实体,系统怀疑这可能是:
迁移会阻断,并在预览中展示 shrink finding。
确认数据完整后,用户应先通过现有恢复或版本选择流程处理,而不是让迁移自动固化删除。
45.5 创建初始 State
只有所有来源都可以安全解释时,系统才把最终 v1 快照转换为第一份 CRDT state。
这份初始 state 为所有实体、字段、设置和字符串 entry 分配 dots,形成统一起点。
46. 已经存在 v2 Provider 时怎样迁移新设备
如果 GitHub 和 WebDAV 中已有 v2,流程不同。
处理顺序如下:
46.1 Legacy Branch
如果某个 v1 来源与 joined payload 不同,并且有可信 baseline,系统把:
转换成一组 synthetic CRDT mutations。
它们在一个稳定 synthetic device ID 下形成独立 branch,例如:
最后把所有 branch 与 v2 state join。
46.2 为什么 Synthetic ID 必须稳定
如果每次迁移随机生成 ID,同一个旧客户端快照重试两次会被解释成两组不同写入。
这会制造重复 dots 和伪冲突。
稳定 ID 让相同来源可以被确定性解释,也让 Provider 遍历顺序不影响结果。
47. 用户确认迁移后的保护事务
预览只是计算,没有写入。
用户确认后,初始化在同一把 convergent Web Lock 内完成。
47.1 再次检查本地数据是否变化
用户查看预览期间可能继续编辑 Host。
如果仍按旧预览初始化,新编辑会被覆盖或遗漏。
所以确认时重新构建当前 payload,并与预览快照比较。
只要发生变化,就取消并要求重新预览。
47.2 创建保护快照
迁移可能应用合并后的云数据。
如果本地存在有意义数据,系统必须先创建加密保护备份。
安全备份不可用或写入失败时,破坏性应用应中止。
47.3 应用 Payload、保存 Baseline 和 Replica
本地应用成功后,保存:
initialized=true、enabled=true配置。47.4 为什么还要强制发布第一份 v2 文件
迁移前后的普通 v1 快照可能完全相同。
例如所有 Provider 原本就一致,只是现在新增了 envelope。
自动同步若只看普通数据 hash,会认为“数据没变”,永远不上传 envelope。
因此初始化在释放同一把 Web Lock 前,强制运行第一次 v2 read–merge–write–verify。
这样其他设备立即能看到 schema 2 和因果 state。
48. 初始化失败时为什么不半启用
出现以下任一情况,迁移都不能只完成一部分:
一个“本地认为已经启用、云端仍只有 v1”的状态会让后续路由和兼容判断非常危险。
因此初始化把本地应用、replica 持久化和首轮发布放在受控流程中。失败会明确提示用户重试,而不是静默退回旧同步。
第六篇要点
首次迁移是在给旧快照补建因果历史。
它必须读取所有 Provider、使用可信 base 解释差异、阻断旧冲突和可疑删除,并让用户查看预览。
确认后还要重新检查本地快照、创建保护备份、保存 canonical replica,并在同一把锁内强制发布首个 v2 envelope。
第七篇:一次真实同步怎样完成
49. 一个 Canonical Replica,多个 Provider 镜像
Netcatty 把所有已连接 Provider 视为同一个逻辑 vault 的镜像。
因此本地只维护一个 canonical CRDT replica。
它不是:
而是:
每个 Provider 有自己的 trusted baseline,但 baseline 主要服务于旧客户端兼容和远端身份验证,不是独立的业务主状态。
这也意味着:如果用户连接一个内容完全不同的新账号,系统会把它视为同一 vault 的另一个副本,尝试合并、报告冲突或安全阻断,而不是自动创建第二套独立 vault。
50. 为什么需要跨窗口 Web Lock
Electron 应用可能同时打开多个 renderer 窗口。
两个窗口都可能:
即使 CRDT join 能处理设备间并发,同一个设备 ID 下重复分配 counter 仍会破坏 dot 唯一性。
所以迁移、同步、冲突解决和降级都使用同一把独占锁:
实现使用 Web Locks API,并设置
ifAvailable: true。如果另一窗口正在执行,当前操作直接报告“另一窗口正在同步”,而不是长时间排队。
为什么不排队?
因为排队期间当前窗口的 payload 可能已经陈旧。拿到锁后继续执行旧 payload,仍可能覆盖刚完成的本地应用。
如果运行环境没有 Web Locks API,v2 fail closed。
51. 一次同步的全景图
核心代码在
infrastructure/services/cloudSync/convergentSyncRuntimeMethods.ts。flowchart TD A["构建当前本地 SyncPayload"] --> B["获取独占 Web Lock"] B --> C["加载并验证 canonical replica"] C --> D["检查本地是否发生可疑大规模删除"] D --> E["并行下载所有已连接 Provider"] E --> F["v2 hydrate;v1 通过 baseline 转换"] F --> G["把本地快照变化写成 local dots"] G --> H["无序 join 全部状态"] H --> I["先持久化本地产生的 dots"] I --> J["最多三轮 read–merge–write–verify"] J --> K{"至少一个 Provider 验证成功?"} K -- "否" --> L["保留与当前 Vault 一致的 durable state"] K -- "是" --> M{"物化结果会改变本地 Vault?"} M -- "否" --> N["提交 canonical replica"] M -- "是" --> O["保护性应用 payload"] O --> P["应用成功后提交 replica"] N --> Q["更新冲突、Provider 状态与 pending 标记"] P --> Q L --> Q后面的章节会逐段展开这张图。
52. 第一步:加载 Replica,而不是从当前快照重新初始化
启用 v2 后,本地 replica 是因果历史的权威来源。
当前 Vault 快照只是它的物化结果,加上用户自上次同步后的本地编辑。
每轮不能直接从快照创建一个新 CRDT state,否则会:
如果 config 说 v2 已初始化,但 replica 缺失,运行时进入错误状态并阻断。
它不会自动从当前快照“重建一个看起来差不多的 replica”。
53. 第二步:在创建删除 Dots 前做 Shrink Guard
当前本地 payload 可能因为存储故障突然少了大量实体。
如果先把这些差异转成 CRDT tombstone,再发现异常,删除历史已经被正式写入。
所以运行时先把当前 payload 与 replica 的物化快照比较。
发现 suspicious shrink 时,在默认策略下阻断,并向每个 Provider 返回 shrink finding。
只有用户明确 force push,或选择
preferCloud恢复云端,才绕过相应路径。54. 第三步:先下载所有 Provider,再做 Join
运行时并行获取所有已连接 Provider adapter 和远端文件。
它不会:
而是先收集当前能得到的所有远端状态。
54.1 远端是 v2
解密、hydrate、校验 envelope,得到完整 CRDT state。
54.2 远端退回 v1
加载该 Provider 的 trusted convergent baseline。
如果当前 v1 payload 与 baseline 物化值相同,直接使用 baseline state。
如果不同,做 legacy diff,并用稳定 synthetic device ID 生成 causal writes。
没有 baseline 就失败关闭。
54.3 Provider 下载失败
本轮记录错误。
Adapter 获取失败是当前周期的终止性错误。
下载、解密或验证失败可在后续 round 或下一次同步重试,但不能把未知状态当作空 Provider。
55. 第四步:把本地编辑转成正式 Dots
当前 Vault payload 与 replica 的物化 payload 可能不同。
这些差异表示用户自上次 replica 后做出的本地编辑。
applyLegacySyncPayload会做字段级 diff,并生成普通 CRDT mutations。这里虽然函数名含 Legacy,但它的含义是“把无 CRDT metadata 的普通快照变化转换成 CRDT 写入”。当前 renderer Vault 本身也是普通 payload,因此复用同一机制。
55.1 Smart Merge
默认策略先把本地 diff 写到 replica,再与所有远端 states join。
不冲突变化自动组合,并发同字段值保留。
55.2 Prefer Local
先 join 当前 replica 和所有远端状态。
再以本地 payload 生成一组支配当前已知状态的 writes。
远端 state 不会被直接丢弃;系统用带有明确因果关系的写入表达“用户选择本地版本”。
55.3 Prefer Cloud
Canonical state 以所有远端 state 的 join 为准。
但 remote-only dots 不会立刻成为本地持久 replica。只有 Provider 验证成功,并且物化 payload 成功应用到本地后,才正式提交。
56. 为什么网络上传前先保存本地 Dots
假设用户修改 Host,系统生成
macbook:20。如果先上传,随后应用崩溃,replica 还停留在 counter 19。
下次重试可能再次分配
macbook:20,但内容或 register 已不同。这会违反 dot 唯一性。
因此本地新写入必须先持久化。
重试时传播同一个 state,而不是重新创造一段历史。
56.1 为什么远端 Dots 又不能太早保存
另一面也有风险。
如果从云端下载到新 dots,马上保存进本地 canonical replica,但本地 Vault 还没成功应用物化 payload,那么 metadata 会声称:
实际 UI 和 localStorage 却还是旧数据。
因此运行时区分:
durableBeforeVerification:包含本地新 dots,与当前 Vault 一致;canonical:还可能包含尚未验证、尚未应用的 remote-only dots。完全网络失败时,只保留前者。
57. Read–Merge–Write–Verify 的每一轮
运行时最多执行三轮。
每轮之间使用很短的 full-jitter 退避,避免多个设备以相同节奏反复互相覆盖。
57.1 Preflight Read
在真正上传前再次下载每个可用 Provider。
为什么刚开始下载过还要再读?
因为从第一次下载到本地 join 期间,其他设备可能已经上传新状态。
Preflight 尽可能缩小竞争窗口。
57.2 Merge
把 preflight state join 进 canonical。
本轮 expected state 就是此刻的 canonical。
57.3 No-op Verification
如果某 Provider 的 v2 vector 已经支配 expected vector,说明它已经包含本轮全部状态。
这次 preflight read 本身就可以作为验证。
运行时不会为了“证明同步过”而重新加密、上传一个内容相同的新修订。
57.4 Write
只向尚未覆盖 expected state 的 Provider 上传新 envelope。
57.5 Read-back
上传返回成功后,再次下载同一 Provider。
57.6 Verify
要求:
这证明回读远端至少保留了本轮预期的全部 dots。
57.7 Re-join Superset
回读 state 可能还包含其他设备刚写入的新 dots。
这种远端状态属于 superset,并非同步失败。
运行时把它 join 进 canonical,下一轮再传播给其他 Provider。
57.8 何时提前结束
所有可用 Provider 都已验证,而且它们的 vectors 支配最新 canonical,且本轮 join 没有继续增长时,可以提前结束。
三轮后仍缺失 canonical state 的 Provider 被标记失败。
58. Provider 部分失败时为什么不回滚成功者
假设 GitHub 成功、WebDAV 暂时断网。
回滚 GitHub 并不能恢复一个全局事务,因为两个 Provider 本来就不支持跨服务原子提交。
更合理的做法是:
pendingLocalSync = true;CRDT 允许副本暂时落后,并在恢复连接后通过反熵同步追上其他副本。
59. 本地应用为什么使用两阶段提交思路
一个
SyncPayload会写入多个 Vault 和 Settings 存储键。浏览器 localStorage 没有跨这些键的完整事务。
如果写到一半崩溃,本地可能处于一半新、一半旧的混合状态。
所以运行时要求调用方提供:
59.1 保护性应用流程
commitReplica();59.2 为什么 Commit 由应用回调触发
只有实际写入本地数据的代码,才能确认各个存储边界均已完成。
运行时若在调用 apply 前就保存 replica,会出现 replica 超前。
如果 apply 返回却没有调用 commit,运行时把它视为错误。
59.3 应用中断后的启动保护
下次启动若发现 apply-in-progress sentinel 仍存在,自动同步拒绝上传当前混合状态。
用户会被引导使用恢复能力,而不是把可能损坏的本地快照扩散到云端。
60. 自动同步怎样避免反向覆盖云端
自动同步编排位于
application/state/useAutoSync.ts。60.1 启动时先检查远端
自动推送 gate 在启动远端检查完成前保持关闭。
这是为了防止本地数据因升级、存储异常或加载尚未完成而暂时为空,随后自动把空状态上传云端。
60.2 v2 不能只检查物化快照
一个 v2 state 可能有两个候选,但 v1 快照只显示 winner。
如果启动检查只下载 v1 快照,再与本地比较,系统可能把 winner 当成普通本地修改,静默覆盖 loser。
所以 v2 启动检查必须走完整 CRDT join。
60.3 空 Vault 恢复提示
如果本地没有云实体,但 Provider join 后有数据,系统先生成只读 recovery preview。
用户可以选择恢复云端,或明确保留空 Vault。
它不会直接把空本地推上去。
60.4 Debounce 与精确 Hash
快速编辑会先 debounce,再构建完整数据 hash,避免每次按键都序列化整个 Vault。
从远端应用 payload 后,系统只跳过那个精确 applied hash。
如果用户在 debounce 期间继续编辑,hash 已变化,新修改仍会正常同步。
60.5 运行期间继续检查远端
另一设备可能在本机启动检查完成后才上传。
因此应用运行期间会定时检查远端;窗口重新可见或网络恢复时,也会触发检查。
61. 本地 Replica 和 Baseline 怎样加密保存
CRDT replica 和 Provider baseline 含有敏感候选,不能明文放进 localStorage。
存储实现位于:
infrastructure/services/cloudSync/encryptedLocalStorage.tsinfrastructure/services/cloudSync/convergentSyncStorageMethods.ts它们使用已解锁主密钥派生出的 AES-GCM key 加密。
读取后还要执行 schema 校验、CRDT 不变量校验和 canonicalization。
62. 更换主密钥为什么需要事务性重加密
同步本地存储不只有一条记录。
它包括:
如果只重加密一半就提交新主密钥,剩余记录会永久无法读取。
流程是:
这相当于在应用层完成一笔事务。
63. 暂停不是降级
本地配置是:
它只保存在当前设备,不参与云同步。
三种状态:
用户关闭开关后:
暂停期间仍允许本地编辑。
恢复同步时,这些编辑相对 replica 物化值的差异会变成正式 CRDT writes。
64. 本地备份为什么不携带活跃 Replica
备份用于恢复用户数据,不应该复制旧设备在旧时刻的因果身份。
若备份包含 replica,恢复可能:
所以普通备份只保存用户 payload。
恢复时,
application/convergentSyncReplica.ts会:从 CRDT 语义看,恢复旧备份相当于当前设备重新写入备份中的内容,并非回退活跃因果历史。
65. 显式降级回 v1 的完整过程
降级会删除 CRDT metadata,必须由用户明确确认。
它也使用同一把 Web Lock。
65.1 先下载所有 Provider
任何 Provider preflight 失败,降级先阻断。
如果直接降级本地,而某个离线 Provider 仍有较新的 v2 state,之后可能无法安全解释。
65.2 合入暂停期间编辑
暂停 v2 后用户仍可能修改本地 Vault。
降级前先把这些修改转成 local CRDT writes,避免用旧 replica 覆盖它们。
65.3 Join 所有远端并检查冲突
把所有 Provider states 与本地 state join。
仍有字段冲突时,必须先解决。
不能把确定性临时 winner 当成用户已经同意的最终 v1 值。
65.4 保护性应用最终 v1 Payload
物化最终 state,安全应用到本地,并提交最终 replica。
65.5 上传并回读每个 Provider
上传不含 envelope 的 v1 文件。
回读后验证:
syncSchemaVersion: 2已不存在;65.6 为旧三方合并建立正确起点
在清理 v2 storage 前,把准确回读的 v1 文件写入:
否则第一次 legacy sync 可能拿降级后的新编辑与迁移前的旧 base 比较,制造错误冲突或覆盖。
65.7 最后才清理 v2
所有 Provider 都成功后,清除:
若任一 Provider 失败,本地仍保持 v2 初始化和 pending 状态,允许重试。
66. 冲突 UI 为什么必须遮蔽秘密值
冲突候选可能包含:
UI 不能为了让用户选候选,就把这些值直接展示出来。
domain/convergentSync/conflicts.ts会检查:秘密候选只显示:
日志也只能记录地址、数量和必要 hash,不能记录明文值。
第七篇要点
CRDT join 只是运行时的一部分。
真实可靠同步还需要:
第八篇:一次完整的同步过程
67. 初始状态
下面仍以三台设备 A、B、C 为例。
设备 A 创建一台 Host:
{ "id": "host-1", "name": "production", "port": 22, "username": "root" }为便于阅读,示例省略真实 register ID、完整 context 和 HLC,只保留关键 dots。
初始状态可简化表示为:
A 把状态上传到 GitHub 和 WebDAV。
设备 B、C 都完成第一次同步,因此它们知道这些 dots。
68. A 和 B 离线修改不同字段
A 把名称改为:
A 写入:
因为实体发生有效修改,A 还会刷新 presence:
B 同时离线把端口改成 2222:
68.1 Join 后 Name 怎样处理
只有 A 修改了 name。
旧
A:2被A:5.context覆盖,所以 name 只剩:68.2 Join 后 Port 怎样处理
只有 B 修改了 port。
旧
A:3被B:1.context覆盖,所以 port 只剩:68.3 Presence 为什么有两个候选却没有业务冲突
A:6 和 B:2 是并发 presence writes,但它们的值都是
true。Register 可能保留两个因果候选,但冲突检测按不同业务值判断。两个候选都表示实体存在,因此不需要用户处理。
最终物化:
{ "id": "host-1", "name": "production-eu", "port": 2222, "username": "root" }旧实体级合并容易丢失其中一项修改,字段级 CRDT 则能直接得到上述结果。
69. B 和 C 并发修改同一个字段
B 离线把 username 改为
admin:C 没看到 B:3,同时改为
ops:两者 context 都没有对方的 dot。
Join 后:
69.1 用户此时看见什么
物化器按 HLC、deviceId 和 counter 选择一个临时 winner。
假设当前显示
ops。活动冲突区仍会列出:
69.2 为什么同步已经“收敛”,UI 仍然有冲突
所有设备最终都会得到同一个候选集合
{admin, ops},并选出同一个临时 winner。所以系统状态已经收敛。
但人类意图仍有两个答案,需要用户决定。
由此可以区分两件事:CRDT 状态已经收敛,业务上的取值分歧仍待解决。
70. 用户选择 Admin
用户在设备 A 上选择
admin。A 创建新写入:
以后任何副本 join 时:
即使 C 离线很久,后来又带着 C:1 回来,C:1 也不会重新制造冲突。
71. A 删除 Host,C 同时修改 Port
A 删除实体:
C 尚未看到删除,把 port 改为 2200:
A:8 与 C:3 互相没见过。
Join 后 presence 有:
71.1 系统为什么暂时保留 Host
物化规则中,普通值优先于并发 tombstone。
所以 Host 暂时可见,port 显示 2200。
同时 UI 显示 presence 冲突,让用户选择保留或删除。
71.2 如果用户确认删除
系统写一个新 tombstone,其 context 覆盖 A:8 和 C:3。
之后实体真正删除,C 的离线修改也不会再让它复活。
71.3 如果用户确认保留
系统写新的
presence=true,context 同样覆盖两个候选。实体明确重建,删除冲突消失。
72. GitHub 和 WebDAV 发生竞争写
设备 A 当前期望 vector:
{ "A": 8, "B": 3, "C": 3 }A 上传 GitHub 后,另一设备 B 又写入
B:4。A 回读 GitHub,得到:
{ "A": 8, "B": 4, "C": 3 }这个 vector 支配 A 的 expected vector。
说明 A 的写入没有丢,同时远端还有新状态。
A 把 GitHub state join 进 canonical,并在下一轮把新 canonical 传播给 WebDAV。
如果回读是:
{ "A": 7, "B": 4, "C": 3 }它缺少 A:8。
即使文件版本号更高,也不能算验证成功。
73. 这个故事说明了什么
字段级 register 解决了不同字段互相覆盖。
Exact context 区分了因果覆盖和真正并发。
MV-Register 保留同字段并发值。
Presence register 让删除/修改竞争可见。
Tombstone 防止旧设备复活删除。
用户解决冲突会产生新的支配写。
Version vector dominance 让 Provider 写后验证有了真实依据。
无论 GitHub 和 WebDAV 以什么顺序传递这些状态,只要最终包含同一批因果事实,所有副本就会收敛。
第九篇:代码地图、测试与维护指南
74. Domain:最值得先读的部分
目录:
domain/convergentSync/这一层是纯逻辑,不读取 localStorage,不调用网络,不依赖 React,也不自行读取当前时间。
74.1
types.ts定义:
初次阅读代码时,宜先从这里熟悉各项类型。
74.2
clock.ts负责:
74.3
registerId.ts把 register 地址编码成碰撞安全、稳定的 JSON 数组字符串。
它与 dotOrigins 不变量直接相关。
74.4
register.ts最核心的单 register 逻辑:
74.5
state.ts负责把 register 组合成完整业务状态:
74.6
serialization.ts负责 strict validation、canonicalization、serialize 和 hydrate。
排查“为什么某个 envelope 被拒绝”时,主要看这里。
74.7
payload.ts负责:
SyncPayload -> mutations -> state;state -> materialized SyncPayload;74.8
legacy.ts负责普通快照 diff 和 synthetic writes。
它同时服务:
74.9
migration.ts纯迁移计划。
输入本地 payload、Provider payload 和 trusted baselines,输出 preview、state 或阻断原因。
74.10
conflicts.ts负责稳定 conflict key、候选选择、支配写和 secret 检测。
75. Application:把领域逻辑接到用户操作
application/convergentSyncMigration.ts下载迁移输入,准备 preview,执行保护性初始化,并强制发布首份 v2 envelope。
application/convergentSyncReplica.ts把备份恢复解释成当前设备的新 writes,而不是回滚活跃 replica。
application/state/useCloudSync.ts向 UI 暴露:
application/state/useAutoSync.ts处理自动同步 gate、启动恢复、暂停、远端轮询、空 Vault 和中断应用保护。
application/localVaultBackups.ts提供保护备份、restore barrier 和 apply sentinel。
76. Infrastructure:I/O 与外部系统
infrastructure/services/cloudSync/convergentSyncRuntimeMethods.ts包含:
infrastructure/services/cloudSync/convergentSyncStorageMethods.ts包含:
infrastructure/services/convergentSyncConfig.ts保存设备本地
enabled/initialized,并通过 storage 事件与订阅同步多个 renderer。infrastructure/services/cloudSync/syncAllStorageMethods.ts根据本地 config 在 legacy 三方合并与 v2 runtime 之间路由。
infrastructure/services/EncryptionService.ts负责云文件 AES-256-GCM 加解密和
syncSchemaVersionmetadata。77. UI:用户实际看见什么
主要组件是
components/cloud-sync/ConvergentSyncPanel.tsx。它展示:
components/CloudSyncSettings.tsx编排 prepare、confirm、resolve 和 downgrade 用户流程。文案覆盖英文、简体中文、繁体中文和俄文。
78. 性质测试怎样验证收敛
运行:
测试使用
fast-check随机生成:78.1 交换律
防止 Provider 到达顺序影响结果。
78.2 结合律
防止分批、树形和多跳同步影响结果。
78.3 幂等性
防止重复下载和重试制造新状态。
78.4 随机网络最终收敛
多个副本在随机图上交换状态。
最终比较所有副本的 canonical serialization,要求完全一致。
79. 行为测试覆盖哪些危险边界
领域测试还覆盖:
运行时测试覆盖:
80. 性能基准关注什么
运行:
基准主要检查 register 数量增加时,create、merge 和 materialize 是否接近线性增长。
目标场景包含 10,000 个实体和大量 Settings registers。
实现中仍然有排序、canonicalization 和完整校验,所以并不追求牺牲正确性后的极低常数。
基准重点排查意外的平方级路径,避免数据量翻倍后耗时增长到四倍甚至更多。
第十篇:限制、取舍与常见问题
81. 为什么不直接使用 Automerge 或 Yjs
Automerge 和 Yjs 都是成熟方案,但它们更偏向通用文档或协同编辑模型。
Netcatty 的同步边界已经存在:
SyncPayload;引入通用库仍需要解决协议兼容、加密文件、迁移、Provider 验证和 UI。并且通用文档 CRDT 的 metadata 与操作语义未必适合当前数据边界。
因此首版选择一个针对 Netcatty schema 的小型纯内核,使每个不变量、序列化格式和冲突地址都可控。
这不代表通用库不好,而是当前问题更适合领域化设计。
82. 为什么不用纯 LWW
LWW 根据时间戳或确定性顺序只保留一个值。
它实现简单,也能收敛。
但它会静默丢弃同字段并发候选。
本需求明确要求“同字段并发值全部保留”,所以使用 MV-Register,并把 winner 仅作为物化投影。
83. 为什么没有中心服务器
中心服务器可以提供事务、操作日志、设备确认和 tombstone GC。
但它也会改变 Netcatty 当前多 Provider、零知识加密和用户自选存储的产品模型。
本版本目标是在现有 Provider 文件接口上获得收敛,而不是建设新的 Netcatty 同步服务。
84. Tombstone 会不会无限增长
会增长。
首版没有时间清理。
安全 GC 需要额外证明,例如:
这些能力当前不存在。
在无法证明安全时,保留 metadata 比让删除数据复活更合理。
85. 数组冲突为什么不能自动逐项合并
数组元素可能没有稳定 ID。
并发插入同一位置、移动同一元素、删除后重新插入,都需要明确序列语义。
对配置数据而言,错误自动重排可能比要求用户选一个完整数组更难理解。
所以数组首版作为原子值。
86. CRDT 是否意味着永远不会出现冲突
不会。
它保证的是:
同字段并发写入两个不同值,本来就存在人类意图冲突。
正确行为是所有设备一致地保留并显示它。
87. HLC 会不会因为时钟错误破坏数据
HLC 只排序并发候选,不决定因果覆盖。
时钟错误可能让另一个候选暂时成为 winner。
但 loser 仍然保留,用户仍可选择,join 仍然收敛。
88. 更换云同步账号会怎样
Provider baseline 与账号、endpoint 或 bucket 身份绑定。
切换账号后,旧账号 baseline 必须清除,不能用来解释新账号数据。
新账号内容不会自动“撤销”当前本地 Host。
系统会把新账号视为同一逻辑 vault 的一个新远端来源,重新下载并尝试安全合并。
如果新账号与当前 vault 数据不同,可能出现:
如果用户需要两套完全隔离的 vault,那是账号/工作区隔离能力,而不是当前多 Provider 镜像模型。
89. 为什么默认仍是旧三方合并
CRDT v2 引入:
这些都是较大的数据协议变化。
实验性 opt-in 让用户先查看迁移预览,并保留明确回退路径。没有主动迁移的用户不会受到影响。
90. 总体思路
旧同步问的是:
CRDT v2 问的是:
所有 Provider 都被视为同一状态的副本。系统先用无序 join 汇总因果记录,再通过写后 vector 验证,确认 Provider 已经保留本轮写入。
普通 v1 快照只是 CRDT 因果状态在当前时刻的确定性展示。
术语表
Base
旧三方合并中,local 与 remote 上一次共同确认的快照。
Candidate
MV-Register 中一个仍未被证明已覆盖的值或 tombstone。
Canonical State
经过统一排序和完整校验的标准 CRDT 状态。
Causal Context
一个候选写入前,在同一 register 中明确观察到的 dots 集合。
CRDT
Conflict-free Replicated Data Type。允许多个副本独立修改,再通过满足收敛性质的规则合并。
Dot
deviceId + counter,一次 CRDT 写入的唯一身份。DotOrigins
每个 dot 永久对应的 register ID 索引,用于防止跨 register 重用。
Envelope
加密 payload 中保存 CRDT vector、dots、contexts、候选和 tombstone 的协议结构。
HLC
Hybrid Logical Clock。为并发候选提供稳定排序,不负责证明因果覆盖。
Hydrate
从序列化 envelope 恢复完整 state,并执行严格校验和 canonicalization。
Join
CRDT 状态合并操作,记作
⊔。Materialize
从 CRDT state 生成普通
SyncPayload和冲突列表。MV-Register
Multi-Value Register。保留所有未被因果覆盖的并发候选。
Observed-remove
删除只覆盖本副本明确观察到的新增,未观察删除是 no-op。
Presence Register
表示实体或字符串 entry 是否存在的 register。
Replica
某台设备保存的完整 CRDT 状态副本。
Synthetic Write
把没有 CRDT metadata 的普通快照差异,用稳定虚拟设备身份转换成的 CRDT 写入。
Tombstone
带 dot、context 和 HLC 的删除候选,是长期保存的删除证明。
Trusted Baseline
最后一次经过验证的 Provider 物化 payload 与对应 CRDT state,用于解释旧客户端 v1 回写。
Version Vector
记录每台设备已知 counter 进度的 map,用于副本进度比较和 Provider 写后验证。
Winner
从并发候选中确定性选出的临时物化值。Winner 不会删除其他候选,也不等于冲突已解决。
复习提纲
以下问题涵盖了这套实现的主要设计:
如有不清楚之处,可按问题返回相应章节查阅。
Beta Was this translation helpful? Give feedback.
All reactions