在开发tldraw的时候,在它的demo中看到一段注释

// Very simple mutex using promise chaining, to avoid race conditions
// when loading rooms. In production you probably want one mutex per room
// to avoid unnecessary blocking!
let mutex = Promise.resolve<null | Error>(null);

在它的demo中,通过Promise调用对房间加载操作进行串行化。当多个客户端同时尝试访问同一个房间时,操作会被排队并依次执行,而不是并发执行,从而防止在房间初始化期间出现竞态条件。

最初的我很困惑,为什么以前在开发基于websocket的web聊天室的时候没有这样的处理,为什么现在就这么做了?背后的原因在于两个场景对数据实时性(和一致性)的要求差异。

协同编辑需要保证所有用户的操作顺序一致,如果多个客户端同时加入房间,可能会导致状态同步的问题。比如,用户A和用户B同时加入,如果他们的初始化操作没有正确排序,可能导致版本冲突或数据不一致。因此,串行化加入过程可以确保每个用户的加入操作按顺序处理,避免并发带来的混乱。

还有可能涉及到房间状态的管理。当用户加入时,需要初始化他们的状态,比如当前文档内容、光标位置等。如果多个用户同时初始化,可能会读取到不一致的状态快照,导致后续操作的基础不同,从而引发冲突。串行化可以确保每个用户的初始化基于最新的房间状态。

而web聊天室场景下,对数据一致性要求没有那么多,只要保证最终一致就可以。消息通常是按顺序广播的,但加入房间的客户端可能不需要立即同步整个文档的状态,只需要接收后续的消息,实时性要求也不高。

差异对比(感谢AI的总结输出能力)

特性协同编辑场景聊天室场景
数据模型共享文档(状态强依赖)独立消息流(状态弱依赖)
一致性要求全局严格一致(OT/CRDT算法)最终一致(消息顺序即可)
同步复杂度高(需同步完整文档状态+操作缓存)低(仅需历史消息+实时广播)
并发风险状态分叉、操作冲突消息重复、短暂延迟
典型设计串行化加入+服务端锁机制并行加入+服务端队列串行化处理

undefined

tldraw在生产环境中的实现

在tldraw的实现中,他们采用了注释所描述的策略——为每个房间配备一个独立的互斥锁。这是通过 Cloudflare 的 Durable Objects 架构实现的。在该架构下,每个房间都拥有专属的 TLDrawDurableObject 实例,这些实例之间互不干扰,从而确保了每个房间的操作都是串行执行的。

生产环境的关键互斥机制包括:

  1. Durable Object 隔离性:每个房间有且仅有一个活跃的 Durable Object 实例,天然保证了该房间所有操作的串行执行。
  2. 关键操作执行队列 (ExecutionQueue):对于需要持久化到数据库的操作,系统使用 ExecutionQueue 确保操作按序执行。
  3. 房间状态管理:_room 属性作为每个文档的单例(singleton),确保只存在一个 TLSocketRoom 实例。

自己实现房间的互斥锁

我不能使用Cloudflare或者R2之类的服务,所以我要自己实现这个房间的互斥锁。

先创建一个简易基于 Promise 的互斥锁 (AsyncMutex)。

class AsyncMutex {
  constructor() {
    this.promise = Promise.resolve(); // 初始已解决的 Promise
  }

  runExclusive(fn) {
    // 将函数 fn 加入执行链
    const result = this.promise.then(fn);

    // 更新链头,忽略错误避免链断裂
    this.promise = result.catch(() => {});
    return result;
  }
}

创建一个RoomManager,用于管理房间和锁。

class RoomManager {
  constructor() {
    this.rooms = new Map(); // roomId -> 房间实例
    this.mutexes = new Map(); // roomId -> 互斥锁/队列
  }

  async getRoom(roomId) {
    // 若不存在则创建该房间的互斥锁
    if (!this.mutexes.has(roomId)) {
      this.mutexes.set(roomId, new AsyncMutex());
    }

    const mutex = this.mutexes.get(roomId);

    // 在互斥锁保护下执行关键操作
    return mutex.runExclusive(async () => {
      if (!this.rooms.has(roomId)) {
        // 从数据库加载房间 - 此处可能发生竞态条件
        const roomData = await this.loadRoomFromDatabase(roomId);
        const room = new TLSocketRoom(roomData);

        this.rooms.set(roomId, room);
      }

      return this.rooms.get(roomId);
    });
  }
}

在Fastify中接入自定义的互斥锁

fastify.register(async function (fastify) {
  const roomManager = new RoomManager();

  fastify.get('/room/:roomId', { websocket: true }, async (connection, req) => {
    const { roomId } = req.params;
    const { sessionId, storeId } = req.query;

    try {
      const room = await roomManager.getRoom(roomId);

      // 处理 WebSocket 连接,类似于 tldraw 的方法
      room.handleSocketConnect({
        sessionId,
        socket: connection.socket,
        meta: { storeId, userId: req.user?.id || null },
        isReadonly: false
      });
    } catch (error) {
      connection.socket.close(1011, 'Room not found');
    }
  });
});

在实际业务场景中,简单的内存互斥锁实现通常是不合适的。我采用这种方式是因为项目在一个迷你小主机上运行,属于单机场景,规模非常小。


话题延伸,为什么tldraw采用互斥锁来保证强一致性,为什么不用CRDT?

房间级互斥锁 (Per-Room Mutex) vs CRDT:解决同一问题的不同方法

互斥锁和 CRDT 都致力于解决协作文档编辑中保持一致性的根本挑战,但它们代表了不同的架构方法:

房间级互斥锁 (tldraw 采用的方法):

  • 在每个房间内串行化操作以防止冲突。
  • 通过 TLDrawDurableObject 进行集中式协调。
  • 操作通过 ExecutionQueue 顺序处理。

