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

资讯详情

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

多人协同编辑系统架构实战:基于OT方案从零搭建指南

多人协同编辑系统架构实战:基于OT方案从零搭建指南

多人协同编辑这个功能,很多团队一开始都会觉得“不就是让几个人同时打开一个文档吗”,真正动手做之后才发现,难点根本不是“谁能打开”,而是“大家同时改同一个位置时,文档内容到底以谁为准”。我前后做过两版多人文档编辑器,踩过不少坑,这套方案是从实际项目里整理出来的,覆盖系统架构、核心领域模型、业务流程设计、API接口文档以及技术实现细节,可以作为从零搭建或重构时的参考底稿。

我也先打个预防针:多人协同编辑的完整方案不等于“引一个第三方库就完事”,它涉及编辑内核、实时通信通道、版本管理、权限控制和应用层存储的联动设计。如果只解决编辑冲突,不管架构和数据模型,后面扩展权限、离线恢复、审计日志时会非常痛苦。这篇文章就是按“一条链路到底”的思路来讲的,既讲原理也讲落地。

1. 多人协同编辑的三条技术路线,先选型再动手

多人协同编辑在2024到2025年这个时间点,主流技术路线基本就三种:diff-patch方案、OT操作转换方案、CRDT无冲突复制方案。我在选型时把这三种都过了一遍,分别做了小规模原型验证,下面直接说结论。

diff-patch方案是很多人最容易想到的:每个人本地编辑,每隔几秒把整份文档发给服务端,服务端用diff算法找出差异,再用patch合并。这个方案做三五分钟同步一次的需求可行,比如企业内部多人轮流编辑一篇周报、一个合同草案,效率要求不高,可以接受。但如果是真正的实时协同编辑,两个用户同一秒内修改同一个段落,diff-patch会产生大量互相覆盖的冲突,经常出现“我刚改的字被对方覆盖了”的情况。另外diff算法本身不稳定,不同diff引擎生成的结果不一样,合并逻辑很难编写和测试。

CRDT方案近几年热度很高,核心思想是每个副本维护一个可交换、可结合、幂等的操作数据结构,不依赖中心化服务端排序,理论上天然解决并发冲突。实际调研下来,CRDT的实现复杂度比OT更高,Yjs是其中最成熟的实现。Yjs可以胜任很多场景,比如多人白板、多人文档、协同画布,社区也活跃。但是CRDT对数据结构有侵入性,文档内容要以CRDT的数据格式存储,编辑器要适配Yjs的协议层,后续如果要接入自己的文档权限体系、内容快照、离线导出,会多一层适配成本。

OT操作转换方案是最“传统”且被大量生产环境验证过的路线,Google Docs早期也采用类似的中心化OT思想。核心概念是:每个用户产生的编辑动作被抽象成操作序列,服务端收到操作后,根据操作之间的位置偏移量做转换,让不同用户的修改能自动对齐到正确位置。OT方案的优点是内容按照“操作原语”而非“整个文档”存储,后端可以实时做冲突调整,也方便做权限校验和版本追踪。

我做技术选型时最终选了OT,原因是:我们需要支持字级别的实时同步,需要服务端做强校验,还需要基于文档版本做快照和审计,OT与这套业务模型的契合度最高。如果你想要的只是“几个同事改文本文档,5秒同步一次”,diff-patch就够了;如果想要离线优先、完全去中心化,可以考虑Yjs这类CRDT实现。下面的架构和模型设计都以OT为主线展开。

2. 系统架构设计:编辑链路、接入层与协作服务的拆分

多人协同编辑的系统架构,本质上是“客户端编辑器、连接网关、协作服务、业务服务、存储层”五部分的联动。很多人一开始会把业务逻辑直接塞进WebSocket服务里,做成一个大而全的实时服务,这样短期能跑,但后期加接口、加权限、加审计都会互相牵制。

我落地时采用了分层架构,各层职责如下:

