在 CRDT 中回归读写分离模型的必要性
以下是我的个人的拙见,我接触 CRDT 的时间其实只有短短的几个月,在这几个月期间也一直在思考如何绑定好 Yjs,但是,似乎这个答案并没有一个固定的结论。不断尝试尝试,而到今天,我觉得我的想法大约就是如此了
TL;DR;
在构建协作应用时,我们试图将 CRDT 协作协议层(Yjs)与 UI 状态层(Valtio Proxy)通过“隐式双向绑定”强行融合。这种看似优雅的抽象,最终在生产环境中制造了难以捉摸的“幽灵写入”(Phantom Writes)。而这些由 UI 自动产生的幽灵写入,一旦进入 CRDT 的更新流,就会被视为合法且具有因果顺序的操作,最终导致了持久化的数据破坏。
解决方式也不是“再加几个 if”,而是:读写分离 + 显式写入边界 + phase gating(重放/迁移期间只读)。
当 Snapshot 遇上 Proxy
为了理解这个“幽灵”,我们需要先理清系统的上下文。我们的后端持久化策略采用的是 Snapshot + Updates Replay 模式。系统定期保存文档快照,并在读取时通过重放增量的 CRDT Updates 来恢复最新状态。这种机制保证了写入成本的可控和历史的可回溯性。
而在前端,我们使用了 Yjs 作为协作核心,并通过 valtio-yjs 库将 Y.Doc 的数据结构(如 Y.Map)直接映射为 Valtio 的 Proxy 对象。
- 使用 Yjs 作为 CRDT 协作核心:
Y.Doc + y-websocket provider - UI 状态层现状:
valtio-yjs bind(proxyState, yRoot)- 把
Y.Map / Y.Array映射为一个 Valtio proxy 对象 - 双向同步:Y 变 -> 改 Proxy;Proxy 变 -> 改 Y
- 把
- 同时还存在:bootstrap/migrate、undo manager、maintenance/coordinator 等运行时逻辑
这种设计的初衷是美好的:开发者像操作普通 JavaScript 对象一样操作 Proxy,库会在底层自动处理 CRDT 的同步——Y 的变动会自动更新 UI,UI 的变动也会自动写入 Y。
然而,正是这种“自动”,埋下了祸根。
非常迷惑的现象
近期开发中,遭遇了一系列极具迷惑性的现象,它们不像是一个确定性的 Bug,更像是一种充满竞态条件的“黑盒副作用”。
最典型的场景是这样的:用户创建一个 Notebook,等待初始化完成并上传 Snapshot。此时一切正常。
但是,当用户刷新页面,修改标题,再次刷新后,标题凭空消失了。甚至在某些极端情况下,连整个 Notebook 的结构都会退化回未初始化的状态。
观察到的现象令人费解:
- 数据莫名丢失:核心字段(如 title)突然消失,且刷新无法恢复。
- 触发防御逻辑:系统频繁检测到“结构异常”或“缺字段”,进而触发自动修复(Reconcile)机制。
- 时序错乱:前端 Y.Doc 的 Vector State 看起来极不稳定,仿佛是“没吃到 Updates”,或者是“吃到了但又被莫名覆盖了”。
调试日志更是加深了这种诡异感:Valtio 的订阅日志显示,字段并不是被置空,而是被明确地执行了 delete 操作。这不是值的错误,这是结构的销毁。
幽灵写入与时序竞态
问题的突破口来自一条不起眼的日志。在一次 Valtio store 的变更事件中,我们捕捉到了这样的序列:先是一个 set 操作,紧接着是一个 delete 操作。这意味着,在同一次运行周期中,系统真的执行过“删除 title”这个动作。
对 CRDT 而言,“删除字段”是一个具有强语义的事件。它会生成 Update 二进制流,会被广播,更会被持久化。于是问题变成了:是谁在用户毫不知情的情况下,删掉了数据?
答案指向了:隐式双向绑定。
中间态被当成了最终态
CRDT 的每一次 Update 都代表着“用户或系统明确做了一个可合并的操作”。但在 UI 编程中,为了处理受控组件、初始化或清理逻辑,我们经常会写出类似这样的代码:
// 如果 title 不存在,赋一个默认空值
state.title ??= '';
// 或者在清理时
delete state.title;在普通的单机应用中,这是防御性编程;但在双向绑定的协作系统中,这却是灾难。Valtio Proxy 无法区分这是“UI 的临时兜底”还是“用户的真实意图”,它会忠实地将这些操作转化为 CRDT Update。
更致命的是竞态条件(Race Condition)。
当页面刷新,系统进入重放(Replay)阶段时,Y.Doc 需要时间来应用后端传来的 Update。这就产生了一个短暂的“中间态”:文档已加载,但数据尚不完整。
想象一下这个时序:
- Sync Step 2:客户端开始接收服务端的 Updates,Y.Doc 处于“未完成”状态。
- Bootstrap/Migrate 介入:此时,前端的初始化或迁移逻辑(Bootstrap)提前运行了。它读取了 Proxy,发现
title字段缺失(因为 Updates 还没应用完)。 - 幽灵写入:Bootstrap 逻辑认为这是一个“坏掉”的文档,于是执行了“修复”——写入默认值或重置结构。
- 因果覆盖:由于这个“修复”操作发生在本地当前时间,它的 Lamport 时钟或因果顺序往往晚于服务端发来的历史 Updates。
结果就是:客户端基于一个残缺的中间态,生成了一个更新的“修复”操作,这个操作在 CRDT 的合并规则中,成功“击败”了原本正确的历史数据。
这解释了为什么刷新后再刷新数据会丢失:你看到的“丢失”,实际上是你自己的代码在数据未就绪时,主动发出的“删除指令”。
正是因为这个删除指令,导致了我们出现了很多无法理解的奇怪现象:
• 我已经完成了全面的代码分析。根据我的调查:
关键发现: 1. 设置 tombstone=true 的唯一代码入口是 softDeleteCell (* soft_delete.ts:42)
2. softDeleteCell 只通过 deleteCell 回调被调用,而这只在用户点击删除按钮时触发
3.没有键盘快捷键、没有拖拽、 没有自动机制会触发删除
4.clock:"local"确认这是本地操作(不是远程同步) 5.deletedAt 时间戳是 2025年 12 月,表明是最近的操作
这意味着两种可能:
1. 用户可能无意中点击了删除按钮
2. 有某种我们未发现的触发机制Debug with claude code
事实是,我们有一个 reconcile 系统,会将异常的(比如在 cellMap 里头的 cell,但是又不存在于 order 内的)放入 tombstone 和 tombstoneMap 当中去,而正是因为 valtio-yjs 的一些删除操作,导致我们的 Yjs Doc 出现了字段丢失,从而导致混乱不堪
遂,诞生了 jotai-yjs
我们意识到,这不仅仅是实现层面的 Bug,而是抽象层级的不匹配。valtio-yjs 把“易变的 UI 状态”误当成了“CRDT 协议的读写接口”。但协作系统需要的是显式的意图、严格的阶段控制(Phase Gating)和强约束的业务不变量。
这种双向的迟早会因为某些原因崩掉,因为读写不分离。
valtio-yjs 把 UI 可变对象当成了 CRDT 协议的读写接口,但协作系统需要的是显式写入,phase gating,操作可归因,业务 invariant(title 不可消失)强约束
换句话说: 这不是一堆边角 bug,是系统边界放错了层。
因此,我们决定重构底层,诞生了 jotai-yjs
jotai-yjs 核心原则是:
Yjs 是唯一真相;Atom 是投影;写入必须显式发生。
1. 读写分离(Command Pattern)
我们在架构上强制区分了 read 和 write。
read(y):纯粹的读取,无副作用。UI 的渲染逻辑无论怎么折腾,怎么 fallback 默认值,绝不会反向污染 Yjs 数据。write(y, next):只有在用户明确触发操作(如输入、点击)时,才调用 Writer。
这直接斩断了幽灵写入的路径:Replay 期间,UI 即使读到了空数据,也只会渲染出 loading 状态,而不会“顺手”去修改底层数据。
2. 阶段控制(Phase Gating)与可归因性
由于写入入口被收敛到了 Atom 的 Setter 中,我们可以轻易实现阶段控制:
// 在文档同步完成前,拒绝任何写入
if (!isSynced) return;同时,我们为每一次写入引入了 Origin 标记机制。
origin='UI':来自用户输入。origin='MAINT':来自迁移脚本。origin='UNDO':来自撤销栈。
这让调试从“猜谜”变成了精准定位。以前我们需要猜测“是谁删了 title”,现在查看 Update 的 Origin 属性,就能知道这是来自用户的误触,还是系统的自动逻辑。
3. 业务不变量的强约束
在新的架构下,我们可以对关键字段实施强策略。例如对于 title 字段,Writer 层可以硬编码策略:允许写入空字符串,但绝不允许执行 delete 操作。这种约束直接作用于协议层,而非 UI 层,确保了数据的结构稳定性。
不是换一个库,而是放对边界
在重构的过程中,对比 ProseMirror 或 Tiptap 等成熟的编辑器方案,我发现了一个共识:UI 永远只是协议的投影。
- 它们都把 CRDT 视为协议层
- UI 永远是投影层
- 写入由 command/transaction 显式发生
- 重放/合并期间不会允许 UI “自动修复”落盘
这与 jotai-yjs 的模式一致,而与 valtio-yjs 的“镜像对象双向同步”相冲突
将 UI 状态对象与 CRDT 文档进行双向镜像同步,听起来很诱人,但在复杂的分布式协作场景下,它模糊了“临时状态”与“持久化意图”的边界。
选择回归 jotai-yjs 并不是因为对某个库有偏见,而是因为读写分离不是一种代码洁癖,它是构建可靠协作系统的工程底座。 只有当写入变成一种显式的、可追踪的、受控的行为时,我们才能真正解决那些在网络延迟与并发冲突中的“幽灵”。