ARTICLE DETAIL

资讯详情

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

微服务架构下的在线协同编辑系统:设计要点与避坑指南

微服务架构下的在线协同编辑系统:设计要点与避坑指南 简介面向毕业设计及微服务初学者的在线协同编辑系统完整源码包整合Vue前端与多模块后端采用前后端分离方式覆盖服务拆分、接口联调、容器化部署等典型环节。包内共253个文件以82个Java类、32个Vue组件、22个TypeScript及17个JavaScript脚本为主YAML与Dockerfile负责服务配置和容器部署SQL脚本用于初始化数据Markdown/HTML文档辅助阅读压缩包仅2.9MB目录分层清晰。源码均经本地编译可运行按配套文档配置环境即可启动项目难度适中内容经助教老师审定适合作为毕业设计、课程设计或微服务入门参考。后端业务模块、前端编辑器交互、容器编排与初始化数据均包含在内便于本地复现完整效果。已有195人学习下载若需一份结构完整、能直接跑起来的协同编辑系统示例可按需选用。1. 在线协同编辑系统做成微服务架构高分毕设的底气在哪多人同时编辑一份文档还能互不覆盖这个需求本身并不新鲜腾讯文档、飞书文档早就把它做成了家常便饭。但很多同学下载了“基于微服务架构的在线协同编辑系统”这类源码后第一反应不是看代码而是被目录结构吓住网关、注册中心、配置中心、一堆服务模块跑起来都不知道先启动哪个。这个项目表面上是“多人编辑”实际要解决的却是实时消息推送、并发冲突、数据一致性三座大山恰好每一座都能在毕设答辩时讲出深度。这篇笔记按我自己梳理源码和部署项目的顺序把服务怎么拆分、协同算法怎么选、WebSocket 怎么落地、一致性怎么保证、翻车点在哪一次讲透。准备答辩的在校生可以用它对照源码逐段验证第一次接触微服务业务项目的开发者也能照着重现。2. 服务拆分与协同原理在线协同编辑系统不等于聊天室加文档表把在线协同编辑系统直接做成聊天室是所有翻车项目的共同起点。聊天室只需要把消息广播给所有人顺序乱一点不影响结果协同编辑不一样每个操作都要落到同一个文档模型上不做控制的话后到的操作会覆盖先到的操作先到的操作也会丢掉后到的修改。所以在写业务代码之前先要把服务边界和协同算法定下来。2.1 按业务边界拆出来的六个服务常见的单体实现是一个 Spring Boot 应用同时处理登录、文档 CRUD、WebSocket、附件上传。功能能跑但答辩时被问一句“哪些模块可以独立扩容”就接不上话。拆成微服务之后每个服务可以单独部署、单独压测这就是最直接的答辩素材。按业务边界拆我一般拆成六个服务既不会少到没有微服务的样子也不会多到演示时手忙脚乱。服务名职责关键技术点gateway-service统一入口、路由转发、鉴权过滤Spring Cloud Gatewayuser-service注册登录、用户信息、Token 签发JWT / Spring Securitydoc-service文档元数据、快照保存、版本记录MySQL MyBatis-Pluseditor-serviceWebSocket 长连接、操作广播、在线状态Netty / Spring WebSocketnotify-service通知、消息推送、异步任务RabbitMQ / RocketMQfile-service附件、导出文件存储MinIO / OSS这里最容易犯的错是把 editor-service 和 doc-service 合并。两者负载特征完全不同editor-service 是长连接密集型连接建立后大部分时间在等待消息CPU 消耗来自消息序列化和广播doc-service 是短请求密集型频繁读写数据库。合并之后一次文档保存的慢 SQL 会影响所有在线用户的打字体验。分离之后即使 doc-service 做版本归档导致 CPU 升高editor-service 的广播链路也不受影响。服务之间的同步调用用 OpenFeign异步场景走消息队列。比如“保存文档”这个动作doc-service 落库成功后发送一条通知消息给 notify-servicenotify-service 再决定是否推送“有人保存了新版本”给其他在线成员。这样拆的目的是让每个服务都能独立演进答辩时被问到“为什么把通知单独拆出来”答案就是“避免保存链路阻塞在短信或 IM 推送这些慢操作上”。2.2 OT 与 CRDT实时协同算法的选型理由协同编辑系统的核心不是 WebSocket而是文档模型和冲突处理策略。常见的实现策略有三种。第一种是加锁。同一时刻只允许一个人编辑其他人只能等待。实现简单但对协同场景没有意义用户不会接受“等别人关掉文档才能打字”。第二种是版本号加后写覆盖。每个客户端保存时带上版本号服务端比较版本号不一致就拒绝。这种方案能防止内容直接覆盖但不解决“两个人同时在不同位置插入文字”的合并问题。第三种是 OT 或 CRDT也是真正能支撑“在线协同编辑系统”这个标题的算法。OTOperational Transformation的核心思路是每个编辑操作不直接应用在原始文档上而是先与并发操作做转换再应用。以文本操作为例操作类型定义为 insert插入、retain保留、delete删除。客户端 A 在第 5 个字符后插入“你好”客户端 B 在第 10 个字符后插入“世界”服务端收到两个并发操作后会调整其中一个操作的插入位置再按顺序应用到文档上。Google Docs 走的正是 OT 路线。OT 的优点是文档可以保持线性结构编辑体验接近本地编辑器缺点是转换函数每增加一种操作类型就要同步扩展处理图片、表格、格式标记时复杂度快速上升。CRDTConflict-free Replicated Data Type走了另一条路每个字符在创建时携带唯一 ID并发插入天然在逻辑上收敛不需要中心服务器做操作转换。以 Yjs 为代表的 CRDT 实现可以把文档结构复制到多个客户端甚至离线编辑后再次联网也能自动合并。CRDT 的优点是合并无需中央协调离线能力天然具备缺点是为了保证字符唯一性每个字符都附带额外元数据文档大时内存占用明显增加而且纯手写 CRDT 对毕设来说工作量过大。对比项OTCRDT是否需要中心服务器排序需要不需要离线编辑支持困难天然支持实现复杂度中高依赖操作类型高依赖数据结构Java 后端落地方案自研操作转换逻辑JGit 等已有实现答辩展示效果可以讲冲突转换细节可以讲最终一致性收敛如果你的源码是 Java 后端配前端自研编辑器最稳妥的方案是简化版 OT前端把每次编辑拆成 op服务端用全局 sequence 排序遇到非连续操作时让客户端做 rebase。如果源码里前端已经接入了 Yjs那后端只需要把 Yjs 的二进制 update 当作不透明数据同步给房间内其他成员冲突合并全部交给前端 CRDT答辩时能省下大量解释成本。2.3 一条编辑操作从按下键盘到落盘要穿过哪些服务把整个链路走通能看出这个项目到底在哪里下功夫。一次完整的协同编辑操作通常分为同步路径和异步路径两条线。同步路径大概是这样用户在浏览器输入字符前端编辑器先把这次改动转成一个结构化操作对象格式类似{docId:abc123,userId:10,op:[{retain:8},{insert:你}],baseVersion:12}。随后前端建立 WebSocket 连接并通过ws://gateway/api/editor/ws?tokenxxx接入网关网关转发到 editor-service。editor-service 收到操作后做三件事校验 baseVersion 是否和当前版本一致通过 Redis INCR 分配一个全局递增的 sequence再把带 sequence 的操作广播给同一文档其他在线客户端。异步路径与同步路径并行editor-service 将操作写入消息队列doc-service 消费消息后追加到操作日志表同时更新文档快照的版本号。这里有一个关键设计用户每次按键不会立刻触发数据库写入而是由前端防抖比如停止输入 300 毫秒后发送一次“保存操作”。否则一分钟输入 60 个字就要写 60 次数据库压测必然翻车。数据落盘的顺序也有讲究。先写操作日志再更新快照。操作日志是追加写的天然有序是“后悔药”快照是可覆盖的追求的是当前可见状态。下次打开文档时加载最新快照再按 sequence 重放快照之后的操作日志文档就能恢复到任意历史版本。理解了这条链路看源码时就不会再迷失在 WebSocket 的消息处理堆里。3. 跑通源码最小闭环网关、Nacos 与 WebSocket 协同服务怎么落地这一章直接对着源码目录找对应模块。拿到“基于微服务架构的在线协同编辑系统源码.zip”后先不要急着点运行按网关到服务到存储的顺序逐层看。只要把注册中心、路由、WebSocket 连接和文档保存这条链路接通项目就已经能演示了。3.1 项目骨架Nacos 注册中心加网关的最小配置最常见的微服务骨架是 Spring Cloud Alibaba 整合 Nacos。Nacos 同时承担注册中心和配置中心两个角色。先看个典型的bootstrap.ymlspring: application: name: editor-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 config: server-addr: 127.0.0.1:8848 file-extension: yml server: port: 8200这段配置把editor-service注册到本机 8848 端口。file-extension: yml表示配置中心的配置文件名需要和application name对应也就是editor-service.yml。如果启动时报“找不到配置”先确认 Nacos 控制台里是否已经建了对应的 Data ID这是第一个高频踩坑点。网关服务的路由配置是第二个关键文件spring: cloud: gateway: routes: - id: editor-route uri: lb://editor-service predicates: - Path/api/editor/** filters: - StripPrefix1lb://editor-service表示让网关通过负载均衡找到注册中心里的editor-service。StripPrefix1 会把/api/editor这个前缀去掉这样网关把请求转发给 editor-service 时路径变成/ws之类的真实路径。这里要注意 WebSocket 握手/api/editor/ws如果走网关必须保证网关的跨域配置允许该路径同时 JWT 鉴权过滤器要放行握手请求。常见做法是网关过滤器只校验 token不解析业务参数避免握手阶段因鉴权过滤器读取 body 而导致连接失败。3.2 协同服务用 WebSocket Handler 管理连接与广播editor-service 的入口是 WebSocket 配置类。下面这段是基于 Spring WebSocket 的实现骨架也是我梳理类似源码时最常看到的结构Configuration public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(editorWebSocketHandler(), /ws/editor) .setAllowedOrigins(*); } Bean public EditorWebSocketHandler editorWebSocketHandler() { return new EditorWebSocketHandler(); } }EditorWebSocketHandler继承TextWebSocketHandler核心方法是afterConnectionEstablished和handleTextMessage。用ConcurrentHashMap维护 sessionComponent public class EditorWebSocketHandler extends TextWebSocketHandler { private final MapString, WebSocketSession sessions new ConcurrentHashMap(); private final ObjectMapper objectMapper new ObjectMapper(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 握手时通过 query 参数拿到 docId 和 userId String docId session.getAttributes().get(docId).toString(); String userId session.getAttributes().get(userId).toString(); session.getAttributes().put(docId, docId); session.getAttributes().put(userId, userId); sessions.put(session.getId(), session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode root objectMapper.readTree(message.getPayload()); String docId root.get(docId).asText(); // 核心业务逻辑校验版本、分配 sequence、应用操作 long seq editorService.applyOperation(docId, root); // 广播给同一文档的其他在线 session String payload objectMapper.writeValueAsString( Map.of(docId, docId, seq, seq, op, root.get(op))); for (WebSocketSession s : sessions.values()) { if (s.isOpen() docId.equals(s.getAttributes().get(docId))) { s.sendMessage(new TextMessage(payload)); } } } }这里handleTextMessage里做了两件事把操作交给业务服务处理然后广播给同文档的其他连接。广播时过滤docId是因为一个在线列表里可能同时开着多份文档不做过滤会把文档 A 的编辑广播给看文档 B 的用户。applyOperation内部会操作 Redis 生成 sequence这部分的实现放在第四章细讲。内存 Map 管理 session 只能支撑单实例部署。如果 editor-service 起了两个实例连接分散在两个进程里跨实例广播就会丢失。常见方案是引入 Redis Pub/SubA 实例收到操作后发布到 Redis 频道B 实例订阅频道拿到消息再推给本地 session。底层还是 TCP 长连接但消息路由从内存 Map 换成了中心化频道。3.3 文档服务快照加操作日志双写避免丢内容doc-service 的表结构决定整个系统的可靠下限。至少需要两张表文档快照表和操作日志表。建表 SQL 大概是CREATE TABLE doc_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id VARCHAR(64) NOT NULL, version BIGINT NOT NULL, content MEDIUMTEXT, updated_at DATETIME NOT NULL, UNIQUE KEY uk_doc_version (doc_id, version) ); CREATE TABLE doc_operation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doc_id VARCHAR(64) NOT NULL, seq BIGINT NOT NULL, user_id BIGINT NOT NULL, op_type TINYINT NOT NULL, op_data TEXT NOT NULL, create_time DATETIME NOT NULL, UNIQUE KEY uk_doc_seq (doc_id, seq) );操作日志表的唯一键(doc_id, seq)是防止重复消费消息的最后一道闸门。RabbitMQ 消费端即使做了手动 ack也可能因为网络抖动导致重投唯一键直接让重复操作插入失败幂等性在数据库层面就兜住了。快照表加(doc_id, version)唯一键保证保存快照时不会出现同一文档同一版本被写两次。这里有一个实际执行顺序问题先写操作日志还是先更新快照。如果先更新快照再写日志日志写入失败时快照已经前进了回放日志会得到和快照不一致的文档反过来先写日志再更新快照即使快照更新失败也可以通过重放日志恢复。所以我在代码里总会让saveOperation先执行再执行updateSnapshot。更新快照用 CAS 语句// mapper 中执行 int rows docSnapshotMapper.updateContentByVersion(docId, content, expectedVersion); if (rows 0) { throw new ConflictException(当前版本已变化请刷新后重试); }updateContentByVersion返回受影响行数为 0 说明版本冲突。这是把“在线协同编辑系统”从演示稿变成可答辩项目的关键一小步。4. 一致性设计全局序号、分布式会话与事务边界怎么选协同编辑最容易被追问的就是“多人同时操作时数据怎么保证一致”。这一章把三处核心设计讲清楚每一处都对应源码里可以直接找到的类或配置。4.1 操作编号为什么必须用 Redis INCR 生成全局 sequence每个文档的操作必须有一个单调递增的序号客户端才能按序应用操作。使用数据库自增主键的问题在于插入操作日志是为了拿到 ID而分配 sequence 是应用操作到内存模型的前置动作两者在时间上不对称。高并发下如果先入库再分配序号操作顺序和落库顺序可能不一致。正确做法是先分配 sequence再带着 sequence 入库。用 Redis INCR 生成 sequence 是常见做法Long seq redisTemplate.opsForValue().increment(doc:seq: docId);increment是原子操作单 Key 下不会生成重复序号。Key 按 docId 隔离避免所有文档共用一个计数器导致长尾竞争。Redis 挂了怎么办这是答辩时几乎必问的问题。常见兜底方案是在数据库维护一张 sequence 表用悲观锁或乐观锁生成序号但性能会下降。更实际的回答是编辑器采用主从或者哨兵架构并且把doc:seq:*的持久化策略设置为 AOF 每秒刷盘即使进程重启也不会丢太多序号。对毕设项目来说Redis 单机加上 AOF 已经足够证明思考深度。另一种方案是号段模式一次从数据库取一个区间比如 [100, 200) 的序号发放权服务在内存里分配。优点是减少数据库访问缺点是多实例部署时每个实例的号段首尾可能重叠需要额外协调。毕设不推荐因为 Redis INCR 已经足够简单且值得讲清楚。4.2 分布式会话把 WebSocket 连接和用户登录态解耦WebSocket 连接一旦建立session 就保存在服务进程里。如果同时有两个 editor-service 实例在前Alice 连在实例 ABob 连在实例 BAlice 的编辑消息广播给实例 B 上的 Bob 时实例 B 需要能识别“这条消息应该推到哪个 session”。最简单的方案是 editor-service 只部署一个实例但这会把微服务架构的说服力打折。所以我一般推荐用 Redis Pub/Sub 做跨实例广播。连接建立时把用户信息写入 Redis// key: ws:online:{docId}:{userId} redisTemplate.opsForValue().set(ws:online: docId : userId, session.getId(), Duration.ofMinutes(30));同时在 Redis 里维护一个文档维度连接数方便监控。广播链路变成实例 A 收到操作后先本地应用并分配 seq然后redisTemplate.convertAndSend(ws:editor: docId, payload)所有实例订阅ws:editor:*频道收到消息后在自己的本地 session 池里找对应连接。找不到说明该用户不在这个实例上直接丢弃即可。心跳和断线重连也需要在会话设计里考虑。客户端每 30 秒发送一次 ping服务端在handleTextMessage里识别 ping 类型并返回 pong连续三次没收到 ping服务端就主动关闭连接。客户端重连成功后不能只恢复 TCP 连接还要重新发送一次“加入文档”消息服务端把当前文档版本号和最近操作日志重新下发否则客户端会漏掉离线期间的所有改动。4.3 事务边界编辑保存场景要不要引入分布式事务这个问题被问住的概率很高。很多人一听到“微服务”就往 Seata 上靠想把 doc-service 落库和 notify-service 发通知包在同一个分布式事务里。实际做下来会发现完全没必要。编辑操作本身追求的是最终一致。操作日志先写到 Redis 或消息队列异步批量刷新到 MySQL这个链路即使中间失败也能靠日志回放补上。真正需要强一致的是“保存快照”这个动作它要求服务端拿到的 version 必须和客户端一致否则就拒绝。这个强一致不需要分布式事务一行 CAS 更新就能实现。CAS 更新代码如下// 参数为 docId、新内容、客户端传回的 baseVersion Update(UPDATE doc_snapshot SET content #{content}, version version 1, updated_at NOW() WHERE doc_id #{docId} AND version #{baseVersion}) int casUpdateContent(Param(docId) String docId, Param(content) String content, Param(baseVersion) Long baseVersion);如果返回行数为 0说明客户端基于的版本已经过期直接返回 409。前端收到 409 后弹窗让用户选择“以我的版本覆盖”或者“重新加载最新内容”这才是真实协同产品会做的事。至于 notify-service 发通知失败根本不影响文档数据本身。可以用本地消息表保证doc-service 在同一个数据库事务里写入快照和一条待发送通知记录后台任务扫描待发送记录推给 MQ推送成功后标记已发送。这既解释了“为什么不引入 Seata”又展示了可靠消息的落地细节是答辩中很容易加分的点。5. 避坑指南在线协同编辑系统开发里的五个典型翻车点从开发到演示我见过太多项目挂在同样的坑上。这里列五个最高频的每一条都按“现象→原因→解决”讲清楚。5.1 现象多人同时编辑后文档内容互相覆盖开两个浏览器同时编辑同一份文档A 保存后B 再保存A 输入的内容全部消失。B 保存时已经覆盖了 A 的改动但 B 的页面没有任何提示。原因doc-service 的保存接口没有做版本校验直接用更新语句无条件覆盖。大部分新手项目都栽在这里因为单机本地测试时很难同时操作两个账号。解决doc_snapshot 表加 version 字段前端每次保存时把当前文档 baseVersion 一起传给后端后端用 CAS 更新语句受影响行数为 0 就返回 409。这属于数据安全底线源码里如果没有建议第一时间补上否则答辩现场演示就会翻车。5.2 现象WebSocket 断线重连后收不到任何消息用户电脑休眠后恢复网络页面显示“已重连”但其他协作者输入的内容一直没有同步过来。原因客户端重连成功后服务端 session 池里保存的是新连接但旧连接没有被清理广播时把消息发给了已经失效的旧 session或者新连接没有重新发送“加入文档”消息服务端不知道这个连接属于哪份文档。解决在afterConnectionEstablished里从握手参数解析 docId 和 userId先移除该用户旧的 session 再放入新 session。同时要求客户端重连成功后主动发送一条{type:rejoin,docId:xxx,baseVersion:xx}服务端收到后把当前快照和缺失操作日志打包下发保证断线期间的内容不丢失。5.3 现象并发编辑时操作到达顺序和产生顺序不一致A 先输入“你”B 后输入“好”但 A 的浏览器收到消息的顺序却是 B 先到 A 后到最终文档变成“好你”。原因没有全局 sequence。操作到达服务端后如果直接按接收时间广播两个编辑请求经过网关、负载均衡、线程池的时序都不一样广播顺序自然和产生顺序无关。解决在 editor-service 里用redisTemplate.opsForValue().increment(doc:seq: docId)给每个操作分配序号广播消息里带上 seq。客户端收到消息后按 seq 排序或者在应用操作之前检查是否是期望的下一个序号乱序就缓存等前面补齐。后端应用操作时也要检查 seq 连续性断档时把消息放到待处理队列而不是直接丢弃。5.4 现象网关超时后客户端重试文档被保存了两遍网络波动时保存接口响应超过网关默认超时时间网关返回 504前端自动重试一次。结果文档快照没有变化但操作日志或通知消息出现了两条重复记录。原因网关默认超时时间通常是 60 秒而保存接口在高负载下很容易超过这个时间重试请求带着同样的数据再次到达 doc-service没有幂等机制。解决在 gateway 配置里把保存相关路由的超时时间调大到 120 秒同时给写接口加幂等处理。前端每次保存生成一个 requestId后端用 RedissetIfAbsent做去重Boolean first redisTemplate.opsForValue() .setIfAbsent(save:idempotent: requestId, 1, Duration.ofMinutes(10)); if (Boolean.FALSE.equals(first)) { // 重复请求直接返回上一次成功结果 return Result.success(previousVersion); }5.5 现象压测到几百并发时连接数上不去CPU 先打满用 JMeter 模拟 300 个用户同时打开文档结果大量连接失败后端 CPU 瞬间拉满控制台全是线程池拒绝异常。原因最常见的是文件描述符限制。Linux 默认单进程文件描述符上限通常是 1024300 个 WebSocket 连接再加数据库连接、日志文件句柄很快就触顶。其次是 Tomcat/Netty 线程池配置太小握手请求全部排队。解决启动前先改 ulimitulimit -n 65535在 Spring Boot 配置里调大容器线程数比如server.tomcat.threads.max400如果是 Docker 部署检查容器 ulimit 是否继承宿主机。压测时还要注意 JMeter 本身不要每个请求都新建连接启用 WebSocket Sampler 的长连接选项每个线程复用一条连接。注意第 2 个和第 4 个坑最容易同时出现断线重连后消息丢失和重复提交往往搅在一起排查时先按连接维度观察再按请求维度去重不要混在一起加日志。6. 进阶验证操作回放、离线编辑与监控让系统摆脱“玩具”标签6.1 操作回放把操作日志变成历史版本回放能力源码里已有的doc_operation_log表不只是用来恢复数据的它可以直接支撑“历史版本回放”功能。增加一个管理端接口按doc_id和seq升序读出所有操作从一个空文档开始逐条重放最终内容和doc_snapshot做哈希比对。一致说明协同链路的日志设计是完整的。答辩时现场对一份编辑过的文档执行回放展示从第 1 版到第 N 版的演化过程比贴十页代码更有说服力。6.2 离线编辑用 CRDT 的思路补齐短板如果源码走的是 OT离线编辑是难以绕开的短板。一个变通方案前端引入 Yjs把本地编辑器内容映射到Y.Doc通过 Yjs 的 update 协议同步给后端。后端不再解析具体插入删除操作只负责把二进制 update 按 docId 广播。这样离线期间的编辑会由前端缓存重连后自动合并冲突收敛交给 CRDT 完成。对毕设来说不需要自己实现 CRDT 数据结构接入 Yjs 已经能讲清楚“服务端无状态、客户端收敛”的一致性模型。6.3 监控用连接数、消息吞吐和版本冲突率回答“系统是否可靠”答辩证时如果有人问“系统上线后怎么知道它正常”最稳的回答是展示监控指标。引入 Spring Boot Actuator 和 Micrometer把/actuator/prometheus暴露给 Prometheus再挂一个 Grafana 面板。重点监控三个指标当前 WebSocket 连接数、每秒广播消息数、版本冲突失败次数。连接数曲线能说明系统的容量边界广播消息数能反映实时性冲突失败次数则直接体现一致性策略是否有效。这些指标比“我觉得系统很稳”有力得多。我自己做这个项目时的教训是不要一上来就写业务代码先把 op 类型和 seq 规则定清楚。后面所有功能——广播、回放、离线合并——都建立在操作日志这个核心资产上。把日志当资产系统才是自洽的把日志当临时表整个项目就会一直缝缝补补。希望帮到你。本文还有配套的精品资源点击获取
返回列表