组件主要职责关键技术与说明
客户端编辑器内核捕获用户输入,生成操作原语;渲染文档;应用远端操作基于ContentEditable或ProseMirror自研,编辑器不必太重,核心是操作原语化
接入网关鉴权、WebSocket接入、REST API反向代理负责连接生命周期管理,承担用户身份识别,不做业务计算
协作服务(核心)接收客户端操作、执行OT转换、更新文档版本、广播增量无状态可横向扩展,通过Redis发布订阅同步跨实例消息
文档业务服务创建文档、成员管理、权限配置、元数据查询独立HTTP服务,与协作服务通过内部接口或事件联动
存储层文档元数据、操作变更集、内容快照、成员数据PostgreSQL存储业务数据,Redis承载实时状态和广播,对象存储保存历史快照和导出产物

这套架构里,最关键的一条设计准则是:业务查询走REST API,实时同步走WebSocket,两类流量在接入层就分开。创建文档、拉取文档列表、配置权限这类操作低频且语义复杂,用REST更清晰;光标位置、文本插入、删除操作这类高频轻量事件,用长连接推送更高效。

WebSocket在整个链路中承担的是“操作透传+广播”的角色,协作服务内部需要维护一份当前在线用户与文档的映射关系。如果只有单实例部署,直接用内存Map存储docId -> connectionId -> userId即可;但线上通常是多实例部署,用户A连接到实例1,用户B连接到实例2,实例之间必须借助Redis这类中间件广播。我最早做MVP时偷懒只用内存Map,单实例压测时一切正常,上线后一扩容就发现用户A看不到用户B的光标,问题就出在跨实例广播链路没打通。

这里也顺便说一下广播方案的选择。很多人一上来就上Kafka,但多人编辑场景的实时消息延迟要求很高,Kafka的吞吐优势在这个场景里并不突出,而且引入Kafka会增加运维成本。我的建议是:中小规模(单文档几百人同时在线)用Redis Pub/Sub就够了,简单直接且毫秒级延迟;只有在需要持久化操作流、重放审计、跨多个数据中心同步时,才考虑Kafka或Pulsar这类消息中间件。

关于系统架构,还有一点容易被忽略:接入网关和协作服务必须支持优雅断连和会话恢复。用户网络抖动导致WebSocket断开,如果直接把他的操作上下文全部清掉,重连后要重新拉全量文档内容,体验会很差。正确做法是连接断开后保留一定时间(比如30秒到2分钟)的会话上下文,包括客户端当前版本号、操作缓冲、游标状态,重连成功时根据版本号做增量补齐。这块能力在导讲领域模型时会详细展开。

3. 核心领域模型设计:文档、版本、变更集与会话

很多方案文档会一上来就画几十个表结构,但多人协同编辑真正核心的领域实体其实是有限的。我把它收敛成六块:文档、文档成员、操作变更集、文档内容快照、游标、连接会话。理解这六块,就理解了整个协作领域的骨架。

3.1 文档与成员模型

文档实体不只存储标题和内容,还要记录当前版本号、最近编辑人、最近编辑时间和锁状态。版本号是每次内容变更后自增的数字,它是后续所有增量同步和冲突处理的基础。内容不能直接存在文档表里,否则每次保存都把整个文档内容更新一次,并发量大时数据库锁竞争会非常严重。

成员关系是独立表,每个成员记录文档ID、用户ID、角色(所有者/编辑者/只读者)、加入时间和权限来源。多人协同编辑的权限控制会贯穿到每条操作的校验,不能让其中一个人是只读者还能通过WebSocket推送insert操作。每次收到客户端操作时,协作服务都要先查权限,再做OT转换。

3.2 操作变更集模型

操作变更集是OT方案的核心,也是控制并发冲突的关键。一次文本输入、一次删除、一次粘贴,都会被转换成一组操作原语序列。我常用的操作原语有三种:

  • retain(n):向前跳过n个字符,不修改内容,相当于定位游标。
  • insert(text):在当前位置插入一段字符。
  • delete(n):删除当前位置起的n个字符。

