Hotdry.
web-development

为 Monosketch 设计基于 CRDT 的实时冲突解决层

面向 Monosketch 这类 ASCII/像素画布,提出一个基于 CRDT 的分层数据模型与冲突解决策略,实现多人协作下的操作语义保留与像素级合并。

Monosketch 是一个出色的、完全运行在浏览器中的 ASCII 图表编辑器。它凭借其客户端优先、无需服务器的特性,在技术文档与草图绘制领域占据了一席之地。然而,其路线图中关于 “共享与协作” 的愿景,揭示了一个核心的技术挑战:如何在一个以字符和像素为基本单元的无限画布上,实现低延迟、高一致性的实时多人协作?本文旨在拆解这一挑战,并设计一个基于 CRDT(无冲突复制数据类型)的实时冲突解决层,为 Monosketch 或类似工具迈向真正的协作平台提供可落地的工程蓝图。

核心挑战:画布的状态同步不是文件传输

将 Monosketch 的协作想象为 “共享同一个文件” 是第一个误区。简单的操作日志广播或定期全量状态同步,在网络延迟、离线编辑和操作冲突面前会迅速崩溃。真正的协作需要的是状态的可合并性。CRDT 正是为此而生,它保证无论操作以何种顺序到达,所有副本最终都能收敛到相同的状态。但问题在于:Monosketch 的画布状态是什么?一个巨大的二维字符 / 像素矩阵吗?直接将其作为一个 CRDT 值(如一个大字符串或数组)是灾难性的,每次局部修改都会产生一个近乎全新的状态,合并开销巨大且无法实现有意义的冲突解决(最终可能退化为 “最后写入获胜”,覆盖他人工作)。

因此,设计的第一原则是:必须对画布进行分层与粒度化建模,让冲突发生在尽可能小的、有语义的单元上。

架构设计:三层 CRDT 数据模型

我们为 Monosketch 的协作画布设计一个三层的数据模型,每一层对应一种 CRDT 结构,共同构成可合并的文档状态。

1. 图元层 (Shapes Layer) – CRDT Map 此层管理所有矢量化的图形元素,如矩形、线条、文本标签。每个图元是一个独立对象,包含其类型、位置、样式(边框字符、填充字符等)、层级(z-index)等属性。我们使用一个 CRDT Map(例如 Automerge 或 Yjs 的 Y.Map)来存储这些图元,键为全局唯一的图元 ID。

  • 并发创建 / 删除:多个用户同时添加或删除图元,在 Map 中表现为独立的插入或删除操作,天然无冲突。
  • 并发修改:两个用户修改同一图元的不同属性(如一人改位置,一人改填充),CRDT Map 允许字段级合并,两者修改均会保留。若修改同一属性(如同时修改位置),则需制定规则(如基于逻辑时间戳的 LWW),但这在图元编辑中已属可接受的明确冲突。

2. 像素块层 (Tiles Layer) – CRDT Map of RLE Arrays 对于自由绘制(Paint Tool)产生的像素或字符点,直接建模每个像素会导致数据量膨胀。我们借鉴图形学中的常见优化:将无限画布划分为固定大小的块(Tile,如 32x32)。每个 Tile 分配一个唯一 ID,其内容使用游程编码(RLE)压缩存储。同样使用一个 CRDT Map 来管理所有 Tile。

  • 用户绘制时,计算笔画影响到的 Tile ID 集合,并生成对应 Tile 的像素更新操作。
  • 冲突发生在同一 Tile 内的同一坐标上。此时,Tile 内部的合并策略需要明确。一个简单高效的策略是 Tile 内像素级 LWW:每个像素值附带一个逻辑时间戳(Lamport 时间戳)和客户端 ID,合并时选择值最大的条目。这保证了确定性,但意味着后到的操作会覆盖先到的。

3. 操作序列层 (Stroke Log) – CRDT List (可选) 为了支持更复杂的撤销 / 重做、动画回放或非破坏性的冲突可视化,可以额外维护一个 CRDT List,按序记录每个绘制 “笔画”(Stroke)。每个 Stroke 包含笔画的路径点、使用的字符、时间戳和作者。这层数据不直接驱动画布渲染,而是作为元数据层。当发生像素覆盖冲突时,可以据此还原冲突各方的原始意图,甚至提供 UI 让用户选择保留哪个笔画。

冲突解决策略:从粗粒度到像素级

