简介:面向毕业设计及微服务初学者的在线协同编辑系统完整源码包,整合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 Gateway |
| user-service | 注册登录、用户信息、Token 签发 | JWT / Spring Security |
| doc-service | 文档元数据、快照保存、版本记录 | MySQL + MyBatis-Plus |
| editor-service | WebSocket 长连接、操作广播、在线状态 | Netty / Spring WebSocket |
| notify-service | 通知、消息推送、异步任务 | RabbitMQ / RocketMQ |
| file-service | 附件、导出文件存储 | MinIO / OSS |
这里最容易犯的错是把 editor-service 和 doc-service 合并。两者负载特征完全不同:editor-service 是长连接密集型,连接建立后大部分时间在等待消息,CPU 消耗来自消息序列化和广播;doc-service 是短请求密集型,频繁读写数据库。合并之后,一次文档保存的慢 SQL 会影响所有在线用户的打字体验。分离之后,即使 doc-service 做版本归档导致 CPU 升高,editor-service 的广播链路也不受影响。
服务之间的同步调用用 OpenFeign,异步场景走消息队列。比如“保存文档”这个动作,doc-service 落库成功后发送一条通知消息给 notify-service,notify-service 再决定是否推送“有人保存了新版本”给其他在线成员。这样拆的目的是让每个服务都能独立演进,答辩时被问到“为什么把通知单独拆出来”,答案就是“避免保存链路阻塞在短信或 IM 推送这些慢操作上”。
2.2 OT 与 CRDT:实时协同算法的选型理由
协同编辑系统的核心不是 WebSocket,而是文档模型和冲突处理策略。常见的实现策略有三种。
第一种是加锁。同一时刻只允许一个人编辑,其他人只能等待。实现简单,但对协同场景没有意义,用户不会接受“等别人关掉文档才能打字”。第二种是版本号加后写覆盖。每个客户端保存时带上版本号,服务端比较版本号,不一致就拒绝。这种方案能防止内容直接覆盖,但不解决“两个人同时在不同位置插入文字”的合并问题。第三种是 OT 或 CRDT,也是真正能支撑“在线协同编辑系统”这个标题的算法。
OT(Operational Transformation)的核心思路是:每个编辑操作不直接应用在原始文档上,而是先与并发操作做转换,再应用。以文本操作为例,操作类型定义为 insert(插入)、retain(保留)、delete(删除)。客户端 A 在第 5 个字符后插入“你好”,客户端 B 在第 10 个字符后插入“世界”,服务端收到两个并发操作后,会调整其中一个操作的插入位置,再按顺序应用到文档上。Google Docs 走的正是 OT 路线。OT 的优点是文档可以保持线性结构,编辑体验接近本地编辑器;缺点是转换函数每增加一种操作类型就要同步扩展,处理图片、表格、格式标记时复杂度快速上升。
CRDT(Conflict-free Replicated Data Type)走了另一条路:每个字符在创建时携带唯一 ID,并发插入天然在逻辑上收敛,不需要中心服务器做操作转换。以 Yjs 为代表的 CRDT 实现,可以把文档结构复制到多个客户端,甚至离线编辑后再次联网也能自动合并。CRDT 的优点是合并无需中央协调,离线能力天然具备;缺点是为了保证字符唯一性,每个字符都附带额外元数据,文档大时内存占用明显增加,而且纯手写 CRDT 对毕设来说工作量过大。
| 对比项 | OT | CRDT |
|---|---|---|
| 是否需要中心服务器排序 | 需要 | 不需要 |
| 离线编辑支持 | 困难 | 天然支持 |
| 实现复杂度 | 中高,依赖操作类型 | 高,依赖数据结构 |
| 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?token=xxx接入网关,网关转发到 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.yml:
spring: 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: - StripPrefix=1lb://editor-service表示让网关通过负载均衡找到注册中心里的editor-service。StripPrefix=1 会把/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维护 session:
@Component public class EditorWebSocketHandler extends TextWebSocketHandler { private final Map<String, 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/Sub,A 实例收到操作后发布到 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 连在实例 A,Bob 连在实例 B,Alice 的编辑消息广播给实例 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 默认单进程文件描述符上限通常是 1024,300 个 WebSocket 连接再加数据库连接、日志文件句柄,很快就触顶。其次是 Tomcat/Netty 线程池配置太小,握手请求全部排队。
解决:启动前先改 ulimit,ulimit -n 65535;在 Spring Boot 配置里调大容器线程数,比如server.tomcat.threads.max=400;如果是 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 规则定清楚。后面所有功能——广播、回放、离线合并——都建立在操作日志这个核心资产上。把日志当资产,系统才是自洽的;把日志当临时表,整个项目就会一直缝缝补补。希望帮到你。
本文还有配套的精品资源,点击获取