例如在“你好世界”这段文本中,把“世界”改成“地球”,操作序列可以表示为retain(2), delete(2), insert("地球")。每个操作变更集必须带有基础版本号,表示它基于哪个版本的文档产生。客户端编辑时本地维护一个递增版本号,每次服务端确认后会更新版本号;如果服务端返回的版本号与客户端本地不一致,说明过程中有其他用户的修改被合并进来了,客户端要基于服务端返回的新版本重新校准。

变更集存储表的设计里,字段包括变更集ID、文档ID、基础版本号、目标版本号、操作用户ID、操作内容(JSON格式)、操作时间戳。这条记录既是同步的基础,也是历史审计的依据。我把操作内容存为JSON数组,比如[{"retain":2},{"delete":2},{"insert":"地球"}],虽然比二进制序列化冗余一些,但排查问题时可以直接肉眼阅读,开发调试极其方便。

3.3 文档内容快照与恢复模型

存量数据不能只存变更集,因为随着编辑次数增加,变更集链会越来越长,新用户加入时如果从第一条变更集慢慢回放,会造成漫长的等待。解决方案是定期生成内容快照。快照表记录文档ID、版本号、全量内容、生成时间和大小。比如版本100是一个快照节点,新用户加入时只要加载版本100的快照,再依次应用100到当前版本之间的增量变更即可,不需要从头回放。

我实际项目中的策略是:版本每累计200次变更且超过20分钟,就自动生成一个快照,时间触发和数量触发两者取其优。这样既避免了频繁写快照对数据库的压力,又能保证新用户进入时增量补齐的代价可控。快照内容建议压缩后存到对象存储,数据库里只保留快照的元数据和访问地址。

恢复机制也要设计好。如果某次操作导致服务端保存的内容损坏,需要找到最近的快照和快照之后的变更集,按顺序重新应用。变更集是不可变的,一旦写入就不能被后续操作修改,只能追加新版本,这是保证恢复正确性的前提。

3.4 游标与会话模型

多人编辑中“看到别人的光标位置”是体验的一部分,但游标不应该和文档内容混存。我单独建了游标表,记录文档ID、用户ID、连接ID、光标偏移量、选区范围和最后更新时间。游标消息通过WebSocket实时广播,不做持久化审计,因为游标本身是瞬态状态,持久化没有意义。

连接会话模型服务于重连恢复。每次WebSocket建立连接,都会为这个连接分配一个会话ID,记录用户ID、文档ID、当前版本号、最近活跃时间和状态。断线时标记会话失效,但保留上下文;重连时根据会话ID找到上下文,对比版本号差异后增量同步。如果连接空闲超过阈值,比如180秒没有操作,会主动释放会话。

以下是核心表结构的一个紧凑示意,字段只展示最能说明问题的部分:

表名核心字段说明
documentsid, title, current_version, owner_id, status文档元数据,不存全文
document_membersid, document_id, user_id, role文档成员与权限
doc_changesid, document_id, base_version, target_version, user_id, op_json, created_at不可变形变集
doc_snapshotsid, document_id, version, content_url, created_at内容快照元数据
cursorsdocument_id, user_id, connection_id, position, selection_start, selection_end, updated_at在线游标状态
connectionsid, user_id, document_id, current_version, status, expires_at连接会话状态

4. 业务流程设计:从输入到广播的完整链路

领域模型确定后,业务流就清晰了。多人协同编辑的完整编辑链路可以分为四条主流程:正常编辑同步流程、并发冲突合并流程、保存与快照流程、断线重连恢复流程。每一条都要想清楚异常情况。

4.1 正常编辑同步流程

用户按下键盘时,编辑器先产生一个操作变更集,这个变更集包含用户输入的内容以及光标位置。以在位置3插入“你好”为例,操作序列可能是retain(3), insert("你好")。客户端先把操作应用到本地内容上,实现无感知的乐观更新,同时把操作发送给服务端。

