[程序员] CRDTs Are Not Enough: 从 CRDT 到本地优先同步引擎

发布时间 2026-09-22 9 天前
来源 V2EX
字数 6,898 字
查看原文

补充自 2026年09月22日

AI智能总结

周日举行了首场本地优先线上会议「CRDTs Are Not Enough:从 CRDT 到本地优先同步引擎」。分享者陈子轩来自 Loro 团队,自 2022 年起开发 loro.dev CRDT 库,并于 2025 年推出本地优先团队 AI agent 控制面 lody.ai。

周日(昨天)本地优先会议,举行了第一场活动「CRDTs Are Not Enough:从 CRDT 到本地优先同步引擎」。

活动的形式是线上(能力有限)。模式是社区开源沙龙,即自由发起活动,社区做支持。工具是 bd.ailishi.ai。

活动逐字稿第一时间发布在:

欧洲的本地优先大会已经是第三届,许多内容很有趣,这得益于他们的历史与生态。欧洲的 Hacker 文化原本就健壮,有较成熟的技术知识流动体系。结合技术,讨论工程、哲学等诸多问题,让新人逐渐发掘工程的乐趣与意义。这对一个新兴技术栈来说更友好。

本会议发起的灵感来自欧洲的会议。我们邀请了一些该大会的分享者。但不认识他们的组织者(如果有人能帮忙认识,非常感谢)。

本地优先是一个新技术栈,但建立在一堆老理论上。本地优先是分布式系统的分支,并且更像是分布式系统领域成熟后的副产品。这一副产品被越来越多人尝试与喜欢,离不开今天的现实——越来越多人质疑云的害处大于益处。

而它的「新」则体现在:社区中有关其想象、定义、协议,能有大量多样的辩论,各执一词,谁也无法说服谁。这反倒让人兴奋,这意味着其中的讨论交流、尝试犯错,本身也在定义与发展本地优先相关的技术。

进微信群,微信在这里 Firstfediverse

:)

前沿

这是第一届本地优先会议。现在活动标题都取得太正式,但目的是为抛砖引玉。不只是日历上的活动,在场的大家一定都有自己擅长的与热爱的领域,值得分享给大家。本地优先领域,如果去看学界论文或者一些工程实践,这几年它的演进速度非常快,并且 agent 加速了这些东西,以后应该还会继续加速。所以,有没有可能出现一种新的计算栈,土计算?我不知道,最近看群聊,大家的想象都很不一样。

今天分享者 Loro 的陈子轩,在华人里可能是最有发言权的团队之一。也许都不止华人,我觉得是世界。他们团队对工程、各种设计的细节都很棒。如果有本地优先或者 CRDT 的问题,寻求他的想法,好一百倍。所以先表达对分享者的尊重,然后我们就开始吧。

分享内容

谢谢。今天我讲一下「CRDTs are not enough」,也就是:如果要做一个本地优先的同步引擎,现在还缺什么东西。

先简单自我介绍一下,我叫陈子轩,和 Leon 从 2022 年开始做 loro.dev,它是一个 CRDT library。到 2025 年,我们开始基于 Loro 做 lody.ai,它是一个 local-first 的团队 AI agent 控制面。通过 Lody,我们从真实的产品需求、从用户 UI/UX 的打磨出发,挖掘本地优先同步引擎现在该有的形态。中间我们发现了很多现在还缺失的东西,今天就分享一下我们的经验。

首先,什么是本地优先软件?

简单说几个点:用户控制数据;可以自由切换不同的云端,作为数据备份。云端是一个备份和辅助者,而不是权威所有者。在这个架构里,云端只是一个备份和额外的副本。local-first 和 local-only 不一样:local-only 是只能在本地做这些事情,而 local-first 仍然希望保持多设备同步的便利性。

它给用户承诺五种体验:能秒开,因为数据读写都在本地;离线可用;多端协作;后端可以切换,不会因为软件服务提供商关门就影响使用;数据归我所有。这里面,一方面是软件服务提供商不能把我阻拦在使用软件之外;另一方面是隐私,我的数据不能被服务提供商随意读取。

最经典的例子是 Git。虽然 Git 还没有做隐私这部分,但它已经非常接近本地优先软件理想中的形态。首先,它以协议的形式定义好本地和远端怎样交换数据。你可以切换不同的后端:GitHub 挂了,或者不想用 GitHub 了,可以切换 origin,改用 GitLab,或者自己 self-host。

