简介:这是一份基于SpringBoot+Vue+WebSocket的多人实时在线协作绘画平台设计源码,适合希望掌握前后端分离开发与实时通信技术的开发者,也可用于远程协作白板、在线教学等场景。项目按后端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之后,同一条画布上多人同时落笔才真正觉得顺。基于SpringBoot+Vue+WebSocket的这套组合,本质是用SpringBoot管房间、用户和轨迹历史,用Vue做画布交互,用WebSocket承载全部画笔事件与状态广播,是做在线白板、团队协作工具、答辩演示系统最常参考的源码方向。下面按完整落地路径拆:先定协议,再做后端广播,再写前端画板,最后把坑和验收手段排一遍。这套方案不复杂,但每一步都有让demo变成半成品的细节。
2. 先定协议再写代码:房间模型、消息格式与画布状态同步策略
2.1 为什么是WebSocket而不是轮询或SSE:三个方案的延迟和连接成本对比
很多现成的协作白板demo,看起来功能都差不多,体验差距全在协议层。画架上的每条线都是一串坐标点,要把这串点从A的浏览器搬到B和C的浏览器,传输方案有三大类:HTTP短轮询、SSE服务端推送、WebSocket。三者不能只看“能不能实时”,要同时看延迟、连接成本和消息方向。
| 方案 | 延迟 | 连接成本 | 典型场景 |
|---|---|---|---|
| HTTP短轮询 | 延迟=轮询间隔+网络往返,做不到真正实时 | 每次请求都带完整Header,10人每秒轮询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维护就够了。常见做法是维护三个Map:
- sessionMap:sessionId → WebSocketSession,负责拿连接、发消息、关闭连接;
- roomMap:roomId → Set<sessionId>,广播时找同房间的其他连接;
- historyMap:roomId → List<轨迹事件>,新用户进房时补全画面。
三张表在单机几十人并发内没问题,代码也最好调试,毕设、内网演示、小团队工具都走这条路。注意historyMap别无限增长,我一般限制每个房间保留最近200笔轨迹或最近30分钟数据,够新成员跟上当前画面就行,多了纯属消耗内存。这一点在历史补帧时还要用到。
要上集群,内存方案就不够了。常见做法是把广播这一层下沉到Redis,用Redis Pub/Sub甚至Redisson的Topic做跨节点广播:A节点收到一条draw,发布到频道,所有节点订阅后把消息发给各自管辖的session。用户的session路由可以按userId哈希到固定节点,也可以在每个节点保存全量session索引。这里给一句实用忠告:不要一上来就上Redis。单机内存方案把边界写清楚,反而比过度设计更容易让人信服;等真正出现跨节点广播需求再迁移,迁移路径也清晰。历史轨迹如果上Redis,可以用LIST存序列化后的消息体,配合序号做范围补发;单机版本直接用ArrayList,seq刚好对应数组下标,简单直接。
3. 后端SpringBoot落地:WebSocket端点、房间路由与心跳机制
3.1 引入依赖与注册WebSocket端点:先解决SpringBoot 2.x和3.x的差异
后端实现从依赖开始,SpringBoot项目引入websocket starter只需要一条依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 Map<String, 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 Map<String, Set<String>> 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) { Set<String> 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走broadcastToRoom;ping只回pong。为什么要排除发送者本人?因为前端在本地已经把自己画的那一笔画上去了,服务端再回传一次,前端一帧里画两遍,性能白白浪费。SESSION_ROOM是给leave用的,离开时得从ROOM_SET里把自己清掉,不然房间集合里全是死sessionId,广播时每一条消息都往不存在的连接上写,累积起来拖垮整体发送性能。
参数说明:广播循环里的JSON序列化在进入循环前已经由Spring完成;不要在循环里再做一次ObjectMapper.readTree,否则100人的房间就意味着100次重复解析,P95延迟直接翻倍。另一个易错点是sendText是同步的,某个客户端网卡、慢处理会拖慢整条循环,单机到几十人时可以把循环放到线程池里,但要注意保持顺序,否则又会引入乱序问题。
3.3 心跳保活与断线清理:为什么前端每30秒要发一次ping
WebSocket在公网部署时,中间每一层网关都可能在一定空闲时间后断开连接。常见的默认空闲超时是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) { Set<String> 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时让服务端回传全量历史,前端重绘后再进入增量接收。历史数据在单机内存里就是historyMap:roomId → 轨迹事件列表,没上Redis前直接用ArrayList:
private static final Map<String, List<JsonNode>> HISTORY = new ConcurrentHashMap<>(); private void sendHistory(Session session, String roomId) throws IOException { List<JsonNode> list = HISTORY.get(roomId); if (list == null) return; Map<String, 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消息写进HISTORY:
case "draw": List<JsonNode> 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 ref="wrapRef" class="board-wrap"> <canvas ref="paintLayer" class="paint-layer"></canvas> <canvas ref="tempLayer" class="temp-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。采集逻辑的核心是:一次落笔收集成一笔stroke,pointerup时一次性通过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-path,WebSocket端点路径也要对应调整,否则浏览器握手请求打到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 9,javax改名jakarta,旧教程大多基于2.x,照抄必翻车。 解决:先确认项目的SpringBoot主版本。3.x用jakarta.websocket,2.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有没有落到消息上。希望帮到你。
本文还有配套的精品资源,点击获取