服务端收到操作后做三段工作:第一步校验用户是否有编辑权限;第二步校验操作基础版本号与当前版本号是否一致;第三步把操作应用到内容上,生成新的版本号。如果基础版本号一致,说明期间没有其他用户的修改,直接提交并广播。如果版本号不一致,说明本地修改期间服务端已经应用了其他人的操作,这时需要走冲突合并逻辑。

服务端提交成功后,会把当前版本号通知给客户端。客户端收到确认消息后,把本地版本号更新为服务端版本号。同时服务端会把这条操作广播给该文档的其他在线用户,他们收到广播后在自己的文档副本上执行同样的操作,就能看到远端用户的修改。

4.2 并发冲突合并流程

两人同时在文档中间插入内容,是协同编辑里最典型的并发冲突。假设文档当前内容是“ABCDEF”,版本号为10。用户A在位置2插入“你好”,用户B在位置4插入“世界”。

如果A的操作先到达服务端,服务端版本号变成11,内容变成“AB你 好CDEF”(注意是原文“ABCDEF”在位置2处插入“你好”)。随后B的插入操作到达,它的基础版本号是10,和当前版本号11不一致。如果服务端直接应用“位置4插入世界”,会插入到错误的位置,因为服务端内容已经因为A的插入而发生了偏移。

此时服务端要做OT转换:把B的操作基于A的操作进行转换。A在位置2插入了2个字符,B原本在位置4插入,相对A而言位置要向后偏移2位,变成位置6插入“世界”。转换后,B的操作应用到最新内容上,结果是“AB你 好CD世界EF”。这个结果既保留了A的修改,也没有丢失B的插入。

如果两个操作发生在完全相同的位置,比如都在位置3插入字符,服务端会约定一个全局规则,比如后到达的操作位置保持不变但排在前一个操作之后,保证操作顺序稳定。关键点是所有实例必须遵循同一套转换规则,不能在多实例中各自实现一套逻辑。

OT转换的具体实现可以利用操作原语相互转换:当处理insert与insert时,后到操作的位置加前到操作的插入长度;处理insert与delete时,删除位置的偏移同样要加上插入长度。同时处理delete与delete时要取交集部分,避免重复删除同一批字符。我把这些转换规则写成了独立的纯函数库,配合几十个单元测试用例来覆盖边界情况,上线之后这块基本没再出过大规模问题。

4.3 保存与快照压缩流程

保存流程的核心原则是“变更集实时写、快照定期生成”。每个操作变更集都会实时写入存储,保证任何时刻宕机都不丢失用户操作。但不能每次操作都触发全量快照,所以快照生成采用定时任务触发。

当文档当前版本号达到快照阈值时,定时任务读取最新快照版本和当前版本之间的所有变更集,依次应用后生成新快照。成功后把新快照地址写入快照表,老快照可以被清理。注意这里有一个坑:如果在生成新快照的过程中又有新的操作提交,新快照不能覆盖这些新操作。我早期的实现里,定时任务直接读取“当前最新版本”生成快照,期间恰好有用户提交了新版本,导致快照内容和已记录变更集重叠。后来改为快照任务生成时锁定一个版本号边界,比如生成到版本300,最新版本311,那快照明确是到300为止,301到311的变更集继续保留。

4.4 断线重连恢复流程

客户端断线后,服务端会话仍保留一段时间。重连时需要做增量同步,而不是重新拉全量。客户端重连时上报自己最后一次确认的版本号,服务端基于这个版本号,查询该版本号后的所有变更集,逐条发给客户端。

这里有三种情况:第一种,客户端落后版本不多,可以直接补发变更集。第二种,客户端落后版本太多,比如落后超过100个版本,直接推送100条变更集效率低,不如先发送最新快照,再发送快照之后的少量变更集。第三种,客户端版本号已经被清理,例如旧快照被删除后,无法从某个版本继续回放,那就只能全量拉取当前快照。

我在设计同步恢复时设置了一个简单策略:落后版本数小于50,增量补发;大于等于50,走快照加增量;如果版本对应的快照或变更集已被清理,走全量无优化路径。这个策略的具体阈值可以根据文档平均编辑频率调整。