local-first 和 local-only 是不一样的。我看到群里挺多人会把两者混在一起。但现在其实缺一个大众级的应用,来完整交付这样的 local-first 体验。很多应用缺少云端的便利性,本来是 local-only,却称自己为 local-first;实际上没有提供云端便利,也没有提供 P2P 同步等选项。

我们和 local-first 社区对同步引擎的想象是什么样的?

可以引用今年 Martin Kleppmann 在 Local-first Conference 上的分享:对于开发者来说,不用再担心请求失败、超时、数据是否到达服务器、要怎样告诉用户——这些复杂问题全部交给同步引擎就好了。但其实我们现在还没有这样的同步引擎。

大家希望一个统一、通用的同步引擎吸纳这些事情:请求失败怎么处理,数据没到服务端怎么处理,前端怎么告知用户,怎么管理权限,让用户能不能编辑这些事情都符合直觉、显而易见。还有,本地和后端怎样持久化,怎样切换不同的服务提供商。

从最终用户的角度,本地优先带来最直接的好处,一个是快,一个是安全。这样的同步引擎能够给终端用户带来这两种好处。

但从理论上来说,它绝对不可能是万能的。有一大类场景它解决不了,就是强一致性的场景。比如抢票、库存管理、支付、会议室抢占式预约,这些都是本地优先同步引擎从原理上没有办法处理的问题。因为这一系列问题需要强一致性,而本地优先的同步方式可以处理好最终一致性的场景。

这类场景很多时候就是创作,比如代码、文档、视频剪辑、音乐创作、各种艺术创作。这一大类场景是这类同步引擎可以统一解决的。

背后的必要复杂度,要从应用开发者转移到同步引擎上。

包括本地怎么存、超时怎么办、怎么告诉用户、要提供哪些事件、怎样表达背后的权限、怎样切换后端、怎样提供 P2P、怎样同时提供端到端加密。这些都变成了同步引擎的职责。

现在 CRDT 库已经提供了这些能力:Loro、Yjs、Automerge 都能像 JSON 一样建模结构化文档,并提供最终一致性。只要各端最终拿到的更新集合一致,最终状态就一定一致。它们也提供增量同步;对文本、富文本、列表等类型,合并结果会尽可能符合预期。这里有比较多的相关论文,成果也被吸纳到这些 CRDT library 里了。更新同步时的乱序、重复等事情,也都被覆盖了。你不用关心一个更新在什么时间到达,可以安全地重发更新,它不会被应用两次。但这些并没有解决刚才说到的所有问题。这些 library 本质上是比较纯的计算,不覆盖网络层、持久化层等涉及 I/O 的部分。这些却都是同步引擎需要做好的事情。

比如,P2P 环境下的实践应该怎么做?这里还缺少很多东西,现在没有答案。同步协议怎么设计?目前也比较百花齐放。UX 怎么设计和交付,尤其在去中心化环境里,怎样让它更符合用户直觉?还有,在去中心化环境下怎么做鉴权,相应的 UI/UX 怎么呈现?

我们在 Lody 落地的过程中一步步反推,积累了一些经验,简单分享一下,作为抛砖引玉。

这个项目现在应该有七千多个 commit。

Lody 是一个 agent 控制面,需要同步各种对话信息,未来还会有更多文档信息。这里面相当于有几千篇文档,要同步它们的数据:怎么做懒加载?怎么同步整个 repo?这是我们遇到的第一个问题。你不可能一打开应用就全量加载所有文档内容。但 Loro、Yjs、Automerge 这三个 CRDT library 本身只提供文档级同步能力。要做整个 repo 的同步,经常需要组合别的方式。

我们研发了一个新的 CRDT,叫 Flock,未来也会开源。它相当于同步一份目录。比如打开 Notion,你先看到有哪些文档;或者打开 Notion database,先看到所有的 row 及其 metadata。点开一篇具体文档之后,才拉取对应的内容。Flock 就是负责背后目录同步这一部分的 CRDT。我们用这个 library 同步 Lody 内所有对话的标题等需要首先看到的信息。

Flock 做过很多次演进。最开始用比较简单的 JSON 格式,很快就撑不住了;后来换成 V1 二进制格式,但设计太复杂,容易出错。再后来切换到列式的 V2 数据格式。V1 有点受 SQLite 原地更新存储方式的启发,V2 则改成另一种受数据库设计启发的文件格式。它的性能和稳定性比之前高很多,整个切换过程对用户没有感知。这是 Flock。

