题干与适用场景
设计一个支持段落、标题、列表、粗体和评论锚点的实时协同编辑器。系统有 2000 万日活用户,峰值维持 200 万条在线连接,其中 20 万名用户正在输入;活跃编辑者平均每秒产生 2 个更新,单个热点文档最多 100 人同时编辑。在线协作者看到远端修改的 p95 低于 200 毫秒。用户可以离线编辑最多 24 小时, 重连后要合并修改;服务端已确认的编辑不能丢失。光标、选区和在线状态允许短暂丢失。
系统还要支持查看、评论和编辑权限,权限变更,版本历史,按用户撤销,以及多地域访问。题目不要求在面试中 从零证明一种 CRDT 或 OT 算法,也不要求复刻某家产品的内部实现。候选人要选定一种冲突解决模型,解释它 如何与编辑器数据结构、网络、持久化和权限边界配合,并说明方案不能保证什么。
2026 年的中英文公开面试材料都直接把协同编辑器列为系统设计题,内容涵盖 OT 与 CRDT、WebSocket、文档房间、 光标 presence、离线编辑、持久更新和快照恢复。Yjs 官方文档进一步给出可交换、可结合、幂等的更新语义, 状态向量差量同步以及不持久化 awareness 的边界。因此这道题既有当前代表性,也能用一手技术资料核验。
面试官考察点
第一,看候选人是否抓住“并发修改如何收敛”,而不是只回答“使用 WebSocket”。WebSocket 提供双向消息通道, 但不会决定两个用户在同一位置插入字符时的结果。答案要比较服务端 OT 与 CRDT 的约束,并在题目条件下作出 明确选择。
第二,看本地体验与服务端承诺是否分开。输入必须先在本地应用,不能等待跨地域往返;“已确认不丢”则要求 服务端在返回 ack 前跨可用区持久化更新。客户端在 ack 前保存待确认更新,超时后用同一个操作标识重试。
第三,看持久内容和临时 presence 是否分层。正文、评论锚点和版本历史需要恢复;光标移动频繁、过期快, 断线后应自动消失。把每次光标移动写入正文日志会放大成本,也会让过期状态污染恢复流程。
第四,看候选人能否处理富文本语义。字符序列收敛不等于整棵文档树一定合法。列表、表格、评论锚点、Schema 升级和撤销范围都需要明确模型、版本与不变量;普通整数下标也会被远端插入立即推移。
最后,看容量、热点、多地域、权限和验证能否闭环。强答案会计算写入与单房间扇出,限制慢客户端,解释离线 编辑遇到权限撤销时为何不能直接合并,并用乱序、重复、网络分区和故障切换证明所有副本最终一致。
回答前需要澄清的问题
- 编辑对象是什么? 主设计是结构化富文本树;图片二进制走对象存储,正文只保存引用和属性。
- 冲突后的产品语义是什么? 并发更新必须确定性收敛且不覆盖彼此;不承诺机器自动理解两段文字的业务意图。
- “已确认”指什么? 更新写入本地域跨三个可用区复制的持久日志后,服务端才返回 ack。
- 是否要求全局线性顺序? 不要求。正文依靠 CRDT 收敛;日志偏移只用于审计、恢复和确认水位。
- 离线多久? 最多 24 小时。客户端保留本地 CRDT 状态和未确认更新,重连时先重新授权再同步差量。
- 权限如何划分?
viewer只能读,commenter只能修改评论域,editor可修改正文;每次重连和写入都校验。 - 光标需要持久化吗? 不需要。presence 依赖心跳和 TTL,丢失后等待下一次状态刷新。
- 多地域能否同时接收同一文档写入? 主方案为每个文档分配 home region,其他地域转发写入,降低授权、审计和故障切换复杂度。
- 版本历史保留多久? 假设用户版本保留 30 天;它与用于在线同步的压缩快照是两种不同产品对象。
- 端到端加密是否在范围内? 主设计是服务端可验证权限和内容 Schema;端到端加密作为追问讨论。
30 秒回答框架
“我会采用服务端中继的 CRDT:本地编辑立即生效,更新经 WebSocket 发送,服务端重新授权并在跨三个可用区 持久化后 ack,再按文档房间广播。重连用状态向量补差量,差距过大则加载快照;正文可恢复,光标等 presence 走独立 TTL 通道。峰值是 40 万更新/秒、约 120 MB/s;100 人热点房间约有 1.98 万次远端投递/秒,因此需要 批处理和限制慢客户端。最后用乱序重复、离线 24 小时、权限撤销和故障切换验证收敛与耐久性。”
分步骤深入解答
先定义七个不变量:
- 本地输入不等待网络,远端更新最终收敛到同一合法文档状态。
- 只有跨可用区持久化成功的正文更新才能被服务端确认。
- 重试、重复广播和乱序交付不能重复应用或改变最终结果。
- 当前身份与权限来自服务端会话,不能相信更新载荷中的角色。
- 正文、评论和版本历史可恢复;光标与在线状态可以过期。
- Schema 不兼容的客户端不能继续写入未知结构。
- 任何丢弃、拒绝、降级和恢复都必须有可观测结果。
第一步:在 OT 与 CRDT 之间作出选择。
OT 通常由一个有序服务端版本作为上下文,把到达的操作与并发操作逐一转换。它适合强服务端权威和已有转换 引擎的系统,但转换函数、历史保留与离线 rebase 必须一起正确。CRDT 把并发关系编码进数据结构,更新可在 不同顺序到达并重复应用,拿到全部更新的副本最终收敛;代价是更多因果元数据、删除标记、复杂的富文本绑定, 以及需要单独处理授权和产品级语义冲突。
题目要求离线 24 小时和跨地域访问,主方案选择成熟的序列/树 CRDT,并通过服务端中继与持久化控制权限和 可靠性。CRDT 不等于去中心化部署,也不等于无需服务端。服务端仍负责身份、Schema、大小限制、耐久 ack、 历史、合规和房间扇出。
第二步:定义结构化文档和消息契约。
文档根节点包含有稳定 ID 的块;段落、标题和列表项拥有 CRDT 文本与格式属性;评论单独保存线程状态,并用 相对位置锚定范围。Schema 版本规定允许的节点、属性和迁移规则。普通整数位置不稳定:远端在索引前插入一个 字符后,原索引会指向别处;相对位置附着于 CRDT 元素,所有副本收敛后可转换到一致位置。
WebSocket 子协议可以使用以下应用消息:
join {
documentId, sessionId, schemaVersion, stateVector
}
update {
documentId, clientId, clientSeq, schemaVersion, payload
}
ack {
clientId, clientSeq, durableOffset
}
sync {
payload, durableOffset, schemaVersion
}
presence {
sessionId, relativeCursor, relativeSelection, statusSeq
}(documentId, clientId, clientSeq) 是重试幂等键。durableOffset 便于确认和审计,不参与 CRDT 合并正确性。 消息有最大大小、解压上限和 Schema 白名单;未知节点或越权域修改被明确拒绝,不能直接广播。
第三步:拆分加载、实时房间和存储路径。
Client
-> HTTPS snapshot service -> metadata + snapshot store
-> WebSocket gateway -> document room router -> collaboration service
-> durable update log
-> room pub/sub -> gateways
Client
-> presence channel -> regional ephemeral store -> room fan-out客户端先通过 HTTPS 取得文档元数据、当前 Schema 和最近压缩快照,再建立 WebSocket。网关持有连接和房间订阅, 不自创合并规则。协同服务按文档路由到 home region,校验权限与更新,持久化成功后 ack 并发布。房间消息只 发送一次到每个有订阅者的网关,由网关在本机扇出,避免服务端为每个接收者重复发布到跨节点总线。
第四步:把本地编辑、确认和重试做成明确状态机。
本地编辑立即更新视图,并追加到本地持久 pending 队列。在线时按 20 到 50 毫秒的小窗口合并相邻击键,减少 消息数;计时是输入假设,需要用体验测试调整。服务端收到更新后依次执行:认证会话、查询当前权限、校验 Schema 与资源限制、写入复制日志、返回 ack、广播。只有 ack 后客户端才能从 pending 队列删除该更新。
若连接在提交后、ack 前断开,客户端用相同 clientSeq 重试。服务端唯一约束返回原确认结果,或依赖 CRDT 更新幂等性安全重复应用,但审计和计费仍应去重。慢客户端有有限发送缓冲;超过水位后停止发送 presence, 再要求客户端从状态向量重新同步,不能让一个慢连接无限占用房间内存。
第五步:用状态向量完成上线、离线和重连同步。
CRDT 更新的可交换、可结合和幂等性质允许副本以不同顺序接收并安全重试。客户端重连时发送状态向量,描述 自己已经拥有的因果状态;服务端据此计算缺失差量。若差量过大、Schema 已升级或历史超出在线保留窗口, 服务端发送当前完整 CRDT 快照,再应用客户端仍获授权的本地更新。
同步顺序必须先认证、再授权、再协商 Schema,最后交换内容。用户离线期间若编辑权限被撤销,服务端不能因 更新能收敛就接收它;应返回稳定的 permission_revoked,保留本地草稿供用户导出或复制,但不写入共享文档。 这条边界牺牲“所有离线编辑必合并”,换取当前访问控制正确性。
第六步:设计快照、版本历史和压缩。
持久日志按 documentId 分区,记录更新 ID、作者、Schema、载荷、接收时间和耐久偏移。后台压缩器加载 CRDT 状态,将多个增量合成可直接加载的快照,并记录覆盖到的偏移。只有新快照校验通过、对象存储耐久且仍保留 回滚点后,才删除超出恢复与审计窗口的旧增量。
合并二进制更新只会消除重复信息,并不自动垃圾回收已删除内容。删除标记、离线同步、评论锚点、按用户撤销 和历史版本相互影响,压缩策略必须用真实文档验证。用户可见版本历史保存独立的命名快照或变更索引,不能把 在线同步压缩等同于产品版本历史。
第七步:分离 presence,并控制热点扇出。
presence 包含会话、显示名、颜色、相对光标、选区和单调 statusSeq,通过心跳刷新 TTL。它不进入正文 CRDT 和耐久日志;乱序状态按序号丢弃,断线后自动过期。丢失一次光标更新只影响短暂显示,下一次更新会覆盖。
一个 100 人热点房间在每人 2 次更新/秒时有 200 次正文更新/秒;每次向另外 99 人发送,约为 19,800 次 远端投递/秒,还未计算 presence。网关要把同一时间片的更新批量编码,按文档房间聚合发布,presence 采样到 固定上限,并对每连接设置字节和消息水位。超热点文档可独占房间 actor 和 pub/sub 分区,但不能通过把正文 更新采样来降载。
第八步:复算全局容量和连接资源。
20 万名编辑者每秒 2 个更新,峰值为 40 万更新/秒。按平均 300 字节二进制载荷计算,原始写入约 120 MB/s, 一天约 10.37 TB;复制、索引、消息头、快照和版本历史另计。若每条在线连接在网关占用 50 KiB,200 万连接 约需 100 GiB 连接状态;按每台网关 2 万连接计算,至少 100 台,再加故障和发布余量。
这些数值是容量起点。真正瓶颈往往是热点房间出站带宽、TLS 与编码 CPU、慢客户端缓冲和持久日志分区,而非 原始正文存储。监控应按文档和租户观察更新率、扇出率、ack 延迟、状态向量差量、重连次数、缓冲水位、快照 年龄和收敛校验失败。
第九步:约束多地域、故障切换与安全。
文档元数据记录 home region 和递增 epoch。客户端就近接入网关,但正文写入路由到 home region;本地先应用 使输入不受跨地域 RTT 影响。home region 故障时,控制面先提升 epoch,再由新地域从复制日志和最新快照恢复。 旧 actor 的 epoch 被持久层拒绝,避免两个房间所有者同时作出耐久确认。
CRDT 能让内容更新收敛,却不能替代单一授权决策、耐久 ack 或故障切换 fencing。安全上还要限制文档大小、 单次更新、更新频率和压缩比;校验 Origin、会话与文档权限;加密传输和静态数据;审计分享、权限与导出; 不把访问令牌、完整文档或光标细节写入普通日志。
第十步:用性质测试和故障注入证明设计。
同一初始文档生成多客户端更新,把它们随机乱序、重复、分批和延迟应用,所有副本最终序列化结果必须相同且 Schema 合法。再覆盖同位置插入、交叉删除、删除父块同时编辑子块、格式与文本并发、评论锚点、按用户撤销和 Schema 升级。
系统测试要在日志写入成功后、ack 前杀死协同服务,确认重试只产生一个审计更新;在 ack 后、广播前杀进程, 确认恢复会补发;断网 24 小时后重连,验证状态向量差量和快照路径;撤销权限后提交离线更新,必须拒绝共享 写入并保留本地恢复入口。最后对 100 人热点房间压测更新与 presence,确认 p95 延迟、内存水位和降级顺序。
高质量示范回答
“我先把目标拆成三条:本地输入立即生效;拿到全部更新的副本最终收敛;只有写入跨三个可用区持久日志的 更新才对客户端确认。题目需要 24 小时离线编辑,因此我选成熟的结构化 CRDT,并保留服务端作为认证、Schema、 持久化和扇出的权威。
客户端加载 CRDT 快照后加入文档房间。本地编辑先应用,并以 clientId + clientSeq 写入 pending 队列;在线时 通过 WebSocket 发送二进制更新。协同服务重新校验当前权限、Schema 和资源上限,持久化后 ack,再向房间发布。 重试使用相同序号,CRDT 更新本身可乱序、重复应用。重连时客户端发送状态向量,服务端返回缺少的更新;差距 过大则发送完整快照。权限在离线期间被撤销时,更新不能合并到共享文档,只允许导出本地草稿。
正文与 presence 分开。光标使用相对位置,presence 依赖心跳和 TTL,不写耐久日志。每个文档有 home region 和 epoch;故障切换先提升 epoch,再从复制日志与快照恢复,旧房间所有者不能继续确认更新。
容量上,20 万编辑者每秒 2 次更新是 40 万更新/秒;按 300 字节为 120 MB/s、10.37 TB/日原始更新。100 人 热点文档约 200 次更新/秒、19,800 次远端投递/秒,所以按房间聚合发布、批量编码、限制慢客户端缓冲,并只对 presence 采样。验证上我会随机排列和重复同一组更新,要求所有副本收敛;再注入 ack 前后崩溃、24 小时离线、 权限撤销、Schema 升级和 home region 故障,证明确认数据不丢、越权更新不进入共享状态。”
常见错误
- 只说使用 WebSocket → 传输层不解决并发冲突 → 明确选择 OT 或 CRDT,并说明收敛条件和代价。
- 每次保存整篇文档 → 并发用户会互相覆盖,带宽也随文档大小增长 → 发送可合并的增量更新。
- 服务端收到内存就 ack → 进程崩溃会丢掉已确认编辑 → 以跨可用区耐久日志提交作为确认边界。
- 把 CRDT 当成权限系统 → 数学收敛不会阻止被撤权用户写入 → 每次加入、重连和写入都重新授权。
- 用整数保存远端光标 → 并发插入会让位置漂移 → 使用与 CRDT 元素关联的相对位置。
- 把光标写进正文日志 → 高频临时状态放大存储与恢复成本 → presence 使用 TTL 和独立通道。
- 声称 exactly-once → 断线重试和广播可能重复 → 使用幂等更新、唯一操作标识和可重放状态。
- 快照成功后立即删完历史 → 离线、回滚、撤销或 Schema 迁移可能失去依据 → 按明确恢复窗口和校验水位回收。
- 只算正文写入 → 热点房间的扇出和慢客户端常先耗尽资源 → 同时计算每房间投递、出站带宽和缓冲。
- 跨地域全部主动写却没有控制面 → 授权、ack 和故障切换边界模糊 → 用 home region 与 epoch 简化耐久所有权。
追问及应对
追问一:为什么选择 CRDT,不选择 OT?
离线 24 小时和可能乱序、重复的跨地域传输更符合 CRDT 的更新模型,成熟实现还能用状态向量交换差量。OT 并非错误;若已有经过验证的服务端转换引擎、所有编辑都经过单一有序服务、离线范围很小,OT 可能有更可控的 元数据和服务端语义。选择依据应来自产品约束与团队能力,不能只说某个名词更先进。
追问二:CRDT 是否意味着可以删除持久更新日志?
不可以。CRDT 解决更新合并与收敛;已确认不丢、审计、版本历史、恢复和新设备加载仍需要耐久状态。可以把 多个增量压缩为快照并按恢复窗口回收旧日志,但必须保留可验证的耐久边界和回滚点。
追问三:离线用户回来时权限已被撤销怎么办?
先重新认证和授权,再交换内容。服务端拒绝共享写入并返回稳定错误;客户端保留本地副本,允许用户复制或 导出,不能悄悄丢弃。若业务允许管理员复核,可把草稿提交到隔离审批区,但它不能绕过当前文档 ACL。
追问四:如何实现多人环境下的撤销?
默认撤销当前用户自己的最近本地操作,并让逆操作继续通过 CRDT 合并;不能简单恢复整篇旧快照,否则会抹掉 他人后续编辑。操作来源要标记用户和事务边界,删除内容与历史保留策略也要支持目标撤销窗口。复杂结构变更 需要编辑器绑定层定义可逆语义,并通过并发测试验证。
追问五:如何防止光标在并发编辑后跳到错误位置?
光标和评论锚点保存相对 CRDT 元素的位置,而非绝对字符下标。远端更新应用后再把相对位置解析为当前索引。 如果锚定元素和父结构都被删除,解析可能失败,产品应隐藏光标、把评论标记为失去锚点,或回退到最近有效块。
追问六:一个文档突然有一万人围观怎么办?
把编辑者和只读观看者分开。正文更新在房间总线上发布一次,由各网关本地扇出;只读端可使用合并后的较低频 更新或短轮询快照,但不能改变编辑者的正确性。presence 只对可见或抽样用户展示,并设置每连接发送缓冲。 若产品确实要求一万人都看全部光标,需要单独预算带宽和 UI,可用性不能靠无限广播假设。
追问七:能否做端到端加密?
可以让客户端加密 CRDT 更新,服务端只中继和持久化密文,但代价是服务端难以校验富文本 Schema、搜索、内容 审核、数据丢失恢复和细粒度导出;密钥轮换、成员移除及旧成员离线更新也更复杂。应先定义威胁模型,再决定 文档级密钥、设备密钥和成员变更协议,不能只在 WebSocket 外层加 TLS 就称为端到端加密。
追问八:如何发现副本已经静默分叉?
客户端在安静窗口或重连后上报文档状态向量和非敏感状态摘要;服务端比较同一耐久水位的结果,发现不一致就 触发完整重同步并保留诊断样本。持续 canary 文档从多个地域执行已知并发更新,再验证最终摘要、Schema、ack 数量与日志水位。摘要必须绑定相同快照和更新集合,否则不同在途水位会制造假告警。