5. API接口设计:REST与WebSocket协议拆分

多人协同编辑系统的API要分成两部分:REST API负责低频业务操作,WebSocket协议负责高频实时操作。两者数据模型一致,但交互方式不同。

5.1 REST API设计

REST API参考以下设计:

接口方法路径说明
创建文档POST/api/v1/documents创建新文档
获取文档详情GET/api/v1/documents/{docId}获取标题、版本号等信息
获取文档内容GET/api/v1/documents/{docId}/content?version=xx按版本获取快照或内容
添加成员POST/api/v1/documents/{docId}/members给文档添加协作成员
修改成员权限PUT/api/v1/documents/{docId}/members/{userId}设置角色
获取变更历史GET/api/v1/documents/{docId}/changes?limit=50查看编辑历史记录
生成快照POST/api/v1/documents/{docId}/snapshot手动触发快照

获取文档内容这个接口比较特殊,它要支持版本参数。如果请求的版本正好有对应快照,直接返回快照内容;如果没有就返回最近快照加增量变更集。返回体里要有base_version(快照对应版本)和changes(增量变更集列表),客户端可以根据这两个字段恢复内容。

成员管理接口的权限变更必须与协作服务联动。比如某个成员被改成只读,服务端要主动推一条权限变更消息给这个用户的连接。如果他正试图推送insert操作,服务端在校验时直接拒绝。如果被移出文档,还要强制断开他的连接会话,否则他仍然持有旧连接,还能继续推送操作。

5.2 WebSocket协议设计

WebSocket连接地址可以设计为/ws/documents/{docId},连接建立时在query参数或首条消息中携带鉴权token。连接成功后的通信协议我按消息类型来划分:

客户端发往服务端的消息类型:

  • join:加入文档,表示客户端开始监听该文档的实时事件,参数带文档ID和最后已知版本号。
  • op:推送操作变更集,参数包含基础版本号和操作序列。
  • cursor:推送自己的光标位置和选区信息。
  • ping:心跳消息,维持连接活性。

服务端发往客户端的消息类型:

  • ready:加入成功,返回当前文档版本号。
  • remote_op:广播远端用户的操作变更集。
  • remote_cursor:广播远端用户的光标和选区位置。
  • confirm:确认本地操作已应用,返回新版本号。
  • kickout:权限变更或连接被踢下线时通知客户端。
  • reject:拒绝操作,附带拒绝原因和最新版本号。
  • pong:响应心跳。

这里有一个比较重要的细节:confirm和remote_op要区分清楚。confirm是给操作发起者的确认,只发给当前连接,不能发广播;remote_op是给其他用户的增量,要发给该文档除去发起者以外的所有在线用户。如果把confirm误发成广播,会造成其他用户重复执行操作,内容错乱。

5.3 错误码设计

REST和WebSocket共用的错误码,必须覆盖协同编辑的常见异常。我定的错误码大概是这样的:

错误码含义处理建议
401未登录或token过期客户端重新鉴权后重连
403无权限操作提示用户当前是只读模式
409版本冲突,操作无法应用客户端基于最新版本重新拉取增量并合并
422操作序列格式非法检查客户端操作生成逻辑
429操作太频繁,触发限流客户端退避等待后重试
480会话不存在或已过期重新加入文档并走快照恢复

版本冲突错误码409是重点。当遇到409时,客户端不能直接丢弃用户的操作,应该把当前本地内容与服务端返回的最新内容做合并,保留用户尚未确认的输入,然后基于新版本重新生成操作并推送。这个过程要无缝进行,否则用户在输入时突然被“打断”重来,体验会非常差。

6. 技术实现细节:操作转换、存储设计与会话稳定性

6.1 操作转换核心实现思路

OT的难点不在概念,而在实现细节。我用一个简化版的转换思路来说明。

假设有两个操作opA和opB,都基于版本10产生,服务端先应用了opA,现在要把opB转换成基于版本11的新操作,使其能正确应用到opA之后的内容上。

