ARTICLE DETAIL

资讯详情

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

SpringBoot+Vue+WebSocket实现多人实时协作绘画:同步架构与踩坑实践

SpringBoot+Vue+WebSocket实现多人实时协作绘画:同步架构与踩坑实践 简介这是一份基于SpringBootVueWebSocket的多人实时在线协作绘画平台设计源码适合希望掌握前后端分离开发与实时通信技术的开发者也可用于远程协作白板、在线教学等场景。项目按后端back-end与前端front-end目录组织包含pom.xml等Maven配置18个Java源文件负责服务端业务、用户认证、绘画状态同步与操作广播6个JS文件并搭配Vue/HTML组件构建前端画布与实时交互JSON、XML、properties等配置文件辅助工程搭建另附架构设计文档和演示视频便于快速理解整体思路。压缩包内有40个文件共16.92MB目录结构清晰可直接导入IDE运行或二次开发。资源已有369人学习下载。整套源码展示了用户认证、实时状态同步和绘画操作广播的实现路径后端通过WebSocket端点处理绘画事件的广播与接收前端以Vue组件响应实时消息便于二次开发时扩展更多协作功能适合作为SpringBoot与WebSocket整合的实战参考。1. 多人实时协作绘画为什么难在同步SpringBoot、Vue、WebSocket各管什么多人实时在线协作绘画第一反应是画板API真正做起来才发现最难的是“同步”三个人同时改一张架构图A落笔的瞬间B和C的屏幕什么时候能看到这条线我用HTTP轮询做过一版延迟压在1秒画出来的线一截一截的被同事吐槽像PPT逐帧动画换WebSocket之后同一条画布上多人同时落笔才真正觉得顺。基于SpringBootVueWebSocket的这套组合本质是用SpringBoot管房间、用户和轨迹历史用Vue做画布交互用WebSocket承载全部画笔事件与状态广播是做在线白板、团队协作工具、答辩演示系统最常参考的源码方向。下面按完整落地路径拆先定协议再做后端广播再写前端画板最后把坑和验收手段排一遍。这套方案不复杂但每一步都有让demo变成半成品的细节。2. 先定协议再写代码房间模型、消息格式与画布状态同步策略2.1 为什么是WebSocket而不是轮询或SSE三个方案的延迟和连接成本对比很多现成的协作白板demo看起来功能都差不多体验差距全在协议层。画架上的每条线都是一串坐标点要把这串点从A的浏览器搬到B和C的浏览器传输方案有三大类HTTP短轮询、SSE服务端推送、WebSocket。三者不能只看“能不能实时”要同时看延迟、连接成本和消息方向。方案延迟连接成本典型场景HTTP短轮询延迟轮询间隔网络往返做不到真正实时每次请求都带完整Header10人每秒轮询1次每秒10次HTTP请求低频非实时数据刷新SSE服务端到客户端毫秒级客户端到服务端仍需HTTP一条长连接但单向新闻提醒、任务进度推送WebSocket双向毫秒级一次握手建立长连接消息帧开销极小协作画板、聊天、实时协作文档画板场景必选WebSocket因为两类操作都高频画线事件从客户端上行清屏、撤销、他人轨迹从服务端下行。SSE下行没问题但上行还得靠HTTP两条链路天然会引入顺序问题轮询则是在延迟和开销上两头吃亏。集成阶段最怕把网络当黑匣子直接看连接数和每消息延迟两个指标摆出来选型就不需要犹豫。HTTP轮询还有个隐性成本消息到达时间是离散的。轮询间隔500ms意味着别人画一条线客户端最多落后500ms才感知到连续画的话线条会一顿一顿。缩短轮询间隔到100ms延迟降低了请求频率却涨到每秒10次后端光解析HTTP头就忙不过来。所以这个场景轮询不是调参能救回来的方案选型阶段就该毙掉。2.2 消息协议用JSON还是二进制事件类型怎么划分画板消息的频率高、单条体积小JSON完全扛得住不为传输效率上二进制协议不值得。真正的重点是把消息结构统一成一套前后端都按同一张表解析否则联调时来回改字段才是浪费时间。我一般这样定义一条上行消息{ type: draw, roomId: room_1024, userId: u_8f3a, seq: 102, version: 3, payload: { color: #1a73e8, lineWidth: 3, points: [ { x: 10, y: 20 }, { x: 12, y: 22 } ] } }type是事件类型后端拿到它决定走哪段分发逻辑roomId是房间维度广播只发给同房间的人userId标识这条轨迹是谁画的前端可以用不同颜色区分用户seq是房间内严格递增的序号用于接收端对抗乱序version是画布版本号clear/undo这类全局操作会让它加一前端只执行比自己记录更新的版本。payload里放业务数据draw的payload是一整条轨迹的points数组不是单个点——一次pointerup打包一笔消息量比逐点发送少一个数量级。事件类型最少要有join、leave、draw、clear、undo、sync、ping、pong。join和sync配合新用户进房draw是常态轨迹clear/undo是全局操作ping/pong走心跳。每种类型都有各自的服务端分支后面第3章逐个展开。这里要特别说一个设计取舍轨迹点数组和颜色、线宽放在一起作为一条draw消息看起来是常识但我见过不少把颜色单独拉一条消息的实现结果是接收端画一笔要等两个消息凑齐一旦乱序就画错属于典型的自找麻烦。2.3 房间状态存内存还是Redis从单机演示到集群扩展的取舍房间状态包含三部分在线连接、房间成员表、画布历史轨迹。单机场景建议全部放内存用ConcurrentHashMap维护就够了。常见做法是维护三个MapsessionMapsessionId → WebSocketSession负责拿连接、发消息、关闭连接roomMaproomId → SetsessionId广播时找同房间的其他连接historyMaproomId → List轨迹事件新用户进房时补全画面。三张表在单机几十人并发内没问题代码也最好调试毕设、内网演示、小团队工具都走这条路。注意historyMap别无限增长我一般限制每个房间保留最近200笔轨迹或最近30分钟数据够新成员跟上当前画面就行多了纯属消耗内存。这一点在历史补帧时还要用到。要上集群内存方案就不够了。常见做法是把广播这一层下沉到Redis用Redis Pub/Sub甚至Redisson的Topic做跨节点广播A节点收到一条draw发布到频道所有节点订阅后把消息发给各自管辖的session。用户的session路由可以按userId哈希到固定节点也可以在每个节点保存全量session索引。这里给一句实用忠告不要一上来就上Redis。单机内存方案把边界写清楚反而比过度设计更容易让人信服等真正出现跨节点广播需求再迁移迁移路径也清晰。历史轨迹如果上Redis可以用LIST存序列化后的消息体配合序号做范围补发单机版本直接用ArrayListseq刚好对应数组下标简单直接。3. 后端SpringBoot落地WebSocket端点、房间路由与心跳机制3.1 引入依赖与注册WebSocket端点先解决SpringBoot 2.x和3.x的差异后端实现从依赖开始SpringBoot项目引入websocket starter只需要一条依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency这里有个版本坑直接影响代码怎么写SpringBoot 2.x用的是javax.websocket命名空间3.x迁移到jakarta.websocket。网上大量教程停留在2.x照着抄import javax.websocket.*在3.x下直接编译失败。所以拿到一个项目源码先看pom里的spring-boot-starter-parent版本再决定import写javax还是jakarta这条顺序反了就是第5章里说的翻车现场。WebSocket端点类常见做法是走ServerEndpoint和原生WebSocket概念对齐、写起来直观。我一般把房间号放在路径里每个房间一个连接入口ServerEndpoint(/ws/paint/{roomId}) public class PaintWebSocketEndpoint { private static final MapString, WebSocketSession SESSIONS new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { SESSIONS.put(session.getId(), session); } OnMessage public void onMessage(String message, Session session) throws Exception { // 业务分发写在这里见3.2 } OnClose public void onClose(Session session) { SESSIONS.remove(session.getId()); } OnError public void onError(Session session, Throwable throwable) { // 错误日志要打全网络断开的异常信息很容易被吞掉 } }逻辑说明SESSIONS是单机连接表key用session.getId()是为了和容器保持一致OnOpen只登记连接房间关系在收到join消息时才建立——把房间放路径里只是为了路由到同一段业务代码不代表连接一上来就知道房间成员。OnError里至少要把throwable打到日志很多“连接无缘无故断开”的根因就藏在这条日志里。要让ServerEndpoint被SpringBoot启动时扫描到还需要一个配置类Configuration public class WebSocketConfig { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }这段的作用是让内嵌容器注册该端点。如果项目打成jar用内嵌Tomcat跑这个Bean必须有如果打成War扔给外部Tomcat它反而可能重复注册需要去掉。参数上没有太多可调的但这个Bean的有无直接决定连接是404还是101属于第一个要排查的开关。3.2 房间路由与消息转发onMessage里如何按类型分发onMessage的任务是解析JSON、定位房间、按类型分发。房间表用CopyOnWriteArraySet存sessionId集合广播时遍历读多写少这个集合类型正合适private static final MapString, SetString ROOM_SET new ConcurrentHashMap(); OnMessage public void onMessage(String message, Session session) throws Exception { JsonNode json new ObjectMapper().readTree(message); String type json.get(type).asText(); String roomId json.get(roomId).asText(); String userId json.get(userId).asText(); LAST_ACTIVE.put(session.getId(), System.currentTimeMillis()); switch (type) { case join: SESSION_ROOM.put(session.getId(), roomId); ROOM_SET.computeIfAbsent(roomId, k - new CopyOnWriteArraySet()).add(session.getId()); sendHistory(session, roomId); // 新成员先拿全量历史 break; case draw: case clear: case undo: broadcastToRoom(roomId, message, session.getId()); // 排除发送者本人 break; case ping: session.getBasicRemote().sendText({\type\:\pong\}); break; default: break; } } private void broadcastToRoom(String roomId, String message, String excludeSessionId) { SetString sessionIds ROOM_SET.get(roomId); if (sessionIds null || sessionIds.isEmpty()) return; for (String sid : sessionIds) { if (sid.equals(excludeSessionId)) continue; WebSocketSession target SESSIONS.get(sid); if (target ! null target.isOpen()) { target.getBasicRemote().sendText(message); } } }逻辑说明join只登记、只回历史不回广播draw/clear/undo走broadcastToRoomping只回pong。为什么要排除发送者本人因为前端在本地已经把自己画的那一笔画上去了服务端再回传一次前端一帧里画两遍性能白白浪费。SESSION_ROOM是给leave用的离开时得从ROOM_SET里把自己清掉不然房间集合里全是死sessionId广播时每一条消息都往不存在的连接上写累积起来拖垮整体发送性能。参数说明广播循环里的JSON序列化在进入循环前已经由Spring完成不要在循环里再做一次ObjectMapper.readTree否则100人的房间就意味着100次重复解析P95延迟直接翻倍。另一个易错点是sendText是同步的某个客户端网卡、慢处理会拖慢整条循环单机到几十人时可以把循环放到线程池里但要注意保持顺序否则又会引入乱序问题。3.3 心跳保活与断线清理为什么前端每30秒要发一次pingWebSocket在公网部署时中间每一层网关都可能在一定空闲时间后断开连接。常见的默认空闲超时是60秒上下所以前端必须用主动心跳把连接维持在活跃状态后端要配合实现超时清理。心跳参数给一组常用值ping间隔30s后端认为超过45s没收到任何消息就算死连接清理扫描30s跑一次。内网直连可以把间隔放宽到60s公网部署30s是安全值。用Scheduled做定时清扫Scheduled(fixedRate 30000) public void sweepInactiveSessions() { long now System.currentTimeMillis(); for (String sid : LAST_ACTIVE.keySet()) { if (now - LAST_ACTIVE.get(sid) 45000) { WebSocketSession session SESSIONS.remove(sid); if (session ! null session.isOpen()) { try { session.close(); } catch (IOException ignored) {} } String roomId SESSION_ROOM.remove(sid); if (roomId ! null) { SetString room ROOM_SET.get(roomId); if (room ! null) room.remove(sid); } } } }这段的逻辑是LAST_ACTIVE在onMessage每次收到消息时更新扫描任务把超过45s没活跃的连接先close再清理三张表。注意清理顺序先移除SESSIONS再移除SESSION_ROOM最后从ROOM_SET的集合里摘掉自己。三张表必须同步清只清一张会导致广播时拿到已关闭的session或者房间集合越积越大。sweep里的30000和45000分别是扫描间隔和判定超时前端30s发一次ping最坏情况下45s兜住余量是故意留的避免网络抖动误杀正常连接。3.4 画布历史与缺席用户补帧新用户进房时怎么把画面补全新用户join后只看到空画布再把实时轨迹画上去体验是断裂的。正确流程是join时让服务端回传全量历史前端重绘后再进入增量接收。历史数据在单机内存里就是historyMaproomId → 轨迹事件列表没上Redis前直接用ArrayListprivate static final MapString, ListJsonNode HISTORY new ConcurrentHashMap(); private void sendHistory(Session session, String roomId) throws IOException { ListJsonNode list HISTORY.get(roomId); if (list null) return; MapString, Object syncMsg new HashMap(); syncMsg.put(type, sync); syncMsg.put(roomId, roomId); syncMsg.put(version, currentVersion(roomId)); syncMsg.put(events, list); session.getBasicRemote().sendText(new ObjectMapper().writeValueAsString(syncMsg)); }逻辑说明sync消息和实时draw消息必须分开类型前端收到sync时清空画布并批量重放events收到draw时只追加一笔。如果共用同一个类型前端无法区分“这是历史补帧”还是“别人刚画的新笔”就会画出重叠的轨迹。历史保留策略再强调限制每个房间最近200笔或最近30分钟配合version决定要不要接受这批历史。实时轨迹入库的时机也有讲究我习惯在广播的同时把draw消息写进HISTORYcase draw: ListJsonNode list HISTORY.computeIfAbsent(roomId, k - new ArrayList()); list.add(json); if (list.size() 200) list.remove(0); // 控制内存上限 broadcastToRoom(roomId, message, session.getId()); break;参数说明HISTORY存的是JsonNode而不是字符串方便后面做过滤和补发200是每个房间的内存上限不是全局上限免得一个房间画太久把整个服务内存吃光。这样新成员进房、老成员断线重连都能拿到和自己断线位置衔接的历史轨迹。4. 前端Vue画板Canvas采集、WebSocket客户端与轨迹回放4.1 画板组件结构为什么用双层Canvas而不是一层“画板看起来就是一个canvas”这是新手最容易做的假设。实际手写协作画板建议用双层canvas叠在一起底层放已确认的轨迹上层放正在画的临时笔迹。为什么自己正在画的线需要实时出现在自己屏幕上但它还没确认可能按了撤销如果和已确认轨迹画在同一个canvas撤销时就得分层处理而且别人的广播消息一旦重绘还会和自己正在画的线互相闪烁。先给Vue组件骨架template div refwrapRef classboard-wrap canvas refpaintLayer classpaint-layer/canvas canvas reftempLayer classtemp-layer/canvas /div /template script setup import { ref, onMounted } from vue const wrapRef ref(null) const paintLayer ref(null) // 已确认轨迹 const tempLayer ref(null) // 当前这笔画到一半的临时轨迹 /script逻辑说明paintLayer存放所有已经确认的轨迹包括别人画的和自己抬手确认的tempLayer只画pointermove过程中还没抬起的轨迹。抬起后把tempLayer的这笔画合并到paintLayer然后清空tempLayer。新消息来了只往paintLayer画永远不会和正在画的临时笔画打架。resize是另一个容易翻车的点。Canvas的width/height是画布的逻辑像素CSS大小是显示大小两者不等就会糊。组件挂载后按容器宽度初始化并用devicePixelRatio校正function resizeCanvas() { const wrap wrapRef.value const dpr window.devicePixelRatio || 1 for (const cv of [paintLayer.value, tempLayer.value]) { cv.width wrap.clientWidth * dpr cv.height wrap.clientHeight * dpr cv.getContext(2d).scale(dpr, dpr) cv.style.width wrap.clientWidth px cv.style.height wrap.clientHeight px } replayHistory() // 重置底层canvas后要重放已确认轨迹 }参数说明dpr是设备像素比Retina屏一般是2不放大画出来的线会发虚scale(dpr, dpr)之后后续绘图坐标仍然按CSS像素写不用自己手动乘。注意resize会清空canvas内容所以replayHistory要把paintLayer上的历史轨迹重新画一遍。这个函数要和窗口resize事件绑定我在onMounted里调用一次并addEventListener(resize, resizeCanvas)。4.2 画笔事件采集与发送pointer事件、节流与轨迹打包画板交互统一用PointerEvent鼠标、触控笔、手指都走同一套事件不用分别维护MouseEvent和TouchEvent。采集逻辑的核心是一次落笔收集成一笔strokepointerup时一次性通过WebSocket发送而不是每次移动都发消息。let currentStroke null let currentColor #1a73e8 let currentWidth 3 paintLayer.value.addEventListener(pointerdown, (e) { currentStroke { color: currentColor, lineWidth: currentWidth, points: [{ x: e.offsetX, y: e.offsetY }] } paintLayer.value.setPointerCapture(e.pointerId) }) paintLayer.value.addEventListener(pointermove, (e) { if (!currentStroke) return const pts currentStroke.points const last pts[pts.length - 1] const dx e.offsetX - last.x const dy e.offsetY - last.y if (dx * dx dy * dy 4) return // 距离小于2px的点丢弃 pts.push({ x: e.offsetX, y: e.offsetY }) drawTempStroke(currentStroke) // 画到tempLayer实现本地实时 }) paintLayer.value.addEventListener(pointerup, () { if (!currentStroke) return commitStroke(currentStroke) // 合并到paintLayer ws.send(buildDrawMessage(currentStroke)) // 通过WebSocket广播 currentStroke null })逻辑说明4是2px的平方这是节流的阈值。采样太密会让单条消息体膨胀画一条长曲线可能塞几百个点太疏会让曲线变成明显折线。2px在常见鼠标和触控笔采样率下够平滑。发送时机定在pointerup所以一次画一笔只有一条上行消息服务端广播也按条转发消息量比逐点发送少一个数量级。buildDrawMessage里要带上当前房间、用户、颜色和线宽格式就是第2章定义的那份JSON。注意setPointerCapture如果pointer移出画布外不捕获的话事件就丢了画到边界时线条会断捕获之后即使笔尖移出画布事件也只发给画布。4.3 接收端渲染别人画的线怎么平滑地出现在自己屏幕上收到服务端广播的draw消息后不能在onmessage里直接画。WebSocket的onmessage回调频率可能比浏览器重绘频率高每条消息都触发一次canvas绘制重绘次数直接拉满帧率反而掉下去。常见做法是先把消息推进一个待渲染队列由requestAnimationFrame统一冲刷const pendingStrokes [] let isReplaying false ws.onmessage (event) { const msg JSON.parse(event.data) if (msg.type sync) { isReplaying true clearCanvas() for (const evt of msg.events) drawStroke(evt.payload) isReplaying false return } if (msg.type draw !isReplaying) { pendingStrokes.push(msg.payload) } } function flushStrokes() { if (pendingStrokes.length 0) return const ctx paintLayer.value.getContext(2d) while (pendingStrokes.length) { drawStroke(ctx, pendingStrokes.shift()) } } function renderLoop() { flushStrokes() requestAnimationFrame(renderLoop) }这段的逻辑是onmessage只做JSON解析和入队renderLoop每帧批量绘制避免高频消息造成掉帧。drawStroke内部用beginPath把一条轨迹的点串成一条路径再统一stroke不要在循环里对每个点单独stroke——stroke是canvas的重操作调用次数和性能直接挂钩。平滑有个小技巧对三个点以上的轨迹用quadraticCurveTo替代lineTo把上一个点作为控制点、当前点作为终点画出来的曲线比折线顺滑且没有额外坐标变换function drawStroke(ctx, stroke) { ctx.strokeStyle stroke.color ctx.lineWidth stroke.lineWidth ctx.lineCap round ctx.lineJoin round ctx.beginPath() const pts stroke.points ctx.moveTo(pts[0].x, pts[0].y) if (pts.length 3) { for (let i 1; i pts.length; i) ctx.lineTo(pts[i].x, pts[i].y) } else { for (let i 1; i pts.length - 1; i) { const xc (pts[i].x pts[i 1].x) / 2 const yc (pts[i].y pts[i 1].y) / 2 ctx.quadraticCurveTo(pts[i].x, pts[i].y, xc, yc) } } ctx.stroke() }参数说明lineCap和lineJoin设成round是为了让画笔的头尾是圆头不然画出来的线头是方的观感差一大截。中点控制的做法会带来最多一个点的视觉位移但整体曲线更润这是白板类工具里最常见的平滑方式。注意drawStroke只被接收端调用不负责发送。4.4 本地回放与离线缓冲断线重连时别人画的线怎么补多人协作最尴尬的画面是自己的网络断了几秒重连后中间少了一块后面的线全接不上。两个方案配合断线期间自己要发的消息放进sendQueue重连后先补发同时带上lastSeq向服务端发起sync让服务端把错过的轨迹补回来let sendQueue [] let lastSeq 0 function sendOrBuffer(msg) { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify(msg)) } else { sendQueue.push(msg) if (sendQueue.length 200) sendQueue.shift() // 只保留最近200笔防止内存失控 } } ws.onopen () { ws.send(JSON.stringify({ type: join, roomId, userId })) // 补发离线期间自己的操作 while (sendQueue.length) { ws.send(JSON.stringify(sendQueue.shift())) } // 请求服务端按seq补发自己错过的轨迹 ws.send(JSON.stringify({ type: sync, roomId, userId, lastSeq })) }逻辑说明先补发自己的队列再请求服务端历史这个顺序不能反。如果先收sync历史再补发自己的自己离线时画的那几笔会盖在别人的轨迹上面正好把协同现场搞成事故现场。lastSeq是本地应用过的最后一个seq服务端扫历史列表把seq大于lastSeq的事件打包成sync回传前端重放完之后继续接收增量。如果前后端是“vue打包放进springboot中”这种方式也就是前端构建完的dist目录放进SpringBoot的static下由同一个进程提供服务WebSocket路径要和静态资源路径错开。我习惯给WebSocket留/ws/前缀静态资源走根路径避免资源路径和握手端点撞在一起。还有一个坑如果应用配了context-pathWebSocket端点路径也要对应调整否则浏览器握手请求打到404。这些部署时的边界条件比业务代码更容易让人白折腾半天。5. 多人协作下的连线调试与并发翻车现场5个必踩的坑与排查解法5.1 连不上Nginx网关转发少了Upgrade透传现象前端WebSocket一直CONNECTING几秒后报错失败后端日志干干净净OnOpen根本没执行。 原因WebSocket握手依赖Upgrade和Connection两个请求头Nginx网关层默认配置不会透传后端收到的只是普通HTTP请求自然拒绝。 解决在Nginx的location里补上协议升级相关配置location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }说明proxy_pass结尾有没有斜杠会影响路径拼接/ws/paint/xxx转发到后端端口时多一层或少一层都会握手失败。排查顺序建议是先看浏览器Network面板握手请求有没有返回101返回了后端的问题没返回先怀疑网关配置。别一上来就改后端业务代码这条血泪经验能省半天时间。5.2 全员一起掉线空闲连接被网关回收现象大家停笔看文档十分钟回来一动笔就发现连接已经断了重连后画面还缺了几笔。 原因Nginx默认空闲超时通常在60秒左右。WebSocket虽然是长连接但空闲时不产生业务流量网关按“一段时间没数据传输”把它关了。这就是“半小时就掉线”的真凶代码一行没写错纯属承载层配置问题。 解决两件事一起做缺一不可。第一把上面的proxy_read_timeout调成3600s让网关别管空闲第二前端每30秒发一次ping后端回pong让链路始终保持活跃。两者的关系是双保险只调超时不做心跳跨多层网关时还是会出问题只做心跳不改超时心跳本身就能续命但网关重启这类异常还是可能掐断。改完Nginx配置记得nginx -t后reload我见过有人配置改了没reload反复测了半小时最后发现改了个寂寞属于自找的坑。5.3 SpringBoot版本太高导致javax/jakarta命名空间错误现象照着旧教程写import javax.websocket.*编译直接报“找不到符号”。 原因SpringBoot 3.x基线迁移到Jakarta EE 9javax改名jakarta旧教程大多基于2.x照抄必翻车。 解决先确认项目的SpringBoot主版本。3.x用jakarta.websocket2.x用javax.websocket。如果必须沿用旧代码把SpringBoot降到2.7.x是最省事的做法。这个问题的本质是版本迁移和业务逻辑无关但我见过不少人把日志打满、把代码理了一遍才发现只是import写错了。版本号这玩意看起来是玄学实际只需记住这条规则先看starter parent的版本号再决定import前缀前后端源码交接时也把这一条写进README省得下一个人再踩。5.4 顺序乱套线条错位、撤销把后面的内容撤了现象两个人同时画先画的线显示在后画的上面C点撤销把D刚画好的内容也撤销了。 原因WebSocket消息在传输和异步处理中没有全局顺序保证全局操作一旦乱序整个画布状态就崩了。 解决给每个房间一个严格的seq序号服务端用AtomicLong在广播前分配前端按seq应用消息小于本地seq的直接丢弃private final AtomicLong roomSeq new AtomicLong(0); // 广播给房间之前 long seq roomSeq.incrementAndGet();参数说明roomSeq以房间维度递增不是全局递增否则两个房间的序号互相踩踏。clear/undo这类全局操作还要额外带version前端只在version大于本地版本时执行。seq对抗的是同房间内消息的先后顺序version对抗的是全局操作之间的新旧关系两个字段职责不同不能互相替代。5.5 房间人数多了卡顿广播风暴和全量重绘现象10个人同时画画布掉帧CPU飙高线条明显滞后。 原因逐点发送消息加每条消息都重绘画布两个问题叠加浏览器和后端都吃不消。 解决方向是减少消息数量和重绘次数。把一次落笔打包成一条draw消息广播时排除发送者本人接收端用rAF批量渲染轨迹做2px抽稀。把这四个动作做完几十人的房间在单机部署下是能扛住的。优化项做法收益落笔打包pointerup时一次性发送整条轨迹消息量从每秒几十条降到每笔一条排除回显广播时跳过发送者下行消息量接近减半批量渲染rAF统一flush待画队列重绘次数降到每帧一次轨迹抽稀距离小于2px的点丢弃单条消息体降低30%-50%说明这张表就是白板协作最基础的性能清单。先做表格里的四件事再考虑分片画布、按区域广播这些更重的优化。我见过有人一上来就上消息队列和集群结果单机几十人都还没跑顺属于过早优化。性能问题先量化再动手在服务端打印每秒消息数和广播耗时看P95延迟超过300ms再回来检查这四项有没有做到位。6. 从“能画”到“能用”的三个进阶验证协商、压测与回放一致性6.1 clear/undo要带版本号而不是清空数组只把clear当成“清空画布”不够。两个成员几乎同时操作时A的clear消息晚到却把B在clear之后画的内容清掉画面就崩了。正确做法是房间进入时记录version每次全局操作version加一前端只接受大于本地version的全局操作if (msg.type clear msg.version localVersion) { clearCanvas() localVersion msg.version }这个判断要在消息入队之前做避免乱序的旧clear把新画面冲掉。6.2 用脚本压测WebSocket广播后端写完别急着拉联调先用脚本模拟几十个用户同时画。一个最小压测脚本长这样const WebSocket require(ws) const clients [] for (let i 0; i 20; i) { const ws new WebSocket(ws://localhost:8080/ws/paint/room_1024) ws.on(open, () { ws.send(JSON.stringify({ type: join, roomId: room_1024, userId: u_${i} })) setInterval(() { ws.send(JSON.stringify({ type: draw, roomId: room_1024, userId: u_${i}, payload: { points: [{ x: i, y: i }] } })) }, 200) }) clients.push(ws) }逻辑说明20个客户端每200ms发一条draw等于每秒钟100条广播消息基本能模拟小型团队的使用强度。跑起来后在服务端打印广播耗时如果P95超过300ms说明广播循环里有阻塞重点检查是不是在循环里做了JSON解析或同步IO。6.3 回放一致性验证“能不能用”最终要验证一件事把服务端记录的轨迹按同步顺序重放一遍和录制时的画面是否一致。这个验证可以在CI里跑重放结束后把画布导出为位图和录制基准位图做像素级diff统计不同像素占比。低于千分之一说明重放逻辑正确高于就要回头查消息是否漏发、顺序是否一致。我做这类项目得到的最大教训是协议设计、连接保活、序号分配这些看起来不起眼的环节才是协作画板真正的黑匣子画布API反而是最好写的部分。每踩过一个坑我就把对应检查项写进项目的启动清单现在新项目开工第一件事就是确认WebSocket握手、心跳间隔、seq/version有没有落到消息上。希望帮到你。本文还有配套的精品资源点击获取
返回列表