你是不是也遇到过这种现场:几个人同时打开同一篇文档,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/documents | POST | 创建文档 |
| /api/v1/documents/{docId} | GET | 获取文档详情与下载凭证 |
| /api/v1/documents/{docId} | PATCH | 修改文档标题、状态 |
| /api/v1/documents/{docId} | DELETE | 删除文档(逻辑删除) |
| /api/v1/documents/{docId}/members | POST | 邀请成员 |
| /api/v1/documents/{docId}/members | GET | 获取成员列表 |
| /api/v1/documents/{docId}/members/{userId} | PATCH | 修改成员角色 |
| /api/v1/documents/{docId}/members/{userId} | DELETE | 移除成员 |
| /api/v1/documents/{docId}/versions | GET | 获取版本历史 |
| /api/v1/documents/{docId}/versions/{version}/rollback | POST | 回滚到指定版本 |
| /api/v1/documents/{docId}/comments | POST | 添加评论 |
| /api/v1/documents/{docId}/comments | GET | 获取评论列表 |
创建文档的请求示例和响应结构大概是这样的:
// 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.join | C -> S | 加入文档房间 |
| doc.leave | C -> S | 离开文档房间 |
| op.apply | C -> S | 提交编辑操作 |
| op.broadcast | S -> C | 广播其他用户的操作 |
| doc.ack | S -> C | 操作确认,携带 serverSeq |
| presence.update | C -> S | 本端光标/选区更新 |
| presence.broadcast | S -> C | 广播在线状态 |
| member.update | S -> C | 成员权限变更通知 |
| doc.sync_req | C -> S | 请求增量同步 |
| doc.sync_resp | S -> C | 返回缺失操作 |
| doc.error | S -> 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 |
| 用户操作偶发报 401 | token 过期未及时刷新 | 看网关日志时间点 | 接入 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 能力。协同编辑这类系统,稳定性和数据一致性永远是第一位的,体验优化都排在后面。