两个操作都是操作原语数组时,转换本质上是用两个指针分别遍历opA和opB的指令序列,比较当前指令影响的位置范围。比如opB当前是insert,opA当前是insert,则opB的位置要增加opA插入的长度;opA当前是retain(n),opB的位置要减去n,直到位置对齐;opA当前是delete(n),如果opB的位置落在删除区间内,则要把opB的位置调整到删除区间开头。

实际项目中的transform代码比这复杂,会涉及更多边界情况,但核心就是这个“基于位置偏移动态调整”的思路。强烈建议把transform实现成纯函数:输入opA、opB,输出opB',不访问任何外部状态。这样单元测试时只需要构造操作序列,就能覆盖大部分并发场景。

6.2 前端编辑器内核选型与适配

前端编辑内核是操作产生的源头,三条技术路线都会涉及编辑器适配。如果全部自研,做文本操作原语化并不困难:监听textarea或ContentEditable的输入事件,记录输入前后文本差异,转换成插入或删除操作。但ContentEditable的细节非常多,浏览器会把粘贴、拖拽、组合输入,尤其是中文输入法都拆成不同事件,处理不好就会出现光标跳跃、内容重复。

我的经验是:优先考虑支持OT的编辑器框架,比如ProseMirror。ProseMirror本身以文档状态模型为内核,每次变更都能得到结构化的步骤,可以很方便地映射成操作变更集。如果团队有富文本需求,用ProseMirror会省大量功夫。如果只做纯文本协同,也可以考虑CodeMirror 6的state与transaction机制,同样能产出结构化的变更。

纯文本协同中,特别注意输入法场景。中文输入时用户连续输入拼音,浏览器会产生compositionstart、compositionupdate、compositionend事件,在composition期间的任何文本变更都不能直接作为操作提交,必须等compositionend后再对比完整文本差异,生成一个批量操作。很多协同编辑器在中文环境下出现“一个字重复输入两次”的问题,绝大多数是因为没有正确处理输入法合成阶段。

6.3 服务端会话管理与状态存储

在服务端实现中,我使用一个SessionManager管理所有在线连接与会话。每个文档对应一个会话集合,保存该文档所有连接的用户信息、版本号、连接状态。操作广播时,向会话集合中所有连接发送增量,除去来源连接。

多实例部署时,每个实例的SessionManager只能管理自己实例上的连接,跨实例广播用Redis Pub/Sub解决。具体是:文档A的实例1收到操作后,先在本地做OT转换和版本提交,然后发布一条消息到Redis频道doc:{docId}:ops,所有订阅该频道的实例收到消息后再转发给自己的连接。这里需要注意消息的去重:实例1自己也会收到Redis广播,需要过滤掉来源连接所在的实例,避免同一连接收到两条重复消息。

保持会话稳定还有一个关键措施:心跳。WebSocket本身有ping/pong,但每层网络设备超时时间不一样,比如某些负载均衡设备会在90秒内自动断开空闲连接。客户端每30秒发送一次ping消息,服务端返回pong,既能确认连接还活着,也能刷新负载均衡的session状态。如果连续三次未收到pong,客户端应主动断开并重连。

6.4 存储优化与并发控制

文档元数据、变更集和快照的存储方案要区分对待。元数据用PostgreSQL,实时状态用Redis,快照放对象存储。

变更集写入PostgreSQL时要注意并发控制。同一文档的两个操作可能同时到达,如果都去更新documents表的current_version字段,会出现脏写。我给documents表增加了version = version + 1的条件更新,也就是带乐观锁的更新方式。实际提交时,根据当前文档版本号是否为操作的基础版本号来决定是否接受。如果不匹配,则走OT转换流程,转换后再尝试提交。为了避免一个文档在密集编辑时频繁触发死锁,我会把同一文档的操作提交串行化,比如按docId进行哈希后分片,同一个文档的操作路由到同一台实例处理,减少跨实例竞争和版本冲突。