接着,本地怎么存、在哪里查,也都变成了新问题。我们最开始尝试在 Web 上接 SQLite。现在有一些 OPFS 之类的实践,包括 Notion 也是这样做的。但很快发现这条路径复杂,很难走通。浏览器每个 tab 都是独立的,访问 SQLite 时,SQLite 本身只能有一个 writer。如果有多个 tab,相当于每个 tab 都要抢占 writer 的权限。所以架构需要变成:浏览器选出一个 leader,持有 SQLite,负责所有写入。这一整套就很复杂。当时找了一下,没有找到可以直接使用的开源解决方案。那是今年二月份,不知道现在有没有什么变化。后来我们换成了 IndexedDB。但这只是问题的一小部分。后面还有全文搜索:在纯云端架构里,其实是很容易解决的问题,直接用 Elastic,或者给 PG 配一些全文搜索插件就可以。可是在本地做这些事情,UX 要怎么设计?尤其在浏览器里,不会一打开就把所有数据加载下来。再加上我们会把云端的数据尽可能包装成端到端加密的形式,这部分怎么做,也是现在还没有解决、仍在尝试解决的问题。我觉得未来可能可以使用语义化搜索,并尝试结合一些加密手段。这也是新问题。

metadata-first 刚才已经讲过了。

还有怎么预加载,我们也迭代过好几次:既不能占尽前端的网络资源和性能,又要照顾用户体验。Lody 同步很多对话,如果每次对话更新,点进去都要再等一下,体验会差很多。这里要做很多细节优化。

CRDT 把计算复杂度从云端挪到了本地,所以有一定的本地性能开销。现在我们把一部分计算移到 worker 上去做。

接下来这个问题,也是我们还没有解决、但如果要提供本地优先同步引擎就需要解决的。在我们目前给 Lody 做的同步引擎里,还没有跨流的事务。比如发一条消息,一方面要更新刚才说的 meta,也就是 Flock 的那篇文档;这篇文档上的数据更新了。元数据更新之后,我会告诉用户有新消息,但真正的新消息写入又是另一个动作。一次发消息变成两个动作,而它们之间没有事务性。这就导致 UI/UX 上可能出现:用户收到更新,被通知有新消息,点进对应的对话,却看不到新消息。因为没有提供事务性,这种情况确实可能发生。可能是发送更新时某条流断了,也可能是接收更新时某条流断了。它无法把两条流的更新捆绑成一次提交。这也是现在给应用造成比较多复杂度的地方。

这个问题不是再加一个 CRDT 就能解决的,而应该从同步引擎的角度去解决。

为了绕过这类问题,建模和同步时序都多了很多复杂度。建模时首先要尽量避免冲突;CRDT 管不到的地方,再做冲突 UI。

接下来讲云端怎么做。

为什么还要讲云端?还是回到 Git 和 GitHub 的例子。现在基本上每个开发者都有 GitHub 账号,GitHub 作为一个托管云端服务很成功。Git 本身是本地优先的协议,但大部分用户仍希望有一个开箱即用的托管云端产品。这不违背本地优先的理想。未来同一协议下会有不同的托管后端,也会提供 self-host 选项。这样很多细节就不需要用户担心了。比如 GitHub 上的组织、权限、接 CI,都变得比较开箱即用。

我们最开始尝试 Cloudflare 的 Durable Object。当时试图让一个 Durable Object 直接管理整个 repo:一个用户或一个组织对应一个 repo,通过这个 Durable Object 转发,以这种形态做数据同步。在 Durable Object 里加载 CRDT document,计算版本差异,做增量同步。但很快发现,它不太能承载这种用法或用量。要做版本对比,要么把相应更新拉出来,加载到 CRDT document 里,再做对比,这样开销很高。一方面内存开销高,另一方面读取和加载的内容也多。要么把 version 等信息保存在 Durable Object 内的 SQLite 上,但这样也有比较高的读写成本,

而且每次更新都要更新对应的版本向量。一个 repo 内还有不同权限:有人只读,有人可写;有些 room 对某些人开放,对另一些人不开放。

这种用法下,要么读写成本很高,要么长连接开销比较高,因为 Durable Object 按连接时长计费。WebSocket 连接维持多久,就有相应的 duration cost。要避免这项成本,可以释放全部内存,进入 hibernation,也就是休眠状态。这个状态下,WebSocket 连接可以保持,但不按这部分时长计费。与此同时,Durable Object 的其他内存状态也会被释放,所以所有状态都要保存在它自带的 SQLite 里。于是这些状态更新的成本,又都转移成了 SQLite 的读写成本。当时我们发现,这个读写成本还是有点高。

