在现代网络应用中,实时多人协作已经从实验性功能成长为许多产品的核心体验。无论是在线文档、图形设计工具,还是团队知识库,用户期望多人编辑时保持低延迟与无冲突的合并。Yjs 作为一个成熟的 CRDT(冲突自由的复制数据类型)框架,解决了合并并发修改的逻辑难题,但并不限定编辑记录如何传输、存储或回放。y-s2 正是在这一分界线上构建的系统,利用可持久化流(S2)与无服务器执行环境,提供一种既具可扩展性又具成本优势的多人房间实现。 从协调器与日志的演化说起,传统多人协作系统通常采用一个"主协调器+后台持久化"的模式。客户端与一个负责合并与广播的进程保持连接,后台定期将内存中的最新状态作为快照持久化。
然而若协调器崩溃,未写入快照的实时修改可能丢失。为了解决这一弱点,引入可追加的持久日志或"记录"成为常见策略:将每次修改以增量记录写入日志,即便协调器崩溃,恢复过程也能从日志回放以追赶到最新状态。这类思路在实践中已被多家产品采用,Figma 就是典型代表。 S2 将"流"(stream)提升为云存储的原语:在命名的 Basin 下管理多个 Stream,每条记录都以原子方式追加到流尾。基础操作采用类似对象存储的语义:Append、Read 与 Trim。每条记录有序且持久化,这意味着同一流既可作为可靠传输通道,也可作为历史存储,任何消费者都可以从任意位置开始回放。
这样的属性自然契合多人协作系统对可回放历史与实时传输的双重需求。 将 S2 与服务器无状态函数(例如 Cloudflare Workers)结合,能够把客户端与存储的职责清晰分离。y-s2 的运行方式是:客户端通过 WebSocket 连接到 Worker,Worker 则与 S2 建立 SSE(Server-Sent Events)会话以接收流中实时追加的记录。每次 Worker 调用可以被看作绑定到某个客户端的"线程":它从日志读取更新并将它们传递给客户端,同时将客户端发来的更新以追加记录的方式写回到流中。 当客户端初次连接时,Worker 会完成一次恢复流程以将文档恢复到最新状态。首先从对象存储(例如 R2)加载最新的快照,快照元数据中包含已处理的序号或偏移。
然后从 S2 流中读取从快照位置到当前尾部的所有记录,并将这些记录依次应用到 Yjs 文档上。完成回放后,Worker 会把物化的文档与客户端同步,并进入实时模式,向客户端推送新到达的记录,同时接收客户端的本地修改并将其追加到 S2 流。 为了减少每次恢复必须读回大量记录的成本,Worker 会在内存中缓存一段时间的更新并在达到阈值后尝试生成新的快照。重要的是,由于所有更新在生成快照前已经被持久化到 S2 流,内存缓冲本质上是一个效率优化,目的是避免在下一次恢复时重复读取那些记录。然而如果多个 Worker 同时竞相生成快照,资源会被浪费,且必须与流的前缀裁剪(Trim)操作配合,以防流无限膨胀。 为了解决并发快照与 Trim 的竞态问题,y-s2 借助 S2 原生支持的"fencing"(栅栏/围栏)机制实现一种时间限制式的"租约"或"锁"。
具体思路是:生成快照的 Worker 试图在流上追加一个特殊的命令记录,该记录携带唯一的 fencing token,格式通常为"{base64uuid} {截止时间的 epoch 秒字符串}"。其他写入操作在没有指定 fencing token 时仍被允许,但如果写入时带有 fencing token 并且该 token 不匹配流当前记录的 token,则写入会失败并返回 412 Precondition Failed。这样每次尝试设置 fencing token 的 Worker 都会表明自己已经赢得了在一段时间内独占制作快照并裁剪流的权利。 S2 的操作保证了批量记录追加的原子性,并且显式的 Trim 也是一种命令记录,因此在成功完成快照后,Worker 可以在同一事务里提交重置 fencing token 与 Trim 命令,从而保证状态的一致性。若某次快照耗时异常或 Worker 崩溃未能释放租约,设定的截止时间会使租约自动失效,其他 Worker 可以再次尝试获取租约。即便在极端情形下多次尝试产生了并行快照,仅有一次能成功提交,从而避免了最终状态的不一致。
在系统调试与可观测性方面,分布式 Worker 的日志追踪一直是痛点。以 Cloudflare Workers 为例,后台日志进入仪表盘通常存在延迟,实时模式偶尔失败,且每次输出大小有限,当通过 wrangler 在本地调试时,WebSocket 相关的日志甚至常常无法看到。针对这些问题,团队把 S2 作为日志收集与追踪的"水槽":为每个 Worker 实例使用单独或共享的 Stream,将运行时日志追加到流里。S2 支持创建任意数量的流,并允许实时读取与回放,使得开发者既可以查看某个具体 invocation 的日志,也可以把多条 invocation 日志交错在同一流中以获得系统级的时间线视图。这种方法显著提升了观察系统协同竞争、租约竞争与快照提交过程的效率。 尽管 S2 与无服务器架构带来许多优点,工程实现上仍有若干需要关注的限制与改进方向。
Cloudflare Workers 对子请求(subrequest)有数量限制:免费方案可发起最多 50 个子请求,付费方案最多 1000 个。现阶段每次对 S2 的 Append 都是一条 HTTP POST,会消耗子请求配额。为缓解这一问题,开发者计划引入 append sessions,利用长连接(例如 HTTP/2 的长链路)传输多条记录,从而避免频繁的子请求并提升吞吐。实现 append sessions 既能降低请求开销,也能提升并发写入时的效率与延迟表现。 另一个常被拿来比较的方案是 Cloudflare Durable Objects(DO)。DO 将状态与计算紧密耦合,一个对象就是长期驻留的实例,适合部分需要低延迟状态访问的场景。
然而在成本模型上,这种耦合常常不利于大规模、多房间场景。以一个典型估算为例,100 个房间、每房 10 个并发用户、平均消息大小 1KB 且每用户每分钟产生 1 条消息的工作负载下,S2 与无服务器函数的成本估算约为每月 100 美元,而 Durable Objects 在内存时长(GB-s)一项上就可能高出约 4 倍。S2 能把存储作为轻量级且可扩展的流服务,避免了为每个房间长期分配计算资源,从成本角度更具吸引力。 在安全性和一致性方面,基于 S2 的架构同样有其天然优势。S2 本身支持有序且持久的追加,结合 Yjs 的 CRDT 特性,可以在客户端离线或网络抖动时先行应用本地修改并在恢复连接时将修改写入流,避免了复杂的冲突解决逻辑。恢复时通过回放日志并应用快照与增量记录,系统能保证最终一致性。
为了控制数据量与加速恢复,定期快照与配套的 Trim 是必要的维护操作,而 fencing token 的存在则保证了对 Trim 的线性化控制,避免不同 Worker 互相覆盖或重复裁剪导致的数据不一致。 实际开发中,工程师需要关注若干实现细节。快照的格式要包含足够的元数据,例如最后处理的流序号或偏移,快照生成时需保证与流的状态关联明确。记录的编码要尽量紧凑以节省带宽与存储成本,但也需兼容后续版本的增量变更。因此对消息格式进行版本化、对流记录采用压缩或批量追加是常见优化手段。对于高并发写入,使用批量原子追加可以减少网络往返次数并提高吞吐,同时也要考虑单条批量记录过大会引入延迟或失败风险。
对于希望采用 y-s2 或类似架构的开发者,工程实践建议包括:优先设计清晰的恢复路径,将快照与增量记录结合;在写入路径中尽可能减少同步阻塞点,利用 S2 的原子追加与 fencing 工具进行协调;在调试阶段把日志写入可回放的流以便重放与时序分析;评估子请求或长连接策略以优化写入效率;并在成本与延迟之间进行权衡,以确定合适的快照频率与 Trim 策略。 展望未来,S2 与无服务器组合的思路还有许多待挖掘的潜力。更高效的 append session 支持将显著降低写入开销并提升吞吐。流级别的访问控制与搜索索引集成会使得多人协作场景更安全与更具功能性。与其他存储或消息系统的互操作性也能拓展该架构的适用范围,例如将流数据同步到长期对象存储以便归档,或打通到实时分析流水线中以实现在线指标监控。 y-s2 的实践告诉我们,在分布式多人协作系统的设计上,分离传输与存储的职责并将"流"作为核心原语,能够同时满足实时性与可恢复性的要求。
结合 CRDT 的最终一致性保障与无服务器环境的弹性伸缩,这种模式在成本、可靠性与可操作性上都具备竞争力。对于希望打造低成本、高可用实时协作体验的产品团队,深入理解流式存储、租约机制与快照策略,将帮助他们在复杂的分布式世界中构建出稳健而可扩展的多人房间解决方案。 。