我们构建的高性能CRDTs框架现在开源啦,Github地址。
在这篇文章中,我们会描述我们对于 local-first (本地优先)软件开发范式的愿景,解释为什么我们对 local-first 软件如此有热情,并且向大家分享 Loro 当前的状态。
With better DevTools, documentation, and a friendly ecosystem, everyone can easily build local-first software.
你可以使用 Loro 轻松构建具有时间旅行(time travel)功能的协作应用软件。在线体验: Online Demo
畅想本地优先的开发范式
分布式状态在众多场景中普遍存在,如多人游戏、多设备文档同步、边缘网络节点的信息缓存等,这些场景都需要同步操作以实现一致性。这通常需要繁琐的设计和编码,例如你可能需要考虑网络出问题怎么办,如果有并行的写操作怎么处理。然而,对于那些广泛应用场景,通过 CRDTs 可以让代码变得非常简单:
- CRDTs 能够自动合并并行的写入,不存在冲突
- 你不需要针对具体需求设计后端数据库的 Schema,不用手动完成符合预期的冲突合并,不用实现接口到内存,内存到持久化结构的转换
- 支持离线,开箱即用
因为数据就在本地,所以客户端程序就可以直接获取和操作本地数据,速度快还高可用。并且因为CRDTs的特性,你还可以不依赖中心化服务器(就像 Git,你随时可以迁移到其他平台而没有数据损失)就能完成同步。它同时也解决了在实时协作场景下面临的并行冲突合并的问题,因为性能上的进步,它在越来越多的场景下开始替代以往的实时协作解决方案。
这是一种新的范式。本地优先不仅仅让用户掌控自己的数据,也能让开发者更轻松。
GitHub 上"本地优先"话题的star数量年增长率已达到 40%+。
CRDTs 与 UI 状态管理的配合
Loro的富文本协作示例
因为 CRDTs 能够无冲突自动合并,使用 CRDTs 时的数据同步问题就可以被转换为『如何在 CRDTs 上表达操作和状态』的问题。
而前端的状态管理库上往往就需要定义 State 的获取 + Actions 的定义,以下是来自 Vue 的状态管理工具 Pinia 的一个例子:
ts
export const useCartStore = defineStore({
id: "cart",
state: () => ({ rawItems: [] as string[] }),
getters: {
items: (state): Array<{ name: string; amount: number }> =>
state.rawItems.reduce((items, item) => {
const existingItem = items.find((it) => it.name === item);
if (!existingItem) {
items.push({ name: item, amount: 1 });
} else {
existingItem.amount++;
}
return items;
}, [] as Array<{ name: string; amount: number }>),
},
actions: {
addItem(name: string) {
this.rawItems.push(name);
},
removeItem(name: string) {
const i = this.rawItems.lastIndexOf(name);
if (i > -1) this.rawItems.splice(i, 1);
},
async purchaseItems() {
const user = useUserStore();
if (!user.name) return;
console.log("Purchasing", this.items);
const n = this.items.length;
this.rawItems = [];
return n;
},
},
});
这种范式和 CRDTs 是能轻松匹配上的:状态管理库的 State 对应 CRDT types,Action 对应一组针对这些 CRDTs 类型的 Operations。
所以通过 CRDTs 实现 UI 状态管理,用户不需要改变自己以往的习惯。它还能具有很多高级功能:
- 把状态变成可自动同步 / 支持实时协作的
- 像 Git 一样保存了完整分布式的编辑历史的时光机
- 它可以以较低的内存占用和紧凑的编码大小存储大量的编辑历史
通过它,你能轻松实现实时 / 异步协作 + 时光机的产品,下图是通过 Loro 构建的 Example:
使用 Loro 对包含 360,000 多次操作的文档进行时间旅行。加载整个历史记录和回放,只需要 8.4MB 内存。整个历史仅占用 361KB 的存储空间。编辑历史记录来自 josephg/editing-traces。
Loro 介绍
Loro 是我们在研发的 CRDTs 框架,最近在 Permissive License 下开源了。我们相信互助友善的开源社区是打造杰出开发者体验的关键。
我们希望能让 Loro 简单易用,可扩展,并维持高性能。我们将围绕它打造实验性的开发者工具,逐步落实我们的上述愿景。以下是我们目前所完成的一些工作。
CRDTs
我们做了很多探索性的设计,支持了一系列尚未被广泛使用的 CRDTs 算法。
OT-like CRDTs
我们使用了来自 Seph Gentle 的 Diamond-type 上的 OT-like CRDT 的思路去构建我们的 CRDTs 库。Seph Gentle 正在写此算法的论文,大家可以期待。它的优秀特性包括让本地操作的代价变得极低,历史数据更容易被回收,以及存储和内存的开销有时可以更低等等,但它依赖高性能的 Diff 算法来让应用远端操作的速度够快。围绕这个设计还有很多可以探索的工作,我们对这个未来充满信心。
Rich Text CRDTs
在今年五月份我们开源了 crdt-richtext 的项目,它集成了 Peritext 和 Fugue 的算法。关于这两个算法的简介可以参考当时的博客。
在现在的 Loro 中,我们基于之前项目的经验,将富文本 CRDT 和 Fugue 集成到了我们的框架中。但这其中最大的难点在于 Peritext 不能很好地和 OT-like CRDTs 配合。我们在近期终于解决了这个难题。我们发明了一种新的富文本 CRDT 算法,它能够运行在 OT-like CRDTs 上,并且通过了 Peritext 论文的 Criteria 上列出的富文本 CRDT 应具备的能力,在我们目前百万个 Fuzzing tests 上没有暴露出新问题。它的性能也很好,每秒可以支持百万次操作。我们未来会写文章来专门介绍该算法。
Movable Tree
我们也支持了 Movable Tree。在类似文件夹移动的场景上数据同步往往很复杂,因为可能会出现循环引用。在 CRDTs 所要支持的分布式的环境中解决该问题更是麻烦。
我们实现了 Martin Kleppmann 的论文 A Highly-Available Move Operation for Replicated Trees。该算法的思路是把所有移动操作排序,保证各端对它排序一致,之后逐个 apply 其中的操作,如果有操作会引起循环引用它就不生效。
我们认为这个算法设计优雅并且性能优异。本地操作是需要 O(k) 的代价(k 为平均树深度,因为要检测循环引用);如果要应用远端操作,即需要在排好序的操作中插入新操作,那就需要 undo 掉排序在它之后的操作,再应用远端操作,再把刚刚 undo 的操作 redo 回来,代价是 O(km) (m 为需要 undo 的操作数量)。
应用远端操作的过程可视化
我们的测试下本地操作中一千个节点一万次随机移动也只需要不到 10ms (M2 MAX 芯片上的测试),并且这个算法在合并远端操作的代价上和 OT-like CRDT 应用远端操作的代价是类似的,从而完全可以接受。关于 Tree 之前我也有尝试过通过log-spaced snapshots 以及 Immutable 数据结构的方式来实现的实验,最终结论是 Undo + Redo 的方式是最快且省空间的。
数据结构
数据结构的设计和实验是 Loro 研发过程中的日常。
我们之前开源了 generic-btree,我们对它的结构也进行了重新的设计,让它内存布局更紧凑且更 Cache-Friendly。除了不错的性能,它也有很强的灵活性,它能让我们用不多的代码轻松支持 Text 上需要的 utf16/unicode code point/utf8 的多种信息。并且我们在多处复用它来表达各种不同的具体需求。这里要大赞 Rust 的类型表达能力。
我们在内部架构上把文档的状态和文档的历史区分开了。状态就是文档当前的样子,就像 Git 的 HEAD 指针,而文档的历史就类似于 Git 背后的完整操作历史。从而对应同一个历史,可以有多种不同的文档状态。这种结构让我们代码变得更简单,并且让未来支持版本管理也更方便。
我们也采用了类似 Yjs 和 Diamond-type 的合并 Op 的优化,让连续的编辑操作能够被轻松合并。
我们此前大多数优化主要针对 Text 场景进行,因为这是长期被认为是 CRDTs 中最棘手的问题之一。在未来我们会针对更丰富的真实场景来进行更全面的优化。
The Future
我们预计在明年年中做到 1.0 版本,在这个过程中我们有很多工作要完成。
因为我们的人手和精力很有限,所以我们会首先提供 WASM 接口,让 Web 开发者可以首先尝试用起来。WASM 体积优化是这个阶段的目标之一。同时很多设计工作还在进行中,希望能在下个季度将它稳定化,我们希望能把它封装得简单易懂且灵活强大。如果你有想法或建议欢迎加入我们的社区进行讨论。
我们还有很多待进行的文档工作来实现『每个人都能轻松构建本地优先软件』的目标。但一个可能不错的指标是基于我们的文档让 GPT 能够生成足够好的代码。
开发者工具是一个很有挑战且很有意思的课题。我相信基于现在的 CRDTs 和开发者生态能够构建出强大且易用的开发者工具。前端中有很多开发者工具和可视化手段做得极为优秀,我们希望能把这种体验带到 CRDTs 中。它能让隐藏在 CRDTs 接口下的状态变得一目了然,让状态维护和 Debug 变得轻松简单。
更丰富的 CRDTs 语义。我们还打算支持 Movable List,以及全局通用的 Undo/Redo 操作。希望能在维持当前性能的情况下添加上这些能力来支持更丰富的应用场景。
寻找协作项目中
我们的设计和优化需要真实的项目的反馈,如果你也为本地优先的未来感到激动,如果 Loro 能够帮助到你,欢迎直接联系我们 zx@loro.dev ,我们很乐于合作并提供帮助。