一个不符合直觉的地方在于,CRDT 本身要求服务端知道的东西其实很少,尤其之后加上端到端加密,服务端作为一个中转者就够了。CRDT 可以让聪明的客户端直接解决冲突、完成计算。不像 OT 这类算法,需要在服务端完成这些计算。所以服务端还要做很多复杂计算、付出一定内存开销,这一点很不符合直觉。

后来我们切换到了另一种模式:采用 ElectricSQL 团队推出的 Durable Streams 协议,并自己实现了一套后端。Durable Streams 大致是这样:每条 stream 对应一个 URL,一条 stream 就像一个 append-only file。每个追加进来的更新都可以被广播出去,也可以拿到这条 stream 上的一个 offset,获取它之后的所有更新,以此完成 CRDT 的 catch-up。因为 CRDT 的属性是,各端只要拿到相同的更新集合,就能达到一致状态。本质上,我们只需要让每端拿到一样的更新集合。类似 offset 这样的概念最直接简单,需要保存的状态也最少。服务端提供这样的流式日志能力就够了。

对于 remote cursor,本地只需要存一个 offset。

在这个哑日志上,我们基于 Durable Streams 做了一些扩展:客户端可以发布 snapshot;有 bootstrap 协议,可以直接从快照开始,再追上尾部。如果 CRDT 只保留更新,一些 metadata 数据比较难压缩;但如果把某个节点之前的所有更新做成 snapshot,快照可以压缩得很小。所以我们提供了客户端发送 snapshot 的能力,又加了 ephemeral channel,用来同步光标、在线状态;再加上 append-CAS 这样的原语,它是端到端加密所依赖的。

如果把端到端加密的能力加进去,其实服务端不需要做太多改动,主要是客户端嵌入这个能力。这样就有一种属性:即使同步后端被攻破,攻击者也拿不到数据,没办法伪造数据。

但现在的局限是,我们设计的鉴权还是中心化的,没有落实到去中心化状态。我们需要比较务实地做,因为这块问题还没有很好解决,也没有很稳定、可以直接使用的 protocol。在端到端加密协议上,我们现在也选择基于服务端的线性日志,也就是刚才的 stream。这是因为我们还没有找到特别好的、在去中心化环境下做鉴权的方案。虽然有 Keyhive 这些,但它们现在还是比较 alpha 的状态。所以我们选择用线性日志处理。

刚才说过,云端只处理字节,所以比较高效。

云端整体架构大致是:客户端到网关,网关做完鉴权,背后是 Raft。每条 stream 落到一个 Raft group;热数据放在这个 Raft group 的机器上,冷数据落到对象存储。

这些带来什么用户体验?

一个是加载快,因为本地始终有数据,可以很快 load 进来;另一个是同步快,因为 CRDT 已经自动处理了增量同步的问题。还有端到端加密,这是 Lody 正在推进的功能,我们可能下个月就会落地。

用户能感受到的区别,可以看这两个来自 X 的评论:感觉 Lody 同步比较流畅、比较丝滑。

我还想讲一点,本地优先同步引擎最被忽略的难点是商业模式。

过去几年,一些做本地优先、或者提到本地优先概念的同步引擎团队,以 acqui-hire,也就是人才收购的形式被收进去了,随后对应的服务下线。包括 Electric 前阵子被收购,InstantDB 也被收购,它们对应的服务都在 sunsetting。

背后的难点在于,这种模式还太新,有太多前面提到的开放性问题,尚无非常完整的解决方案。即使有团队卖这样的方案,对采购方来说成本也太高,会消耗太多创新预算:他们要花很多精力适配这套机制,所以很难发生比较大、比较重的采购。但如果只卖一个模块,又只能卖给比较小的爱好者,做不了很大的单子。

基础设施的生意,经常要等到客户端某种需求暴增,带动基础设施需求,量起来了,这门生意才成立。本地优先目前还没有等到这样的场景。下一波机会可能是本地 AI:当大语言模型能在本地推理,个人、家庭、小组织的场景都会有更多这方面需求,可能让本地优先模式的采用比以前多得多。

总结一下,本地优先同步引擎现在还缺这些东西:怎么做鉴权,怎么做好端到端加密,跨文档的事务性,去中心化的 UI/UX,还有商业模式。

我今天的分享就到这里。大家有什么问题吗?