操作量很大时,批量写入和批量广播都很有必要。客户端本地可以做一个100毫秒的聚合窗口,把窗口内的操作合并成一个大操作再推送,减少网络包数量。但注意聚合窗口不能太长,否则交互延迟会超过200毫秒,影响打字体验。服务端广播时也可以采用批量发送,WebSocket允许在一次send中携带多条消息,能明显降低框架层的消息开销。

7. 常见问题与排查技巧实录

多人协同编辑系统排障比普通项目难,因为问题经常是“偶发”“只在多人同时操作时出现”。我整理了实战中遇到的高频问题和排查思路,做成速查表。

现象可能原因排查方法
用户A能看到自己的修改,但B看不到跨实例广播未打通,或广播时过滤逻辑错误检查Redis频道是否订阅成功,在广播代码中加日志打印连接ID和目标连接ID
重连后文档内容重复断线期间本地操作未确认,重连后既发了增量又发了本地缓存操作重连时先清空未确认操作缓存,基于服务端最新版本做合并
中文输入时文字重复未处理输入法composition事件检查编辑器是否在composition期间提交操作,禁止该阶段发送
两个人同时编辑时偶发内容丢失OT转换没有覆盖delete与insert交叉场景打开双方操作日志,重放操作序列,用OT转换纯函数测试回归
服务端CPU高,但连接数不多每个操作都触发全量内容序列化广播优化为增量操作广播,禁止广播时发送整个文档内容
新用户加入很慢每次都回放全部变更集检查快照生成是否正常,加入时优先加载快照
WebSocket连接频繁断开负载均衡空闲超时配置太短调整LB超时到120秒以上,客户端增加心跳保活
踢人后用户仍能继续编辑只更新了数据库权限,未断开连接踢人时必须主动关闭连接,并让协作服务拒绝该用户的后续操作

其中“重连后文档内容重复”这个问题我印象最深。最开始设计时,客户端在断线期间继续接收用户输入,生成了本地操作但没有确认。重连成功后,服务端补发了断线期间的增量操作,客户端本地把这些增量应用到已有内容上,同时又把断线期间产生的本地操作推送给服务端,两边都执行了同一批内容,导致重复。解决办法是重连成功后,先暂停本地编辑操作,等服务端增量补齐完毕,再拿服务端最新版本与本地内容做一次统一合并,合并完成后再恢复可编辑状态。

限流也要做。极端情况下某个用户疯狂点击粘贴,会产生大量操作,打满服务端。限制策略可以按连接维度做令牌桶,比如每个连接每秒最多提交50个操作原语,超过直接返回429。这不会影响正常打字速度,因为普通用户每秒输入的操作通常不超过20个。

关于监控,我建议重点盯三个指标:操作延迟(客户端发出到收到confirm的时间)、广播成功率、文档版本增长速率。操作延迟超过500毫秒就需要排查网络和Redis性能;广播成功率下降通常意味着Redis连接异常;版本增长速率异常说明某篇文档正在被密集编辑,可以为它单独扩容。

最后再补充一个多实例部署时特别容易踩的坑:OT转换和版本提交必须保证原子性。如果服务端在应用操作时先更新了版本号,再写变更集,中途进程崩溃会导致版本号跳跃,客户端收到新版本号却找不到对应变更集,永久卡在增量同步状态。我的做法是使用数据库事务:把版本号更新、变更集插入和文档内容最新状态更新放进同一个事务,任何一步失败则整体回滚。这个看似微小的实现细节,决定了整个同步链路的一致性边界。

根据我个人实际项目的经验,真要完整落地一套多人协同编辑系统,建议把复杂度控制在自己团队的消化能力范围内。第一版不要追求跨数据中心CRDT和全离线能力,先保证同一房间内几十到几百人实时编辑稳定不丢字,再逐步扩展。做好操作原语化、版本管理、OT转换和会话恢复这四个核心点,这个系统就能支撑起主流的在线文档协作场景了。

返回列表