拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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 的操作进行变换,让它适应当前版本。我实现时是在内存里暂存最近一段时间的所有操作,客户端操作进来后与这些操作逐个做转换,转换后再应用并广播。

你可以把整个过程理解成“排队叫号”:每个操作都有一个期望的版本号,服务端就是号码屏幕,只有当你的号码和当前号码一致时你才被受理,不一致时,就把你的号码按当前排队顺序调整后再受理。这个调整的动作,就是 OT(Operation 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 },code=0表示成功,非 0 表示业务错误。分页参数统一为page和pageSize,pageSize最大 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?token=<jwt>,连接建立后客户端首先发送doc.join事件进入文档房间,服务端返回doc.joined,携带当前版本号、最近快照引用和最近操作列表。通信协议统一是 JSON 消息,每条消息带一个seq字段,客户端和服务端都靠它做消息顺序校验。

核心事件我用一张表列出来:

事件名方向说明
doc.joinC -> S加入文档房间
doc.leaveC -> S离开文档房间
op.applyC -> S提交编辑操作
op.broadcastS -> C广播其他用户的操作
doc.ackS -> C操作确认,携带 serverSeq
presence.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 后,才能把clientSeq=17这条操作从未确认队列里删掉。如果服务端在冲突处理时对操作做了变换,那么 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 的取舍

这是多人协同编辑技术方案里最核心的岔路口。OT(Operational Transformation)和 CRDT(Conflict-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 插入字符 x,B 在位置 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 能力。协同编辑这类系统,稳定性和数据一致性永远是第一位的,体验优化都排在后面。

返回列表