拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Quill与OT算法的多人协同编辑系统技术方案设计

基于Quill与OT算法的多人协同编辑系统技术方案设计 接手这个项目之前我先说一个比较现实的判断做一套“类飞书文档”的多人协同编辑系统真正的难点不在编辑器本身而在于“让人感受到别人正在和你一起打字”这件事。飞书文档、Google Docs给人的那种行云流水的同步体验背后是一整套操作转换、版本管理、存储回放机制在支撑。我这次写的技术方案设计文档会围绕几条主线索来展开先从选型说起编辑器内核、协同算法再拆解从一次击键到远端所有人屏幕更新的完整链路然后落到存储与历史版本设计最后讲离线补偿和真实项目里踩过的坑。这套方案不依赖任何商业SDK适合想自研协同能力的技术团队参考。1. 选型决策Editor内核与协同算法怎么定1.1 三个候选编辑器和我的选择市面上可用的编辑器内核不少我这次重点比较了三个Quill、Slate、ProseMirror。这三个我都写过实际项目特点差别非常大。先说Quill。它的核心优势有两点一是自带一套非常成熟的Delta数据模型所有文档变化都能被描述成有序的insert/retain/delete操作序列这给协同算法提供了天然基础二是Quill的API设计得很干净getContents()、updateContents()、getSelection()这些接口几乎就是为协同场景量身定做的。我在做模块划分时直接把Quill当做一个“纯渲染引擎”所有内容变更都走Delta通道不碰DOM。Slate的优势在于React生态融合得很好开发者对数据结构有完全控制权。但问题也出在这里自由度太高需要自己维护整套文档模型而没有官方的协同时代方案时光是确定自定义数据结构是一件相当危险的事。ProseMirror的schema约束很好协同SDK久经考验学习曲线稍陡文档质量很高。我最终选了Quill加自研协同层。理由比较朴素Delta模型足够简单、足够规范OT转换的逻辑在工程上可计算、可测试。数据规范统一协同服务端所需的op类型只有几种做转换、做undo/redo都方便。可以说在多数情况下Quill不是“最好用的编辑器”但它是“最适合做协同的编辑器”。1.2 OT与CRDT为什么选OT这条路选型会上一定会被问一个核心问题做协同你选OT还是CRDT这里不搞清楚概念后面所有系统设计都是空中楼阁。OTOperational Transformation的核心思想是每个用户的编辑操作都有一个“操作的预期基于哪个版本”的上下文。当两个用户的并发操作到达同一个接收者时接收者不能直接把新操作应用到本地文档要先对操作做转换让它对应到当前文档的实际版本。经典例子用户A和用户B在同一段文字里插入内容如果不做转换后到的插入操作可能插入在错误的位置做了转换后B的插入位置要自动后移才能保证两个人都同时看到两段内容都被插进去。CRDTConflict-free Replicated Data Type的思路不一样。它让每个节点都基于“操作ID向量时钟”做合并不需要服务端做转换理论上是真正的最终一致。Yjs在文本协同里用了YATA算法效果很好Git项目Y-swift也用这个方案。我在选型对比表里是这么列的维度OT带中心服务端CRDT以Yjs为例一致性收敛依赖服务端串行化与转换函数节点本地自动合并天然收敛操作类型insert/delete/retain/format等少量类型Delete/Insert/Struct操作底层复杂富文本格式合并文本格式冲突时需要额外规则存在formats合并子算法复杂度高离线批量提交顺序回放逻辑清晰任意顺序合并逻辑更松散工程实现难度中等transformation函数可控上等内部算法极其精细服务端压力按版本号递增分发给所有订阅者依赖中继连接管理复杂实际生产里CRDT要解决的不只是文本插入删除还有富文本的样式归属问题。同一段文字被两个人赋予不同加粗属性时CRDT要对format做专门合并这个算法非常容易数错节点。OT因为操作类型少转换规则明确在文本协同这种场景下工程上更受控。所以我给这个项目的结论是用OT且用带中心服务端同步节点的OT。1.3 版本号与服务端串行化的设计方案明确了OT路线之后要设计一个核心机制服务端给每个文档维护一个全局递增的版本号每一条已提交的op都挂在这个版本号之下。客户端在提交操作时必须带上自己当前看到的lastVersion服务端收到后从lastVersion1开始把之前所有op按顺序返回给该客户端同时把客户端新提交的op追加到该文档版本链尾部再把版本号递增。这套串行化方案看起来简单但能解决OT里最麻烦的“上下文偏差”问题。比如客户端C1本地版本是v10它提交了一个插入操作但它不知道服务端还有个v11别人刚提交的。服务端收到C1的op时会发现op的baseVersion是v10就跟本地最新版v11不匹配于是不能直接应用这个op。处理办法是先做一次转换把C1的op从“基于v10”转换为“基于v11”再应用。转换后的op不仅能正确插入还会间接影响后续版本号。这里有个细节必须写清楚服务端原则上不应直接修改客户端传上来的op对象结构。要创建新对象保留原op的clientId和seq序号转换函数只负责调整op里的index和长度字段。2. 端到端协同链路拆解从一次击键到远端更新2.1 一次击键的完整旅程用户在输入框敲下一个英文字母到远端另一个用户看到它中间经历了一个标准化的流水线。我分析这条流水线的每个环节对于后面做性能调优很有帮助。第一步是Quill在本地渲染出字符同时把这次输入行为抽象成一个Delta对象。这个Delta可能长这样{ ops: [ { retain: 12 }, { insert: a, attributes: {} } ] }含义是在位置12之后插入一个字符“a”。这就是一次最小粒度的user operation简称为op。第二步客户端Quill内部先把这个delta应用到本地文档。这里有个概念叫“本地先行”local-first。用户输入发生的瞬间就应该立刻在屏幕上出现不能等网络往返否则体验会卡顿到无法接受。第三步本地应用完成后这个delta被放进一个操作队列经过合并、序列化、打上clientId和seq序号发送给协同服务端。第四步服务端收到之后先做版本校验然后和最新版本做一次OT转换再写入版本链最后把这条op广播给当前正在订阅同一个文档的其他客户端。第五步远端客户端收到op后以同样的方式把Delta应用到本地Quill文档里。由于Quill本身支持updateContents(delta)这里不需要重新渲染整个文档性能开销非常小。2.2 操作合并与防抖如果不做合并网络里会塞满极其细碎的消息。比如一个用户按住Backspace键狂删一秒钟可能产生50个删除操作每个操作都触发一次WebSocket推送服务端和UI都有压力。我这里的做法是给每个客户端维护一个“防抖合并缓冲池”。缓存策略是这样的每收到一个本地变更先不立刻发送而是放入缓冲池等待一小段时间我配置的是200ms。在这个窗口期内如果用户又产生了新的变更就把两个Delta按顺序合并合成一个Delta再发送。合并的规则不是简单连接两个op列表而是把前一个delta作为基准计算出它对后一个delta位置相关的影响。Quill Delta库的compose()方法可以完成这个事。实测下来单用户高速输入场景下op合并率大约在65%到80%意味着网络消息量直接砍掉一大半。用远端应用角度看合并还能减少transform的频次。代价是本地op的应用会延迟一小段时间发送但这个延迟完全不影响本地体验——本地变更始终立即渲染。2.3 远端变更如何落到本地视图远端op到达本地后有一步容易踩坑的地方不能直接把这个op传给Quill的updateContents()就算完了必须在应用之前先检查当前本地文档还有其他未提交的本地op。假设本地还有一个“我刚刚敲了字母B”的op没发出去此刻远端op到了直接应用远端op会导致位置错乱本地未发送的op并没有进入远端版本链远端op计算的位置是参照它自己那边的历史文档根本不包含我这个未提交的B。正确的做法是先把本地未发送的op罗列出来让远端op跟每一个本地op做一次转换转换成“适配当前本地文档状态”的新op再交给Quill应用。这个转换过程和OT转换是完全一致的我封装成了一个函数内部统一处理。另外还要处理Quill的source参数。Quill的updateContents(delta, source)接口中source传入user会被当成新的本地输入事件可能触发额外的selection变化远端应用要传silent让Quill知道这不是用户行为不要触发编辑事件回调否则会造成本地更新的重复回放。2.4 游标同步与会话管理协同编辑最直观体验就是看见别人的光标在移动。实现方案是每个客户端在用户selection变化鼠标点击、移动光标、选区变化时将当前光标位置或者选区范围格式化成相对位置的range随一个几乎可以忽略的发送频率我设置为500ms的心跳推给服务端服务端转发给其他客户端。远端的处理比较复杂一点。假设远端用户的光标当时停在文章开头此时另一个用户在某段中插入了一大段文字如果不做补偿光标会仍然指向旧的绝对位置视觉上就出现“光标浮在文字中间”的错乱。解决方案是在远端每次应用op之后把本地保存的其他人游标位置同步做一次转换处理——具体就是根据该op的insert/delete把光标位置向前或向后偏移对应的字符数。游标数据结构我建议存成区间的形式而不只是单个位置。因为协同场景里拖动选择是很常见的行为区间需要经过相同的转换逻辑。服务端维护每个连接的用户在线状态断开后要清理游标展示我用的是一个全局会话表通过心跳定期清理过期session。3. 存储模型、历史版本与导出PDF的底层逻辑3.1 快照加Op Log为什么不能只存Op技术方案里最容易犯的错误是只记录Op Log以为“历史重放可以无限回溯”。理论上确实成立但工程上不是这样从空文档开始重放一万个op每次打开文档都要重放完整操作序列耗时和内存消耗都会线性膨胀。正常的做法是“快照 增量op”以这个项目为例每累积100个版本就生成一份文档快照存储进快照表历史版本查询时只需找到最近一个快照在快照基础上重放后续op序列即可。这个策略还有个额外好处导出功能可以复用快照。很多类飞书文档的产品都有“导出PDF、导出Word”的需求本质上是把某个版本的文档模型渲染成静态文档。用了快照设计导出服务只需要按版本号定位到对应快照然后把文档模型交给渲染队列不需要对在线协同服务产生额外压力。3.2 表结构设计与数据一致性我在设计里至少会建三张核心表。第一张是doc表记录文档基础信息doc_id是全局唯一文档标识version是当前文档的最新版本号snapshot_id指向最近一次生成的快照last_op_time用于清理无心跳的会话和判断活跃度。第二张是doc_op_log表记录每一条已确认的操作。字段包括doc_id、versionop在这个文档内的顺序号、client_id、seq客户端内部分段序号、op_content一个JSONB字段存放操作转换后的完整Delta结构、create_time。第三张是doc_snapshot表。字段有doc_id、start_version这个快照对应用哪个版本、end_version、content序列化后的完整文档内容、create_time。数据一致性的关键点在于写入顺序不能先更新doc表的version再写op_log。服务端代码应该先在op_log表插入记录再更新doc表的version。我建议把这两步放进同一数据库事务同时通过乐观锁控制并发写入在update语句里加上where version x条件防止两个客户端操作并发导致版本冲突。3.3 历史版本回放与导出功能如果是需要支持“回到任意历史版本查看”回放步骤是这样的根据用户指定的目标版本号找到距离它最近且end_version 目标版本的一个快照。从快照的content重建完整文档。查询快照end_version 1到目标版本之间所有op记录。将这些op按version顺序依次应用到一个离线的Quill实例上得到目标版本文档。需要注意的一点是回放过程不应该使用在线协同的实时通道要另建一个隔离的Quill实例去渲染否则会影响正在编辑用户的选区和光标状态。导出PDF的原理其实就是这里第四步结束后对文档模型做一次静态渲染导出。4. 踩坑记录与性能优化编辑器集成里真实遇到的问题4.1 中文输入法下的“事件冻结”中文用户输入拼音时会触发compositionstart到compositionend的一整段事件流。如果在这段期间拦截编辑操作、提交op会出现严重的选字错乱。比较典型的场景用户正在拼音选字另一个远端用户的op恰好到达本地Quill在中间状态做了一次diff导致未确认的拼音字符串被当成普通变更协同两端的内容直接不一致。处理原则是本地输入状态要明确区分“完成态”和“组合态”。我维护一个isComposing标志在组合态期间所有Quill产生的Delta都不进入协同发送队列远程op照样可以被Quill应用但应用时不得影响当前未确认的组合文本。Quill自身在这个处理上有一些历史包袱我选择等compositionend后再统一发送一个合并的最终Delta这样既保证同步又不干扰中文输入体验。4.2 双缓冲渲染与冻结状态直接监听Quill的text-change事件去操作真实DOM做覆盖层是我早期吃过亏的做法。Quill内部有自己的渲染器如果覆盖层直接操作DOM两者会互相覆盖。我的做法是双缓冲在协同引擎里挂载一个隐藏的Quill实例作为“逻辑文档”界面层Quill和隐藏实例之间通过text-change事件单向同步。所有op先应用在隐藏实例之上界面层监听到变化后再统一做一次渲染更新。隐藏实例与界面实例永远不会互相干扰。另一个细节是“冻结状态”。当服务端返回错误比如版本校验失败、网络断了重连客户端的输入不应该继续产生op。此时要进入冻结模式Quill设置editable(false)用户看到一把锁的提示直到重连并完成版本补偿之后才恢复。如果没有冻结机制断网期间用户的输入会出现堆积重连后这批op无法对齐版本轻则全部丢失重则发生大规模文本错位。4.3 性能指标与实测结果我建了一套监控埋点用Canvas记录op的生命周期。主要看的指标有五个本地应用延迟、op发送到服务端确认的往返时延、服务端每次transform的耗时、远端op应用耗时、WebSocket断线重连次数。以下是一些基于内部压测环境的参考数据指标目标值实测平均值本地op到服务端确认 150ms45ms局域网 / 120ms公网服务端单文档transform P99 10ms3ms远端op应用耗时 5ms1.8ms单文档订阅用户数官方对标100人30人内无掉帧首屏完整渲染 1000ms600ms快照加载如果文档非常长比如超过一万行每次transform都要遍历整个delta op列表这时单次操作延迟会明显上升。我的处理方案是分段分片文档按行和块来自动拆成区块每个区块独立计数和transform区块之间通过区块id建立衔接索引。代价是代码复杂度增加但收益在长文档协同场景里很值得。5. 离线断线补偿与容灾机制看似可选实则必备的模块5.1 客户端Op队列与补偿流程网络不可能是理想的。我用WebSocket作为协同通道但它会断服务端重启、网络切换、移动端锁屏后进程被杀。我把这些失败场景都归到“本地离线状态”来处理。离线期间用户的所有编辑行为仍然在本地生效也会进入合并缓冲池但不发送。池子里的op要带递增的本地序号seq。等到网络恢复客户端要发一条recovery请求携带自己最后的已确认版本号。服务端返回缺失的op列表客户端先按序把所有缺失op转换并应用再把本地缓冲池里的op逐一提交。注意一个细节过长的离线缓冲池不能无限增大。我设定缓冲池最多保存500个op或30分钟内的变更。超出这个阈值就放弃合并强制触发生成一份本地快照并让用户再次连接到服务端时做一次全量替换。5.2 Redis Lua脚本保障服务端原子性服务端在同一个文档上极容易发生并发提交两个WebSocket连接同时投递op。上一节讲了数据库事务保障版本递增但实际高并发下更容易形成“先写日志再更新version”的一个竞态。我采用Redis的Lua脚本来做原子门的控制if redis.call(get, doc:version) baseVersion then redis.call(incr, doc:version) redis.call(rpush, doc:oplist, newOp) return 1 else return 0 end这个脚本在Redis里是单线程执行的有效避免了两个op并发递增到同一个版本号。如果返回0客户端收到冲突信号后需要推断当前服务端版本把op做transform再重试。在压测里5个并发客户端同时修改同一个文档没有发生版本重复提交的案例。5.3 无状态化与多副本容灾服务端节点本身不保存连接状态所有session状态都放在Redis里包括客户端最近版本号、游标位置、心跳时间。这样即使某个WebSocket网关挂掉客户端重连到另一台机器也能通过同一文档的Redis信息恢复协同上下文。查询链路不阻塞所有op先进入Redis队列再异步落库到MySQL。这套设计的取舍是最坏情况下可能丢极少量op例如Redis主从切换瞬间的未落盘数据但得换来吞吐量和横向扩容能力。对于内部协作类文档系统这个取舍完全可接受。作为可以扩展的高级功能我还会考虑在服务端做“增量存储压缩”定期把op_log里的旧操作压成增量补丁只保留最近100个完整op其他全折叠成快照让未来回放更稳更快。写到这里这个方案的骨干已经出来了。我个人在整个设计中最深的体会是多人协同编辑表面上是编辑器的活实际上70%的工作量落在稳定性上剩下的30%才是算法和效率。OT算法只在核心几条路径上写得复杂真正的护城河是快照回放、版本号校验、断线补偿、组合输入国家化这些不那么起眼的细节。如果你正在做类似项目我建议从最简单的“单文档双人同时打字”用例开始跑通完整链路再一步步往里加复杂度。多人同时在线的稳定性是靠一个个补丁垒出来的不是靠一次架构就能一步到位的。
返回列表