├── crdt ├── yjs │ ├── index.md │ ├── yjs-fqa.md │ └── are-crdts-suitable-for-shared-editing.md ├── crdt-glossary.md └── 5000x-faster-crdts-an-adventure-in-optimization.md ├── README.md ├── ot └── this-is-how-to-build-a-collaborative-text-editor-using-rails.md └── fairy-fight └── collaborative-editing.md /crdt/yjs/index.md: -------------------------------------------------------------------------------- 1 | Yjs 框架相关技术资料。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # awesome-collaboration 2 | [OT 与 CRDT 的Battle,堪称神仙打架](https://github.com/pubuzhixing8/awesome-collaboration/blob/master/fairy-fight/collaborative-editing.md) 3 | 4 | # CRDT 5 | [CRDT 词汇表](https://github.com/pubuzhixing8/awesome-collaboration/blob/master/crdt/crdt-glossary.md) 6 | 7 | 8 | [Slate 集成 Yjs 问题总结](https://github.com/pubuzhixing8/awesome-collaboration/blob/master/crdt/yjs/yjs-fqa.md) 9 | 10 | [(翻译) CRDT 是否适合协同编辑?](https://github.com/pubuzhixing8/awesome-collaboration/blob/master/crdt/yjs/are-crdts-suitable-for-shared-editing.md) 11 | 12 | [(翻译) CRDTs 速度提高 5000x:优化挑战](https://github.com/pubuzhixing8/awesome-collaboration/blob/master/crdt/5000x-faster-crdts-an-adventure-in-optimization.md) 13 | 14 | 15 | # OT 16 | [SharedPen 之 Operational Transformation](https://objcer.com/2018/03/05/SharePen-Operational-Transformation/) 17 | 18 | [(翻译) 这是使用 Rails 构建协作文本编辑器的方法](https://github.com/pubuzhixing8/awesome-collaboration/blob/master/ot/this-is-how-to-build-a-collaborative-text-editor-using-rails.md) -------------------------------------------------------------------------------- /crdt/yjs/yjs-fqa.md: -------------------------------------------------------------------------------- 1 | 主要收集 slate 集成 yjs 的常见问题,技术方案是 slate + slate-angular + y-slate(slate-yjs) + yjs。 2 | 3 | > y-slate 是基于 slate-yjs 的修改版本,没有使用 slate-yjs 是因为我们视图层是基于 angular 的版本,slate-yjs 有一些不兼容的地方。 4 | 5 | 6 | #### 1. toJSON 7 | 8 | 弃用 `slate-yjs` 中的方法 toSlateDoc ,改用 Yjs 本身的 toJSON 方法(性能问题) 9 | 10 | #### 2. 表格合并单元格撤回同步失败 11 | slate 数据转 yjs 数据的过程中,同步 set_node 操作到 yjs 数据结构是移除的属性没有在 yjs 数据结构中删除,导致同步失败(slate-yjs 缺陷)。 12 | 13 | > 只有撤回操作才有的问题,当前基于 Yjs UndoManage 实现撤回时应该不会遇到这问题了,但是这是数据 slate-yjs 实现 slate 操作转 yjs 数据结构时本身的缺陷 14 | 15 | #### 3. 页面初始化后编辑器直接崩溃(slate 数据和 yjs 数据对应不上) 16 | 初始化过程采用 `applyYjsEvents` ​的方式,一些不太标准的 slate 数据会触发 normalize,而这是个时候的数据变更是不会同步给 yjs 的(asRemote控制),所以 slate 和 yjs 的数据无法对应上,那么整个同步机制就会受影响。 17 | 18 | #### 4. Undos/Redos 管理,需要借助Yjs的 UndoManage 的实现 19 | Slate 提供的 slate-history 是不具备协同编辑处理能力的,即使可以通过 withoutMerging 实现只入栈本地的操作记录,但是这样操作栈的选区会因为忽略协同者的操作而出现脏路径,导致撤回异常。 20 | 所以 Undos/Redos 的管理是必须要通过一种无冲出的方式实现的。 21 | 22 | > 这里要实现的 Undos 管理是用户的撤回栈只能撤回自己的操作,协同者的修改是不会记录到撤回栈。 23 | 24 | #### 5. 撤回操作导致编辑器崩溃(触发了 normalize ) 25 | 26 | 应用了 yjs 的 undos 管理后,撤回操作的数据流是 yjs -> slate 这样,数据修改的触发源是 yjs 数据结构的修改,从 yjs -> slate 数据同步是产生的 slate 操作是不会再向 yjs 同步的,这时如果触发了 normalize 会造成数据的修改无法同步给 yjs(当做远程操作了),导致数据不一致,编辑器崩溃。目前的处理办法是在处理 yjs -> slate 数据同步时禁用 noramalize,这样处理也合理,normalize 只在本地操作中触发。 27 | 28 | #### 6. 协同者焦点异常 29 | ![image.png](https://atlas-rc.pingcode.com/files/public/613c7957dc22b8c1f13691a5/origin-url) 30 | 这其中涉及到焦点位置的转换(absolute -> relative),这有点细节,yjs 在进行焦点定位时是相对定位,上图中 Alexane ​ 的焦点位置与 `*秋天不会远了*` *​ * 这句话对应的数据结构关联关联,​理论上即使这句话应为回车操作变到了第二行,焦点位置应该也可以定位准确,但是因为数据操作的原因,从 slate -> yjs 数据变化的时候,导致 `*秋天不会远了*` *​* 这句话先在第一行被删除,然后再在第二行被插入,那么 Alexane ​ 的位置被关联在了一个已经被删除的数据结构上,导致无法正确定位。 31 | 回车后的焦点位置如下:(理论上 Alexane ​ 的位置是不符合预期的,但是也可以接受) 32 | ![image.png](https://atlas-rc.pingcode.com/files/public/613c7b94dc22b8003a3691a6/origin-url) 33 | > 额外问题: 1. 两个人在同一行在段落,一个用户对一段文字进行了加粗操作后,另外一个用户焦点会意外跳动 34 | 35 | 36 | #### 7. 开启实时协同编辑总是Loading中 37 | 这个是整个 y-protocol 机制的问题,服务端因为要进行 Auth 验证可能会有一定延时后才开始监控 onmessage 事件,如果前端在发送 step1 时服务端还没有开始监控 onmessage ,那么客户端可能会接收不到 step2 的消息,进而无法初始化完成,所以一直在Loading。 38 | 39 | #### 8. 路径变换问题 40 | 这个是一个容易理解的问题,就是编辑器在处理弹框时,可能会存储一个操作的路径(Path),如果是单机编辑那没有任何问题,但是如果是实时协同编辑,那随着协同者的操作(插入/删除节点),这个路径可能会变化,这个时候再使用这个 Path 就会导致操作异常,所以需要使用 Slate 提供的 PathRef 记录每次变换后的Path,应用操作时使用最新的路径就可以了。 41 | 42 | #### 9. 弹框位置冲突 43 | 多人实时协同编辑测试的过程中发现太多,弹框冲突的问题,以前的弹框实现方案,很多是基于onChange事件做的,但是协同编辑其实要求,其它协同者数据更改触发的onChange对我的弹框状态不要产生影响,涉及到的有 提及插件、标签插件、链接插件等,还有弹框位置的更新(尤其是placeholder、块级菜单) 44 | 45 | #### 10. 点击聚焦 - 光标闪烁后跳动到原来的位置 46 | 现象:当协同编辑人数较多、并且操作比较频发是,通过鼠标点击移动光标位置,会有一定的概率导致焦点移动失败,跳回原来的地方。 47 | 目前的办法是移除 selectionchange 的防抖函数 throttle (并未完全解决问题) 48 | 49 | 问题原因分析: 50 | 51 | 通过点击触发了 slate-react 底层的 selectionchange 事件,但是它有一个防抖的延时(100ms),所以这个时候不会立即执行浏览器原生选区 到 Slate 选取的同步,假如在这个100ms 时间内有一个远程操作传递过来,slate 在应用这个远程操作时,会重新计算 slate 选区的状态,然后根据新计算的选区状态直接更新浏览器原生选区,导致刚刚通过点击操作产生的选区更新被重置。 52 | 53 | #### 11. Yjs UndoManage 的 captureTimeout 参数 54 | 操作合并逻辑? 55 | Slate 的撤回栈操作合并逻辑来源于操作类型的判断 56 | Yjs UndoManage 撤回栈合并的逻辑是基于时间差的 如果操作产生的时间间隔小于一个值则进行撤回栈的合并。 57 | 58 | #### 12. Yjs 中的操作意图流失 59 | move 对应先删除再插入 60 | split_node 也是对应也是删除和插入 61 | 62 | #### 13. 两个光标在同一个段落导致中文输入被打断问题 63 | 这个问题根源在编辑器的实现机制,绘制协同者的光标时通过 decorate 模式实现的,当协同者位置发生变换时,当前段落对应的DOM其实会触发删除和重新创建的,这个时候加入另外一协同者正在当前段落输入中文,由于焦点所在的DOM被移除,这个时候中文组合输入会被打断,焦点自动跳到段落的开始位置,这是问题其一,其二是编辑器视图层底层监控的 compositionend 事件不会再次被触发,那么底层针对 isComposing 状态做的特殊出都会变的失效,导致无论协同者无论怎么移动光标位置都不会触发 slate 选区的同步,编辑器无法继续使用,除非再次进行中文输入 重新触发 compositionstart/compositionend 事件把 isComposing 调整正确才可以,这个问题相当严重。 64 | 修复思路: 65 | 在视图层进行视图层刷新是做这种 **​脏状态** ​的判断,如果 isComposing = true 并且 DOM 中不存在 compositionText 则可以认为当前状态是脏状态,说明中文组合输入被意外的打断,那么直接把 isComposing 状态修正为 false,然后重新定位焦点到原本位置。 66 | 67 | #### 14. 加粗斜体等标记null问题 68 | 跟问题「2. 表格合并单元格撤回同步失败」类似,给元素取消属性标记时,没有正确同步到 yjs 数据结构中, slate-yjs 实现问题。 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /crdt/crdt-glossary.md: -------------------------------------------------------------------------------- 1 | # CRDT 词汇表 2 | 原文链接: [https://crdt.tech/glossary#crdt-glossary](https://crdt.tech/glossary#crdt-glossary) 3 | 4 | 5 | **Add-wins set (AWSet):** 6 | 7 | A set datatype in which additions take precedence over removals. For example, if one replica removes and re-adds an element, while another replica concurrently removes the element, then the merged outcome is that the element is in the set. Contrast with remove-wins set. 8 | 9 | 一种集合数据类型,其中添加优先于删除。 例如,如果一个副本删除并重新添加一个元素,而另一个副本同时删除该元素,则合并的结果是该元素在集合中。 与 remove-wins 集相对。 10 | 11 | **Commutative Replicated Data Type (CmRDT):** 12 | 13 | An old name for “operation-based CRDT”. The term “operation-based” is now preferred. 14 | 15 | “基于操作的 CRDT”的旧称。 现在首选术语“基于操作”。 16 | 17 | **Concurrent:** 18 | 19 | Two events are concurrent if they happened without knowledge of each other (i.e. the node on which event A occurred had not heard of event B at the time of A, and vice versa). 20 | 21 | 如果两个事件在彼此不知情的情况下发生(即发生事件 A 的节点在 A 的时间没有听说过事件 B,反之亦然),则这两个事件是并发的。 22 | 23 | **Convergence:** 24 | 25 | The key correctness property of CRDTs. Convergence requires the following: for any two replicas, if they have processed the same set of updates, then those replicas must be in the same state (regardless of the order in which they processed those updates). 26 | 27 | CRDT 的关键正确性属性。 收敛需要以下条件:对于任何两个副本,如果它们处理了相同的更新集,那么这些副本必须处于相同的状态(无论它们处理这些更新的顺序如何)。 28 | 29 | **Convergent Replicated Data Type (CvRDT):** 30 | 31 | 32 | An old name for “state-based CRDT”. The term “state-based” is now preferred. 33 | 34 | 35 | “基于状态的 CRDT”的旧名称。 术语“基于状态”现在是首选。 36 | 37 | **Delta-CRDT:** 38 | 39 | A variant of a state-based CRDT in which replicas don’t always send their entire state, but rather send state differences (deltas). See Delta State Replicated Data Types by Paulo Sérgio Almeida, Ali Shoker, and Carlos Baquero. 40 | 41 | 基于状态的 CRDT 的一种变体,其中副本并不总是发送它们的整个状态,而是发送状态差异(增量)。 请参阅 Paulo Sérgio Almeida、Ali Shoker 和 Carlos Baquero 撰写的 Delta State Replicated Data Types。 42 | 43 | **Effector:** 44 | 45 | In operation-based CRDTs, an effector is a function that updates the state of a replica by applying an operation. 46 | 47 | 在基于操作的 CRDT 中,效应器是通过应用操作来更新副本状态的函数。 48 | 49 | **Generator:** 50 | 51 | In operation-based CRDTs, a generator is a function that processes a data modification action taken by a user, and produces an operation that can be sent over the network and applied by an effector. 52 | 53 | 在基于操作的 CRDT 中,生成器是一个函数,它处理用户采取的数据修改动作,并产生可以通过网络发送并由效应器应用的操作。 54 | 55 | **Last write wins (LWW):** 56 | 57 | A timestamp is attached to each update, and updates with higher timestamps always overwrite values written by updates with lower timestamps. However, if a replica has already applied an update with a higher timestamp, and it subsequently receives an update with a lower timestamp, the latter update is ignored. This approach is one way of ensuring convergence. 58 | 59 | 每个更新都附加一个时间戳,具有较高时间戳的更新总是覆盖具有较低时间戳的更新写入的值。 但是,如果副本已经应用了具有较高时间戳的更新,并且随后接收到具有较低时间戳的更新,则忽略后一个更新。 这种方法是确保收敛的一种方式。 60 | 61 | **Observed-remove set (ORSet):** 62 | 63 | Another name for add-wins set. “Add-wins” is now preferred. 64 | add-wins 集的另一个名称。 “Add-wins”现在是首选。 65 | 66 | **Operation-based CRDT:** 67 | 68 | A CRDT in which each modification of the replica data is encoded as an operation, and operations are sent over the network. The system must ensure that operations are not lost (by resending if necessary) and that each operation is applied once (by ignoring duplicates). Operation-based CRDTs ensure that any two concurrent operations commute; that is, replicas can apply those operations in either order, and the outcome is the same. 69 | 70 | CRDT,其中数据的每次修改都被编码为一个操作,并且操作通过网络发送。 系统必须确保操作不会丢失(必要时通过重新发送)并且每个操作都应用一次(通过忽略重复项)。 基于操作的 CRDT 确保任意两个并发操作可交换; 也就是说,副本可以按任一顺序应用这些操作,结果是相同的。 71 | 72 | **Optimistic replication:** 73 | 74 | An approach to replication in which a replica can process updates locally, without waiting for communication with other replicas. This means that replicas can become temporarily inconsistent, but CRDTs ensure that replicas nevertheless converge towards a consistent state (see Strong Eventual Consistency). See Optimistic Replication by Yasushi Saito and Marc Shapiro. 75 | 76 | 一种复制方法,其中副本可以在本地处理更新,而无需等待与其他副本的通信。 这意味着副本可能会暂时不一致,但 CRDT 确保副本仍然会收敛到一致状态(请参阅强最终一致性)。 请参阅 Yasushi Saito 和 Marc Shapiro 的乐观复制。 77 | 78 | **Remove-wins set (RWSet):** 79 | 80 | A set datatype in which removals take precedence over additions. For example, if one replica removes and re-adds an element, while another replica concurrently removes the element, then the merged outcome is that the element is not in the set. Contrast with add-wins set. 81 | 82 | 一种集合数据类型,其中删除优先于添加。 例如,如果一个副本删除并重新添加一个元素,而另一个副本同时删除该元素,则合并的结果是该元素不在集合中。 与加赢集相反。 83 | 84 | **Replication:** 85 | 86 | maintaining a copy of some data on multiple computing devices (maybe servers, maybe end-user devices). Those copies are called replicas. 87 | 88 | 在多个计算设备(可能是服务器,也可能是最终用户设备)上维护一些数据的副本。 这些复制版本称为副本。 89 | 90 | **State-based CRDT:** 91 | 92 | A CRDT in which replicas synchronise by sending each other their entire state over the network; when one replica receives such a state from another replica, it uses a merge function to combine the two states. This merge function is defined in such a way that it is commutative (i.e. merge(a,b) = merge(b,a)), associative (i.e. merge(a,merge(b,c)) = merge(merge(a,b),c)), and idempotent (i.e. merge(a,a)=a). 93 | 94 | 一个 CRDT,其中副本通过在网络上相互发送它们的整个状态来同步; 当一个副本从另一个副本接收到这样的状态时,它会使用合并函数来组合这两个状态。 这个合并函数是这样定义的,它是可交换的(即merge(a,b) = merge(b,a)),关联的(即merge(a,merge(b,c)) = merge(merge(a) ,b),c)) 和幂等(即合并(a,a)=a)。 95 | 96 | **Strong Eventual Consistency (SEC):** 97 | 98 | A formal consistency model for CRDTs. It requires convergence (see above) and eventual delivery (if one replica has processed an update, every other replica that has not failed will also eventually process it). See Conflict-Free Replicated Data Types by Marc Shapiro, Nuno Preguiça, Carlos Baquero, and Marek Zawirski. 99 | 100 | CRDT 的正式一致性模型。 它需要收敛(见上文)和最终交付(如果一个副本已经处理了更新,所有其他没有失败的副本也将最终处理它)。 请参阅 Marc Shapiro、Nuno Preguiça、Carlos Baquero 和 Marek Zawirski 撰写的无冲突复制数据类型。 101 | 102 | **Tombstone:** 103 | 104 | A special object used in some CRDTs to indicate that a value is absent (e.g. because it has been deleted). Tombstones may increase the memory consumption of a CRDT, as they continue to exist even if the corresponding data has been deleted at the application level. There are algorithms for garbage-collecting tombstones and freeing their memory. 105 | 106 | 某些 CRDT 中使用的特殊对象,用于指示值不存在(例如,因为它已被删除)。 Tombstones 可能会增加 CRDT 的内存消耗,因为即使在应用程序级别删除了相应的数据,它们也会继续存在。 有一些算法用于垃圾收集墓碑并释放它们的内存。 107 | -------------------------------------------------------------------------------- /crdt/yjs/are-crdts-suitable-for-shared-editing.md: -------------------------------------------------------------------------------- 1 | # CRDT 是否适合共享编辑? 2 | [CRDT](https://crdt.tech/)通常被誉为构建协作应用程序的 "holy grail",因为它不需要中央机构来解决同步冲突。它为扩展后端基础设施开辟了新的可能性,也非常适合作为完全不需要服务器的分布式应用程序的数据模型。 3 | 4 | 但是,一些文本编辑器开发人员反映不要使用它们,因为它们会产生过大的开销。 5 | 就在最近,Marijn Haverbeke 写了一篇文章,反对使用 CRDT 作为 CodeMirror 6的数据模型: 6 | 7 | > [..] 这样做的成本是巨大的,最后,我认为收敛位置的要求太模糊了,不足以证明这种额外的复杂性和内存使用的合理性。 [(来源)](https://marijnhaverbeke.nl/blog/collaborative-editing-cm.html) 8 | 9 | [Xi Editor](https://github.com/xi-editor/xi-editor) 使用 CRDT 作为其数据模型,以允许不同的进程(语法高亮器、类型检查器等)在不阻塞进程的情况下同时访问编辑器状态。他们回归到同步模型是因为… 10 | 11 | > [..] CRDT并没有承担起它(足够的)责任 [(来源)](https://github.com/xi-editor/xi-editor/issues/1187#issuecomment-491473599) 12 | 13 | 14 | 显然,每个人都认识到 CRDT 有很大的潜力,但是得出的结论是,使用它们的内存开销对于真实的应用程序来说肯定太昂贵了。 15 | 16 | 他们提出了一个合理的观点。大多数 CRDT 为在文档中创建的每个字符分配了一个唯一的ID。为了确保文档始终能够收敛,CRDT模型即使在删除字符时也会保留此元数据。 17 | 18 | 这对于像 JavaScript 这样的动态语言来说似乎特别昂贵。其他语言允许你在内存中使用 [structs](https://en.wikipedia.org/wiki/Struct_(C_programming_language)) (例如 C 或 Rust )有效地表示所有这些字符和 ID 。在 JavaScript 中,所有东西都表示为一个对象 —— 基本上是一个需要跟踪其所有键和值的键值映射。CRDT 为文档中的每个字符分配多个属性,以确保解决冲突。仅仅将文档表示为 CRDT 的内存开销可能是巨大的。 19 | 20 | 每个用户交互都会创建更多的元数据,CRDT 需要保留这些元数据以确保冲突解决。CRDT 创建数百万个对象来存储所有这些元数据的情况并不少见。JavaScript 引擎管理堆上的这些对象,检查它们是否被引用,如果可能的话,对它们进行垃圾收集。另一个主要问题是,随着对象创建数量的增加,创建对象的成本呈指数级增长。如下图所示: 21 | 22 | > 创建一个对象的平均时间随着堆上对象的数量的增加而增加 [(数据源)](https://jsperf.com/cost-of-objects) 23 | > 24 | > ![image.png](https://atlas-rc.pingcode.com/files/public/60efa878f6d53ddae85c5186/origin-url) 25 | 26 | 27 | 因此,问题就来了,CRDT 是否真的适合在 web 上进行协同编辑,或者它是否会带来太高的成本从而无法在实践中实现。 28 | 29 | 或许你不认识我,我是一个 CRDT 实现库 [Yjs](https://github.com/yjs/yjs) 的作者,Yjs 是专门为在 web 上构建协同编辑应用程序而设计的。 30 | 31 | 在本文中,我将向你介绍 CRDT 的简单优化,并研究使用 Yjs 进行协同编辑的具体性能权衡。我希望使你相信,即使对于具有较长编辑历史的大型文档,开销实际上也是很小的。 32 | 33 | 34 | 35 | ## Yjs 36 | 37 | Yjs 是一个使用 CRDT 作为数据模型来构建协作应用程序的框架。它拥有不断增长的扩展生态系统,可以使用不同的编辑器 ( [ProseMirror](https://docs.yjs.dev/ecosystem/editor-bindings/prosemirror) , [Remirror](https://docs.yjs.dev/ecosystem/editor-bindings/remirror) , [Quill](https://docs.yjs.dev/ecosystem/editor-bindings/quill) , [CodeMirror](https://docs.yjs.dev/ecosystem/editor-bindings/codemirror) , ..),不同的网络技术( [WebSocket](https://docs.yjs.dev/ecosystem/connection-provider/y-websocket) , [WebRTC](https://docs.yjs.dev/ecosystem/connection-provider/y-webrtc) , [Hyper](https://docs.yjs.dev/ecosystem/connection-provider/y-hyper) , ..),以及不同的持久化层 ( [IndexedDB](https://docs.yjs.dev/ecosystem/database-provider/y-indexeddb) , [LevelDB](https://docs.yjs.dev/ecosystem/database-provider/y-leveldb) , [Redis](https://docs.yjs.dev/ecosystem/database-provider/y-redis) , ..)来进行协同编辑。大多数协同编辑解决方案与特定的编辑器和特定的后端绑定。使用 Yjs,你可以通过自定义的通信通道,通过点对点 WebRTC 网络,或通过可伸缩的服务器基础设施,使任何受支持的编辑器协作和交换文档更新。我对这个项目的愿景是,你可以简单地使用对你的项目有意义的技术组合你的协作应用程序。 38 | 39 | 40 | ![image.png](https://atlas-rc.pingcode.com/files/public/60efae70f6d53d5ee85c518c/origin-url) 41 | 42 | 43 | 这不仅仅是一个很酷很典型的业余项目。Yjs 是一种久经考验的技术,被多家公司用来实现协作。我只是在这里提到我的赞助商: 44 | 45 | - [Nimbus Note](https://nimbusweb.me/note.php) 使用Yjs水平扩展协同注释编辑。 46 | - [Room.sh](https://room.sh/) 是一个会议软件,允许通过WebRTC进行协作编辑和绘图。 47 | 48 | 我维护了一组可重复的基准测试,用于比较不同的 CRDT 实现。Yjs 是迄今为止速度最快、编码最高效的基于 web 的 CRDT 实现。在本文中,我经常将 [crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks) 测试库中包含的特定基准测试称为“ [[B1.11]](https://github.com/dmonad/crdt-benchmarks/#b1-no-conflicts) ”。 49 | 50 | ![image.png](https://atlas-rc.pingcode.com/files/public/60efb4d9f6d53dff8e5c5197/origin-url) 51 | 52 | 53 | 54 | ## 数据表示 55 | 56 | 你可能已经熟悉了 CRDT 工作原理的一般概念。如果没有,你想要深入这个未知领域,我推荐这个有趣的互动系列: 57 | 58 | > [关于crdt的有趣互动系列](https://lars.hupel.info/topics/crdt/01-intro/) 59 | > 60 | > ![image.png](https://atlas-rc.pingcode.com/files/public/617244769b49db9877b21eb6/origin-url) 61 | 62 | 63 | > [找到更多与CRDT相关的资源是一个很好的切入点](https://crdt.tech/resources) 64 | > 65 | > ![image.png](https://atlas-rc.pingcode.com/files/public/617244849b49db70ccb21eb7/origin-url) 66 | 67 | 68 | [Researchgate](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types) 上提供了描述 Yjs 冲突解决算法 YATA 的概念论文。 这里讨论的概念非常通用,几乎可以扩展到任何 CRDT。 69 | 70 | 71 | 为了确定性能成本,我们将研究 Yjs 如何维护数据。请耐心听我描述如何使用 JavaScript 对象表示 CRDT 模型。这将是相关的。 72 | 73 | 与其他 CRDT 类似,YATA CRDT 为每个字符分配一个唯一 ID。然后将这些字符保存在一个双链表中。 74 | 75 | 唯一的 ID 是 [Lamport Timestamps](https://en.wikipedia.org/wiki/Lamport_timestamp) .。它们由一个唯一的用户标识符和一个随着每个字符插入而增加的逻辑时钟组成。 76 | 77 | 当用户从左到右键入内容“ABC”时,它将执行以下操作: insert(0, "A") • insert(1, "B") • insert(2, "C")。 对文本内容建模的 YATA CRDT 的链表将如下所示: 78 | 79 | 80 | > 插入内容“ABC”的CRDT模型(假设用户具有唯一的客户端标识符“1”) 81 | > 82 | > ![image.png](https://atlas-rc.pingcode.com/files/public/60efcac4f6d53d28ba5c519b/origin-url) 83 | 84 | 85 | 请注意如何通过唯一的客户端 ID 和不断增加的时钟计数器的组合来唯一标识每个字符。 86 | 87 | Yjs 将链表中的项表示为 Item 对象,该对象包含一些内容(在本例中为 String )、唯一ID、到相邻 Item 对象的链接,以及与 CRDT 算法相关的附加元数据。 88 | 89 | 所有的 CRDT 都会为每个字符分配某种唯一的 ID 和附加的元数据,这对于大型文档来说非常消耗内存。我们不能删除元数据,因为它是解决冲突的必要条件。Yjs 还唯一地标识每个字符和分配元数据,有效地表示了这些信息。较大的文档插入表示为单个 Item 对象,使用字符偏移量唯一地单独标识每个字符。下面的 Item 将字符“A”唯一标识为 {client:1,clock:0},字符“B”为 {client:1,clock:1},依此类推...... 90 | 91 | Yjs 对链表中item的内部表示: 92 | ``` 93 | Item { 94 | id: { client: 1, clock: 0 }, 95 | content: 'ABC', 96 | ... 97 | } 98 | ``` 99 | 100 | 101 | 如果用户将大量内容复制/粘贴到文档中,则插入的内容由单个 Item 表示。此外,从左到右写入的单字符插入可以合并为单个 Item。重要的是,我们能够在不丢失任何元数据的情况下拆分和合并项。 102 | 103 | CRDT 模型的这种复合表示及其拆分功能首先在“ [用于智能和大规模协作系统的字符串 CRDT 算法](https://kundoc.com/pdf-a-string-wise-crdt-algorithm-for-smart-and-large-scale-collaborative-editing-sys.html) ”中进行了描述。 Yjs 为 YATA 调整了这种方法,还包括合并 Item 对象的功能。 104 | 105 | 106 | 107 | ## 操作成本 108 | 109 | 考虑到这个简单的优化,让我们看看文档上的修改数量与需要保留的元数据数量之间的关系。我们将通过创建的 Item 对象的数量来度量元数据,然后检查单个 Item 的成本是多少。 110 | 111 | 每个用户与文本编辑器的交互都可以表示为 **插入** 或 **删除** 操作。 112 | 113 | - `insert(index: number, content: string)` 任何大小的插入都会创建集成到文档中的单个 Item。在某些情况下,集成需要拆分现有的 Item。因此,每次插入最多可以创建两个 Item。 114 | - `delete(index: number, length: number)` 删除一个 Item 只会将其标记为已删除。即 `item.deleted = true` 。因此,Item 的删除是自由的,并且会减少使用的内存量,因为 Item.content 可以被删除。Item 不需要保留执行冲突解决的内容。但是删除一系列内容可能需要拆分两个现有 Item。因此,删除的代价也最多为创建两个 Item。 115 | 116 | 117 | 通过使用 CRDT 的复合表示,元数据的数量只与用户产生的操作数量相关,而不是插入字符的数量。这在实践中有很大的不同。大多数较大的文档是通过复制粘贴现有内容或将段落移动到其他位置来创建的。任何类型的操作,甚至是复制-粘贴和撤销/重做,最多只能创建两个 Item 对象。 118 | 119 | Yjs 还支持富文本和结构化文档。上面的语句(元数据的数量只与操作的数量相关)对于这类文档仍然成立。但是,测量更复杂操作的操作成本超出了本文的范围。实践中一个有趣的观察是,在结构化文档(例如,使用 [y-prosemirror](https://docs.yjs.dev/ecosystem/editor-bindings/prosemirror) 绑定到 [ProseMirror](https://prosemirror.net/) 编辑器)上的长时间编辑会话的文档大小实际上比线性文本文档小得多。这是由于其他优化在 Yjs 中起作用,可能会在另一篇文章中进行研究。 120 | 121 | 122 | ## 测量性能 123 | 124 | 在学术研究中,通过 CRDT 每秒可以处理的(并发)操作量来衡量性能已经成为一种常见的做法。对于具有高吞吐量的数据库应用程序,这可能是一个重要的基准测试。但是在协同编辑应用程序中,相对较少的用户每秒只产生几次操作。因此,单个操作的集成过程需要1纳秒还是100纳秒并不重要。此外,很少发生冲突,因为大多数 CRDT 是相对字符寻址的,只有两个用户同时在同一位置插入一个字符时才会发生冲突。 125 | 126 | 当我们只使用特定场景(例如,随机位置的插入数)来对性能进行基准测试时,我们最终可能得到一个只在这个特定场景中表现良好的CRDT。在实践中,其他性能特征也发挥了作用。我试图在 [crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks) 资源库中捕获不同场景中的相关性能特征。它表明某些 CRDT 在某些场景中表现良好,但在其他场景中表现不佳。例如,RGA 实现在附加内容时表现良好,但在只附加内容时则表现很差。用于协同编辑的 CRDT 在所有场景中都应该表现良好。下面的列表描述了协同编辑应用程序的生命周期,并提供了对不同性能特征相关性的更多了解。 127 | 128 | 1. 文档是从网络或本地文件加载的。解析已编码的文档通常需要大量时间,尤其是在文档很大的情况下。 `parseTime` 表示解析已编码文档所需的时间。在我看来, `parseTime` 是最重要的性能特征,因为它在应用程序开始时阻塞了进程。所有其他任务都可以在用户不工作时执行。 129 | 2. 文档与其他对等方一致。 如果文档在脱机时被修改,可能会发生冲突。 [[B2]](https://github.com/dmonad/crdt-benchmarks#b2-two-users-producing-conflicts) 基准测试仅由两个客户端(例如在客户端-服务器环境中)产生的同步冲突。 [[B3]](https://github.com/dmonad/crdt-benchmarks#b3-many-conflicts) 基准测试测量在多个客户端之间的同步冲突(在 p2p 环境中可能发生的同步冲突)所需的时间。在大多数CRDT实现中,需要在加载文档时再次解决同步冲突,所以在查看基准测试时要特别注意 parseTime。 130 | 3. 用户对本地文档应用变更。Yjs 使用一个链表来表示文档中的字符。除非编辑器直接处理 CRDT 模型,否则编辑器将使用索引位置应用 `insert` 和 `delete` 操作。CRDT 实现需要遍历其内部表示以找到位置,执行更改,然后生成发送给其他对等方的更新。 `time` 表示执行某个任务所需的时间(例如,添加100万个字符,同步N个并发更改,..)。 [[B1]](https://github.com/dmonad/crdt-benchmarks#b1-no-conflicts) 基准测试模拟单个用户对文档执行更改而实际上不会产生冲突。它表明,在简单地对文档应用更改时,某些 CRDT 有着很大的开销。 131 | 4. 协作者将文档更新发送到远程对等方。 我们假设在远程对等点上应用单个更新不会花费大量时间。 [[B2]](https://github.com/dmonad/crdt-benchmarks#b2-two-users-producing-conflicts) 和 [[B3]](https://github.com/dmonad/crdt-benchmarks#b3-many-conflicts) 基准测试涵盖了应用多个操作。大多数 CRDT 实现都支持某种形式的增量更新功能。每次更改都会产生一个小的更新,发送给其他对等点。 `avgUpdateSize` 表示文档更新的平均大小。 它只是确认一个 CRDT 产生小的增量更新。 132 | 5. 文档存储在数据库中或发送给远程对等方。 `encodeTime` 表示将文档转换为二进制表示所花费的时间。 `docSize` 表示编码文档的大小。 133 | 134 | [crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks) 自述文件显示了许多与协同编辑相关的不同场景的性能特征。 在本文中,我们只介绍了几个衡量最相关性能特征的基准: 135 | 136 | - `memUsed` 应用所有更改后JavaScript引擎的堆大小。在 crdt-benchmark 库中, `memUsed` 只是所用内存的近似值,因为我们不能可靠地运行垃圾收集器来删除以前基准测试的痕迹。我单独运行了本文的基准测试,并在性能检查器中直接测量堆大小,以获得更准确的结果。 137 | - `docSize` 编码文档的大小。Yjs有一个非常高效的编码器,可以将 Item 对象写入二进制压缩格式。它通过网络(WebSocket, HTTP, WebRTC, ..)发送给其他客户端,所以我们要确保文档大小是合理的。 138 | - `parseTime` 解析已编码文档所花费的时间。在我们从网络上收到编码文档后,我们希望尽快呈现它。因此,我们期望在合理的时间内解析它。 139 | 140 | 141 | ## 最糟糕的情况 142 | 143 | 在Yjs的最佳情况下,用户从左到右写内容。在这种情况下,所有操作都合并到一个Item中。 144 | 145 | 最糟糕的情况是用户从右向左编写内容。这个场景准确地反映了没有复合优化的Yjs的性能开销。请注意,这个场景不是自然发生的,因为即使是从右到左的书写系统(例如 Hebrew )也会按照逻辑顺序(从左到右)存储数据。 146 | 147 | 当用户写大文档时,通常会产生多少插入操作?如果我在单个文件中从头开始编写 Yjs,我将编写大约 20 万个字符。整个 CodeMirror 源代码由 568k 个字符组成。假设 Yjs 需要处理一百万个插入操作同时从右向左写。这相当于一个很大的文件。情况有多糟? 148 | 149 | 我们担心 Yjs 使用太多内存来表示所有这些 Item 对象。 毕竟,JavaScript 中的内存使用效率似乎很低,而且我们还不得不担心创建 JavaScript 对象的时间呈指数级增长。 150 | 151 | 为了对最坏情况进行基准测试,我们将通过在位置 0 产生一百万个单字符插入操作来创建一百万个 Item 对象: 152 | 153 | ``` 154 | import * as Y from 'yjs' 155 | 156 | const ydoc = new Y.Doc() 157 | const ytext = ydoc.getText('benchmark') 158 | 159 | // Insert one million 'y' characters from right to left 160 | // 从右到左插入一百万个“y”字符 161 | for (let i = 0; i < 1000000; i++) { 162 | ytext.insert(0, 'y') 163 | } 164 | 165 | // transform ydoc to its binary representation 166 | // 将 ydoc 转换为其二进制表示 167 | const encodedDocument = Y.encodeStateAsUpdateV2(ydoc) 168 | const docSize = encodedDocument.byteLength 169 | console.log(`docSize: ${docSize} bytes`) // => 1,000,046 bytes 170 | 171 | // Measure time to load the Yjs document containing 1M chars 172 | // 测量加载包含 1M 个字符的 Yjs 文档的时间 173 | const start = Date.now() 174 | const ydoc2 = new Y.Doc() 175 | Y.applyUpdateV2(ydoc2, encodedDocument) 176 | 177 | const parseTime = Date.now() - start 178 | console.log(`parseTime: ${parseTime} ms`) // => 368.39 ms 179 | ``` 180 | 对解析包含一百万个字符插入的文档的时间进行基准测试,无需优化。 等效于 [ [B1.3]](https://github.com/dmonad/crdt-benchmarks/#b1-no-conflicts) ,其中 N=1,000,000 181 | 182 | **结果:** 183 | 184 | - memUsed: 112 MB 185 | - docSize: 1,000,046 bytes 186 | - parseTime: 368.39 ms 187 | 188 | 事实证明,最坏的情况并不算太糟糕。 Yjs 不会消耗过多的内存。 考虑到应用于文档的更改量,我想说 112 MB 的总体内存消耗是可以忍受的。 在不到 400 毫秒的时间内解析该大小的文档似乎也不错。 请记住,这绝对是 Yjs 最坏的情况。 189 | 190 | 我展示了元数据的数量只与产生的变化量有关,与插入的内容量无关。 从一百万次插入的角度来看:键盘压力测试机在每分钟 120 次击键的情况下需要 139 小时才能产生一百万次插入。( [https://youtu.be/pYXGtxIfprM](https://youtu.be/pYXGtxIfprM) ) 191 | 192 | ![image.png](https://atlas-rc.pingcode.com/files/public/617244a19b49db1418b21eb8/origin-url) 193 | 194 | 195 | ## 检查内存使用情况 196 | 197 | JavaScript 对象的工作方式类似于键值映射。 这意味着每个对象都需要跟踪其所有键,并将它们映射到各自的值。 [ C-struct](https://en.wikipedia.org/wiki/Struct_(C_programming_language)) 不需要在内存中保留键,只以有效编码的格式保存值。因此,在 JavaScript 中处理大量对象时,自然会有很多恐惧。但是 JavaScript 引擎中的对象表示实际上非常有效。 当你创建许多具有相同结构的对象(它们都具有相同的 key entries)时,JavaScript引擎表示它们的效率几乎与 [ C-struct](https://en.wikipedia.org/wiki/Struct_(C_programming_language)) 一样。在 V8/Chrome 中,这种优化被称为 **hidden classes** 。在 SpiderMonkey/Firefox 中,同样的优化被称为 **shapes** 。这种类型的优化实际上比 web 更古老,并且是所有 JavaScript 运行时引擎的一部分。 所以对象表示不是我们需要担心的。 198 | 199 | 200 | > [V8 如何优化 JavaScript 代码的简要概述](https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e) 201 | > 202 | >![image.png](https://atlas-rc.pingcode.com/files/public/60efe823f6d53dc7855c51c5/origin-url) 203 | 204 | 让我们跳回到最坏的情况并检查每个 Item 到底消耗了多少内存。 205 | 206 | > 创建100万个项目时内存检查器的截图 207 | > 208 | > ![image.png](https://atlas-rc.pingcode.com/files/public/60efe8d0f6d53d1b4b5c51c6/origin-url) 209 | 210 | 211 | Item 由一些内容(在本例中是 ContentString )和一个 ID 对象组成。而 ID 则由一个不断增加的数字时钟和一个在此场景中不会改变的数字客户端标识符组成。我们只有超过一百万(number)对象,每个 Item 的成本是 88 字节的内存使用量,不包括其内容。你可以通过将 Item、ID 和(number)的“ Shallow Size ”相加并除以 Item 数量来获得此数字【(56000000 + 20000000+12000000)/ 1000000 = 88 **】** 。除了插入字符串的大小外,每个ContentString 对象还占用 16 个字节。 212 | 另一个 5.2 Mb 用于仅使用数组索引这些项目。 通常,与创建的项目数量相比,所需的索引信息数量可以忽略不计。 213 | 214 | 基于 web 的 CRDT 实现的性能与它创建的对象数量直接相关。分析最坏情况下的运行时性能,我们可以观察到 40% 的时间花在执行 V8 内存清理上 (Major & Minor GC)。 215 | 216 | > 创建 100 万个项目所花费的时间 217 | > 218 | > ![image.png](https://atlas-rc.pingcode.com/files/public/60efeca6f6d53d798b5c51cb/origin-url) 219 | 220 | 221 | 这是动态编程语言的一大缺点。但是,在下一节中,我们将看到我们的优化在实践中减少了对象创建的数量,因此也显著提高了性能。 222 | 223 | 224 | ## 真实场景 225 | 226 | Yjs针对人类输入行为进行了优化。一个很明显的观察是,文本通常是从左到右插入的。尽管我们经常需要纠正拼写错误,但我们倾向于删除整个单词,然后重新开始。Yjs利用了这种行为,并通过在单个 Item 中表示连续的插入来优化批量插入。将一个巨大的文本块复制粘贴到文档中也只会创建一个 Item。此外,删除是自由的,可以减少使用的内存量。正如我们在现实场景中看到的那样,这些优化在实践中产生了巨大的差异。 227 | 228 | Martin Kleppmann 分享了他在撰写关于 “ [无冲突复制JSON数据类型](https://arxiv.org/abs/1608.03960) ” 的长达17页的会议论文时创建的文本操作的 [编辑轨迹](https://github.com/automerge/automerge-perf) 。编辑跟踪包括182,315个单字符插入和77,463个单字符删除。最终的文档包含104852个字符(包括空格)。 229 | 230 | 在 Yjs 文档上应用编辑跟踪的基准测试结果 [[B4]](https://github.com/dmonad/crdt-benchmarks/#b4-real-world-editing-dataset) 证实了我的预测,即人们通常会产生批量插入: 231 | 232 | - memUsed: 19,7 MB 233 | - Item objects created: 10,971 234 | - 5,799 contain content( 包含的内容) 235 | - 5,172 are marked as deleted and don't contain any content(标记为已删除且不包含任何内容) 236 | - docSize: 159,927 bytes 237 | - parseTime: 20 ms 238 | 239 | 一个简单的实现会将 260k 单字符插入/删除的每一个都表示为一个单独的 JavaScript 对象。在Yjs中,完整的文档结构只包含 11k 个 Item 对象。实际使用的内存大约是 2.1 MB。其余的用于 V8 的内部代码优化(下一个基准测试证实了这一点)。 240 | 241 | 编码后的文档大小约为 160 kB。即使对于慢速网络设备,仅53%的文档大小开销也不会影响网络性能。实际上,与其他解决方案相比,Yjs 可能更有利,因为它允许你在浏览器数据库中存储文档,这样你只需要从网络中提取差异。即使没有管理编辑历史的中央授权,它也可以工作。 242 | 243 | 在 20 毫秒内解析完整会议论文的编辑轨迹是没问题的。尽管在集中式协同编辑解决方案中解析的开销接近于零,但我认为去中心化的好处超过了在实践中不易察觉的开销。 244 | 245 | 246 | ## 解析大文档 247 | 248 | 实际场景表明,在处理科学论文等中等大小的文档时,Yjs没有任何显著的开销。但是用 Yjs 写真正的大文档呢?当然,解析如此大的文档所需的时间将呈指数级增长。在本文开始时,我给出了基准测试结果,结果显示创建对象的时间随着已创建对象的数量呈指数级增长。此外,索引项的数据结构应该至少导致时间的对数增长。 249 | 250 | 基准测试 [[B4 x 100] ](https://github.com/dmonad/crdt-benchmarks/#b4-x-100-real-world-editing-dataset-100-times) 显示,解析文档的时间只随着操作的数量线性增加。我应用了超过 260k 次插入和删除操作的相同的编辑轨迹100次,从而产生了一个巨大的文档。解析此文档的时间仅线性增加(20 ms * 100)。 251 | 252 | - memUsed: 220 MB 253 | - docSize: 15,989,245 bytes 254 | - parseTime: 1952 ms 255 | - Item objects: 1,097,100 256 | 257 | 最终文档包含 10,485,200 个字符(1800 万次插入操作和 800 万次删除操作)。再者,换个角度来看 :小说《权力的游戏:冰与火之歌》仅包含大约 160 万个字符(开玩笑,没有别的意思)。 258 | 259 | 以每分钟30个字符的速度计算,一个人需要连续写1.65年才能完成2600万次操作。这甚至都不考虑光标移动会在文档中产生碎片时间。Yjs仅使用220 MB内存就可以轻松处理2600万项更改。 260 | 261 | 这个基准测试表明,Yjs可以处理非常大的文档,解析文档的时间只是线性增长,而不是我们所怀疑的指数增长。即使在这种情况下,Yjs的性能开销也几乎不明显。从网络中拉取 10 MB 大小的大文档并在浏览器中显示的时间都比使用 Yjs 解析文档花费的时间要长得多。 262 | 263 | 创建对象时间的指数级增长并没有真正影响Yjs,因为它一开始创建的对象很少。测试证实,你需要应用实际数据集 1000 次,以创建 1000 万个 Item,这些 Item 在解析文档时会额外花费一秒钟的垃圾收集开销。到目前为止,解析文档的时间只会线性增加。 264 | 265 | 至于索引 Item 对象的数据结构,执行查询的时间确实随着已插入的项目数量呈对数增加。然而,开销是如此微不足道,以至于你无法度量它。我还怀疑 JavaScript 引擎会逐渐优化执行的代码并消除对数开销。在实践中,解析文档的时间只会随着用户交互的数量线性增加。 266 | 267 | 268 | ## 结论:CRDT 是否适合协同编辑? 269 | 270 | 当然!人类基本上不可能写出Yjs无法处理的文档。我详细的展示了 Yjs 在实际场景中表现非常出色,甚至在没有任何优化可以应用的情况下也表现良好。 271 | 272 | Yjs 对性能的权衡是非常有利的。为了换取每次操作的少量内存,Yjs 允许你在任何网络堆栈(甚至是对等)与其他对等方同步文档。当然,它还具有与协同编辑相关的其他特性: 273 | 274 | - 选择性撤销 / 重做管理 275 | - 快照并能够恢复到旧文档状态 276 | - 计算版本之间的差异,并由创建更改的用户呈现差异(git blame) 277 | - 同步浏览器数据库的更改以允许离线编辑 278 | - 一种协作感知模型,用于表示光标位置等位置 279 | - 意识功能(目前谁在线,他们在做什么,..) 280 | 281 | 在我看来,跟我们分析的这些小开销相比,在分布式环境中使用协作感知模型所带来的好处是值得的。 282 | 283 | 如果你想了解更多关于 Yjs 的信息,请访问 [GitHub](https://github.com/yjs/yjs) 并在 [twitter](https://twitter.com/kevin_jahns) 上关注我,了解最新的发展。 284 | 285 | ![image.png](https://atlas-rc.pingcode.com/files/public/60efae70f6d53d5ee85c518c/origin-url) 286 | 287 | 维护 Yjs,关心 GitHub 问题,管理不断增长的社区占用了我大量的空闲时间。如果你想让我做更多的公共工作,比如写博客文章,那么请在 GitHub 上赞助 [我](https://github.com/sponsors/dmonad) 。 288 | 289 | ![image.png](https://atlas-rc.pingcode.com/files/public/60effcb9f6d53d03f15c51d7/origin-url) 290 | 291 | 在下一篇博文中,我将讨论 CRDT 增加应用程序复杂性的概念。 Yjs 有一个协作感知模型,该模型非常有助于在协作(富)文本文档上实现注释、共享光标、位置标记、状态或建议等功能。 实现像 Yjs 这样的 CRDT 并正确地进行优化并非易事。 我将讨论用于测试分布式系统的方法,这些方法使我对文档总是收敛有很高的信心。 292 | 293 | 294 | ## Notes: 295 | 296 | - [Recent publications](https://arxiv.org/abs/1905.01302) 将 Yjs 描述为一种 CRDT,它执行某种垃圾收集方案来收集 tombstone 以减少文档的大小。由于本文中列出的优化,文档大小只会减小。Yjs不会执行会导致收敛问题的垃圾收集方案。公平地说,我在2016年发表的第一篇文章描述了一个垃圾收集方案,包括它的局限性。上面提到的垃圾收集方案确实能按预期工作,但在默认情况下从未启用,因为正如上面描述的那样,它只在特定的情况下工作,需要更多的工作。垃圾收集方法在2017年被移除,取而代之的是复合表示,以提高性能。这篇文章表明,即使 CRDT 在实践中工作也不需要 tombstone 垃圾收集。 297 | 298 | - 经常有人说 CRDT 比 [OT](https://en.wikipedia.org/wiki/Operational_transformation) 快。我之前解释过,学术研究主要以 ops/second 来衡量性能,这并不能真正反映协同编辑应用程序的要求。在实践中,有些 CRDT 不适合共享编辑,因为它们在某些场景中表现不佳。在 parseTime 方面,集中式方法比 CRDT 更具优势。例如,在 [ShareDB](https://github.com/share/sharedb) 中,文档发送到客户端时几乎没有额外的元数据,因此可以更快地解析文档。去中心化方法会发送额外的元数据,这会产生解析开销。但是,本文表明这种开销在 Yjs 中并不重要。 299 | 300 | 301 | ## Edits: 302 | 303 | - 2020/12/30 - 更新了关于 [从右到左书写系统](https://en.wikipedia.org/wiki/Right-to-left) 的论点。 以前,我担心 Yjs 在从右到左的书写系统(例如希伯来语和阿拉伯语)中不能很好地工作,因为复合优化仅适用于从左到右书写的文本。 但是一位用户通知我,即使使用的书写系统是从右到左,编辑器也始终按逻辑顺序(从左到右)存储文档内容。 304 | 305 | 306 | 307 | 【 原文链接 】: [https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/) 308 | -------------------------------------------------------------------------------- /ot/this-is-how-to-build-a-collaborative-text-editor-using-rails.md: -------------------------------------------------------------------------------- 1 | 原文地址: [https://www.aha.io/blog/text-editor](https://www.aha.io/blog/text-editor) 2 | PingCode 分享地址:https://pingcode.com/pages/sNIdUxuT6L 3 | 4 | 这是一个痛苦的认识。 您刚刚在错误跟踪器的文本编辑器中添加了一个漂亮的多页描述,并附有照片和一个简短的截屏视频。 然后你的同事在他们去吃午饭的时候把窗户开着,帮助修正了一个错字……并覆盖了你刚刚做的一切。 噗——所有这些工作都消失了。 5 | 6 | 关于 Rails,我最喜欢的事情之一是它解决了大多数应用程序 99% 的问题——你知道,获取一些信息,用它做一些事情,然后把这些信息放回人们面前。 但随着您的应用越来越受欢迎,同一记录中输入的信息也越来越多,而且通常是多人同时输入。 这可能是灾难性的。 7 | 8 | 如果每个人都可以同时工作在同一条记录上,该有多好? 如果记录可以处理所有这些更新,那么您不必像《蝇王》中的一群滞留的青春期少女那样协商谁来进行更新? 9 | 10 | 随着越来越多的组织将所有工作流程和数据转移到网上,人们期望每个人都可以实时协同工作。 科技公司正在围绕协作编辑或在现有产品中添加协作来构建整个业务。 11 | 12 | 这是我们在 Aha 一直在考虑的事情! 一段时间。 我们知道我们的客户希望在我们的应用程序中创建注释或编写功能描述时能够无缝协作。 因此,我们一直在研究如何才能提供最佳的协作文本编辑器体验。 13 | 14 | 如果您不希望您的客户为了完成工作而不得 **不通过海螺壳(话语权、靠喊)** ,那么您也需要它。 15 | 16 | **协同编辑是什么样的?** 17 | 18 | 作为开发人员,您可能会想到 Git。 您对文档进行更改,其他人对其文档进行更改,您合并,然后你们中的一个人修复冲突。 19 | 20 | 这个过程的一部分很棒。 您可以直接进行更改,而无需等待其他任何人。 这就是所谓的乐观地做出改变,从某种意义上说,你可以做一些事情而不必先告诉其他人并假设你的改变会实现。 21 | 22 | 这个过程的一部分不是很好。 当你我同时编辑同一个文档时,我们不想每隔几分钟就被打断处理冲突。 像这样工作的文本编辑器将无法使用。 但冲突不会经常发生。 它们只会在我们尝试同时编辑同一个地方时发生,这并不常见。 23 | 24 | 但是,当冲突确实发生时,如果我们没有被打扰怎么办? 系统可以对要做什么做出最好的猜测,如果它错了,我们中的一个人可以修复它。 从理论上讲,这似乎是一个永远行不通的可怕想法。 在实践中,它主要是有效的。 25 | 26 | 那么这在实践中是如何运作的呢? 系统不需要是正确的,它只需要保持一致,并且需要努力保持你的意图。 27 | 28 | 下图显示了这一点。 如果我输入“hello”这个词,系统应该尽最大努力确保“hello”最终出现在文档的某个地方。 那是意图。 29 | 30 | ![image.png](https://atlas-rc.pingcode.com/files/public/60e97796f6d53dd3455c4ff7/origin-url) 31 | 32 | 如果其他人同时在同一地点键入“bye”? 我们的两个文档最终应该完全相同,无论是“hellobye”还是“byehello”——文档需要保持一致。 33 | 34 | 冲突怎么办? 嗯,人是天生的冲突解决机器。 如果你正走在走廊上,有人要走进你(找你),你会停下来。 可能你们两个都会移到同一边。 然后也许你们会移到另一边,然后你会笑。 但最终你们中的一个会移动,另一个会静止不动,一切都会好起来的。 35 | 36 | 因此,您希望您的编辑器快速响应使用它的人。 如果您正在输入,您不想等待网络请求才能看到您输入的内容。 并且您希望编辑您的文档的其他人看到您的更改。 您希望尽快完成所有这些工作。 你怎么能做到这一点? 37 | 38 | 您可能会想到的第一件事是发送差异,如下图所示。 “ This person 1 changed line 5 from this to this ” 但是很难在差异中看出意图。 所有的差异只是告诉你发生了什么变化,而不是为什么。 39 | 40 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaa148f6d53d2bf85c4ffb/origin-url) 41 | 42 | 更好的方法是考虑一个人可以采取的行动:“我在位置 5 插入了字符‘a’。” “我删除了位置 8 之后的字符‘b’。” 43 | 44 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaa197f6d53d86925c4ffc/origin-url) 45 | 46 | 您几乎可以随心所欲地进行这些行为(或操作)。 从“插入一些文本”到“使这部分文本加粗”的所有内容。 您可以将这些应用到文档,当您这样做时,文档会发生变化。 因此,如下所示,应用此操作会将“Hello”更改为“Hello, world”。 47 | 48 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaa39ef6d53d28fa5c4ffd/origin-url) 49 | 50 | 如果我们有操作,我们可以发送操作,我们可以通过应用操作更改文档,那么我们几乎就有了协作。 51 | 52 | 如果客户端 A 向客户端 B 发送了一个“在 5 处插入‘world’”操作怎么办? 客户端 B 可以应用该操作,您将拥有相同的文档! Binggo - 工作已完成,完美无缺。 实际上它不是。 53 | 54 | 要记住 — 你可能改动文档在相同的时间。那么,让我们看看两个 客户端的场景。像下图展示的那样,他们每一个都是文本 “at”的文档。 55 | 56 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaa504f6d53d2b075c4ffe/origin-url) 57 | 58 | 现在,左边的客户端输入 “c” 在位置 0,产生了单词 “cat”。在相同的时间,另外一个客户端输入 “r” at position 1,产生了单词 “art”。 59 | 60 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaa561f6d53d51535c4fff/origin-url) 61 | 62 | 现在,右边的客户端的收到了来自其他客户端的 “insert c at 0” 操作以“cart”结束。目前,还好。但是左边的客户端收到了其他客户端的操作“insert r at 1”,以“crat”结束。 63 | 64 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaa720f6d53de6175c5000/origin-url) 65 | 66 | 我不知道 “crat” 是什么(你呢?) 67 | 68 | 比“crat”更糟,我们已经违反了最重要的规则。两个文档需要互相兼容另外一个 — 他们需要以相同的状态结束。因为现在,如果左边的客户端删除了字符 “a” 在位置2之后,它也在另外一个客户端删除了 “r” 而不知道它正在做的是什么。它是错误的并且以人不能修复的方式被破坏。 69 | 70 | 那么,一些别的事情需要发生。它就是 operational transformation 操作转换。 71 | 72 | **Transforming operations** 73 | 74 | 那么让我们再看看问题。我们有两个同时发生的操作,这意味着它们都来自同一个文档状态——我们之前谈到的“at”示例。 75 | 76 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaa9c9f6d53d33945c5001/origin-url) 77 | 78 | 每当我们在同一个文档上发生两个操作时,这意味着我们可能需要更改其中之一。这是因为您只能按顺序一个接一个地进行更改——就像我们刚刚看到的“cart”和“crat”一样,顺序很重要。在这个客户端上,这意味着插入“r”必须改变,以便它可以在插入“c”之后发生。那么这会是什么样子呢? 79 | 80 | 好吧,在你插入“c”之后,原来的位置 1(下面用黄色突出显示)现在是位置 2。一切都移动了一个。如果“r”介于“a”和“t”之间(就像它在另一个客户端上所做的那样),它应该进入位置 2——对吗?所以,当你得到“在 1 处插入 r”时,你在应用它之前将它转换为“在 2 处插入 r”。 81 | 82 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaaa6af6d53d75905c5002/origin-url) 83 | 84 | 另一边呢?它得到“在 0 处插入 c”。但是位置 0 没有移动,所以“在 0 处插入 c”可以保持原样。 85 | 86 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaaa9ff6d53d90565c5003/origin-url) 87 | 88 | 你想要做的是说,“如果操作 A 和操作 B 同时发生,我怎么能改变操作 B 以适应操作 A 所做的?” 89 | 90 | That can sometimes be abstract and hard to think about. So I draw boxes instead. (Yep, I have lots of pieces of paper filled with boxes.) But check this out below. In the upper-left corner, I write a document state — “at.” 91 | 92 | 这有时可能是抽象的,难以思考。所以我画框代替。 (Yep, I have lots of pieces of paper filled with boxes.)但请在下面查看。在左上角,我写了一个文档状态——“at”。 93 | 94 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaaad5f6d53d1fda5c5004/origin-url) 95 | 96 | I draw an arrow going right and I write one of the operations (“insert c at 0”) on it. Then in the upper-right, I write what things look like after that happens (“cat”). Then I draw a line going down. 97 | 98 | 我画了一个向右的箭头,然后在上面写了一个操作(“在 0 处插入 c”)。然后在右上角,我写下事情发生后的样子(“cat”)。然后我画一条向下的线。 99 | 100 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaab41f6d53d136e5c5005/origin-url) 101 | 102 | 接下来,我画了一个向下的箭头。这个得到另一个操作(“在 1 处插入 r”)。和以前一样:我写下事情发生后的样子(“art”)。我画了一个向右的箭头。我们最终得到了您在下面看到的内容。 103 | 104 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaab7cf6d53d678a5c5006/origin-url) 105 | 106 | 现在我们要做出决定。在右下角,文档应该是什么样的?这就是考虑用户的期望会有所帮助的地方,但有时您必须做出决定。 107 | 108 | 不过,在这里,答案并不含糊——应该是“购物车”。将“cat”变成“cart”需要什么? “在 2 处插入 r。”将“art”变成“cart”需要什么? “在 0 处插入 c。”所以我们将填写空白箭头。这两个箭头是我们的两个转换操作。 109 | 110 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eaabb1f6d53db2165c5007/origin-url) 111 | 112 | 一旦你知道了这一点,就可以很容易地测试驱动转换操作的代码。顶部和左侧是您的输入,右侧和底部是您的预期输出。 113 | 114 | 不过,在我们开始编写这些之前,还有最后一个问题需要解决。你如何打破关系?如果两个客户端都试图在完全相同的位置插入文本,谁的文本最先结束? 115 | 116 | 记住——你不一定是对的,你只需要保持一致。所以,选择一个一致的决胜局。如果您正在与服务器通信,您可以决定服务器总是获胜。或者你可以给每个客户一个随机 ID,最大的一个获胜。只要保持一致。 117 | 118 | **写转换函数** 119 | 120 | 我们有一些要转换的操作和一些预期的返回值。这个转换函数实际上是什么样子的? 121 | 122 | 我们将从这个开始: 123 | 124 | ``` 125 | def transform(top, left, win_tiebreakers = false) 126 | bottom = transform_operation(top, left, win_tiebreakers), 127 | right = transform_operation(left, top, !win_tiebreakers) 128 | [bottom, right] 129 | end 130 | ``` 131 | 132 | 它将顶部与左侧转换为底部箭头,然后将左与顶部转换为向右箭头,然后返回它们,这完成了我们的正方形。 但这只是提出问题。 transform_operation 函数是什么样的? 133 | 134 | 让我们关注以 `right =` 开头的那一行。你如何转换左边的操作,让它表现得好像它发生在上面的操作把所有东西都移过来并变成右箭头——“在 1 处插入 r”之后? 135 | 136 | ``` 137 | # ours: { type: :insert, text: “r”, position: 1 } 138 | # theirs: { type: :insert, text: “c”, position: 0 } 139 | def transform_operation(ours, theirs, win_tiebreakers) 140 | # TODO: handle other kinds of operations 141 | 142 | transformed_op = ours.dup 143 | 144 | if ours[:position] → theirs[:position] || 145 | (ours[:position] == theirs[:position] && !win_tiebreakers ) 146 | transformed_op[:position] = 147 | transformed_op[:position] + theirs[:text].length 148 | end 149 | 150 | transformed_op 151 | end 152 | ``` 153 | 154 | 如果我们现在只是考虑插入文本,那么编写 transform_operation 并不太难。我们写了一个待办事项以备后用。 155 | 156 | 接下来,我们返回一个新操作,因为我们不想通过更改传递给我们的操作来搞砸任何事情。在 `if` 行中,我们回答以下问题:什么会导致我们的位置发生变化? 157 | 158 | 如果另一个客户在我们的位置之前插入文本,我们将需要移过去。如果他们在与我们相同的位置插入文本,而我们失去了决胜局,我们也将不得不移过去。如果发生这些情况中的任何一种,我们需要将我们的位置移动他们插入的文本的长度。如果他们正在输入一个字符,我们就会移动一个字符。就像我们之前看到的一样——因为有人在我们的“r”之前输入了“c”,我们需要移动,否则我们会得到“crat”。 159 | 160 | 这和转换函数一样简单,但大多数都遵循相同的模式: 161 | 162 | 1. 检查其他操作是否会以某种方式影响我们。 163 | 1. 如果影响,则返回一个考虑到该影响的新操作。 164 | 1. 否则,返回原始操作。 165 | 166 | 167 | 转换可能会变得复杂。 但它们在数学意义上非常实用,这使得它们易于测试。 这些函数必须满足一些数学特性。 168 | 169 | 1. 如果您有两个状态相同的文档... 170 | 1. 然后应用第一个操作,然后应用转换后的第二个操作…… 171 | 1. 您最终会得到相同的文档,就好像您应用了第二个操作,然后是转换后的第一个操作。 172 | 173 | 174 | 那是一中表达。但要形象化它的真正含义,请看下面这个方块,从左上角开始。从那里开始,如果您先选择顶部箭头,然后选择向右箭头,您最终会得到与您选择向左箭头和底部箭头相同的文档。 175 | 176 | ![image.png](https://atlas-rc.pingcode.com/files/public/60eba783f6d53d7b015c5013/origin-url) 177 | 178 | 如果你正确地改变事物,无论你走哪条路,你最终都会到达同一个目的地。数学使测试转换函数变得更加容易。您可以生成一大堆随机操作,将它们相互转换,将它们应用到文档,并且——只要文档最终相等——你就知道这些转换函数是有效的。 179 | 180 | 因此,即使转换函数可能会变得复杂,但要确保它们起作用并不难。 181 | 182 | 现在,这些方图只有在有两个客户端同时发送操作时才真正起作用——对吗?您实际上只能让两个箭头从左上角射出并到达左下角。如果你有三个客户,你会得到三维图,如果你有四个,你会得到四维图,依此类推。并且通过这些图表的每条路径都必须以相同的状态结束。 183 | 184 | 但是如果你有一个单一的事实来源,一个中央服务器,这一切都会变得容易得多。您有几个二维图而不是三维图——每个客户端-服务器连接一个。客户之间不直接交谈。他们通过服务器交谈。 (作为 Rails 开发人员,我们大多数人都相当习惯于依赖后端服务器。)所以从现在开始,让我们假设我们有一个服务器并且我们的操作通过它。 185 | 186 | **什么时候需要转换操作?** 187 | 188 | 我们刚刚谈到了转换函数。这些函数转换操作,因此您最终得到的操作序列都将在同一个文档中结束。但是您还需要另一条信息。 189 | 190 | 我们仍然需要知道要转换哪些操作。为此,我们有一个控制算法。为了弄清楚这一点,算法需要知道两个文档是否相同,这样我们才能判断两个操作是否同时发生。 191 | 192 | 由于我们正在与服务器交谈,因此这很容易 - 服务器是您的真相来源。它可以为每个文档提供一个唯一的版本号,并使用该编号来判断两个文档是否相同。 193 | 194 | 一旦我们有了文档版本,我们就可以跟踪每个操作发生在哪个文档版本中。因此我们将文档的版本号添加到我们创建的每个操作中,如下所示: 195 | 196 | ``` 197 | → operation 198 | { 199 | type: :insert, 200 | text: “r”, 201 | position: 1, 202 | version: 2 203 | } 204 | ``` 205 | 206 | 假设我们的“at”示例是版本 2。我们有两个客户端在同一个文档上运行操作(“insert C”和“insert r”),因此它们也得到版本 2。 207 | 208 | 通过这种方式,您可以判断这些操作是同时发生的,并且您知道需要对它们进行转换。应用每个操作后,我们最终会得到一个新的文档版本。 209 | 210 | 但是,如果您在同步之前更进一步,会发生什么?如果两个客户端在相互交谈之前分别运行两个操作,而不是一个操作呢? 211 | 212 | **转换多操作** 213 | 214 | 就像我们之前的例子一样,如果你画一个正方形,这样你就可以更容易地形象化,这样你就可以看到发生了什么。 215 | 216 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebac96f6d53d3bfc5c5014/origin-url) 217 | 218 | 您在顶部有一些箭头,在左侧有一些箭头,并且您想用右侧的箭头和底部的箭头来完成正方形。您可以使用您已经编写的相同转换函数。 219 | 220 | 但这里有一个技巧。因为这不是一个正方形。正如你在下面看到的,它实际上是四个。 221 | 222 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebad2ef6d53db9545c5015/origin-url) 223 | 224 | 这非常重要,因为一个正方形的右侧成为下一个正方形的新左侧。 225 | 226 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebad6bf6d53d80e45c5016/origin-url) 227 | 228 | This is a little brain-bending. So, do not worry if it does not really sink in at first. It took years for people to find and fix this problem. 229 | 230 | 这有点伤脑筋。 因此,如果它一开始没有真正理解它,请不要担心。 人们花了数年时间才发现并解决这个问题。 231 | 232 | 请记住,您必须填写这些方格中的每一个。 正方形的每一边只能进行一次操作。 对于每个方块,从顶部然后右侧移动必须产生与从左侧然后底部移动相同的值。 233 | 234 | 所以你的转换算法有点像这样: 235 | 236 | 1. 取两个操作列表:顶部列表和左侧列表。 237 | 1. 创建两个空列表:右侧列表和底部列表。 238 | 1. 将第一个 top 操作和 first left 操作转换为底部和右侧的值。 239 | 1. 将底部值推到底部列表中。 240 | 241 | 242 | 243 | 244 | - 保留右边的值(我称之为“tranformed left”),因为接下来您将使用它。 245 | 246 | 247 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebb087f6d53d5aec5c501b/origin-url) 248 | 249 | - 然后,变换下一个 top 操作和“transformed left”操作。 (你一直抓着的那个。) 250 | - 将返回的底部操作推到底部列表中。 251 | - 如果你有更多的顶部元素,你只需不断地将右边的元素变成新的左边元素,然后继续前进。 252 | 253 | 254 | 255 | 256 | - 到达一行末尾后,将最后一个正确的值推送到您的右侧列表中。 257 | 258 | 259 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebb126f6d53d26715c501e/origin-url) 260 | 261 | 现在您的右侧列表中有一个元素和一整行底部操作。将底部列表转换为新的顶部列表,并使用左侧列表的第二个元素重复整个过程。最终,您会一直走到底部并完成两个列表。 262 | 263 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebb18af6d53d0abf5c501f/origin-url) 264 | 265 | 在代码中看到这一点可能会有所帮助: 266 | 267 | ``` 268 | def transform(left, top) 269 | left = Array(left) 270 | top = Array(top) 271 | 272 | return [left, top] if left.empty? || top.empty? 273 | 274 | if left.length == 1 && top.length == 1 275 | right = transform_operation(left.first, top.first, true) 276 | bottom = transform_operation(top.first, left.first, false) 277 | return [Array(right), Array(bottom)] 278 | end 279 | 280 | right = [] 281 | bottom = [] 282 | 283 | left.each do |left_op| 284 | bottom = [] 285 | 286 | top.each do |top_op| 287 | right_op, bottom_op = transform(left_op, top_op) 288 | left_op = right_op 289 | bottom.concat(bottom_op) 290 | end 291 | 292 | right.concat(left_op) 293 | top = bottom 294 | end 295 | 296 | [right, bottom] 297 | end 298 | ``` 299 | 300 | 首先要做的是确保我们只处理数组以使以后的代码更简单。这样,对于我们的控制算法的其余部分,我们只考虑转换操作列表。 301 | 302 | 接下来,我们处理一些简单的情况。如果我们的左侧列表或顶部列表为空,则意味着我们不必做任何事情。从用户的角度来看,这可能是当你是唯一一个做出改变的人,或者你离开办公桌而其他人在做改变的时候。没有什么可以改造的。 303 | 304 | 如果您只是将一个操作转换为另一个操作,这与您之前看到的简单正方形完全相同。您将左操作转换为顶部操作以获得正确的操作,然后反向执行以获得底部操作。 305 | 306 | 现在是棘手的部分 - 当我们连续进行多个操作时。第 13 和 14 行创建了一些空数组,以便在我们得到它们时挂在我们的转换操作上。对于第一行,我们遍历顶部的每个操作。 307 | 308 | 我们通过递归调用这个转换函数来转换它们——通常,这会遇到这两种简单情况之一,所以不值得考虑太多。它返回新的转换操作,我们将保留这些操作。 309 | 310 | 我们恢复了正确的操作。 请记住,我们下次使用该操作作为新的左操作。 所以让我们将右操作设置为左操作。 接下来,我们将返回的底部操作添加到底部列表中。 311 | 312 | 一旦我们完成了一整行,最后一个操作就是我们最终得到的操作,因此我们将其添加到我们的右侧列表中。这使用了 left_op,但此时,left_op 和 right_op 是相等的。 313 | 314 | 然后,在下一次循环中,我们的底部操作列表成为新的顶部列表,我们继续进行。这就像我们之前看到的第二次迭代一样。 315 | 316 | 并且,当这完成后,我们将右侧和底部列表返回给用户。小菜一碟吧? 317 | 318 | 现在,一个好处 - 您可能不必自己编写。那是因为控制算法是通用的。您可以为各种不同的应用程序使用相同的功能,而不必更改它。你的控制算法根本不关心你的操作实际做什么。 319 | 320 | 您的运营实际上应该做什么?无论您的应用需要什么! 321 | 322 | 只要您可以编写不违反转换属性的转换函数,您就可以整天发明新的操作。这很棒,因为您可以越来越接近代表一个人实际在做什么。 323 | 324 | 但就像所有事情一样,有一个权衡。您拥有的操作越丰富,您往往拥有的操作就越多。并且您拥有的操作越多,您需要编写的转换函数就越多,而且越难使它们正确。 325 | 326 | 当我解决这个问题时,我有 13 种不同的操作,最终我编写了一百多个转换函数。 但是有更具体的操作意味着当两个人编辑文档的同一部分时,我可以保持一些非常强烈的用户意图。 327 | 328 | **如何让协作更轻松?** 329 | 330 | 您可以做一些事情来使编写协作编辑器变得容易,而其他一些事情则几乎不可能。 331 | 332 | 1. 考虑运营,而不是状态变化:如果您计划转变运营,您必须从运营的角度来考虑——一个人可以采取的行动。如果您只存储完整的文档状态,那么您的前路可能会很艰难 333 | 334 | 335 | 有办法解决这个问题。您有时可以查看文档的差异并从中推断操作。但是这样你会失去很多用户意图。想想“在位置 1 插入 t”而不是“文档从 a 更改为 at”。 336 | 337 | 1. 保持线性:如果您可以将文档视为一组事物(字符、丰富的对象等),那就容易多了。 转换数组索引只是加法和减法。 有时您也可以线性地表示树。 只需在数组中包含表示“进入子树”或“退出子树”的项目。 在这种情况下,它仍然很容易转换,但使用起来有点困难。 338 | 339 | 340 | 字符数组比分层数据更容易转换,但如果必须转换树,这并不是最糟糕的事情。 除了使用索引,您还可以使用索引数组。 例如,这个节点可以通过路径 [1, 1] “child 1 of child 1”到达。 341 | 342 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebb890f6d53d51bb5c5021/origin-url) 343 | 344 | 1. 使您的数据尽可能可转换:字符串可以很容易地转换。 你可以找出一些与数字有关的东西,比如将它们相加。但是,如果您对自定义对象进行了相互冲突的编辑,那么您的决定就会困难得多。 345 | 346 | 347 | **这如何配合?** 348 | 349 | 我们有文档状态。 (为了简单起见,我们称它们为字符数组。)文档也有一个版本。每个客户端以及服务器在某个时间点都有一份文档副本。 350 | 351 | 您可以立即对自己的文档应用操作,因此无需等待查看。然后您将其发送到服务器,该服务器将其发送给其他客户端。 352 | 353 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebbb3df6d53d98b25c5023/origin-url) 354 | 355 | 有时,服务器会说,“那很好,我还没有看到任何新的操作,我的版本和你的一样。”它将确认您的版本,您更新您的文档版本,并且每个人都保持同步。 356 | 357 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebbb66f6d53d5a285c5024/origin-url) 358 | 359 | 其他时候,服务器会说,“我无法进行该操作,因为我看到了不同的文档。但这里是你的版本和我的版本之间的所有操作。” 360 | 361 | 当这种情况发生时,您将这些操作转变为您的操作,因为从您的角度来看,您的操作已经发生了。 记住? 用正确的方式执行它。 然后,您将转换后的服务器中的操作应用到您的文档。 362 | 363 | 之后,您针对所有服务器的操作转换您的操作,因为从服务器的角度来看,您的操作发生在他们的操作之后。服务器还没有看到你的。然后您将转换后的版本发送回服务器——希望这一次,服务器会接受它。该过程如下所示。 364 | 365 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ebbc64f6d53d8f2c5c5027/origin-url) 366 | 367 | 现在,一切都是一致的。如果同时发生多个操作,我们必须做更多的工作,但思路还是一样的。 368 | 369 | **你还需要什么?** 370 | 371 | 当您转换操作时,您可以构建一个可以同时处理多人编辑的文本编辑器。 但这还不足以真正提供出色的体验。 我会告诉你两个原因。 372 | 373 | 首先,如果您有几个人在编辑同一个文档,那么在用户看来,字母和单词似乎是凭空出现的。 用户不知道在什么地方会发生变化,或者在它发生之前将要发生什么。 如果其他编辑文档的人的光标是可见的,这样用户就会知道会发生什么,那就太好了。 374 | 375 | 其次,假设用户在打字时犯了一个错误并点击了撤消。文本编辑器可以撤消两种不同的更改: 它应该撤消您所做的最后一次更改吗?或者任何人所做的最后一次更改? 376 | 377 | 让我们将您只撤消自己的更改的场景称为“本地撤消”。第二,您可以在其中撤消其他人的更改“全局撤消”。 378 | 379 | 如果您尝试过具有这些不同撤消风格的文本编辑器,您很快就会明白本地撤消感觉很正常。如果您键入一个字符,撤消应该删除该字符,无论其他人后来键入了什么。要拥有出色的协作编辑器,我们需要添加光标报告和本地撤消。 380 | 381 | **光标同步** 382 | 383 | 让我们从一个哲学问题开始。什么是光标,真的吗?如果您的文档是一个列表,那么光标实际上只是该数组中的一个位置。 384 | 385 | 在下面的文档中,你有“你好”,我的光标在“e”之前。你可以说光标在位置 1。如果它在“o”之后,你会说它在位置 5。 386 | 387 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec29e6f6d53d7cf45c506a/origin-url) 388 | 389 | 其他人的光标呢?它们也可以是数字,但您可能还想知道谁的光标是谁的。您可以附加某种标识符。我们将只使用一个数字并将其称为客户 ID。因此,有两个数字:位置和客户 ID。 390 | 391 | 现在我们的文档有点复杂,但还不错。我们有我们的东西列表、一个版本、我们自己的光标偏移量和一个远程光标列表。这足以让您可以随心所欲地渲染文本编辑器和那些光标。 392 | 393 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec2a81f6d53d5c3d5c506b/origin-url) 394 | 395 | 添加字符或删除字符会发生什么?让我们回到我们的第一个例子,“cart”。假设我们正在看我们的屏幕,客户端 2 将光标留在位置 2,在“a”和“r”之间。 396 | 397 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec2b04f6d53d30245c506c/origin-url) 398 | 399 | 然后我们运行操作,“在位置 1 插入 h”。现在我们有了“图表”。它应该在哪里为客户端 2 绘制光标?将它保持在原来的位置是最有意义的——在“a”和“r”之间,对吧? 400 | 401 | 所以我们可以假设“将光标放在这个位置”是一个操作,我们将它转​​换为我们的“在位置 1 插入 h”操作,如下所示。我们在光标前插入了一个字符,因此我们将它移到一个位置上。 402 | 403 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec2b9ef6d53d09b15c506d/origin-url) 404 | 405 | 这是我们的规则:无论何时执行操作,您都需要针对该操作转换您所知道的所有光标,以将它们保持在正确的位置。 这些转换往往非常简单——大多数与 insert_text 转换完全相同,因为您只是在两者中移动一个位置。 406 | 407 | 一旦您从另一个客户端收到一个光标,您就需要知道另一条信息。该光标来自哪个版本的文档? 408 | 409 | 如果光标位于您的客户尚未看到的文档版本上,则您的客户无法绘制它——因为您没有该文档。光标可能指向位置 15,但您的文档只有 10 个字符。因此,您可以稍后保留光标(如果您愿意)或忽略它并希望其他客户端稍后再次发送它。 410 | 411 | 如果光标位置来自旧版本,它也可能不适用于文档的当前版本。发生这种情况时,您可以在该版本之间的所有操作中转换光标。例如,如果您的文档是版本 2 并且您看到版本 1 光标,您可以将其转换为将您的文档从版本 1 转换为版本 2 的操作。或者,如果您希望看到更新的游标,也可以忽略它很快就好了。 412 | 413 | 如果光标在同一版本上,您可能认为这很清楚。它是……但前提是服务器已确认您的所有操作。但是,如果您在版本 15,并且另一个客户端的光标在版本 15 上,但是您运行了尚未发送到服务器的插入“h”操作?嗯,看起来像这样。 414 | 415 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec2e35f6d53d8e4e5c506e/origin-url) 416 | 417 | 您必须针对该操作转换其他客户端的光标。该客户端在位置 3 向您发送了一个光标,您必须将其移动到位置 4。 418 | 419 | 发送光标怎么样?您可以随时发送光标,只要您没有进行服务器尚未确认的任何更改。否则,您的光标可能对客户没有意义,他们将不知道该怎么做。服务器确认您的操作后,您可以再次开始发送光标。 420 | 421 | **协同 Undo** 422 | 423 | 就像使用光标一样,要弄清楚如何处理本地撤消,我们必须了解撤消通常是如何工作的。 请记住,我们正在考虑操作——“在位置 3 插入‘a’。” 424 | 425 | 你会如何撤消那个? 您将运行操作,“删除位置 3 处的‘a’。” 你会怎么重做? 您将运行操作,“在位置 3 插入‘a’。” 426 | 427 | 这两个操作互为逆——它们相互抵消。 如果您运行一个操作,然后运行它的逆操作,就好像原始操作从未发生过一样。 这正是您想要撤消的内容。 428 | 429 | 撤消也像堆栈一样工作。 你做的最后一件事是你撤消的第一件事。 430 | 431 | 因此,如果我们的文本编辑器不是协作式的,那么您将如何应用撤消操作: 432 | 433 | 1. 您执行一个操作,例如“在位置 1 之前插入 h”。 434 | 1. 你反转这个操作,所以它变成了“在位置 1 移除 h”。 435 | 1. 然后,您将该反向操作推送到撤消堆栈上。 436 | 437 | 438 | 当你点击撤消时呢? 439 | 440 | 1. 您从堆栈中弹出操作(“删除位置 1 处的 h”)。 441 | 1. 你应用它就像你开始执行它一样。 442 | 1. 然后,如果您想支持重做,则再次将其反转并将反转推入重做堆栈。 443 | 444 | 445 | 够简单了吧? 让我们看看当其他人与您合作时,这种情况如何打破。 你运行“insert s, 4”——将“remove s, 4”压入撤销栈。 然后将插入内容发送到服务器。 446 | 447 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec3160f6d53dec385c506f/origin-url) 448 | 449 | 稍后,服务器向您发送操作“将 h 插入 1”——这不会同时发生,因此您不必对其进行转换。 现在我们的状态是下图。 450 | 451 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec317ff6d53d75d05c5070/origin-url) 452 | 453 | 现在看看我们的撤销堆栈。 如果你点击撤销会发生什么? 你会运行“remove s at 4”——但在位置 4 没有“s”,对吧? 454 | 455 | ![image.png](https://atlas-rc.pingcode.com/files/public/60ec31eaf6d53dcb4b5c5071/origin-url) 456 | 457 | 显然,我们错过了一步。 当您从服务器获取操作时,您需要针对该操作转换撤消堆栈中的所有操作。 因此,撤消堆栈是“remove s, 4”。 我们收到“insert h, 1”并且必须转换撤消堆栈,使其看起来像“remove s, 5”。 458 | 459 | 现在,当我们撤消时,我们运行“remove s at 5”,它会删除位置 5 处的“s”,一切都很好。 460 | 461 | 当您收到一个操作时,您必须针对该操作转换撤消堆栈。 幸运的是,我们已经有了一个函数(之前的那个大转换),它非常擅长将操作列表转换为其他操作列表。 462 | 463 | 我们可以这样使用: 464 | 465 | ``` 466 | def transform_stacks(remote_op) 467 | self.undos, _ = transform(self.undos, remote_op) 468 | self.redos, _ = transform(self.redos, remote_op) 469 | end 470 | ``` 471 | 472 | 以下是协作本地撤消的工作方式: 473 | 474 | 1. 当您执行一个操作时,将其反转并将其压入堆栈。 475 | 1. 当您收到一个操作时,根据它转换堆栈。 476 | 1. 撤消时,从堆栈中弹出顶部项目并运行它,将其发送到协作服务器。 477 | 478 | 479 | 这大部分情况是有效的,但并不完美。 事实上,它可能会违反一些您在撤销时应该遵守的规则。 例如,如果每个客户端都撤消了一组操作,然后重做,则文档应该处于与原始状态相同的状态。 有时,使用这种方法,它不是。 480 | 481 | 但这是复杂性和足够好的行为之间的务实平衡。 而且我不是唯一一个这么认为的人——我使用过的几乎所有协作文本编辑器,包括 Google Docs,都可能以完全相同的方式撤消失败。 482 | 483 | **把这一切放在一起** 484 | 485 | 以下内容足以使协作与任何类型的应用程序一起工作。 您从一个文档开始,它可以是一个简单的数组、一个版本、一个光标、一个远程光标列表和一个撤消堆栈。 您有对该状态起作用的操作,例如插入字符和删除字符。 这些操作知道它们来自文档的哪个版本。 486 | 487 | 您有一组转换函数,它们接受同时发生的两个操作并转换它们,以便它们可以一个接一个地运行。 488 | 489 | 您有一个控制算法,它可以采用两个操作列表,并将每一侧相互转换,以生成最终位于同一位置的文档。 你有转换光标的函数和发送和接收光标的函数,在输入的过程中转换它们。 490 | 491 | 并且您有一个撤消堆栈和一个重做堆栈,它们包含在远程操作进入时转换的反向操作。 492 | 493 | 当您执行操作时,您: 494 | 495 | - 将其应用于您的文档。 496 | - 针对它变换所有光标。 497 | - 将其发送到服务器。 498 | - 一旦一切平静下来,发送您当前的选取。 499 | 500 | 501 | 当您接收操作时,您: 502 | 503 | - 针对它转换您的待处理操作以完成转换方块。 504 | - 将转换后的操作应用到您的文档,并将待处理的转换操作发送到服务器。 505 | - 根据您收到和转换的操作转换您知道的所有光标。 506 | - 也可以针对它转换您的撤消堆栈。 507 | 508 | 509 | 当您更改光标位置并且没有挂起的操作时: 510 | 511 | - 发送您当前的光标位置。 512 | 513 | 514 | 当你从别人那里得到光标时: 515 | 516 | - 如果光标指向文档的旧版本,请忽略它,或将其转换为当前版本。 517 | - 如果是针对文档的当前版本,则针对任何挂起的操作对其进行转换。 518 | - 如果它用于文档的未来版本,请忽略它或保留它直到您看到文档的该版本。 519 | 520 | 521 | 我有一个将所有这些放在一起的 [demo](http://justinweiss-editor.herokuapp.com/) ,您可以使用它。 522 | 523 | **下一步做什么** 524 | 525 | 构建协作应用程序的方法有很多,但这是一个很好的开始。 它适用于所有不同类型的应用程序,构建起来并不难,而且非常灵活。 您会看到很多公司都在使用这种模型。 526 | 527 | 但它并不完美,因为: 528 | 529 | - 这个模型需要一个服务器才能工作。 530 | - 有一些边缘情况,特别是在撤销方面,如果你想修复它们会增加很多复杂性。 531 | - 根据您正在构建的内容,还有其他可能更简单或更正确的协作方法。 532 | 533 | 534 | 如果您想构建不依赖中央服务器的对等协作,请查看无冲突复制数据类型 (CRDT)。 如果你只是处理纯文本,也是一样——CRDT 在这方面往往做得很好。 CRDT 是较新的协作方法,非常适合某些特定类型的文本编辑器,并且它们变得越来越好。 535 | 536 | 如果您正在使用操作转换并且您不想自己编写服务器或控制算法,请查看 [ShareDB](https://github.com/share/sharedb) 。 如果您想查看 CRDT, [Yjs](https://github.com/yjs/yjs) 、 [Gun](https://gun.eco/) 和 [Automerge](https://github.com/automerge/automerge) 都是非常酷的项目。 537 | 538 | 现在,我喜欢我们可以在 [Aha](https://www.aha.io/company/careers) 完成我们的工作! 远程。 团队中的每个人都在家庭办公室工作——整个公司是完全分布式的。 我喜欢远程工作变得越来越流行。 539 | 540 | 这也让一些事情变得更加困难。 在一个项目上一起工作可能很困难。 最糟糕的是,当事情变得困难时,这些项目有时根本不会产生。 我喜欢能够让一个团队聚在一起完成比我们自己更大的事情。 但我不想担心做一个小的改变会破坏你的大事。 541 | 542 | 协同编辑是一种神奇的体验。 突然之间,这东西不再是你的,而是我们的。 543 | 544 | 我有信心做出改变,因为我的贡献不会与你的冲突。 我希望这种魔法无处不在,即使我不一直使用它。 因为在同一件事上工作的两个人应该让它变得更好,而不是更糟。 545 | 546 | 自从我加入了 Aha!! 在这个团队,我曾参与过一些真正有趣的项目。 我只参与了我们在 Aha 进行的许多许多有趣项目中的几个! 547 | 548 | 因此,您喜欢为大客户解决很酷的问题,并且想为一家快速发展、远程且盈利的软件公司工作? [我们正在招聘](https://www.aha.io/company/careers/current-openings) ,我很乐意与您合作。 -------------------------------------------------------------------------------- /fairy-fight/collaborative-editing.md: -------------------------------------------------------------------------------- 1 | 2 | 我们团队最近在调研基于Slate的协同编辑方案,在 Slate 的一个陈旧的 Issue 中发生了一段激烈的讨论(核心是关于 OT 与 CRDT),觉得非常有意思就稍微整理了下。 3 | 4 | Issue 原始链接: [https://github.com/ianstormtaylor/slate/issues/259](https://github.com/ianstormtaylor/slate/issues/259) 5 | 6 | german-jablo commented 2021-6-24(讨论发起者) 7 | 8 | TinyMCE [explained](https://www.tiny.cloud/blog/real-time-collaborative-editing-slate-js/) everything they had to do to achieve an OT with E2E encryption. Could someone please tell me if the solutions posted here meet that goal? Thank you very much for the help! 9 | 10 | TinyMCE 解释了他们通过 E2E 加密实现 OT 所必须做的一切。 有人可以告诉我这里发布的解决方案是否符合该目标? 非常感谢你的帮助! 11 | 12 | --- 13 | 14 | TheSpyder commented(TheSpyder 是 Slate 的维护者,TinyMCE开源富文本编辑的核心开发者,在调研基于Slate的协同编辑时选择了OT,并发表了两篇相关的说明文章) 15 | 16 | Not really. It's a lot easier to do E2E with CRDT, but I don't think any of those frameworks offer it today. We made it hard for ourselves by deciding to go the OT route - and then add E2E which I don't believe any other OT-based editors offer - but we're really happy with the result. 17 | 18 | 并不真地。 使用 CRDT 进行 E2E 会容易得多,但我认为今天这些框架中没有任何一个提供它。 我们决定走 OT 路线,这让我们自己很难过——然后添加 E2E,我不相信任何其他基于 OT 的编辑会提供这种服务——但我们对结果非常满意。 19 | 20 | --- 21 | 22 | dmonad commented (dmonad 是 Yjs 的创作者,Yjs 是一个 CRDT 的实现框架,提供了实时协同编辑的核心实现,非常强大) 23 | 24 | Both Skiff and Serenity Notes built E2E note-taking apps with the Yjs CRDT. The latter is now open-source and might provide a good starting point. 25 | 26 | Skiff 和 [Serenity Notes](https://github.com/SerenityNotes/serenity-notes-backend) 都使用 Yjs CRDT 构建了 E2E 笔记应用程序。 后者现在是开源的,可能会提供一个很好的起点。 27 | 28 | --- 29 | 30 | german-jablo commented 31 | 32 | Thanks to both of you. I'm going to review those two tools you mention. I am more interested in OT than in CRDT because it preserves the intention better. Although as far as I know TinyMCE is the only OT with E2EE, and I am still not in a position to pay its price. 33 | 34 | 感谢你们俩。 我将回顾你提到的这两个工具。 我对 OT 比对 CRDT 更感兴趣,因为它更好地保留了意图。 虽然据我所知 TinyMCE 是唯一一个带 E2EE 的 OT,我仍然无法支付它的价格。 35 | 36 | --- 37 | 38 | dmonad commented 39 | 40 | > it preserves the intention better 41 | 42 | I can say with some authority that this is not the case. OT has other advantages, but intention-preservation is not something that OT does particularly well. 43 | 44 | 我可以权威地说,事实并非如此。 OT 还有其他优点,但意图保存并不是 OT 做得特别好的。 45 | 46 | --- 47 | 48 | german-jablo commented 49 | 50 | Well, until now I had heard otherwise. I will do some tests to verify it. 51 | 52 | Another problem I have is that I want my product to have a version history. 53 | 54 | Also, I am not an expert but I understand that CRDT consumes more memory (since it does not "erase" characters). 55 | However it confuses me that some say it is faster / efficient than OT and others not. 56 | 57 | Were those the advantages of OT you were referring to? 58 | 59 | 好吧,直到现在我还听说过其他情况。 我会做一些测试来验证它。 60 | 61 | 我的另一个问题是我希望我的产品有一个版本历史。 62 | 63 | 另外,我不是专家,但我知道 CRDT 消耗更多内存(因为它不会“擦除”字符)。 64 | 65 | 然而,让我感到困惑的是,有人说它比 OT 更快/更高效,而其他人则不然。 66 | 67 | 那些是你所指的 OT 的优势吗? 68 | 69 | --- 70 | 71 | LionsAd commented(这个人通俗的解释了 OT 和 CRDT 的区别) 72 | 73 | @german-jablo You can decide for yourself: 74 | 75 | OT is like a navigation system: 76 | 77 | go 5 blocks left, go three blocks up, … 78 | 79 | Once you’ve already moved 1 block left, the instructions read: 80 | 81 | go 4 blocks left, go three blocks up, … 82 | 83 | CRDT is an address, which never changes: 84 | 85 | Go to client1-40 street 86 | 87 | OT and CRDT are in the end equivalent ways and can actually transformed into each other (a paper has proven that). 88 | 89 | And it makes intuitive sense: 90 | 91 | If you have the address, then you can always get the directions of how to get to that address - regardless of how the state changes. 92 | 93 | If you have directions, you can ensure that if the state (your position) changes that you still find the same address by transforming the operations how to get there. 94 | 95 | I personally find it intuitively easier to “name” each character with a client unique ID. 96 | 97 | It feels much easier to think of objects that change in relation to each other, than to transform operation stacks, but to each their own. 98 | 99 | As for E2EE: We have an interview with Serenity Notes author and E2EE up at Tag1.com/yjs, but the secret nutshell is: 100 | 101 | Just sync the whole document always. 102 | 103 | Feels excessive, but if you think of the megabytes of data transferred on YouTube etc. it’s actually not a big deal. 104 | 105 | Hope that helps! 106 | 107 | OT 就像一个导航系统: 108 | 109 | 向左走 5 个街区,向上走三个街区,…… 110 | 111 | 一旦你已经向左移动了 1 个街区,说明如下: 112 | 113 | 向左走 4 个街区,向上走三个街区,…… 114 | 115 | CRDT 是一个地址,永远不会改变: 116 | 117 | 去客户1-40街 118 | 119 | OT 和 CRDT 最终是等效的方式,实际上可以相互转换(一篇论文已经证明了这一点)。 120 | 121 | 它具有直观的意义: 122 | 123 | 如果你有地址,那么你总能得到如何到达那个地址的方向——不管状态如何变化。 124 | 125 | 如果你有方向,你可以通过转换操作如何到达那里来确保如果状态(你的位置)发生变化,你仍然可以找到相同的地址。 126 | 127 | 我个人发现使用客户端唯一 ID 来“命名”每个角色在直觉上更容易。 128 | 129 | 考虑相对于彼此发生变化的对象,比转换操作堆栈更容易,但要对每个对象进行转换。 130 | 131 | 至于 E2EE:我们在 Tag1.com/yjs 上对 Serenity Notes 作者和 E2EE 进行了采访,但秘诀是: 132 | 133 | 只需始终同步整个文档。 134 | 135 | 感觉有点过分,但是如果您考虑在 YouTube 等上传输的兆字节数据,实际上这没什么大不了的。 136 | 137 | 希望有帮助! 138 | 139 | --- 140 | 141 | mitar commented 142 | 143 | There is also a middle ground: [https://github.com/campadrenalin/ConcurrenTree](https://github.com/campadrenalin/ConcurrenTree) 144 | 145 | 还有一个中间立场: [https://github.com/campadrenalin/ConcurrenTree](https://github.com/campadrenalin/ConcurrenTree) 146 | 147 | --- 148 | 149 | TheSpyder commented 150 | 151 | I'm not going to get into a mud-slinging match but I feel like someone needs to defend OT in this thread. I stand by what I have said in my blog posts - for simple cases (plain text, JSON) yes OT and CRDT are equivalent and CRDT is usually the better choice. 152 | 153 | For a rich text editor, however, OT offers preservation of intent. That's a lot more than just mathematical equivalence. With Slate's 9 operations there is a matrix of 81 transforms to implement, but the payoff is that user intent is preserved. The key example I keep returning to is splitting a node. In Slate it's a split node operation, not a combination of inserts and deletes that a plain JSON OT or CRDT model would use to describe it. That offers very useful context when deciding how to transform conflicting operations. 154 | 155 | > As for E2EE: We have an interview with Serenity Notes 156 | author and E2EE up at Tag1.com/yjs, but the secret nutshell is: 157 | Just sync the whole document always 158 | Feels excessive, but if you think of the megabytes of data transferred on YouTube etc. it’s actually not a big deal. 159 | 160 | It is a big deal, and TinyMCE encryption is done at the operation level. Open up our demo and monitor the websocket connection - only tiny amounts of data are sent and received. 161 | 162 | 我不会参加一场泥泞的比赛,但我觉得有人需要在这个 Issue 中捍卫 OT。 我支持我在我的博客文章中所说的 - 对于简单的情况(纯文本,JSON)是的,OT 和 CRDT 是等效的,而 CRDT 通常是更好的选择。 163 | 164 | 然而,对于富文本编辑器,OT 提供了意图的保留。 这不仅仅是数学上的等价。 使用 Slate 的 [9 个操作](https://github.com/ianstormtaylor/slate/blob/main/packages/slate/src/interfaces/operation.ts#L119-L138) ,需要实现一个包含 81 个转换的矩阵,但回报是保留了用户意图。 我保持返回的关键示例是拆分节点。 在 Slate 中,它是一个拆分节点操作,而不是普通 JSON OT 或 CRDT 模型用来描述它的插入和删除的组合。 在决定如何转换冲突操作时,这提供了非常有用的上下文。 165 | 166 | > 至于 E2EE:我们在 Tag1.com/yjs 上对 Serenity Notes 作者和 E2EE 进行了采访,但秘诀是: 167 | 只需始终同步整个文档。 168 | 感觉有点过分,但是如果您考虑在 YouTube 等上传输的兆字节数据,实际上这没什么大不了的。 169 | 170 | 这是一个大问题,TinyMCE 加密是在操作级别完成的。 打开我们的演示并监控 websocket 连接 - 仅发送和接收少量数据。 171 | 172 | --- 173 | 174 | german-jablo commented 175 | 176 | I appreciate everyone's contribution. @TheSpyder What you are saying sounds like the holy grail of the RTC. From what little I understand, I think that the solution TinyMCE is working on is the best on the market in RTC, be it OT or CRDT. I think not many appreciate it because [the research you did](https://www.tiny.cloud/blog/real-time-collaboration-end-to-end-encryption/) is very technical. 177 | 178 | It would be great if you could publish for more clumsy people like me the basics of the French paper you used and how you combined Jupiter / Soct5 to achieve the result. 179 | By the way, do you have plans to integrate RTC in the core version? 180 | 我感谢每个人的贡献。 @TheSpyder 你所说的听起来像是 RTC 的圣杯。 据我所知,我认为 TinyMCE 正在开发的解决方案是 RTC 市场上最好的,无论是 OT 还是 CRDT。 我认为没有多少人会欣赏它,因为 [您所做的研究](https://www.tiny.cloud/blog/real-time-collaboration-end-to-end-encryption/) 非常技术性。 181 | 182 | 如果你能向像我这样笨手笨脚的人发布你使用的法国论文的基础知识以及你如何结合 Jupiter / Soct5 来实现结果,那就太好了。 183 | 184 | 顺便问一下,你们有计划在核心版本中集成RTC吗? 185 | 186 | --- 187 | 188 | TheSpyder commented 189 | 190 | > What you are saying sounds like the holy grail of the RTC. 191 | 192 | I am not the first - there are others who have successfully built an OT-based collaborative editor with Slate. Some have posted in this thread. They are the ones who inspired us to do it. 193 | 194 | 我不是第一个 - 还有其他人成功地使用 Slate 构建了基于 OT 的协作编辑器。 有些人在这个帖子里发过帖子。 他们是激励我们去做这件事的人。 195 | 196 | > I think not many appreciate it because the research you did is very technical. 197 | It would be great if you could publish for more clumsy people like me the basics of the French paper you used and how you combined Jupiter / Soct5 to achieve the result. 198 | 199 | Ah. I am not Tim, who wrote that post; I am Andrew, author of the earlier posts. I will let him know there is interest in a deep-dive. 200 | 啊。 我不是写那篇文章的蒂姆; 我是安德鲁, [早期帖子](https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt/) 的作者。 我会让他知道有人对它很感兴趣。 201 | 202 | > By the way, do you have plans to integrate RTC in the core version? 203 | 204 | That's still TBD. For now, our only announced plan is to include it with our premium offering; it will be cloud-only at launch with an on-prem version available later (we're hoping it will be in beta at launch). 205 | 206 | 那还是待定。 目前,我们唯一宣布的计划是将其包含在我们的高级产品中; 它将在发布时仅用于云,稍后将提供本地版本(我们希望它在发布时处于测试阶段)。 207 | 208 | --- 209 | 210 | german-jablo commented 211 | 212 | > I am not the first - there are others who have successfully built an OT-based collaborative editor with Slate. Some have posted in this thread. They are the ones who inspired us to do it. 213 | 214 | Yes, but if I'm not mistaken there are none that are 215 | 216 | (1) E2EE compatible 217 | 218 | (2) that only store changes and 219 | 220 | (3) allow a version history. 221 | 222 | For everything else, thank you very much! 223 | 224 | 是的,但如果我没记错的话,没有一个是 225 | 226 | (1) E2EE 兼容 227 | 228 | (2) 只存储更改和 229 | 230 | (3) 允许一个版本历史。 231 | 232 | 同时满足,非常感谢! 233 | 234 | --- 235 | 236 | dmonad commented 237 | 238 | @TheSpyder 239 | 240 | > For a rich text editor, however, OT offers preservation of intent. 241 | 242 | I know what you mean. But no conflict-resolution algorithm that exists can preserve the actual intent of the user because the algorithm doesn't understand what the user wants to do. All conflict-resolution approaches offer different tradeoffs when it comes to intent-preservation. Even CRDTs can offer a high degree of intent-preservation. 243 | 244 | 我明白你的意思。 但是现有的无冲突解决算法都不能保留用户的实际意图,因为该算法不了解用户想要做什么。 在意图保留方面,所有冲突解决方案都提供了不同的权衡。 甚至 CRDT 也可以提供高度的意图保留。 245 | 246 | Your article made me kinda sad when I read it: [https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt/](https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt/) Citing from the article: 247 | 248 | 你的文章让我读起来有点难过: [https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt/](https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt/) 249 | 引用文章: 250 | > If you can show me a rich text editor with support for CRDT, I can show you why I struggle to call it a "rich" text editor. Perhaps we should call them moderately wealthy text editors. 251 | 252 | 如果您能向我展示一个支持 CRDT 的富文本编辑器,我就能向您展示为什么我很难称其为“富”文本编辑器。 也许我们应该称他们为中等富裕的文本编辑器。 253 | 254 | Maybe you should have done a bit more research. Your split-node problem has been solved in Yjs since 2016. You completely misrepresent all the work that so many people put into different CRDT-based editor bindings. You just happened to use a very bad implementation of a CRDT to make the assumption that all CRDT implementations are bad. 255 | 256 | 也许你应该做更多的研究。 自 2016 年以来,您的拆分节点问题已在 Yjs 中得到解决。您完全歪曲了许多人投入到不同的基于 CRDT 的编辑器绑定中的所有工作。 你只是碰巧使用了一个非常糟糕的 CRDT 实现来假设所有 CRDT 实现都是糟糕的。 257 | 258 | None of the 10 editors that Yjs supports has the behavior that you are describing: [https://github.com/yjs/yjs-demos ](https://github.com/yjs/yjs-demos) + [https://www.tiptap.dev/](https://www.tiptap.dev/) + [https://remirror.io/](https://remirror.io/) 259 | 260 | Yjs 支持的 10 个编辑器中没有一个具有您所描述的行为: [https://github.com/yjs/yjs-demos ](https://github.com/yjs/yjs-demos) + [https://www.tiptap.dev/](https://www.tiptap.dev/) + [https://remirror.io/](https://remirror.io/) 261 | 262 | Personally, I find the debate pointless to compare generic CRDTs vs OT. CRDTs can do everything that OT can do, and vice versa. Most "researchers" in this field have done half-assed research only to support their argument. Every CRDT paper that claims that it is "faster" than OT doesn't talk about the tradeoffs that they had to make. Every paper that claims that OT is clearly superior to CRDT is wrong because OT-operations can be represented on top of a CRDT. Yjs, for example, has full support for the [OT RichText type](https://github.com/ottypes/rich-text) (it transforms the OT operation to a CRDT operation to offer a stronger consistency model). 263 | 264 | 就个人而言,我发现比较通用 CRDT 与 OT 的争论毫无意义。 CRDT 可以做 OT 可以做的一切,反之亦然。 该领域的大多数“研究人员”都做了半途而废的研究,只是为了支持他们的论点。 每篇声称它比 OT“更快”的 CRDT 论文都没有谈论他们必须做出的权衡。 每篇声称 OT 明显优于 CRDT 的论文都是错误的,因为 OT 操作可以在 CRDT 之上表示。 例如,Yjs 完全支持 [OT RichText type](https://github.com/ottypes/rich-text) (它将 OT 操作转换为 CRDT 操作以提供更强的一致性模型)。 265 | 266 | So instead of mud-slinging, we should probably just compare implementations by their features and performance metrics. None of your numerous posts describes a single valid argument against the Yjs CRDT. 267 | 268 | 因此,我们应该只通过它们的特性和性能指标来比较实现,而不是泥泞。 您的众多帖子都没有描述反对 Yjs CRDT 的单一有效论点。 269 | 270 | @german-jablo 271 | 272 | If TinyMCE's OT approach does everything you are looking for, go ahead and use it. I just wanted to make clear that intent-preservation is not something that OT is inherently better at. 273 | 274 | 如果 TinyMCE 的 OT 方法可以满足您的所有要求,请继续使用它。 我只是想说明,意图保留并不是 OT 天生擅长的。 275 | 276 | > Another problem I have is that I want my product to have a version history. 277 | Also, I am not an expert but I understand that CRDT consumes more memory (since it does not "erase" characters). 278 | However, it confuses me that some say it is faster / efficient than OT and others not. 279 | Were those the advantages of OT you were referring to? 280 | 281 | The slate-yjs binding currently doesn't support versions and tracking changes (only y-prosemirror does). CRDTs in general don't have to consume much more memory than OT (although some certainly do). It's part of the same fallacy that @TheSpyder ran into. They looked at a single bad implementation and judged that all implementations consume too much memory. Yjs has excellent performance metrics even for huge documents. You can write the Bible into a Yjs document while using less than 5MB of ram. 282 | 283 | slate-yjs 绑定当前不支持版本和跟踪更改(只有 y-prosemirror 支持)。 通常,CRDT 不必比 OT 消耗更多的内存(尽管有些确实如此)。 这是 @TheSpyder 遇到的同样谬论的一部分。 他们查看了一个糟糕的实现,并判断所有实现都消耗了太多内存。 即使对于大型文档,Yjs 也具有出色的性能指标。 您可以使用不到 5MB 的内存将圣经写入 Yjs 文档。 284 | 285 | 286 | Just to be clear. I have nothing against OT. Let's just stop with these pointless debates of citing papers from researchers that only want to popularize their approach. If you can't reproduce a "bad behavior" in a specific implementation, then you shouldn't make an argument that a certain thing is not possible. If OT works for you, that's good for you. Keep using it. 287 | 288 | 只是要清楚。 我不反对OT。 让我们停止这些关于引用研究人员的论文的毫无意义的辩论,他们只想普及他们的方法。 如果您无法在特定实现中重现“不良行为”,那么您不应该争论某件事是不可能的。 如果 OT 对你有用,那对你有好处。 继续使用它。 289 | 290 | --- 291 | 292 | TheSpyder commented 293 | 294 | I'm sorry you feel that way. But if we can't keep this on topic I'm going to lock the conversation. If you want to have a go at me please feel free to do it privately. 295 | 296 | Every example you linked to - including ot-richtext - is either plain text, based on Quill, or based on Prosemirror. These are the editors I was describing that only support a subset of HTML (although prosemirror is better than it was when we made the call to use Slate+OT for TinyMCE in 2019). I have repeatedly said CRDT is perfect for that context and if those editors are sufficient more power to you. 297 | 298 | This conversation is about Slate. Slate has a much more flexible document model, and while it isn't an officially supported yjs editor there are [yjs bindings](https://bitphinix.github.io/slate-yjs-example/) that do demonstrate this specific issue. And it's not like this is the only issue that comes up, it was just the easiest to show in a blog post. 299 | [https://www.loom.com/share/1450a0c84f9b4cf58b4aedec6a0cc00a](https://www.loom.com/share/1450a0c84f9b4cf58b4aedec6a0cc00a) 300 | 301 | 我觉得抱歉让你有种感觉。 但是,如果我们不能保持这个话题,我将锁定对话。 如果你想继续讨论,请随时私下进行。 302 | 您链接到的每个示例(包括 ot-richtext)都是基于 Quill 或 Prosemirror 的纯文本。 这些是我描述的仅支持 HTML 子集的编辑器(尽管 prosemirror 比我们在 2019 年呼吁将 Slate + OT 用于 TinyMCE 时更好)。 我一再说过 CRDT 非常适合这种情况,如果这些编辑器对你来说足够强大的话。 303 | 304 | 这个对话是关于 Slate 的。 Slate 有一个更灵活的文档模型,虽然它不是官方支持的 yjs 编辑器,但 [yjs binding](https://bitphinix.github.io/slate-yjs-example/) 确实演示了这个特定问题。 这并不是唯一出现的问题,它只是在博客文章中最容易展示的。 305 | 306 | [https://www.loom.com/share/1450a0c84f9b4cf58b4aedec6a0cc00a](https://www.loom.com/share/1450a0c84f9b4cf58b4aedec6a0cc00a) 307 | 308 | --- 309 | 310 | dmonad commented 311 | 312 | You are committing the same fallacy again. Just because you see one counter-example, you are judging that this is an inherent problem. I'm familiar with the Slate data model. Yjs has data types that enable you to linearize the content, similarly to how you do it. Slate-yjs just doesn't use this feature yet. This is not a hard problem to solve, as it can be seen on hand of numerous complex editor bindings that don't show the same behavior. OT doesn't have inherently better intention-preservation than CRDTs. This is just a wrong statement to make. 313 | 314 | 你又犯了同样的谬论。 仅仅因为你看到一个反例,你就判断这是一个固有的问题。 我熟悉 Slate 数据模型。 Yjs 具有使您能够线性化内容的数据类型,这与您的做法类似。 Slate-yjs 还没有使用这个功能。 这不是一个很难解决的问题,因为可以在众多复杂的编辑器绑定中看到,这些绑定不显示相同的行为。 OT 在本质上并不比 CRDT 具有更好的意图保留。 这只是一个错误的陈述。 315 | 316 | --- 317 | 318 | TheSpyder commented 319 | 320 | And you seem to have missed the conclusion of my article. If slate-yjs can be that good, please help this community by guiding it to get there. I'll help in any way I can. It would make my life a lot easier if TinyMCE could connect yjs to our Slate model instead of our custom OT solution :) 321 | 322 | 你似乎错过了我文章的结论。 如果 slate-yjs 可以那么好,请通过引导它到达那里来帮助这个社区。 我会尽我所能提供帮助。 如果 TinyMCE 可以将 yjs 连接到我们的 Slate 模型而不是我们的自定义 OT 解决方案,那将使我的生活更轻松:) 323 | 324 | --- 325 | 326 | BitPhinix commented 327 | 328 | @dmonad I'm really curious on how you would go about linearizing the content. Could you share some pointers? 329 | 330 | 我真的很好奇你将如何线性化内容。 你能分享一些点吗? 331 | 332 | --- 333 | 334 | dmonad commented 335 | Sure, I'm happy to help. In order to solve the split-node problem on text-nodes you can make use of the formatting attributes. 336 | 337 | 当然,我很乐意提供帮助。 为了解决文本节点上的拆分节点问题,您可以使用格式属性。 338 | 339 | Assuming you have the text **"Hello World"** , where **"Hello"** is bold and **"World"** is bold and italic: 340 | 341 | ``` 342 | { 343 | "object": "text", 344 | "leaves": [ 345 | { 346 | "object": "leaf", 347 | "text": "Hello", 348 | "marks": [italic] 349 | }, { 350 | "object": "leaf", 351 | "text": "World", 352 | "marks": [bold, italic] 353 | } 354 | ] 355 | } 356 | ``` 357 | 358 | You can represent this text object using a [Y.Text](https://docs.yjs.dev/api/shared-types/y.text) and formatting attributes (this is basically equivalent to the idea of marks): 359 | 360 | 您可以使用 [Y.Text](https://docs.yjs.dev/api/shared-types/y.text) 和格式属性来表示此文本对象(这基本上等同于 `marks` 的想法): 361 | 362 | ``` 363 | const ytext = new Y.Text() 364 | 365 | ytext.insert(0, 'Hello World', { italic: true }) // insert "Hello World" as italic text 366 | ytext.format(6, 5, { bold: true }) // assign bold formatting attributes to the word "World" 367 | ``` 368 | Or using the delta notation: 369 | 或者使用 delta 表示法: 370 | 371 | ``` 372 | ytext.applyDelta([ 373 | { insert: 'Hello ', attributes: { italic, true } }, 374 | { insert: 'World', attributes: { italic: true, bold: true } } 375 | ]) 376 | ``` 377 | 378 | A formatting attribute can be any JSON-encodable key-value pair. You can granularly remove or update attributes without replacing the underlying text. These are meta-properties that you can assign to ranges of content in the Yjs document. 379 | 380 | 格式化属性可以是任何 JSON 可编码的键值对。 您可以在不替换底层文本的情况下精细地删除或更新属性。 这些是您可以分配给 Yjs 文档中的内容范围的元属性。 381 | 382 | ``` 383 | ytext.toDelta() // => [{ insert: 'Hello ', attributes: { italic, true } }, { insert: 'World', attributes: { italic: true, bold: true } }] 384 | ``` 385 | 386 | In order to solve the split-node scenario that Andrew described, you just need to map Y.Text with formatting attributes to a Slate text node. I recommend working with the Y.Text delta events that should map nicely to Slate's operations, and vice versa. 387 | 388 | 为了解决 Andrew 描述的拆分节点场景,您只需要将带有格式属性的 Y.Text 映射到 Slate 文本节点。 我建议使用应该很好地映射到 Slate 操作的 Y.Text delta 事件,反之亦然。 389 | 390 | > I'll help in any way I can. It would make my life a lot easier if TinyMCE could connect yjs to our Slate model instead of our custom OT solution :) 391 | 392 | @TheSpyder That would be great :) Let me know when you need help. For Yjs-specific discussions, I'm also available on the discussion board [https://discuss.yjs.dev/](https://discuss.yjs.dev/) 393 | 那太好了 :) 当您需要帮助时请告诉我。 对于特定于 Yjs 的讨论,我也可以在讨论板上找到 [https://discuss.yjs.dev/](https://discuss.yjs.dev/) 394 | 395 | --- 396 | 397 | BrentFarese commented 398 | 399 | @dmonad and @TheSpyder or anyone else on this thread. We use Slate for [Aline](https://www.aline.co/) and are going to get to collaborative this year for sure. As with everyone, we have looked at OT vs. CRDT. YJS looks very interesting and we have considered using it. 400 | 401 | We would definitely sponsor some open source work to improve official YJS-Slate bindings that would help the community (and allow us to use the bindings for Aline). We might be able to commit some resources ourselves in a couple of months too. 402 | 403 | Is anyone interested in participating/co-sponsoring that type of work? I think it could be valuable to both Slate and YJS to extend the reach of both projects. @dmonad have you done anything like that in the past? 404 | 405 | Thanks! 406 | 407 | 408 | @dmonad 和 @TheSpyder 或此Issue上的任何其他人。 我们为 [Aline](https://www.aline.co/) 使用 Slate,今年肯定会进行协作。 与所有人一样,我们已经研究了 OT 与 CRDT。 YJS 看起来很有趣,我们已经考虑使用它。 409 | 410 | 我们肯定会赞助一些开源工作来改进官方 YJS-Slate 绑定,这将有助于社区(并允许我们使用 Aline 的绑定)。 我们也可以在几个月内自己投入一些资源。 411 | 412 | 有人有兴趣参与/共同赞助这种类型的工作吗? 我认为扩大这两个项目的影响范围对 Slate 和 YJS 来说都是有价值的。 @dmonad 你过去做过类似的事情吗? 413 | 414 | 谢谢! 415 | 416 | --- 417 | 418 | BrentFarese commented 419 | 420 | Can we also list Slate bindings in YJS docs (maybe designate as WIP if they're not ready yet)? 421 | 422 | 我们是否还可以在 YJS 文档中列出 Slate 绑定(如果尚未准备好,可以指定为 WIP)? 423 | 424 | --- 425 | 426 | dmonad commented 427 | 428 | Hi @BrentFarese, 429 | 430 | Thanks so much for offering this! @BitPhinix did an awesome job on the current slate-yjs binding. It would be great if you could sponsor him to work improve this implementation a bit. 431 | 432 | 非常感谢你提供这个! @BitPhinix 在当前的 slate-yjs 绑定上做得很棒。 如果你能赞助他改进这个实现,那就太好了。 433 | 434 | We finance Yjs additions with our [open-collective](https://opencollective.com/y-collective) . We can open a separate project for slate-yjs if @BitPhinix or someone else is interested in working on this. I don't have the time currently and can only offer my feedback. 435 | 436 | 我们通过我们的开放集体资助 Yjs 的增加。 如果@BitPhinix 或其他人对此感兴趣,我们可以为 slate-yjs 打开一个单独的项目。 我目前没有时间,只能提供我的反馈。 437 | 438 | > Can we also list Slate bindings in YJS docs (maybe designate as WIP if they're not ready yet)? 439 | 440 | Yeah, I thought I already added it. Will do it in a bit. 441 | 442 | 是的,我以为我已经添加了它。 一会就搞定。 443 | 444 | --- 445 | 446 | BitPhinix commented 447 | 448 | Thanks @dmonad! Opening a slate-yjs open collective project would be really helpful indeed. 449 | 450 | I'd be happy to work on improving the current slate-yjs binding 451 | 452 | 谢谢@dmonad! 打开一个 slate-yjs 开放集体项目确实会很有帮助。 453 | 454 | 我很乐意改进当前的 slate-yjs 绑定 455 | 456 | --- 457 | 458 | BrentFarese commented 459 | 460 | @dmonad and/or @BitPhinix let me know the link to the open collective when set up and we would be glad to contribute. 461 | 462 | @dmonad 和/或 @BitPhinix 在设置时让我知道开放集体的链接,我们很乐意做出贡献。 463 | 464 | --- 465 | 466 | hanspagel commented 467 | 468 | Hi @BrentFarese! I’m running the y-collective together with Kevin, and we’re eager to bring new people from the Yjs eco system on board. 🙃 469 | 470 | 嗨@BrentFarese! 我和 Kevin 一起经营 y-collective,我们渴望从 Yjs 生态系统中招募新人。 🙃 471 | 472 | I’ve just created a slate-yjs project: [https://opencollective.com/y-collective/projects/slate-yjs](https://opencollective.com/y-collective/projects/slate-yjs) In other words, it’s open to collect donations. I’m in contact with @BitPhinix anyway, so I’ll discuss with him all further details, but that’s probably out of scope of this issue. If any of you wants to reach out in private, my inbox is open: humans@tiptap.dev. Happy to connect everyone with everyone and make the Yjs ecosystem a little bit better every day. ✌️ 473 | 474 | 我刚刚创建了一个 slate-yjs 项目:https://opencollective.com/y-collective/projects/slate-yjs 换句话说,它是开放的,可以收集捐款。 无论如何,我与@BitPhinix 保持联系,因此我将与他讨论所有进一步的细节,但这可能超出了本问题的范围。 如果你们中有人想私下联系,我的收件箱是开放的:humans@tiptap.dev。 很高兴将每个人与每个人联系起来,让 Yjs 生态系统每天都变得更好。 ✌️ 475 | 476 | --- 477 | 478 | TheSpyder 的两篇文章链接: 479 | 480 | [To OT or CRDT, that is the question](https://www.tiny.cloud/blog/real-time-collaboration-ot-vs-crdt/) 481 | 482 | [Collaboration needs a clean Slate](https://www.tiny.cloud/blog/real-time-collaborative-editing-slate-js/) 483 | -------------------------------------------------------------------------------- /crdt/5000x-faster-crdts-an-adventure-in-optimization.md: -------------------------------------------------------------------------------- 1 | > 原文地址: [https://josephg.com/blog/crdts-go-brrr/](https://josephg.com/blog/crdts-go-brrr/) 作者: [Seph Gentle](https://github.com/josephg) (知名开源框架 sharedb/ottypes 作者) 2 | 3 | 译者简评: 4 | 5 | 这是一篇介绍 CRDTs 数据结构及对应算法优化的文章,基于主流开源框架: [automerge](https://github.com/automerge/automerge) 、 [yjs](https://github.com/yjs/yjs) 展开,后面介绍作者在它们之上所做的一些创造性的优化: [diamond-types](https://github.com/josephg/diamond-types) ,是一篇非常有深度的文章。 6 | 7 | 第一部分:主要介绍了 automerge 在数据结构及算法在性能上的一些核心问题,以及当下它为什么没有很快被解决。 8 | 9 | 第二部分:重点阐述了 Yjs 双向链表数据结构选择解决的问题及性能的掣肘,以及 Yjs 所做的一些创造性的优化。 10 | 11 | 第三部分:重点分享了作者本人在 Rust 上基于 b-trees 数据结构设计的 CRDTs 的实现,在一些性能表现上比 Yjs 的实现版本要快 5x 之多。 12 | 13 | 同时作者罗列并且盛赞 Yjs 、automerge 所作出的一些创作性贡献,字里行间让人动容,称自己的 diamond-types 是站在巨人的肩膀上,并且建议当下如果有协同编辑的需求,可以选择生态完善,性能俱佳的 Yjs。 14 | 15 | 16 | 17 | **以下为正文。** 18 | 19 | 几年前,我真的被一篇学术论文所困扰。 20 | 21 | 法国的一些研究人员进行了比较,展示了实现实时协作编辑的多种方式(如 Google Docs)。 他们实现了很多算法——CRDTs 和 OT 算法等等。 他们对它们进行了基准测试,以查看它们的表现。 (酷!!)一些算法运行得相当好。 但其它的算法需要 3 秒以上的时间来处理编辑会话中的简单粘贴操作。 哎呀! 22 | 23 | 那是什么算法? 好吧,这很尴尬,但是..这是我的。 我的意思是,它不是我发明的 - 但它是我用于 ShareJS 的算法。 我们用于 Google Wave 的算法。 我知道一个事实(坚持)该算法并没有花费 3 秒来处理大型粘贴事件。那么测试中究竟发生了什么? 24 | 25 | 我仔细看了他们的论文。 在他们的实现中,当用户粘贴一大块文本(如 1000 个字符)时,他们的代码将插入拆分为 1000 个单独的操作,而不是使用 1000 个字符创建 1 个操作。 并且这些操作中的每一个都需要单独处理。 好吧 - 当然,如果你这样做会很慢! 这不是操作转换算法的问题。 这只是它们 *特定实现* 的问题。 26 | 27 | 令人气愤的是,有几个人给我发了这篇论文的链接,并(有针对性地)问我对此有何看法。 写成一篇已发表的科学论文,这些速度比较似乎是关于宇宙的一个事实。 这可能不是真的 - 一些代码的实现细节,可能由一个过度紧张的研究生编写,这可能是他们一堆代码中的一部分。 28 | 29 | “不!不是每个人的Review技术都正确!请相信我!”。 但是我没有发表论文来证明我的主张。 我可以很好工作的代码,但感觉没有一个聪明的计算机科学人员关心这个。 我又是谁呢? 这不重要。 30 | 31 | --- 32 | 33 | 34 | 即使谈论这些东西,我们也有语言问题。 我们将每个系统描述为一个“算法”。 Jupiter 是一种算法。 RGA 是一种算法。 但实际上有两个非常不同的方面: 35 | 36 | 1. 并发编辑的黑盒 *行为* 。 当两个客户端同时编辑相同的文本区域时,会发生什么? 它们是否合并,如果合并,按什么顺序合并? 都有些什么样的规矩? 37 | 1. 系统的白盒 *实现* 。 我们使用什么编程语言? 什么数据结构? 代码优化得如何? 38 | 39 | 40 | 如果某些学者的代码运行缓慢,这实际上教会了我们什么? 也许这就像测试。 代码测试的全部通过,但永远不能 *证明* 没有错误。 同样,缓慢的实现虽然很慢,但永远不能证明系统的每个实现都会很慢。 如果您等待的时间足够长,就会有人发现更多错误。 而且,也许有人可以设计一个更快的实现。 41 | 42 | 多年前,我将旧文本 OT 代码翻译成 C、Javascript、Go、Rust 和 Swift。 每个实现都具有相同的行为和相同的算法。 但性能甚至不接近。 在 javascript 中,我的转换函数每秒运行大约 100 000 次。 不错! 但是 C 中的相同函数每秒执行 20M 次迭代。 那快了 200 倍。 不可思议! 43 | 44 | 学者们是在测试这段代码的慢版本还是快版本? 也许,在没有注意到的情况下,他们有一些算法的快速版本和其他算法的慢版本。 这些信息从论文上是无法得到的! 45 | 46 | ## 使 CRDT 变快 47 | 48 | 如您所知,我最近对 CRDT 产生了兴趣。 对于初学者来说,CRDT(无冲突复制数据类型)是一种花哨的编程工具,可以让多个用户同时编辑相同的数据。 它们让您可以毫无延迟地在本地工作。 (您甚至不必在线)。 当您与其他用户和设备同步时,一切都会神奇地同步并最终保持一致。 CRDT 最好的部分是它们可以完成所有这些工作,甚至不需要云中的中央计算机来监视和控制一切。 49 | 50 | 我想要没有谷歌的谷歌文档。 我希望我的应用程序能够在我的所有设备之间无缝共享数据,而无需期望所依赖的某些初创公司的服务器在下一个十年仍然存在。 我认为它们是协作编辑的未来。 也许所有软件的未来 - 但我还没有准备好谈论这个。 51 | 52 | 但是你在学术论文中读到的大多数 CRDT 都非常慢。 十年前,我决定停止阅读学术论文并将其驳回。 我认为 CRDT 有一些固有的问题。 每个字符的 GUID?对位置的东西感觉很疯狂! 但是——承认这一点很尴尬——我想我和那些研究人员犯了同样的错误。 我正在阅读描述不同系统行为的论文。 我认为这意味着我们知道如何以最佳方式实施这些系统。 哇,我错了。 53 | 54 | 错到什么程度? 好。 运行此 [编辑跟踪](https://github.com/automerge/automerge-perf/) , [Automerge](https://github.com/automerge/automerge/) (一种流行的 CRDT,由一位 [受欢迎的研究人员](https://github.com/automerge/automerge/) 编写)需要近 5 分钟才能运行。 我有一个 [新的实现](https://github.com/josephg/diamond-types) ,可以在 56 毫秒内处理相同的编辑跟踪。 那是 0.056 秒,快了 5000 多倍。 这是我从优化工作中获得的最大速度提升 - 我对此感到非常高兴。 55 | 56 | 让我们谈谈为什么 automerge 目前很慢,我将带您完成使其超快的所有步骤。 57 | 58 | 首先,我们需要从: 59 | 60 | ### automerge 是什么? 61 | 62 | Automerge 是一个帮助您进行协作编辑的库。 它由马丁·克莱普曼 (Martin Kleppmann) 撰写,他的著作和 [出色的演讲](https://martin.kleppmann.com/2020/07/06/crdt-hard-parts-hydra.html) 让他颇有名气。 Automerge 基于一种称为 RGA 的算法,如果您对此感兴趣,可以在学术论文中阅读该算法。 63 | 64 | [https://www.youtube.com/watch?v=x7drE24geUw&t=1237s](https://www.youtube.com/watch?v=x7drE24geUw&t=1237s) 65 | 66 | Automerge(以及 Yjs 和其他 CRDT)将共享文档视为字符列表。 文档中的每个字符都有一个唯一的 ID,每当您插入到文档中时,您都会为要插入的内容命名。 67 | 68 | 想象下我(seph)在一个空文章中输入“abc”。Automerge 创建 3 个 items: 69 | 70 | - 插入 ’ *​a* ‘ (seph, 0) 在ROOT之后 71 | - 插入 ' *​b* ' (seph, 1) 在 (seph, 0) 之后 72 | - 插入 '​ *​i* ' (seph, 2) 在 (seph, 1) 之后 73 | 74 | 75 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cae115e429e861587f6008/origin-url) 76 | 77 | 让我们看看Mike(人名)在 ’ *​a* ‘ 和 ’ *​b* ‘ 之间插入一个 'X',我们得到 ’aXbc‘,我们得到如下: 78 | 79 | - 插入 ’ *​a* ‘ (seph, 0) 在ROOT之后 80 | - 插入 ' *​X* ' (mike, 0) 在 (seph, 0) 之后 81 | - 插入 ' *​b* ' (seph, 1) 在 (seph, 0) 之后 82 | - 插入 '​ *​i* ' (seph, 2) 在 (seph, 1) 之后 83 | 84 | 85 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cae223e429e861587f600b/origin-url) 86 | 87 | 请注意“X”和“b”都共享同一个父级。 当用户在文档中的同一位置同时键入时,就会发生这种情况。 但是我们如何确定哪个字符先出现呢? 我们可以只使用他们的代理 ID 或其他东西进行排序。 但是啊,如果我们这样做,文档最终可能会变成 abcX,即使 Mike 在 b 之前插入了 X。 那真的会很混乱。 88 | 89 | Automerge (RGA) 通过巧妙的技巧解决了这个问题。 它为每个Item添加一个额外的整数,称为 *序列号* 。 每当你插入一些东西时,你将Item的序列号设置为比你见过的最大序列号大 1: 90 | 91 | - 插入 ’ *​a* ‘ (seph, 0) 在ROOT之后 *seq: 0* 92 | - 插入 ' *​X* ' (mike, 0) 在 (seph, 0) 之后 *seq: 3* 93 | - 插入 ' *​b* ' (seph, 1) 在 (seph, 0) 之后 *seq: 1* 94 | - 插入 '​ *​i* ' (seph, 2) 在 (seph, 1) 之后 *seq: 2* 95 | 96 | 97 | 这是一个算法版本 “哇,我看到一个序列号,竟然这么大!”。 “是吗?我的更大!” 98 | 99 | 规则是子项首先根据它们的序列号排序(更大的序列号在前)。 如果序列号匹配,则更改必须是并发的。 在这种情况下,我们可以根据代理 ID 对它们进行任意排序。 (我们这样做是为了让所有对等点最终得到相同的结果文档。) 100 | 101 | Yjs - 我们稍后会看到更多 - 实现了一个名为 YATA 的 CRDT。 YATA 与 RGA 相同,只是它通过稍微不同的 hack 解决了这个问题。 但这里的区别并不重要。 102 | 103 | Automerge (RGA) 的行为由该算法定义: 104 | 105 | - 构建树,将每个Item连接到其父项 106 | - 当一个项目有多个子项时,先按序列号再按 ID 对它们进行排序。 107 | - 结果列表(或文本文档)通过使用深度优先遍历把树打平制作。 108 | 109 | 110 | 那么你应该如何 *实现* automerge 呢? automerge 库以一种显而易见的方式完成它,即将所有数据存储为一棵树。 (至少我是这么认为的 - 在输入“abc”之后,这是 automerge 的 [内部状态](https://gist.github.com/josephg/0522c4aec5021cc1dddb60e778828dbe) 。呃,呃,我不知道这里发生了什么。所有这些 Uint8Arrays 都在做什么?无论如何,automerge 库有效,通过这些Items构建一个Tree。 111 | 112 | 对于一个简单的基准测试,我将使用 Martin 自己制作的 [编辑跟踪](https://github.com/automerge/automerge-perf/) 来测试自动合并。 这是马丁输入学术论文的逐字记录。 此跟踪中没有任何并发编辑,但用户几乎从未真正将光标放在完全相同的位置然后输入,所以我不太担心这一点。 我也只计算在 *本地* 应用此跟踪所需的时间,虽然这并不理想,但还可以(代表是正确的)。 如果您喜欢这类事情,Kevin Jahns(Yjs 的作者)在这里有一个更广泛的 [基准测试](https://github.com/dmonad/crdt-benchmarks) 。 这里的所有基准测试都是在我的 chonky ryzen 5800x 工作站上完成的,在合适的时候使用 Nodejs v16.1 和 rust 1.52。 (剧透!) 113 | 114 | 编辑轨迹有 260 000 次编辑,最终文档大小约为 100 000 个字符。 115 | 116 | 正如我上面所说,automerge 需要不到 5 分钟的时间来处理此跟踪。 这只是每秒 900 次编辑,这可能没问题。 但是当它完成时,automerge 正在使用 880 MB 的 RAM。 哇! 这是每次按键 10kb 的内存。 在高峰期,automerge 使用了 2.6 GB 的 RAM! 117 | 118 | 为了了解有多少开销,我将把它与 [基准](https://gist.github.com/josephg/13efc1444660c07870fcbd0b3e917638#file-js_baseline-js) 进行比较,在基准中我们只是将所有编辑直接拼接到一个 javascript 字符串中。 这丢弃了我们进行协作编辑所需的所有信息,但它让我们了解 javascript 的运行速度。 事实证明,在 V8 上运行的 javascript 速度很快: 119 | 120 | |Test|Time taken|RAM usage| 121 | |---|---|---| 122 | |**automerge (v1.0.0-preview2)**|291s|880 MB| 123 | |*Plain string edits in JS*|0.61s|0.1 MB| 124 | 125 | 126 | 这是一个图表,显示了在整个测试过程中处理每个操作所花费的时间,平均每组 1000 个操作。 我认为这些峰值是 V8 的垃圾收集器试图释放内存。 127 | 128 | ![image.png](https://atlas-rc.pingcode.com/files/public/61caef34e429e861587f601e/origin-url) 129 | 130 | 在接近尾声的最慢峰值中,一次编辑需要 1.8 秒的处理时间。 糟糕。 在实际的应用程序中,整个应用程序(或浏览器选项卡)有时会在您打字的过程中冻结几秒钟。 131 | 132 | 当我们将所有内容平均并缩放 Y 轴时,图表更易于阅读。 我们可以看到平均性能随着时间的推移逐渐(大致线性地)变差。 133 | 134 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cc1ac3e429e861587f606e/origin-url) 135 | 136 | ### 为什么 automerge 这么慢? 137 | 138 | Automerge 运行缓慢的原因有很多: 139 | 140 | 1. 随着文档的增长,Automerge 的基于核心树的数据结构变得又大又慢。 141 | 1. Automerge 大量使用 [Immutablejs](https://immutable-js.github.io/) 。 Immutablejs 是一个库,它为 javascript 对象提供类似 clojure 的 copy-on-write 语义。 这是一组很酷的功能,但 V8 优化器和 GC 努力优化使用 immutablejs 的代码。 因此,它会增加内存使用量并降低性能。 142 | 1. Automerge 将每个插入的字符视为一个单独的Item。 还记得我之前讲过的论文,复制+粘贴操作很慢吗? Automerge 也是如此! 143 | 144 | 145 | Automerge 从来没有考虑过性能。 他们的团队正在研究算法的替代 [Rust 实现](https://github.com/automerge/automerge-rs/) 来运行 wasm,但在撰写本文时它尚未落地。 我想让 master 分支正常工作,但是在它准备好之前他们有一些问题需要解决。 切换到 automerge-rs 后端也不会使此测试中的平均性能更快。 (尽管它确实将内存使用量减半并使性能更平滑。) 146 | 147 | --- 148 | 149 | 150 | > 你不能让电脑更快。 你只能让它做更少的工作。 151 | 152 | 我们如何让计算机在这里做更少的工作? 通过检查代码和改进许多小东西,可以获得很多性能上的胜利。 但是 automerge 团队有正确的方法,最好从整体优化开始,在转向优化单个方法之前修复核心算法和数据结构。优化的函数很可能在优化核心算法和数据结构时被删除掉,那么这样的小的优化就没有意义了。 153 | 154 | 到目前为止,Automerge 最大的问题是其复杂的基于树的数据结构。 我们可以用更快的方式替换它。 155 | 156 | ## 改进数据结构 157 | 158 | 幸运的是,有一种更好的方法来实现 CRDTs,这是 [Yjs](https://github.com/yjs/yjs) 中首创的。 Yjs 是由 Kevin Jahns 制作的另一个(竞争)开源 CRDT 实现。 它很快,文档完善,生态完善。如果我今天要构建支持协作编辑的软件,我会使用 Yjs。 159 | 160 | Yjs 不需要整篇博文来讨论如何使其更快,因为它已经非常快了,我们很快就会看到。 它通过使用一个聪明的、明显的数据结构“技巧”实现,我认为该领域的其他人没有想到它。 而不是像 automerge 那样将 CRDT 实现为树: 161 | 162 | ``` 163 | state = { 164 | { item: 'a', id: ['seph', 0], seq: 0, children: [ 165 | { item: 'X', id, seq, children: []}, 166 | { item: 'b', id, seq, children: [ 167 | { item: 'c', id, seq, children: []} 168 | ]} 169 | ]} 170 | } 171 | ``` 172 | 173 | Yjs 只是将所有Item放在一个单一的列表中: 174 | 175 | ``` 176 | state = [ 177 | { item: 'a', id: ['seph', 0], seq: 0, parent: null }, 178 | { item: 'X', id, seq, parent: ['seph', 0] }, 179 | { item: 'b', id, seq, parent: ['seph', 0] }, 180 | { item: 'c', id, seq, parent: [..] } 181 | ] 182 | ``` 183 | 184 | 这看起来很简单,但是如何在列表中插入一个新项目呢? 使用 automerge 很容易: 185 | 186 | 1. 找到 Parent Item 187 | 1. 将New Item 插入到 Parent Item 的 Children 中的正确位置 188 | 189 | 190 | 但是实现上面的目标会很复杂: 191 | 192 | 1. 找到 Parent Item 193 | 1. 从 Parent Item 之后开始,遍历列表直到我们找到应该插入新项的位置(?) 194 | 1. 插入那里,拼接成数组 195 | 196 | 197 | 本质上,这种方法只是一种花哨的插入排序。 我们正在使用列表实现列表 CRDT。 天才! 198 | 199 | 这听起来很复杂 - 你是如何确定 New Item 的位置的?但它的实现复杂程度和它的数据复杂度一样。 很难理解,但是一旦理解了,大约20行代码就可以实现整个插入功能: 200 | 201 | (但如果这看起来令人困惑,请不要惊慌 - 我们可能可以让地球上今天理解此代码的每个人都进入一个小会议室。) 202 | 203 | ``` 204 | const automergeInsert = (doc, newItem) => { 205 | const parentIdx = findItem(doc, newItem.parent) // (1) 206 | 207 | // Scan to find the insert location 208 | let i 209 | for (i = parentIdx + 1; i < doc.content.length; i++) { 210 | let o = doc.content[i] 211 | if (newItem.seq > o.seq) break // Optimization. 212 | let oparentIdx = findItem(doc, o.parent) 213 | 214 | // Should we insert here? (Warning: Black magic part) 215 | if (oparentIdx < parentIdx 216 | || (oparentIdx === parentIdx 217 | && newItem.seq === o.seq 218 | && newItem.id[0] < o.id[0]) 219 | ) break 220 | } 221 | // We've found the position. Insert at position *i*. 222 | doc.content.splice(i, 0, newItem) // (2) 223 | 224 | // .. And do various bookkeeping. 225 | } 226 | ``` 227 | 228 | 我在我的实验性 [*reference-crdts*](https://github.com/josephg/reference-crdts/blob/main/crdts.ts) 代码库中使用这种方法实现了 Yjs 的 CRDT (YATA) 和 Automerge。 [这是插入功能,还有一些注释](https://github.com/josephg/reference-crdts/blob/fed747255df9d457e11f36575de555b39f07e909/crdts.ts#L401-L459) 。 这个函数的 Yjs 版本在同一个文件中,如果你想看看。 尽管是来自不同的论文,但插入的逻辑几乎相同。 尽管代码不尽相同,但这种方法在语义上与实际的 automerge、Yjs 和 sync9 代码库相同。 ( [Fuzzer verified (TM)](https://github.com/josephg/reference-crdts/blob/main/reference_test.ts) )。 229 | 230 | 如果你有兴趣更深入地了解这一点,我在几周前的一次 [braid](https://braid.org/) 会议上 [讨论了这种方法](https://invisiblecollege.s3-us-west-1.amazonaws.com/braid-meeting-10.mp4#t=300) 。 231 | 232 | 重要的一点是这种方法有一下更多好处: 233 | 234 | 1. 我们可以使用平面数组来存储所有内容,而不是不平衡的树。 这使得计算机处理的一切都变得更小、更快。 235 | 1. 代码真的很简单。 更快更简单移动 [Pareto efficiency frontier](https://en.wikipedia.org/wiki/Pareto_efficiency) 。 这样做的想法是罕见的,而且是真正的黄金。 236 | 1. 您可以像这样实现很多 CRDT。 Yjs、Automerge、Sync9 和其他工作。 您可以在同一个代码库中实现许多列表 CRDT。 在我的 reference-crdts 代码库中,我同时实现了 RGA(automerge)和 YATA(Yjs)。 他们共享他们的大部分代码(除了这个函数之外的所有代码)并且他们在这个测试中的表现是相同的。 237 | 238 | 239 | 理论上,当在文档中的同一位置存在并发插入时,该算法会减慢速度。 但这在实践中真的很少见 - 您几乎总是在父项之后插入。 240 | 241 | 使用这种方法,我对 automerge 算法的实现比真正的 automerge 快了大约 10 倍。 它的内存效率提高了 30 倍: 242 | 243 | |Test|Time taken|RAM usage| 244 | |---|---|---| 245 | |automerge (v1.0.0-preview2)|291s|880 MB| 246 | |**reference-crdts (automerge / Yjs)**|31s|28 MB| 247 | |*Plain string edits in JS*|0.61s|0.1 MB| 248 | 249 | 250 | 我希望我能将 *所有* 这些差异归因于这个甜美而简单的数据结构。 但是这里的很多区别可能只是 immutablejs 对 automerge 进行了处理。 251 | 252 | 它比 automerge 快得多: 253 | 254 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cc2dd0e429e861587f608b/origin-url) 255 | 256 | ## 1000次扫描导致死机 257 | 258 | 我们现在正在使用干净且快速的核心数据抽象,但实现仍然 *不快* 。 我们需要修复此代码库中的两大性能瓶颈: 259 | 260 | 1. 找到要插入的位置,以及 261 | 1. 实际把它插入到数组中 262 | 263 | 264 | (这些行为在上面的代码块中标记为 *(1)* 和 *(2)* )。 265 | 266 | 为了理解为什么这个代码是必要的,假设我们有一个文档,它是一个项目列表。 267 | 268 | ``` 269 | state = [ 270 | { item: 'a', isDeleted: false, id: ['seph', 0], seq, parent: null }, 271 | { item: 'X', isDeleted: false, id, seq, parent: ['seph', 0] }, 272 | { item: 'b', isDeleted: true, id, seq, parent: ['seph', 0] }, 273 | { item: 'c', isDeleted: false, id, seq, parent: ['seph', 1] }, 274 | ... 275 | ] 276 | ``` 277 | 278 | 其中一些项目可能已被删除。 我添加了一个 isDeleted 标志来标记哪些。 (不幸的是,我们不能将它们从数组中删除,因为其他插入可能依赖于它们。见鬼! 但这是其它时候需要讨论的问题。) 279 | 280 | 想象一下,文档中有 150 000 个数组项,代表 100 000 个尚未删除的字符。 如果用户在文档中间(文档位置 50 000)键入一个 'a',那么它对应于我们数组中的什么索引? 为了找出答案,我们需要扫描文档(跳过已删除的项目)以找出正确的数组位置。 281 | 282 | 因此,如果用户在位置 50 000 插入,我们可能必须线性扫描超过 75 000 个项目或其他东西才能找到插入位置。 哎呀! 283 | 284 | 然后当我们实际插入时,代码会这样做,这是双重的: 285 | 286 | ``` 287 | doc.content.splice(destIdx, 0, newItem) 288 | ``` 289 | 290 | 如果数组当前有 150 000 个项目,javascript 将需要将 newItem 之后的每个 Item 向后移动一次。 这部分发生在本机代码中,但是当我们移动这么多 Items 时它可能仍然很慢。 (旁白:V8 在这方面的速度实际上令人怀疑,所以也许 v8 没有在内部使用数组来实现数组?谁知道!) 291 | 292 | 但一般来说,将一个 item 插入到一个包含 *n* 个 items 的文档中大约需要 *n* 个步骤。 等等,不 - 比这更糟糕,因为删除的项目仍然存在。 插入到曾经有 *n* 个 items 的文档需要 *n* 个步骤。 这种算法相当快,但每次输入都会变慢。 插入 *n* 个字符将花费 *O(n^2)* 。 293 | 294 | 如果我们放大上图,您可以看到这一点。 这里发生了很多事情,因为 Martin 的编辑位置在文档周围跳来跳去。 但是向右上方有很强的线性趋势,这就是我们所期望的在插入时花费的时间是 *O(n)* 的: 295 | 296 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdc2d2e4058eb01f92e7e1/origin-url) 297 | 298 | 为什么特别是这种形状? 为什么性能在接近尾声时变得更好? 如果我们简单地绘制在整个编辑轨迹中每个编辑发生的 *位置* ,使用相同的水平和垂直刻度,结果是一条非常熟悉的曲线: 299 | 300 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdc40fe4058eb01f92e7e2/origin-url) 301 | 302 | 看起来应用更改所花费的时间主要取决于扫描文档数组所花费的时间。 303 | 304 | ## 改变数据结构 305 | 306 | 我们能解决这个问题吗? 我们可以! “我们”是指 Kevin 在 Yjs 中解决了这些问题。 他是怎么做到的? 307 | 308 | 所以请记住,有两个问题需要解决: 309 | 310 | 1. 我们如何找到特定的插入位置? 311 | 1. 我们如何高效地在该位置插入内容? 312 | 313 | 314 | Kevin 通过思考人类实际上如何编辑文本文档来解决第一个问题。 通常在我们打字的时候,我们实际上并不会在一个文档周围跳来跳去。 Yjs 不会在每次编辑时扫描文档,而是缓存用户进行编辑的最后一个(索引、位置)对。 下一个编辑可能与上一个编辑非常接近,因此 Kevin 只需从上一个编辑位置向前或向后扫描。 这对我来说听起来有点狡猾 - 我的意思是,这是一个很大的假设! 如果编辑随机发生怎么办?! 但人们实际上并不会随意编辑文档,因此它在实践中效果很好。 315 | 316 | (如果两个用户同时编辑文档的不同部分怎么办?Yjs 实际上存储了一整套缓存位置,因此无论他们在文档中的哪个位置进行更改,每个用户附近几乎总是有一个缓存的光标位置。 ) 317 | 318 | Yjs一旦找到目标插入位置,就需要高效插入,而不是复制所有现有的项目。 Yjs 通过使用双向链表而不是数组来解决这个问题。 只要我们有一个插入位置,链表就允许在恒定时间内插入。 319 | 320 | Yjs 还做了一件事来提高性能。 人类通常会输入一系列字符。 因此,当我们在文档中输入“hello”时,不是存储: 321 | 322 | ``` 323 | state = [ 324 | { item: 'h', isDeleted: false, id: ['seph', 0], seq, parent: null }, 325 | { item: 'e', isDeleted: false, id: ['seph', 1], seq, parent: ['seph', 0] }, 326 | { item: 'l', isDeleted: false, id: ['seph', 2], seq, parent: ['seph', 1] }, 327 | { item: 'l', isDeleted: false, id: ['seph', 3], seq, parent: ['seph', 2] }, 328 | { item: 'o', isDeleted: false, id: ['seph', 4], seq, parent: ['seph', 3] }, 329 | ] 330 | ``` 331 | 332 | Yjs 仅仅存储: 333 | 334 | ``` 335 | state = [ 336 | { item: 'hello', isDeleted: false, id: ['seph', 0], seq, parent: null }, 337 | ] 338 | ``` 339 | 340 | 最后那些讨厌的粘贴事件也会很快! 341 | 342 | 这是相同的信息,只是存储得更紧凑。 不幸的是,我们无法使用此技巧将整个文档折叠为单个项目或类似内容。 该算法只能在 ID 和父项按顺序排列时折叠插入 - 但每当用户键入一串字符而不移动光标时就会发生这种情况。 这种情况经常发生。 343 | 344 | 在这个数据集中,使用跨度换算将数组条目的数量减少了 14 倍。 (180k 条目下降到 12k)。 345 | 346 | 现在有多快? 这让我大吃一惊——在这个测试中,Yjs 比我的 reference-crdts 实现快 30 倍。 而且它只使用大约 10% 的 RAM。 它比 automerge 快 300 倍! 347 | 348 | |Test|Time taken|RAM usage| 349 | |---|---|---| 350 | |automerge (v1.0.0-preview2)|291s|880 MB| 351 | |reference-crdts (automerge / Yjs)|31s|28 MB| 352 | |**Yjs (v13.5.5)**|0.97s|3.3 MB| 353 | |*Plain string edits in JS*|0.61s|0.1 MB| 354 | 355 | 356 | 老实说,我对这次测试中使用的 ram Yjs 的使用量感到震惊并有点怀疑。 我确信 V8 中有一些魔法使这成为可能。 这是非常令人印象深刻的。 357 | 358 | Kevin 说他编写并重写了 Yjs 的部分内容 12 次,以使这段代码运行得如此之快。 如果有程序员版的跑速社区,他们会喜欢 Kevin。 我甚至不能把 Yjs 放在与其他算法相同的规模上,因为它太快了: 359 | 360 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdc7d8e4058eb01f92e7e4/origin-url) 361 | 362 | 如果我们隔离 Yjs,您可以看到它的性能基本持平。 与其他算法不同,它不会随着文档的增长而变慢: 363 | 364 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdc801e4058eb01f92e7e5/origin-url) 365 | 366 | 但我不知道接近尾声的那些尖峰是什么。 从绝对值来看,它们很小,但仍然很奇怪! 也许当用户在文档周围移动光标时会发生这种情况? 或者当用户删除块时? 我不知道。 367 | 368 | 这很好,但真正的问题是:我们能走得更快吗? 老实说,我怀疑我能否让纯 javascript 比 Kevin 在这里管理的更快地运行此测试。 但也许……只是也许我们可以…… 369 | 370 | ## 比Javascript更快 371 | 372 | 当我告诉 Kevin 我认为我可以做出比 Yjs 快得多的 CRDT 实现时,他不相信我。 他说 Yjs 已经优化得非常好,可能不可能再快得多。 “如果你只是把它移植到 Rust,也许会快一点。但不会快很多!现在 V8 真的很快!!” 373 | 374 | 但我知道一些 Kevin 不知道的事情:我知道内存碎片和缓存。 Rust 不仅仅是 *更快* 。 它也是一种低级语言,它为我们提供了控制分配和内存布局所需的工具。 375 | 376 | > Kevin 现在也知道这一点,他正在努力应用到 [Yrs](https://github.com/yjs/y-crdt) 上,看看他是否能夺回表现桂冠。 377 | 378 | 想象一下我们在 javascript 中的文档项之一: 379 | 380 | ``` 381 | var item = { 382 | content: 'hello', 383 | isDeleted: false, 384 | id: ['seph', 10], 385 | seq: 5, 386 | parent: ['mike', 2] 387 | } 388 | ``` 389 | 390 | 这个对象在内存中实际上是这样的: 391 | 392 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdc9b3e4058eb01f92e7e6/origin-url) 393 | 394 | 坏消息: *你的电脑讨厌这个* 。 395 | 396 | 这很糟糕,因为所有数据都是碎片化的。 都是用指针隔开的。 397 | 398 | > 是的,我知道,V8 在可能的情况下尽最大努力防止此类事情发生。 但它不是魔法。 399 | 400 | 像这样排列数据,计算机必须为每一项一项地分配内存。 这很慢。 然后垃圾收集器需要额外的数据来跟踪所有这些对象,这也很慢。 稍后我们需要读取该数据。 要读取它,您的计算机通常需要从主内存中获取它,这 - 您猜对了 - 也很慢。 401 | 402 | 主存读取有多慢? [At human scale](https://gist.github.com/hellerbarde/2843375) ,每次 L1 缓存读取需要 0.5 秒。 从主内存读取需要接近 2 分钟! 这是单次心跳与刷牙所需时间之间的差异。 403 | 404 | 像 javascript 一样安排内存就像编写购物清单一样。 但不是“奶酪、牛奶、面包”,你的清单实际上是一个寻宝游戏:“沙发底下”、“冰箱顶上”等等。 沙发底下有一张小纸条,上面写着你需要牙膏。 毋庸置疑,这使得去杂货店购物需要做很多工作。 405 | 406 | 为了更快,我们需要将所有数据压缩在一起,以便计算机可以在每次读取主内存时获取更多信息。 (我们想要一次阅读我的购物清单来告诉我们我们需要知道的一切)。 正是因为这个原因, **链表在现实世界中很少使用——内存碎片会破坏性能** 。 我也想摆脱链接列表,因为用户有时会在文档周围跳来跳去,这在 Yjs 中具有线性性能成本。 这在文本编辑中可能没什么大不了的,但我希望这段代码在其他用例中也能很快。 我不希望程序需要那些缓慢的扫描。 407 | 408 | 我们无法在 javascript 中解决这个问题。 javascript 中奇特的数据结构的问题是你最终需要大量的奇异对象(比如固定大小的数组)。 所有这些额外的对象都会使碎片变得更糟,因此由于您的所有工作,您的程序最终通常会运行得更慢。 这与 immutablejs 有相同的限制,也是为什么它的性能在发布后的十年中没有太大提高。 V8 优化器非常聪明,但它不是魔术,聪明的技巧只能让我们到此为止。 409 | 410 | 但我们不仅限于 javascript。 即使在制作网页时,我们现在也有 WebAssembly。 我们可以将其编码为 *任何内容* 。 411 | 412 | 为了看看我们到底能走多快,我一直在悄悄地用 Rust 构建一个名为 [Diamond](https://github.com/josephg/diamond-types) 类型的 CRDT 实现。 Diamond 与 Yjs 几乎相同,但它在内部使用 [range tree](https://en.wikipedia.org/wiki/Range_tree) 而不是链表来存储所有项目。 413 | 414 | 在引擎下,我的 range tree 只是一个稍微修改过的 b-tree。 但通常当人们谈论 b-trees 时,他们指的是 [BTreeMap](https://doc.rust-lang.org/std/collections/struct.BTreeMap.html) 。 那不是我在这里做的。 b-tree 的每个内部节点不存储键,而是存储该 item 的 children 中的字符总数(递归)。 因此我们可以通过字符位置查找文档中的任何 item,或者在 *log(n)* 时间内在文档中的任何位置插入或删除。 415 | 416 | 此示例显示了存储当前具有 1000 个字符的文档的树: 417 | 418 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdcd39e4058eb01f92e7e7/origin-url) 419 | 420 | > *这是一个range tree,对吧? 关于* [*wikipedia article on range trees*](https://en.wikipedia.org/wiki/Range_tree) *对我在这里所做的事情的描述非常薄弱。* 421 | 422 | 这解决了我们之前的线性扫描问题: 423 | 424 | 1. 当我们想在位置 200 处找到项目时,我们可以遍历树并向下遍历。 在上面的示例中,位置为 350 的项目必须在此处的中间叶节点中。 树非常整洁 - 我们可以在树中仅将 Martin 的编辑跟踪存储在 3 个级别中,这意味着在此基准测试中,我们可以在大约 3 次读取中从主内存中找到任何项目。 实际上,这些读取中的大部分已经在您的 CPU 缓存中。 425 | 1. 更新树也很快。 我们更新一个叶子,然后更新它的父级和它的父级的字符数,一直到根。 同样,经过 3 个左右的步骤后,我们就完成了。 比在 javascript 数组中打乱所有内容要好得多。 426 | 427 | 428 | 在这个测试中,我们从不合并来自远程对等方的编辑,但无论如何我也做得很快。 合并远程编辑时,我们还需要通过 ID 查找项目( *例如 ['seph', 100]* )。 Diamond 几乎没有索引可以通过 ID 搜索 b-tree。 不过,该代码路径并未在此处进行基准测试。 它很快,但现在你必须相信我的话。 429 | 430 | 我没有使用 Yjs 缓存最后一个编辑位置的技巧——至少现在还没有。 它可能会有所帮助。 我只是还没试过。 431 | 432 | Rust 使我们可以完全控制内存布局,因此我们可以将所有内容都紧密地打包。 与图中不同,我的 b-tree 中的每个叶节点都存储了一个包含 32 个条目的块,这些条目打包在内存中的固定大小数组中。 用这样的结构插入会导致一些 memcpy-ing,但是一点 memcpy 就可以了。 Memcpy 总是比我想象的要快 - CPU 每个时钟周期可以复制几个字节。 它不是对主内存查找的史诗般的追捕。 433 | 434 | 为什么是 32 个条目? 我用一堆不同的块大小运行了这个基准测试,32 个运行良好。 我不知道为什么结果是最好的。 435 | 436 | 说到快,到底有多快? 437 | 438 | 如果我们将 [此代码编译为 webassembly](https://github.com/josephg/diamond-js) 并像在其他测试中一样从 javascript 驱动它,我们现在可以在 193 毫秒内处理整个编辑跟踪。 这比 Yjs 快 5 倍。 尽管做了所有支持协作编辑的工作,但编辑原生 javascript 字符串的速度比我们的基线测试快了 3 倍! 439 | 440 | Javascript 和 WASM 现在是一个瓶颈。 如果我们跳过 javascript 并 [直接在 rust](https://github.com/josephg/diamond-types/blob/42a8bc8fb4d44671147ccaf341eee18d77b2d532/benches/yjs.rs) 中运行基准测试,我们可以在短短 56 毫秒内处理此编辑跟踪中的所有 260k 编辑。 这比我们开始使用 automerge 时快 5000 倍以上。 它每秒可以处理 460 万次操作。 441 | 442 | |Test|Time taken|RAM usage| 443 | |---|---|---| 444 | |automerge (v1.0.0-preview2)|291s|880 MB| 445 | |reference-crdts (automerge / Yjs)|31s|28 MB| 446 | |Yjs (v13.5.5)|0.97s|3.3 MB| 447 | |*Plain string edits in JS*|0.61s|0.1 MB| 448 | |**Diamond (wasm via nodejs)**|0.19s|???| 449 | |**Diamond (native)**|0.056s|1.1 MB| 450 | 451 | 452 | 性能如黄油般顺滑。 b-tree 不关心编辑发生的位置。 该系统在整个文档中速度一致。 Rust 不需要垃圾收集器来跟踪内存分配,因此没有神秘的 GC 峰值。 由于内存非常紧凑,因此处理整个数据集(全部 260 000 个)只会导致对 malloc 的 1394 次调用。 453 | 454 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdd013e4058eb01f92e7e8/origin-url) 455 | 456 | 噢真可惜。 它太快了,你几乎看不到它旁边的 yjs (fleexxxx)。 让我们放大一点,看看那条平线: 457 | 458 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdd03ae4058eb01f92e7e9/origin-url) 459 | 460 | 嗯,几乎是一条直线。 461 | 462 | 请记住,此图表显示的是慢速版本。 该图表由 javascript 生成,通过 WASM 调用 Rust。 如果我在本地运行这个基准测试,它又快了大约 4 倍。 463 | 464 | 为什么 WASM 比本地执行慢 4 倍? 对 WASM VM 的 javascript 调用真的那么慢吗? LLVM 是否更好地优化了原生 x86 代码? 还是 WASM 的内存边界检查会减慢它的速度? 我很好奇! 465 | 466 | ## 数组结构还是结构数组? 467 | 468 | 这个实现还有另一个小的、重要的变化——我不确定我是否喜欢它。 469 | 470 | 在 Rust 中,我实际上是在做这样的事情: 471 | 472 | ``` 473 | doc = { 474 | textContent: RopeyRope { 'hello' }, 475 | 476 | clients: ['seph', 'mike'], 477 | 478 | items: BTree {[ 479 | // Note: No string content! 480 | { len: 5, id: [0, 0], seq, parent: ROOT }, 481 | { len: -5, id: [1, 0], seq, parent: [0, 0] }, // negative len means the content was deleted 482 | ... 483 | ]}, 484 | } 485 | ``` 486 | 487 | 请注意,文档的文本内容不再存在于项目列表中。 现在它在一个单独的数据结构中。 我为此使用了一个名为 [Ropey](https://crates.io/crates/ropey) 的 Rust 库。 Ropey 实现了另一个 b 树来有效地管理文档的文本内容。 488 | 489 | 这不是普遍的胜利。 不幸的是,我们来到了令人不安的工程权衡之地: 490 | 491 | 1. Ropey 可以进行文本特定的字节打包。 所以使用ropey,我们使用更少的RAM。 492 | 1. 插入时,我们需要更新 2 个数据结构而不是 1 个。这使得一切都慢了两倍多,并且使 wasm 包的大小增加了一倍(60kb -> 120kb)。 493 | 1. 对于许多用例,无论如何我们最终都会将文档内容存储在其他地方。 例如,如果您将此 CRDT 与 VS Code 挂钩,则编辑器将始终保留该文档的副本。 因此,根本不需要将文档存储在我的 CRDT 结构中。 这种实现方法可以很容易地关闭那部分代码。 494 | 495 | 496 | 所以我仍然不确定我是否喜欢这种方法。 497 | 498 | 但无论如何,我的 CRDT 实现在这一点上是如此之快,以至于算法的大部分时间都花在了更新 ropey 中的文档内容上。 Ropey 本身需要 29 毫秒来处理此编辑跟踪。 如果我只是......关闭ropey会怎样? 这只小狗到底能跑多快? 499 | 500 | |Test|Time taken|RAM usage|Data structure| 501 | |---|---|---|---| 502 | |automerge (v1.0.0-preview2)|291s|880 MB|Naive tree| 503 | |reference-crdts (automerge / Yjs)|31s|28 MB|Array| 504 | |Yjs (v13.5.5)|0.97s|3.3 MB|Linked list| 505 | |*Plain string edits in JS*|0.61s|0.1 MB|*(none)*| 506 | |Diamond (wasm via nodejs)|0.20s|???|B-Tree| 507 | |Diamond (native)|0.056s|1.1 MB|B-Tree| 508 | |*Ropey (rust) baseline*|0.029s|0.2 MB|*(none)*| 509 | |**Diamond (native, no doc content)**|0.023s|0.96 MB|B-Tree| 510 | 511 | 512 | Boom。 这虽然有点没用,但现在比 automerge 快 14000 倍。 我们在 23 毫秒内处理了 26 万次操作。 那是每秒 1100 万次操作。 我可以通过按键使我的家庭互联网连接饱和,而且我还有 CPU 可用。 513 | 514 | --- 515 | 516 | 517 | 我们可以计算每个算法处理编辑的平均速度: 518 | 519 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdd28de4058eb01f92e7ea/origin-url) 520 | 521 | 但这些数字具有误导性。 请记住,automerge 和 ref-crdts 并不稳定。 它们一开始很快,然后随着文档的增长而变慢。 尽管 automerge 平均每秒可以处理大约 900 次编辑(这速度快到用户不会注意到),但在此基准测试期间最慢的编辑使 V8 停滞了整整 1.8 秒。 522 | 523 | 如果我使用对数刻度,我们可以将所有内容放在一个漂亮的图表中。 这看起来非常整洁: 524 | 525 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdd2f8e4058eb01f92e7eb/origin-url) 526 | 527 | 呵呵 - 看看底部的两行。 yjs 和 diamond 的抖动相互映衬。 yjs 变慢的时期,diamond 变快。 我想知道那里发生了什么! 528 | 529 | 但对你的直觉来说,对数可读是垃圾食品。 在线性尺度上,数据如下所示: 530 | 531 | ![image.png](https://atlas-rc.pingcode.com/files/public/61cdd368e4058eb01f92e7ec/origin-url) 532 | 533 | 我的朋友们,这就是让计算机少做很多工作的方法。 534 | 535 | 536 | 537 | ## 结论 538 | 539 | 多年前我读过的那篇愚蠢的学术论文说一些 CRDTs 和 OT 算法很慢。 每个人都相信这篇论文,因为它是已发表的科学。 但是论文是错误的。 正如我所展示的,我们 *可以* 使 CRDsT 变快。 如果我们对我们的实施策略发挥创意,我们 *可以* 让他们快的疯狂。 通过正确的方法,我们可以使 CRDT 变得如此之快,以至于我们可以与原生字符串的性能竞争。 那篇论文中的表现数字不仅仅是错误的。 他们是“一位亿万富翁,猜测一根香蕉值 1000 美元”,这有点错误。 540 | 541 | 但你知道吗? 我现在有点欣赏那篇论文。 他们的错误是可以的。 这是人类。 我曾经在学术上感到不足 - 也许我永远不会那么聪明! 但这整件事让我意识到一件显而易见的事情:科学家不是神,从天而降,带着真理的礼物。 不,他们是美丽的、有缺陷的人,就像我们其他人一样。 擅长我们所痴迷的一切,但在其他任何地方都处于中等水平。 我可以很好地优化代码,但我仍然把西葫芦和黄瓜搞混了。 而且,无论我从朋友那里得到什么戏弄,那都可以。 542 | 543 | 十年前,Google Wave 确实需要一个高质量的列表 CRDT。 当 CRDT 的论文开始出现时,我感到非常兴奋。 [LOGOOT](https://hal.inria.fr/inria-00432368/document) 和 [WOOT](https://hal.inria.fr/inria-00445975/document) 看起来很重要! 但是当我意识到算法太慢且效率低下而无法实际使用时,那种兴奋就消失了。 我犯了一个大错误——我认为如果学者们不能让他们快速完成,那么没有人能做到。 544 | 545 | 但有时最好的作品来自具有不同技能的人之间的合作。 我不擅长学术论文,我很擅长让代码运行得很快。 然而,在我自己的领域,我甚至没有尝试提供帮助。 研究人员正在尽自己的一份力量使 P2P 协作编辑工作。 我只是对他们嗤之以鼻,继续致力于运营转型。 如果我提供帮助,也许十年前我们会有快速、可行的 CRDT 用于文本编辑。 哎呀! 结果证明协作编辑需要我们所有人的协作。 真讽刺! 谁能猜到?! 546 | 547 | 嗯,这花了十年时间,来自一群聪明人的一些辛勤工作和一些伟大的想法。 Martin 为 Automerge 发明的二进制编码系统非常出色。 通过使用递增(agent id、sequence)元组来避免 UUID 的系统是天才。 我不知道是谁提出的,但我喜欢它。 当然,我在此描述的 Kevin 的列表表示 + 插入方法使一切变得更快、更简单。 我敢打赌,在过去十年中,一定有 100 名聪明人在没有任何人提到的情况下从这个想法中走出来。 我怀疑我也不会想到它。 我的贡献是使用运行长度编码的 b-trees 和巧妙的索引。 并且展示 Kevin 的快速列表表示可以适用于任何 CRDT 算法。 我认为之前没有人注意到这一点。 548 | 549 | 现在,经过十年的等待,我们终于找到了如何制作快速、轻量级的列表 CRDT 实现。 实用的去中心化实时协同编辑? 我们下一次来找你。 550 | 551 | ## 附录 A: 我想为我的应用程序使用 CRDT。 我该怎么办? 552 | 553 | 如果您现在正在构建基于文档的协作应用程序,则应该使用 Yjs。 Yjs 具有稳定的性能、低内存使用和强大的支持。 如果您需要帮助在您的应用程序中实现 Yjs,Kevin Jahns 有时会接受金钱以换取帮助将 Yjs 集成到各种应用程序中。 他用它来资助全职从事 Yjs(和相关)的工作。 Yjs 已经运行得很快,很快它就会变得更快。 554 | 555 | automerge 团队也很棒。 我和他们就这些问题进行了一些很好的对话。 他们将性能作为 2021 年的第一期,并计划使用许多这些技巧来加快自动合并。 当您阅读本文时,它可能已经快得多了。 556 | 557 | Diamond 真的很快,但在我与 Yjs 和 Automerge 具有同等功能之前还有很多工作要做。 一个好的 CRDT 库除了操作速度之外还有很多。 CRDT 库还需要支持二进制编码、网络协议、非列表数据结构、presence(光标位置)、编辑器绑定等。 在撰写本文时,Diamon 几乎没有做这些。 558 | 559 | 如果你想要数据库语义而不是文档语义,据我所知,还没有人在 CRDT 之上做得很好。 您可以使用使用 OT 的 [ShareDB](https://github.com/share/sharedb/) 。 我多年前编写了 ShareDB,它使用良好、维护良好并经过实战测试。 560 | 561 | 展望未来,我对 [Redwood](https://github.com/redwood/redwood) 感到兴奋——它支持 P2P 编辑并计划全面支持 CRDT。 562 | 563 | ## 附录 B:谎言、该死的谎言和基准 564 | 565 | 这是真的吗? 是的。 但是性能很复杂,我不会在这里讨论全貌。 566 | 567 | 首先,如果你想使用我自己运行的任何基准测试,你可以。 但一切都有些混乱。 568 | 569 | JS 纯字符串编辑基线、Yjs、automerge 和 reference-crdts 测试的基准代码都在这个 [github gist](https://gist.github.com/josephg/13efc1444660c07870fcbd0b3e917638) 中。 一团糟; 但凌乱的代码总比缺少代码好。 570 | 571 | 您还需要 [josephg/crdt-benchmarks](https://github.com/josephg/crdt-benchmarks) 中的 automerge-paper.json.gz 来运行大多数这些测试。 在这个版本中,reference-crdts benchmark依赖于 [josephg/reference-crdts 中的 crdts.ts](https://github.com/josephg/reference-crdts/tree/fed747255df9d457e11f36575de555b39f07e909) 。 572 | 573 | Diamond's benchmarks 来自  [josephg/diamond-types, at this version](https://github.com/josephg/diamond-types/tree/42a8bc8fb4d44671147ccaf341eee18d77b2d532) . Benchmark 靠运行  `RUSTFLAGS='-C target-cpu=native' cargo criterion yjs` . 可以通过编辑 [src/list/doc.rs](https://github.com/josephg/diamond-types/blob/42a8bc8fb4d44671147ccaf341eee18d77b2d532/src/list/doc.rs#L15) 顶部的常量来启用或禁用内联绳索结构更新。. 您可以通过运行  `cargo run --release --features memusage --example stats` 查看内存统计信息. 574 | 575 | Diamond 使用此 [this wrapper](https://github.com/josephg/diamond-js/tree/6e8a95670b651c0aaa7701a1a763778d3a486b0c) 编译为 wasm,硬编码以指向来自 git 的 diamond-types 的本地副本。 wasm 包使用 wasm-opt 进行了优化。 576 | 577 | 这些图表是在 [ObservableHQ](https://observablehq.com/@josephg/crdt-algorithm-performance-benchmarks) 上制作的。 578 | 579 | ### Automerge 和 Yjs 做同样的事情吗? 580 | 581 | 在这篇文章中,我一直在比较 RGA(automerge)和 YATA(Yjs + 我的 Rust 实现)的实现性能。 582 | 583 | 这样做的前提是假设 YATA 和 RGA 的并发合并行为基本相同,并且您可以在不更改实现或实现性能的情况下在 CRDT 行为之间进行交换。 这是一个我认为以前没有人看过的新颖想法。 584 | 585 | 我对这个声明充满信心,因为我在我的 [参考 CRDT 实现](https://github.com/josephg/reference-crdts) 中展示了它,当使用 Yjs 或 automerge 的行为时,它具有相同的性能(和几乎相同的代码路径)。 冲突严重的编辑跟踪可能存在一些性能差异 - 但在实践中这种情况极为罕见。 586 | 587 | 我也相信您可以修改 Yjs 以实现 RGA 的行为,而无需更改 Yjs 的性能。 您只需要: 588 | 589 | 1. 更改 Yjs 的 integrate 方法(或进行替代),该方法对并发编辑使用略有不同的逻辑 590 | 1. 在每个项目中存储 seq 而不是 originRight 591 | 1. 将 maxSeq 存储在文档中,并使其保持最新和 592 | 1. 更改 Yjs 的二进制编码格式。 593 | 594 | 595 | 我和 Kevin 讨论过这个问题,他认为在他的库中添加 RGA 支持没有任何意义。 这不是任何人真正要求的。 在预置项目时,RGA 可能会有奇怪的 [交错](https://www.cl.cam.ac.uk/~arb33/papers/KleppmannEtAl-InterleavingAnomalies-PaPoC2019.pdf) 。 596 | 597 | 对于 Diamond ,我让我的代码接受一个类型参数,用于在 Yjs 和 automerge 的行为之间切换。 我不确定我是否愿意。 Kevin 可能是对的 - 我不认为这是人们想要的。 598 | 599 | --- 600 | 601 | 602 | 嗯,Yjs 有一种方式比 automerge 具有明显的优势: *当* 文档中的每个项目都被删除时,Yjs 不会记录。 仅记录每个项目是否已被删除。 这有一些奇怪的含义: 603 | 604 | 1. 在每次删除发生时进行存储对内存使用和磁盘存储大小有很大的影响。 添加此数据后,diamond 的内存使用量从 1.12mb 增加到 2.34mb,并使系统速度降低约 5%。 605 | 1. Yjs 没有存储足够的信息来实现按键编辑重播或其他类似的东西。 (也许这就是人们想要的?记录每个错误的击键是否很奇怪?) 606 | 1. Yjs 需要将有关哪些Item已被删除的信息编码到版本字段中。 在 diamond 中,版本是几十个字节。 在 yjs 中,版本是 ~4kb。 随着文档的增长,它们会随着时间的推移而增长。 Kevin 向我保证,这些信息在实践中基本上总是很小的。 他可能是对的,但这仍然让我感到奇怪的紧张。 607 | 608 | 609 | 目前,Diamond 的主分支包括时间删除。 但是这篇博文中的所有基准测试都使用 [yjs 风格的 diamond 类型分支](https://github.com/josephg/diamond-types/tree/yjs-style) ,它与 Yjs 的工作方式相匹配。 这可以与 yjs 进行更公平的比较,但 Diamond 1.0 的性能配置可能略有不同。 (这里有很多关于 diamond 尚未打磨的的双关语,但我现在还不够敏锐。) 610 | 611 | ### 这些基准衡量错误的东西 612 | 613 | 这篇文章只测量了重放本地编辑跟踪所需的时间。 我正在测量由此产生的 RAM 使用量。 可以说,接受来自用户的传入更改只需要 *足够* 快地发生。 手指根本不会打字很快。 一旦 CRDT 可以在大约 1 毫秒内处理任何本地用户编辑,速度更快可能无关紧要。 (并且自动合并通常已经很好地执行了,除非一些不幸的 GC 暂停。) 614 | 615 | 实际上重要的的指标是: 616 | 617 | 1. 文件在磁盘或网络上占用多少字节 618 | 1. 文档保存和加载需要多长时间 619 | 1. 更新静态存储的文档(in database)需要多长时间(更多内容见下文) 620 | 621 | 622 | 我在这里使用的编辑跟踪也只有一个用户进行编辑。 当用户进行并发编辑时,阴影中可能潜伏着病态的性能案例。 623 | 624 | 我这样做是因为我还没有在我的 reference-crdts 实现或钻石中实现二进制格式。 如果我这样做了,我可能会复制 Yjs & automerge 的二进制格式,因为它们非常紧凑。 所以我希望所有这些实现之间得到的二进制大小是相似的,除了删除操作。 加载和保存的性能可能大致反映我上面显示的基准。 或许。 或者也许我错了。 我以前错了。 找出答案会很有趣。 625 | 626 | --- 627 | 628 | 629 | 我认为目前还没有人认真对待另一项绩效衡量标准。 也就是说,我们如何更新静态文档(在数据库中)。 大多数应用程序都不是协作文本编辑器。 通常,应用程序实际上是在与充满微小对象的数据库进行交互。 这些对象中的每一个都很少被写入。 630 | 631 | 如果您想使用 Yjs 或 automerge 今天更新数据库中的单个对象,您需要: 632 | 633 | 1. 将整个文档加载到 RAM 中 634 | 1. 做出改变 635 | 1. 再次将整个文档保存回磁盘 636 | 637 | 638 | 这将非常缓慢。 对此有更好的方法 - 但据我所知,根本没有人在做这件事。 我们可以使用你的帮助! 639 | 640 | > Kevin 说你可以调整 Yjs 的提供者以合理的方式实现这一点。 我很想看到它在行动。 641 | 642 | --- 643 | 644 | 645 | 还有另一种快速制作 CRDT 的方法,我在这里根本没有提到,那就是 *​pruning(修剪)* 。 默认情况下,像这样的列表 CRDT 只会随着时间的推移而增长(因为我们必须为所有已删除的 items 保留 tombstones )。 CRDT 的很多性能和内存成本来自加载、存储和搜索不断增长的数据集。 有一些方法可以通过找到完全摆脱某些数据的方法来解决这个问题。 比如Yjs的GC算法,或者 [Antimatter](https://braid.org/antimatter) 。 也就是说,git 存储库只会随着时间的推移而增长,而且似乎没有人会介意太多。 也许只要底层系统足够快就无所谓了? 646 | 647 | ### 这个旅程的每一步都改变了太多的变数 648 | 649 | 优化过程中的每一步都涉及对多个变量的更改,我不会孤立这些更改。 例如,从 automerge 转移到我的 reference-crdts 实现发生了变化: 650 | 651 | 1. 核心数据结构(tree 到 list) 652 | 1. 删除了 immutablejs 653 | 1. 删除了 automerge 的前端/后端协议。 显然,无论出于何种原因,在整个自动合并过程中弹出的所有 Uint8Array 都消失了。 654 | 1. javascript 风格完全不同。 (FP javascript -> imperative) 655 | 656 | 657 | 我们从这一切中获得了 10 倍的性能。 但我只是在猜测 10 倍的加速应该如何在所有这些变化中分配。 658 | 659 | 从 reference-crdts 到 Yjs,从 Yjs 到 Diamond 的跳跃同样是单一的。 Diamond 和 Yjs 之间的速度差异有多少与内存布局无关,而与 LLVM 的优化器有关? 660 | 661 | automerge-rs 并不比 automerge 快这一事实让我相信 Diamond 的性能不仅仅归功于 Rust。 但老实说我不确认。 662 | 663 | 所以,是的。 这是对我的方法的合理性持批判的态度。 如果这个问题困扰着您,我希望有人能够分解我在此处展示的实现之间的每个性能差异,并梳理出更详细的细分。 我会把它读出来的。 我喜欢基准化分析这些故事。 这很正常,对吧? 664 | 665 | ## 附录 C: 我还是不明白——为什么 automerge 的 javascript 这么慢? 666 | 667 | 因为它并没有试图变得快。 查看 [automerge](https://github.com/automerge/automerge/blob/d2e7ca2e141de0a72f540ddd738907bcde234183/backend/op_set.js#L649-L659) 中的这段代码: 668 | 669 | ``` 670 | function lamportCompare(op1, op2) { 671 | return opIdCompare(op1.get('opId'), op2.get('opId')) 672 | } 673 | 674 | function insertionsAfter(opSet, objectId, parentId, childId) { 675 | let childKey = null 676 | if (childId) childKey = Map({opId: childId}) 677 | 678 | return opSet 679 | .getIn(['byObject', objectId, '_following', parentId], List()) 680 | .filter(op => op.get('insert') && (!childKey || lamportCompare(op, childKey) < 0)) 681 | .sort(lamportCompare) 682 | .reverse() // descending order 683 | .map(op => op.get('opId')) 684 | } 685 | ``` 686 | 687 | 这在每次插入时调用,以确定应如何对项目的子项进行排序。 我不知道它多热(译者:可能是比喻说CUP 产生的温度),但有 *很多关于这个的事情* 很慢: 688 | 689 | 1. 我可以在这个函数中发现 7 处内存分配。 (应该提升 2 闭包)。 (你能全部找到吗?) 690 | 1. 在调用此方法之前,项目已经排序为 reverse-lamportCompare。 对反排序列表进行排序是对任何内容进行排序的最慢方法。 这个代码应该只反转 lamportCompare 中的参数(或否定返回值),而不是 sorting ,然后 reverse()'ing 。 691 | 1. 目标是将新项目插入到已排序的列表中。 使用 for 循环可以更快地做到这一点。 692 | 1. 这段代码将 childId 包装到一个 immutablejs Map 中,以便参数匹配 lamportCompare - 然后再次解开它。 停下——我要死了! 693 | 694 | 695 | 但在实践中,这段代码将被 WASM 调用替换为 automerge-rs。 也许在您阅读本文时它已经被 [automerge-rs](https://github.com/automerge/automerge-rs) 取代了! 所以没关系。 尽量不要去想它。 绝对不要提交任何 PR 来解决所有悬而未决的问题。 *抽搐* 。 696 | 697 | ## 致谢 698 | 699 | 这篇文章是 [Braid 项目](https://braid.org/) 的一部分,由 [Invisible College](https://invisible.college/) 资助。 如果这是您想为之做出贡献的工作,请与我们联系。 我们正在招聘。 700 | 701 | 感谢在此帖子上线之前提供反馈的所有人。 702 | 703 | 特别感谢 Martin Kleppmann 和 Kevin Jahns 在 Automerge 和 Yjs 方面所做的工作。 Diamond 站在巨人的肩膀上。 --------------------------------------------------------------------------------