CRDT 方法:

  • 允许无协调的并发操作。
  • 通过数学特性自动解决冲突。
  • 每个客户端可以立即应用操作而无需等待。

为何 tldraw 使用互斥锁而非纯 CRDT?

观察 tldraw 的实现,他们选择互斥锁方法是因为:

  • 状态管理更简单: 房间加载模式 (TLDrawDurableObject) 确保每个文档只有一个房间实例存在,使得状态推理更容易。
  • 持久化控制: ExecutionQueue 串行化数据库写入 (TLDrawDurableObject),确保一致的快照。
  • 复杂操作: 绘图操作(如移动图形、编辑文本)相比设计成无冲突操作,进行集中协调更为容易。

混合方法

许多现代协作系统实际上同时使用两种技术:

  • 类 CRDT 的本地行为: 用于实现即时响应性。
  • 服务端协调: 用于处理复杂操作和持久化。 房间级互斥锁并未消除对冲突解决的需求——它只是将冲突解决的位置集中到了服务器端,而非将其分散到所有客户端。

房间级互斥锁和 CRDT 解决的是同一个核心问题,但有不同的权衡取舍。互斥锁提供了更简单的推理和更强的一致性保证。CRDT 则提供了更好的离线支持和减少的服务端协调需求。tldraw 选择互斥锁,符合其对复杂绘图操作进行实时协作的需求,这些操作受益于集中式协调。

下面的内容来自Kimi的辅助。


在在线协作软件中,互斥锁(Mutex)和 CRDT(无冲突复制数据类型)的应用场景有所不同,以下是它们的适用情况:

使用互斥锁的场景

  • 资源竞争激烈的场景 :当多个用户或进程需要同时访问某个共享资源,且该资源在同一时间只能被一个用户或进程操作时,可使用互斥锁。如在协作绘图软件中,对画布上某个特定图形元素的编辑操作,如果同一时间只能由一个用户进行修改,以防止多个用户同时操作导致图形元素状态混乱,此时互斥锁可确保每次只有一个用户能编辑该元素。
  • 数据一致性要求高的简单操作场景 :对于一些对数据一致性要求高,但操作相对简单且不涉及复杂数据结构和多副本同步的场景,互斥锁可以简单有效地确保数据一致性。比如在在线文档的某些基础设置操作中,如设置文档的访问权限、修改文档名称等,这些操作相对独立且不涉及文档内容的并发编辑,使用互斥锁可以防止多个用户同时进行这些设置导致数据混乱。
  • 与数据库事务结合的场景 :在进行数据库操作时,如果需要确保一系列相关操作的原子性,即要么全部成功、要么全部失败,可以结合互斥锁和数据库事务来实现。例如,在在线协作项目管理软件中,当多个用户同时尝试更新某个任务的状态时,通过互斥锁确保每次只有一个用户的更新操作被执行,避免出现任务状态不一致的情况,并结合数据库事务保证更新操作的完整性和可靠性。

使用 CRDT 的场景

  • 高并发协作编辑场景 :当多个用户需要同时对一个文档或数据进行编辑,且希望实时看到彼此的修改时,CRDT 是理想的选择。比如在在线文档编辑软件中,多个用户可同时对同一文档的内容进行修改,CRDT 能自动解决冲突,确保所有用户最终看到一致的文档内容,无需担心数据冲突和版本不一致的问题。
  • 支持离线编辑和网络不稳定场景 :在需要支持用户离线编辑,然后在恢复网络后自动同步数据的场景下,CRDT 的优势明显。如移动办公场景下的在线文档编辑,用户在没有网络连接的情况下也可正常编辑文档,当网络恢复后,CRDT 能确保离线编辑的内容与其他用户所做的修改自动合并,实现数据的一致性。
  • 分布式系统中的多副本同步场景 :在分布式架构的在线协作软件中,数据需要在多个服务器副本之间进行同步,以保证数据的一致性和高可用性。CRDT 可以轻松应对这种多副本数据同步的挑战,如分布式数据库中的数据同步、分布式缓存系统中的数据一致性维护等。
  • 需要强最终一致性的场景 :对于一些对数据一致性要求较高,但可以接受在短时间内数据存在暂时不一致的情况,只要最终能达成一致的场景,CRDT 是合适的选择。例如,在多人在线游戏中的游戏状态同步、实时聊天应用中的消息同步等场景,CRDT 能确保所有用户最终看到一致的游戏状态或消息内容。

互斥锁和 CRDT 结合使用的场景

  • 复杂协作场景中的关键操作保护 :在一些复杂的协作场景中,虽然整体上使用 CRDT 来处理数据的并发更新和一致性问题,但对于某些关键操作或敏感数据的访问,仍可能需要结合互斥锁来进一步加强保护。例如,在一个基于 CRDT 的协作软件中,对于用户账户信息的修改、支付操作等关键业务逻辑,使用互斥锁确保这些操作在同一时间只能由一个用户或进程执行,防止出现并发操作导致的账户数据混乱或支付错误等问题。
  • 混合数据结构和操作场景 :当在线协作软件中存在多种数据结构和操作类型时,可以针对不同的数据和操作选择合适的同步机制。对于一些简单数据结构和操作,使用互斥锁进行同步;而对于复杂的、需要多用户并发编辑的数据结构,则采用 CRDT 来实现高效的协作和一致性。例如,在一个包含文本编辑、表格处理和图形绘制等多种功能的协作软件中,对于文本编辑部分可以使用 CRDT,而对于图形绘制中的某些特定图形操作或表格中的某些单元格更新操作,则可结合互斥锁来确保操作的正确性和数据的一致性。