基于上述模型,冲突解决变得层次分明:

  • 图元间冲突:几乎不存在。不同图元的操作完全独立。
  • 图元内字段冲突:使用 LWW 或自定义合并函数。例如,对于 “位置” 字段,LWW 是合理的;对于 “标签文本”,或许可以尝试合并字符串(但这在 ASCII 绘图中不常见)。
  • 像素块内冲突:这是主战场。纯粹的像素级 LWW 可能过于粗暴。更友好的策略是 “笔画优先,像素后备”
    1. 首先,系统尝试基于操作序列层(Stroke Log)进行合并。如果两个冲突的像素分属不同的原始笔画,且笔画时间接近(例如 1 秒内),则将其标记为 “待决冲突”,并在 UI 上高亮显示受影响区域(如半透明红色覆盖)。
    2. 用户点击冲突区域时,可以预览两个笔画的原始效果,并选择保留其一,或手动进行修改。用户的选择会被记录为一个特殊的 “冲突解决操作”,同步给所有客户端,从而消解该冲突。
    3. 如果未启用 Stroke Log 或用户未干预,则回退到像素级 LWW 策略。

这种混合策略平衡了自动化与用户控制,适用于创意性较强的草图协作场景。

工程实现:性能、同步与监控要点

1. 增量同步与网络优化 绝不传输完整画布状态。同步单元是细粒度的操作(Op),例如 {type: "pixel-update", tileId: "t1", diff: [[x,y,char,ts], ...]}。使用 WebSocket 或 WebRTC 数据通道进行广播。对高频操作(如鼠标移动绘制)进行节流(throttle)和批量打包,减少网络报文数量。

2. 客户端渲染优化 采用 “脏矩形” 重绘策略。当接收到远程操作或本地操作被应用后,CRDT 状态更新,UI 层(如 Canvas 渲染器)根据操作影响的范围(Bounding Box)计算需要重绘的 “脏区域”,仅更新这部分画布。这对于 Monosketch 可能存在的无限大画布至关重要。

3. 离线支持与状态恢复 CRDT 的天然优势是支持离线编辑。所有本地操作都先应用于本地 CRDT 文档并持久化(例如 IndexedDB)。当网络恢复时,客户端计算本地与远程状态的差异(向量钟),并交换缺失的操作集进行合并。

4. 监控与调试 实时协作系统需要可观测性。关键监控点包括:

  • 操作延迟:从本地操作发出到收到所有远程确认的平均时间。
  • 冲突率:触发像素级 LWW 或用户干预的冲突操作占比。
  • 状态大小:CRDT 文档(Map + 压缩 Tile 数据)的内存占用增长趋势。
  • 网络流量:进出操作的数据量。

可以在开发模式中内置一个状态可视化面板,实时显示 CRDT 文档的结构、向量钟和操作传播图。

可落地的实施清单

为 Monosketch 集成此冲突解决层,可按以下步骤推进:

  1. 技术选型:选用成熟的 CRDT 库,如 Yjs(针对 Web 优化,自带网络连接器)或 Automerge。Yjs 的性能和社区生态可能更合适。
  2. 状态建模:在现有 Monosketch 数据模型之上,抽象出前文所述的图元 Map像素块 Map。将现有操作(画矩形、写文字、涂像素)转换为对这两个 Map 的增删改操作。
  3. 冲突规则表:明确定义每一类操作和字段的合并策略(LWW、合并、自定义)。
  4. 网络层:集成 Yjs 的 WebSocket Provider,或基于其协议自建中继服务器。实现用户身份、房间管理等基础协作功能。
  5. UI 适配
    • 渲染层改为从 CRDT 状态驱动。
    • 实现脏区域重绘逻辑。
    • 添加冲突可视化与解决界面(如高亮、选择面板)。
  6. 测试与监控
    • 构建模拟多客户端、随机延迟与丢包的测试环境。
    • 实现前述监控指标,并设置警报。

总结

将 Monosketch 从一个优秀的单机工具升级为实时协作平台,其核心在于设计一个能够优雅处理并发修改的数据层。基于 CRDT 的分层模型 —— 以 Map 管理图元与像素块,以 List(可选)记录操作序列 —— 提供了一条清晰的技术路径。它既保证了最终一致性和离线可用性,又通过分层的冲突策略(从无冲突的图元操作,到可自动合并的字段更新,再到需谨慎处理的像素覆盖),在自动化与用户控制之间取得了平衡。实现这一层,不仅是给 Monosketch 添上 “协作” 功能,更是为其注入了应对未来复杂编辑场景的 “可扩展的骨架”。

本文的思考基于对现有 CRDT 协作框架(如 Yjs、Automerge)和图形应用(如 Excalidraw)的研究,以及对 Monosketch 项目本身技术架构的分析。

查看归档