ARTICLE DETAIL

资讯详情

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

多人协同编辑系统架构设计:从并发冲突到实时同步的完整方案

多人协同编辑系统架构设计:从并发冲突到实时同步的完整方案 你是不是也遇到过这种现场几个人同时打开同一篇文档A 在第三段补了一句B 在同一段改了两个字然后大家看到的内容互相覆盖光标在屏幕上来回“漂移”最后甚至出现字不见了的情况。文档多人协同编辑这块表面看就是把编辑器接上一个 WebSocket实际上要把“并发写入、顺序一致、断线恢复、权限控制、历史回溯”这些都扛住工程量一点不比做一个支付系统小。我做协作相关功能整整折腾了几年从最开始的“能改能存就行”到后来被线上问题逼着把架构、领域模型、接口协议全重新设计了一遍。这篇文章就是把我沉淀下来的完整技术方案整理出来包含系统架构、核心领域模型、业务流程设计、API 接口文档和技术实现细节。我不打算写“教科书”就按照我实际会怎么搭、怎么踩坑、怎么排查来聊适合准备做协作功能的后端、全栈和前端同学参考。1. 整体设计与架构思路1.1 协同编辑到底难在哪先拆清楚要解决的问题说到多人协同编辑很多人第一反应是“实时同步”但在真实场景里这件事会拆成好几层问题。首先是并发写入两个人同时改同一行时系统必须保证最终大家都看到同一个结果而且不能出现内容丢失或顺序错乱。其次是实时同步的延迟如果用户打了几个字要等 500ms 才显示出来体验基本就废了。再往下还有断线恢复、历史版本、权限变更、离线编辑和内容审计任何一个没考虑清楚都会在某个版本上线后炸出来。我自己习惯把协同编辑类比成“多人同时记账”不是每个人记自己的账然后后台合并而是要保证所有人在同一本公开账本上按统一顺序记账。每个人产生的每一笔“操作”都要有明确的流水号、时间顺序和归属人。这样一来哪怕中途有人断线重连也能把缺掉的流水补回来哪怕两个人先后改了同一处内容也能通过顺序机制确定哪一次修订是有效的。这里要提醒一句别一上来就设计微服务先把业务闭环打通。我见过太多团队一开始就把用户、文档、协作、通知拆成四个服务结果光是跨服务事务和接口联调就拖了一个月。对中小团队来说模块化单体反而是更稳的起步方案。把文档服务、协作服务、权限服务在代码层面分成清晰模块数据库共存后期确实有独立拆分的必要再拆。1.2 分层架构与模块划分我最终落地的方案是经典的四层结构客户端 SDK、接入层、协作服务、文档服务。客户端 SDK 负责编辑器内核、本地操作生成、操作缓存和网络重连接入层承担 HTTP API 和 WebSocket 的网关职责统一做鉴权、限流、协议解析协作服务是最核心的一层负责维护在线房间、操作序列、广播和冲突处理文档服务则负责文档元数据、权限关系、版本记录的持久化。在这个结构里协作服务和文档服务为什么要分开因为协作服务本质上是一个高并发、有状态的内存系统它需要把当前在线用户、操作队列、未确认的消息都存在内存里才能做到低延迟。而文档服务是典型的持久化系统处理的是“最终落库”这件事。二者混在一起会导致数据库连接被高频操作拖垮而且协作服务一旦扩容很难把内存态平滑迁移。接入层的设计也要多想一步。所有 WebSocket 连接进来之后不能直接打到协作服务而是先经过一个轻量级的网关完成 token 校验和连接参数解析。这样做的好处是即使协作服务升级或重启网关还可以维持连接客户端不用感知后端变化。客户端的连接状态管理也全部封装在 SDK 里业务层只需要调用joinDocument、applyOperation、onAck这几个方法。技术选型上我给出的是当前比较成熟的一套组合。后端可以用 Go 或 Node.js前者适合高并发转发后者如果前端团队全员 JavaScript 会更容易上手。实时通信用 WebSocket房间内消息广播走 Redis Pub/Sub文档元数据和权限存 PostgreSQL大的历史快照丢对象存储。前端编辑器内核建议直接用成熟库比如 ProseMirror 或 Slate再在它上面叠加协作协议。2. 核心领域模型设计2.1 领域模型全景与名词解释领域模型是整个方案的地基模型没定义好后面写 API 和算法都会很别扭。我按“用户与权限、文档内容、协作会话、周边能力”四个维度来划核心领域对象如下。领域对象核心职责关键字段生命周期User用户身份userId, name, avatar独立于文档存在Document文档元数据docId, title, ownerId, status, createTime从创建到归档DocumentMember文档成员与角色docId, userId, role, joinTime随加入/移除而变CollaborationSession协作会话上下文sessionId, docId, userId, connId, status一次实时连接的生命周期ClientPresence在线状态与光标信息userId, cursorPos, selection, lastActiveAt连接存续期间Operation文档编辑操作opId, clientId, clientSeq, serverSeq, version, type, payload产生后不可变DocumentVersion文档版本号docId, version, snapshotRef, createTime递增不回退Snapshot历史内容快照snapshotId, docId, version, contentRef随版本定期生成Comment评论与标注commentId, docId, anchor, content, authorId, replyTo逻辑删除AuditLog审计日志logId, userId, action, target, detail只追加不修改单独解释一下 CollaborationSession。它不等于“用户”而是“某用户某次连接”的上下文。同一个用户可能在电脑和手机上同时打开文档那就是两个 Session。Session 里保存着连接状态、当前同步到的版本号、未确认操作队列。重连时Session 可能被销毁重建但用户身份不会变这是一个很重要的区分。还有 Operation 这块我强调它是“不可变”的。一旦客户端提交、服务端确认、版本号递增这个操作就永远不能修改。如果发现有错误操作解决的方案不是去改操作而是追加一条“补偿操作”把它抵消掉。这种设计对审计和重放特别友好你想回放某个时刻的内容直接把所有操作按 serverSeq 排好重放一遍就行。2.2 模型关系与一致性约束模型之间不是随便挂个外键就行有几条约束是必须写清楚、并且在实现时用代码和数据库双重保证的。第一条约束文档版本严格递增。每次服务端确认一个操作版本号加一。这个版本号是全局唯一的用来对齐所有客户端的同步进度。第二条约束操作序列有序。同一个文档的所有操作在服务端都有一个单调递增的 serverSeq客户端在应用广播时必须按照 serverSeq 的顺序应用不能按收到的时间先后应用否则会乱。第三条约束角色权限约束。owner、editor、commenter、viewer 四类角色权限是降级的owner 可以删文档editor 能编辑内容commenter 只能加评论viewer 只能看。权限的校验既要放到文档服务做也要在协作服务里做。关系上Document 对 DocumentMember 是一对多Document 对 Operation 是一对多CollaborationSession 对 ClientPresence 是一对多Comment 通过 anchor 字段挂在文档内容的某个位置。anchor 的设计要特别说明它不能简单记成“第 N 段第 M 个字符”因为文档一直在变。更稳妥的做法是记录一个起始操作 id 或一个稳定的节点 id关联到内容结构树上的位置。还有个数据一致性问题是很多团队容易忽略的数据库里的文档内容可能和操作日志不一致。比如你把最新内容存成了一个字段每次操作都 UPDATE 一次那么一旦某个操作重复执行或丢失这个字段就会和数据字典里的版本对不上。我的建议是数据库里不存“最新内容”这个冗余字段而是只存操作日志和定期快照最新内容永远通过“最近快照重放到最新版本”计算出来。查询压力大就加一层缓存缓存失效后重建。2.3 文档内容、操作日志与快照的关系把“文档内容”这样东西拆成“快照 操作日志”两个数据载体是协同编辑领域很经典的做法。快照相当于银行账户的余额操作日志相当于每一笔流水。余额能让你快速知道现在有多少钱但只有流水能说清楚钱是怎么来的。对一个长期运营的协同文档系统来说流水比余额更重要。快照不能存得太密否则存储成本高也不能存得太疏否则恢复时要重放大量操作。我实际使用的策略是每个文档每累积 100 次操作或者每当运行时间超过 5 分钟且期间有操作就生成一个快照。同时保留最近 50 个快照和全部操作日志超过 50 个快照后把更老的快照归档到对象存储并在数据库里只保留引用。新用户加入协作时不要让他从第 1 个操作开始同步而是找到他加入时最近的一个快照把快照拉下来然后只补快照之后的增量操作。这样能把新用户的同步时间从几十秒压缩到一两秒。这也解释了为什么领域模型里必须有 Snapshot 和 DocumentVersion它们是协同效率和恢复能力的地基。3. 业务流程设计3.1 核心业务流程总览领域模型定清楚之后业务流程就是把模型串起来跑。我把核心流程归纳为六条主线文档创建与初始化、协作者邀请、实时编辑会话、冲突处理、版本保存与回溯、权限变更与踢出。文档创建与初始化这条路比较简单。用户创建文档时文档服务生成 docId 和初始版本号 0同时创建一条初始快照“空文档”然后返回给客户端。这里有个细节不要等到第一个编辑操作才初始化协作会话而是创建文档后立即在协作服务里注册一个空的会话对象这样后续成员加入时能快速定位。协作者邀请流程上owner 或 editor 调用邀请接口传入被邀请人邮箱和角色。文档服务写入 DocumentMember 记录生成一条通知事件。被邀请人点击链接后前端先调用“加入文档”接口获取文档基础信息和会话凭证再建立 WebSocket 连接。如果被邀请人还没有账号系统可以走临时邀请码流程让受邀者在 72 小时内注册并绑定。实时编辑会话是整个系统的核心流程我会在下一节单独拆。版本保存这边我采用的是“每次操作落库 定期快照”的模式而不是单独设计一个“保存版本”按钮。用户或者管理员需要回溯历史时直接查版本列表选中某个版本后发起回滚。回滚并不是把当前内容删掉而是生成一条“重置操作”把文档内容设置到目标版本的状态这样保留完整的审计链。权限变更与踢出流程有一个很容易踩的坑权限改了但已经在线的用户不会马上失效。必须在文档服务更新权限后通过 Redis 发布一个member_permission_changed事件协作服务收到事件后根据新角色决定是否踢出连接。比如把某个 editor 改为 viewer他当前打开的会话要立刻降级为只读同时推送一个permission_updated消息给客户端。3.2 实时编辑与冲突处理的时序实时编辑的正常流程可以分为六个步骤。第一步客户端编辑产生一个本地 Operation此时客户端先乐观更新界面让用户感觉输入无延迟。第二步客户端把 Operation 通过 WebSocket 发给服务端同时把这条操作放入“未确认操作队列”。第三步协作服务收到操作后校验文档版本和权限。第四步如果服务端当前版本与操作基于的版本一致接收操作并递增版本号如果不一致进入冲突处理逻辑。第五步服务端把操作广播给房间里所有其他客户端。第六步服务端返回一个确认消息给发送方客户端客户端收到确认后把这条操作从未确认队列移除。这里最关键的是第四步的校验。假设客户端 A 发送操作时基于版本 10但服务端当前版本已经是 12说明期间有别的操作被发布了。服务端不能直接丢弃 A 的操作也不能无脑接收而是要先把 A 的操作进行变换让它适应当前版本。我实现时是在内存里暂存最近一段时间的所有操作客户端操作进来后与这些操作逐个做转换转换后再应用并广播。你可以把整个过程理解成“排队叫号”每个操作都有一个期望的版本号服务端就是号码屏幕只有当你的号码和当前号码一致时你才被受理不一致时就把你的号码按当前排队顺序调整后再受理。这个调整的动作就是 OTOperation Transformation里的 transform 函数做的事情。如果选用 CRDT 的方案则在客户端本地就可以完成大部分合并服务端主要做记录和广播我在后面技术细节部分会展开。# 伪代码协作服务端接收操作的核心流程 async def handle_operation(session, op): doc await get_document(session.doc_id) if doc.version ! op.base_version: transformed_ops await transform_op(op, doc.pending_ops) for top in transformed_ops: await apply_and_broadcast(doc, top) else: await apply_and_broadcast(doc, op)这段伪代码里base_version就是客户端操作基于的版本号。每个操作还必须带一个全局唯一的opId服务端在落库和广播时都按opId做幂等避免网络重放导致同一操作被应用两次。3.3 断线重连、离线编辑与增量恢复断线重连是多人协同编辑里最容易被低估的问题。我见过一个线上事故用户在公司网络切换的瞬间写了一段 500 字的内容结果切换回来之后内容消失用户当场炸毛。要处理这种场景客户端 SDK 必须做三件事未确认队列、重连补偿和增量同步。未确认队列指客户端本地产生操作后在没有收到服务端 ack 之前所有操作都缓存到队列里。断线重连后先把这些未确认操作重放到本地编辑器上保证用户看到的内容不丢再重新连接服务端。增量同步则是连接恢复后客户端带着最近一个已确认的 serverSeq 去问服务端“从这个序号之后产生了哪些操作”服务端把缺失的操作返回给客户端客户端把这些操作应用到本地编辑器完成状态对齐。我在设计重连策略时使用指数退避加随机抖动。第一次重连延迟 1 秒第二次 2 秒第三次 4 秒最多延迟 30 秒。每次重连前先发一个sync_req消息携带last_seq服务端返回sync_resp时附带缺失操作和当前版本号。如果客户端本地未确认的操作与服务端新同步下来的操作发生冲突本地操作会被重新变换后再应用这步通常直接交给协作库处理。离线编辑这块要看你的选型。如果用的是 CRDT天然支持多端离线修改之后合并不需要服务端做复杂变换。如果用的是 OT离线编辑会麻烦一些因为离线产生的操作可能基于一个很旧的版本服务端要做多重变换。我的经验是如果产品需求明确有“移动端离线编辑”或“本地优先”场景建议直接上 CRDT如果只考虑 Web 在线协同OT 反而更可控。3.4 权限变更与会话踢出流程权限变更最容易出问题的不是权限本身而是会话状态没有同步。文档服务把权限更新落库之后抛出事件协作服务消费事件后更新内存中的权限缓存。这里我特意要求在协作服务里不要每次都查数据库判断权限而是启动时加载文档成员列表到内存然后订阅变更事件。这样做的好处是操作路径上少一次数据库查询延迟更低。踢出流程的幂等性也要考虑到。比如管理员重复调用移除接口第一次已经把这个用户踢出第二次如果还当成一场新的踢出来处理就会误伤其他同名会话。我的做法是在 Session 上记录成员版本的标识只有成员当前版本与事件携带的版本一致时才执行踢出。踢出之前服务端会向该用户的所有 Session 推送一个member_removed或permission_updated消息然后延迟 2 秒关闭连接给客户端留出保存现场的时间。千万不要直接关闭 TCP 连接否则客户端看到的只是“连接断开”根本不理解发生了什么。4. API 接口文档设计4.1 接口设计原则与统一约定API 是业务的门面我把接口分成两块REST 接口负责文档管理、成员管理、版本查询、评论等低频操作WebSocket 负责实时编辑、光标同步、在线状态等高频消息。两者共用同一套鉴权体系JWT token 既放在 HTTP Header 里也放在 WebSocket 连接参数里。统一约定上所有 REST 接口的 base path 是/api/v1鉴权方式为Authorization: Bearer token。响应结构统一为{ code, message, data, requestId }code0表示成功非 0 表示业务错误。分页参数统一为page和pageSizepageSize最大 100。所有写接口要求返回Idempotency-Key支持防止客户端超时重试时产生重复数据。错误码这块我单独拎出来设计因为实际运营中至少一半的反馈都是“接口报错但不理解错了什么”。我把自己用过的错误码整理成了统一字典HTTP 状态码只承担粗粒度分类真正的业务错误靠code字段说明。4.2 核心 REST 接口明细用户相关的接口相对常规注册、登录、获取当前用户信息。文档相关的接口是业务重点我列一下我觉得最关键的一组。接口方法说明/api/v1/documentsPOST创建文档/api/v1/documents/{docId}GET获取文档详情与下载凭证/api/v1/documents/{docId}PATCH修改文档标题、状态/api/v1/documents/{docId}DELETE删除文档逻辑删除/api/v1/documents/{docId}/membersPOST邀请成员/api/v1/documents/{docId}/membersGET获取成员列表/api/v1/documents/{docId}/members/{userId}PATCH修改成员角色/api/v1/documents/{docId}/members/{userId}DELETE移除成员/api/v1/documents/{docId}/versionsGET获取版本历史/api/v1/documents/{docId}/versions/{version}/rollbackPOST回滚到指定版本/api/v1/documents/{docId}/commentsPOST添加评论/api/v1/documents/{docId}/commentsGET获取评论列表创建文档的请求示例和响应结构大概是这样的// POST /api/v1/documents // Request { title: 产品需求文档, templateId: template_common, initialContent: {} } // Response { code: 0, message: ok, data: { docId: doc_8f3k2d, title: 产品需求文档, ownerId: user_1024, version: 0, createTime: 2025-03-21T10:00:00Z, shareUrl: https://app.example.com/doc/doc_8f3k2d }, requestId: req_6f2a91c0 }这里有个设计细节shareUrl由服务端生成而不是前端拼。这样以后要加访问控制、短链或过期时间时不用改前端只改服务端生成逻辑就行。分享链接本身不携带任何权限信息打开链接的人还需要经过一次“加入文档”的鉴权步骤防止链接泄露导致数据泄露。版本接口的rollback是高频误操作的高发区。实现时必须校验调用者身份是 owner 或 editor并且建议在回滚前创建一个版本存档点确保误回滚之后还能再回滚回去。这是我的经验教训不保留回滚前的版本就等于把“后悔药”扔了。4.3 WebSocket 事件协议WebSocket 连接地址是/api/v1/ws?tokenjwt连接建立后客户端首先发送doc.join事件进入文档房间服务端返回doc.joined携带当前版本号、最近快照引用和最近操作列表。通信协议统一是 JSON 消息每条消息带一个seq字段客户端和服务端都靠它做消息顺序校验。核心事件我用一张表列出来事件名方向说明doc.joinC - S加入文档房间doc.leaveC - S离开文档房间op.applyC - S提交编辑操作op.broadcastS - C广播其他用户的操作doc.ackS - C操作确认携带 serverSeqpresence.updateC - S本端光标/选区更新presence.broadcastS - C广播在线状态member.updateS - C成员权限变更通知doc.sync_reqC - S请求增量同步doc.sync_respS - C返回缺失操作doc.errorS - C错误上报举一个op.apply消息的例子这是最核心的实时消息。// Client - Server { type: op.apply, data: { docId: doc_8f3k2d, baseVersion: 101, ops: [ { type: retain, len: 42 }, { type: insert, text: 这里是新增内容 }, { type: retain, len: 8 } ], clientId: client_abc123, clientSeq: 17 } } // Server - Client (ack) { type: doc.ack, data: { docId: doc_8f3k2d, clientId: client_abc123, clientSeq: 17, serverSeq: 102, version: 102 } }这里clientSeq是客户端本地操作序号serverSeq是服务端确认后的全局序号。客户端只有收到 ack 后才能把clientSeq17这条操作从未确认队列里删掉。如果服务端在冲突处理时对操作做了变换那么 ack 里会额外带回transformedOps客户端要用变换后的操作更新本地状态。4.4 边界条件、限流与错误码处理接口文档写得再漂亮边界条件处理不好照样线上出事。我遇到最多的问题之一就是鉴权失败。token 过期后客户端会收到 HTTP 401很多团队的处理方式是直接弹窗“登录已过期”但用户在文档里写了一半的内容就没了。正确做法是客户端在收到 401 时先尝试用 refresh token 换取新的 access token然后基于原请求重放一次。WebSocket 连接里出现 401 时同样走这个逻辑不能直接断开。还有一个高频边界是操作体积超限。一个文档可能被用户同时粘贴了几万字操作体非常大如果不加限制会被当成攻击流量。我在网关层设置了单次op.apply消息体最大 1MB超过就返回 413 错误并提示用户分批粘贴。这个限制要在客户端先做不要等服务端打回来否则用户会觉得系统很卡。限流策略上我按用户和按文档两个维度限制。单用户每秒最多提交 30 个操作单文档每秒最多接受 200 个操作。超限返回 429客户端收到后要退避重试不能死循环。所有限流数据放在 Redis 里用滑动窗口实现方便多实例共享。记住限流是为了保护系统但一定要给客户端一个明确的 Retry-After 头否则客户端不知道等多久。5. 技术实现细节与核心难点5.1 一致性算法选型OT 与 CRDT 的取舍这是多人协同编辑技术方案里最核心的岔路口。OTOperational Transformation和 CRDTConflict-free Replicated Data Type都能解决并发冲突但思路完全不同落地难度也不一样。OT 的核心思路是“变换”。服务端作为中心仲裁者把每个操作与并发操作做转换使所有客户端都能最终收敛到一致状态。它的优点是模型直观基于文档位置的插入、删除、保留操作很容易理解缺点是变换函数极其复杂尤其是富文本、嵌套结构、多光标场景很容易写出边界 bug。推进 OT 时一定要有充足的测试用例覆盖各种交叉操作顺序。CRDT 的核心思路是“类型本身保证无冲突”。每个节点产生的操作带有唯一的标识符比如 UUID 序号合并时根据特定规则如 LWW、因果序决定取舍不需要中心仲裁。它天然支持离线编辑和去中心化但缺点是数据结构和存储模型比较复杂如果不做压缩操作日志会膨胀得很快。我实际落地时的选择建议是文本类文档编辑器直接使用成熟的 CRDT 库比如 Yjs、Automerge不要再自研富文本协同如果团队已有 ProseMirror/Slate 编辑器内核优先选择与编辑器深度集成的协作方案多人在线表格这类结构化数据OT 或者定制 CRDT 都要面对严格的坐标映射需要专项设计。选型之后服务端的职责也要明确。即使是 CRDT服务端也不能彻底不管一致性至少要承担权威记录、广播、权限校验、审计的角色。不要天真地以为“用了 CRDT 就可以没有服务端了”生产环境里服务端缺席等于放弃了权限和审计能力。5.2 操作模型与基本变换逻辑不论选 OT 还是 CRDT操作模型都会长得比较像核心是三类原语retain跳过、insert插入、delete删除。比如把文档看成字符数组在位置 42 插入“你好”操作就是[{ retain: 42 }, { insert: 你好 }]。delete 则用{ retain: 50 }配合{ delete: 2 }表示删除 50 之后的 2 个字符。变换逻辑解决的是“两个基于同一版本的操作如何合并”。假设两个用户同时基于版本 10 编辑A 在位置 5 插入字符 xB 在位置 8 插入字符 y。如果 A 的操作先被服务端接收版本变为 11那么 B 的操作到达时它的插入位置 8 已经因为前面插入的 x 而向右偏移了 1 位服务端需要把 B 的操作变换成“在位置 9 插入 y”。这就是最基本的 transform 过程。// 一个简化版的位置变换逻辑 function transformAgainst(op, prevOp) { // 以 prevOp 的插入为例把 op 中所有大于等于插入位置的位置向后偏移 const insertPos prevOp.insertPos; const newOp JSON.parse(JSON.stringify(op)); for (const step of newOp.ops) { if (step.type retain) { // 位置跨越插入点则调整长度 } } return newOp; }真实实现远比我这个简化逻辑复杂因为一个操作里包含多个步骤每个步骤都要与另一个操作的每个步骤交叉变换。这也是我强烈建议使用成熟库的原因自研 OT 变换函数没有几个月测试打磨根本不敢上生产。5.3 WebSocket 连接管理实战连接管理是协同编辑体验的“生命线”我总结了一套比较耐用的连接状态机连接中 - 已认证 - 已加入房间 - 会话中任意时刻因为网络原因断开进入重连中重连成功且增量同步完成后恢复为会话中。每个状态都要有对应的超时阈值和用户提示。心跳策略我采用“客户端每 30 秒发一次 ping服务端在 90 秒内未收到任何消息就判定连接失效”。这里要注意应用层消息也算活跃消息不用只盯着 ping。如果 90 秒内没有任何数据包服务端主动关闭连接并通知房间内其他成员该用户离线。重连时客户端会带上一个sessionResumeKey服务端利用这个 key 找到断线前的 Session恢复未 ack 的操作。但如果服务端已经将这个 Session 标记失效比如超时 5 分钟客户端就要走“全新加入房间”流程重新拉取快照。把这两个流程分开能避免把新会话误当成旧会话恢复降低数据混乱的概率。连接建立后我还会做一次“房间路由”。在单机部署下无所谓但在多实例部署时同一个文档的所有连接要尽量打在同一个协作服务实例上。我用一致性哈希按 docId 路由同时通过 Redis Pub/Sub 广播跨实例消息。如果房间状态都在内存里不做跨实例同步那么路由错一个实例用户就会看到两个互相不同步的“平行世界”。5.4 性能优化与水平扩展性能优化的目标数字我给自己定的是操作广播 p95 延迟低于 100ms用户感知输入延迟低于 200ms单文档支持 100 人同时在线编辑不卡顿。要达到这个目标必须从协议、代码、部署三个层面同时下手。协议层优化最实际的一招是合并小操作。用户在输入时可能在几百毫秒内连续产生多个单字符插入操作我会在客户端做一个 100ms 的合并窗口把同一位置附近的连续插入合并成一个操作提交。这一下能让服务端的消息量减少 60% 以上。另外广播消息里的在线状态可以每 2 秒合并一次不需要每个光标移动都推全量坐标只推变化值。服务端的内存管理也很重要。每个连接都持有未确认队列、用户信息、光标信息如果连接数上万内存很容易失控。我给每条连接设置了内存配额超过配额直接强制重连让客户端重新走轻量级同步。同时操作历史不会无限保留在内存里最多保留最近 2000 个操作更早的操作交给快照机制。水平扩展方面前期单实例协作服务可以扛住几千连接但不要等到扛不住才考虑扩展。我建议从第一天就把广播层设计成 Redis Pub/Sub 模型。每个文档在 Redis 上有一个 channel操作消息发布到 channel所有订阅了这个 channel 的协作服务实例都能收到并推送给连接在该实例上的用户。这样即使某个实例挂掉其他实例也能接管新连接。5.5 数据一致性与持久化策略数据持久化是整个系统最后的兜底。我对操作日志的持久化策略是“先落库再 ack”。客户端操作到达服务端后先写入操作日志表通过数据库主键唯一约束保证 opId 不重复成功后再向客户端发送 ack。绝对不能先 ack 再落库否则服务端一旦宕机客户端以为操作成功了实际上操作丢了。操作日志表本身也会膨胀所以要有归档机制。每个文档都有一个当前快照快照生成之后快照之前的操作日志可以移到冷存储。但要注意审计需求要求操作日志保留一定年限不能随便删。我会在归档表里保留操作全量只是把“热存储”转到“冷存储”查询历史时通过版本号定位到对应快照再重放增量。最后我要坚持一个原则所有写操作都带时间戳和用户身份所有读操作都走版本号对齐。系统里不能存在“无主的修改”每一条内容变更都能追溯到操作 id、用户 id 和精确到毫秒的时间。这个设计在出问题复盘时价值极大在线协作文档这类业务里内容丢了或错了最怕的就是查无实据。6. 常见问题与排查技巧实录6.1 高发问题速查表真实环境里遇到的问题往往不是你设计时会想到的问题。我整理了一张高发问题速查表基本覆盖了协作系统上线后最常见的几类故障。症状可能原因排查手段解决建议多人编辑时字突然消失客户端未按 serverSeq 顺序应用操作查操作日志对比各端 serverSeq客户端严格按序号排队应用光标不断跳动、选区异常广播消息覆盖了本地临时状态抓包看广播顺序和内容广播只发增量不覆盖本地未确认断线重连后内容不一致增量同步漏了操作或版本对齐错误对比 last_seq 与操作日志重连时全量核对 serverSeq用户操作偶发报 401token 过期未及时刷新看网关日志时间点接入 refresh token 重放机制WebSocket 频繁断开心跳超时或服务端主动断开查服务端断连日志调整心跳周期与超时阈值内存持续上涨Session 未释放或队列堆积看内存监控与连接数增加内存配额与离线清理任务单文档操作延迟升高大量操作广播导致 CPU 飙高查消息量与处理耗时开启合并窗口与批量广播我自己在线上踩得最狠的一个坑是“未按 serverSeq 应用广播”。当时客户端收到多少消息就立刻应用多少结果两条不同连接的操作因为网络延迟到达顺序倒了过来导致用户看到一段文字刚插入又消失。排查了很久才发现应用层根本不能依赖“到达顺序”必须依靠服务端下发的 serverSeq 做二次排序。6.2 排查问题的方法论排查协同问题我有一套固定的方法论否则会淹没在大量信息里。第一步是录痕。每个操作消息在服务端都打日志字段为 opId、clientId、clientSeq、serverSeq、version、userId、时间戳、操作内容摘要。为了方便检索每条日志都会带 requestId客户端上报问题时会附上这个 id我就能直接定位到整个操作链路。第二步是重放。拿到操作序列之后在本地测试环境按 serverSeq 顺序重放复现内容错乱现场。相比在线上猜测重放几乎能 100% 复现问题而且能精确知道是哪一步开始出错的。所有协作库的测试都要把重放能力做成标配。第三步是灰度。任何涉及操作协议、变换逻辑或存储格式的改动都必须走灰度发布只放量一部分文档或一部分用户。协作系统最怕的就是协议不兼容导致老客户端产生错误操作灰度能显著降低爆炸半径。还有一个容易被忽略的点监控面板上除了看 RPS、CPU、内存一定要看“操作广播延迟”和“未确认操作堆积数”。操作广播延迟是用户体验最直接的指标未确认操作堆积数则是连接是否健康的风向标。这两个指标一告警基本说明客户端或服务端某一侧的同步链路出问题了。6.3 部署与发布注意事项最后说几句部署和发布的事。协作服务是有状态服务虽说做了 Redis Pub/Sub 之后可以多实例容灾但发布时仍然建议采用“先扩容、再滚动替换、最后缩容”的方式。千万不要直接 kill 掉所有旧实例再启动新实例那会导致所有在线用户同时断线重连产生连接风暴。数据库表结构变更也要小心。操作日志表、快照表都是超大表直接加索引或锁表迁移很可能造成写入阻塞。我建议用在线 DDL 工具或者先创建新表双写确认后再切换。这些底层表一旦出问题整个协同链路都会瘫痪。压测的时候不要只测单文档并发还要测多文档、多实例、大量离线重连的场景。我自己压测时踩过一个问题单文档 50 人同时在线没问题但 500 个文档同时各 50 人在线Redis Pub/Sub 的消息量直接翻了几十倍需要给 Redis 预留足够的带宽和内存。7. 一些落地经验和后续扩展方向整套方案写到这里我个人最想强调的经验是不要一上来就自研核心算法。无论 OT 还是 CRDT都建议站在成熟库的肩膀上实现把精力放到架构、协议、数据持久化这些真正影响稳定性的地方。协作系统的核心技术难点往往不是算法本身而是工程落地的各种边界条件。我最后悔的一个决定是在项目早期为了“灵活可控”选择了自研 OT 变换结果花了整整一个迭代在修各种并发冲突 bug反而耽误了业务上线。后来替换成成熟方案只花了两周时间完成了接入和联调稳定性还提高了不少。这个教训让我一直保留到现在。后续想扩展的方向也很明确。第一是评论与 提醒在文档里做锚点评论核心难点是锚点位置在内容变化后如何保持稳定。第二是协同表格表格的坐标映射和行列插入比文本复杂很多需要独立设计。第三是移动端离线编辑这基本只能靠 CRDT 实现服务端协议要做兼容升级。第四是 AI 辅助写作AI 生成内容也要作为一条“操作”写入协作流这样才能让所有人看到 AI 生成的过程而不是一次性贴一段最终文本。如果你们团队正要启动类似功能我的建议很简单先做透“单人能流畅编辑、双人并发不丢字、断线重连不丢内容”这三个基础场景再考虑花哨的多光标、版本对比和 AI 能力。协同编辑这类系统稳定性和数据一致性永远是第一位的体验优化都排在后面。
返回列表