ARTICLE DETAIL

资讯详情

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

基于WebSocket的多端实时通信实战:连接管理、心跳与消息路由

基于WebSocket的多端实时通信实战:连接管理、心跳与消息路由 简介基于WebSocket的LAS多端互通毕业设计项目面向需要实现实时位置感知与多端数据同步的开发者重点解决传统HTTP只能请求/响应、无法主动推送导致的位置信息滞后问题。压缩包共55个文件包含46个Python源码文件、7张JPG效果图、1个Markdown说明文档及License文件整体仅485KB轻量而完整。项目采用服务端集中转发与客户端异步接入的方式并配有插件化模块设计覆盖聊天桥接、在线玩家查询、签到、坐标共享等典型场景源码对服务端、客户端、插件逻辑做了清晰划分JPG预览图可直观对照运行效果便于快速理解WebSocket双向通信在LAS系统中的应用机制。目前已有38人学习参考适合作为毕业设计参考或课程项目拓展也可在读懂核心流程后自行扩展消息类型与前端交互界面尤其能体现对通信协议与工程结构的综合运用。1. 基于WebSocket的LAS多端互通先说清楚这东西解决什么中午改完桌面端的排班模板手机上的小程序要能立刻看到新状态而不是等用户手动刷新或者轮询兜底。这就是LAS多端互通最典型的场景一套业务服务同时挂了桌面端、手机端、网页端任何一端产生变化其它端要在秒级内感知到。传统HTTP做不到实时下行推送WebSocket长连接才是承担这个角色的主干。标题里的LAS在这里不是某个公开标准它就是一个业务代号你可以把它映射成你手头任何一套需要多端协作的系统名。「基于WebSocket的LAS多端互通.zip」拆开看就三件事用WebSocket建立长连接在连接之上做LAS业务消息的转发再解决多端同时在线的身份识别与消息路由。比做一个单聊或通知推送复杂的地方在于同一个用户可能同时在电脑浏览器、手机App、微信小程序里挂着服务端得知道一条消息该发给哪几个连接哪些连接其实已经死了以及消息发过去之后对方到底收没收到。这篇笔记适合两类人。一类是后端要接WebSocket但之前只写过接口的能跟着把连接管理、心跳机制和消息路由跑起来另一类是前端要把网页、小程序、桌面端接到同一套实时链路上的看完能知道服务端是怎么判定自己掉线的以及前端该怎么配合。下面所有代码我都按Node.js的ws库来写这套方案换到Netty、Spring WebSocket或者Go的gorilla/websocket上思路是同一套。2. 连接管理是互通的底座把每个端变成可寻址的对象多端互通的第一步不是写消息转发而是先让服务端能认得出每一个连接。裸的WebSocket连接在服务端只是一个socket对象它不知道自己属于哪个用户、哪个端、在哪个房间。如果直接拿这个socket做消息收发你会很快发现代码变成一团乱麻要广播的时候不知道发给谁用户换设备登录后旧连接也没法处理。所以第一层要做的是给连接套上身份。2.1 为什么不能直接用裸连接来收发消息很多第一次接触WebSocket的人会直接这样写connection事件里拿到socket然后往socket上绑onmessage收到什么转发什么。单连接demo没问题一旦出现「用户A在手机上发一条消息他桌面端和网页端也要同时收到」光有socket就不够了因为服务端根本没有「用户」这个概念只有一堆不知道是谁的连接。LAS多端互通里最常见的状态是一个用户同时挂了三个连接三个连接的契约还可能不一样——网页端只关心排班变更桌面端还会同步模板文件手机端要收审批通知。你不能把这三类消息无差别群发给所有连接。所以必须在连接之上加一层会话Session抽象把「物理连接」和「逻辑身份」分开。连接断开只会影响某个connId而用户的多个连接之间是弱关联其中一个掉了不应该影响另外两个继续收消息。我一般会把连接生命周期里的注册、心跳、鉴权、路由全部收口到一个ConnectionManager里业务层不直接碰ws对象。这样做还有个好处将来把单机改成多实例部署时ConnectionManager内部换成Redis维护连接索引业务代码不用动。2.2 连接注册clientId 与 connectionId 的双层索引先定协议客户端握手时在URL的query里带clientId这个ID代表一个业务用户由LAS自己的登录态生成服务端每接受一条连接就生成一个全局唯一的connectionId。中间层维护三张表connMapconnectionId - WebSocket 实例用于直接发消息userMapclientId - Set 用于按用户找到他所有的端roomMaproomId - Set 用于按房间做广播。下面是最小可运行的注册逻辑// server.js —— WebSocket 连接注册与用户索引维护 const { WebSocketServer } require(ws); const crypto require(crypto); const connMap new Map(); // connectionId - ws const userMap new Map(); // clientId - SetconnectionId // 用 wss 实例监听端口按 LAS 网关配置调整比如 8080 const wss new WebSocketServer({ port: 8080, maxPayload: 64 * 1024 * 1024 // 允许 64MB 单帧给文件类消息留余量 }); function parseClientId(url) { // 握手地址形如: /?clientIdU10086tokenxxx // 生产环境这里的 token 要做签名校验不能用纯明文 return new URLSearchParams(url.split(?)[1]).get(clientId); } wss.on(connection, (ws, req) { const connId crypto.randomUUID(); const clientId parseClientId(req.url); if (!clientId) { ws.close(4001, missing clientId); // 握手失败直接断开 return; } connMap.set(connId, ws); if (!userMap.has(clientId)) userMap.set(clientId, new Set()); userMap.get(clientId).add(connId); // 把 connId 挂到 ws 上后面 onmessage 里好取 ws.connId connId; ws.clientId clientId; ws.on(close, () { connMap.delete(connId); const conns userMap.get(clientId); if (conns) { conns.delete(connId); if (conns.size 0) userMap.delete(clientId); } }); });注册这段逻辑里parseClientId 从握手URL解析业务用户ID这个设计是故意的WebSocket 的握手就是一次普通HTTP请求可以把鉴权信息放进query或header不要在建立连接之后再单独发一条「登录消息」——那样会给中间层留下一个没身份的空窗期。ws.close(4001, missing clientId) 是拒绝握手的标准姿势客户端会收到4001错误码并触发onclose便于前端区分「被拒」和「网络断开」。注意maxPayload这个参数。LAS如果涉及模板文件、简报图片甚至点云预览单帧消息很容易超过默认1MB上限。我按64MB开是给大数据量消息留余地但代价是内存压力增加业务不需要传大文件时建议调回1MB~8MB。2.3 房间分组把广播范围圈出来多端互通不可能所有消息都发给所有人。LAS里典型的房间模型是「项目组」一个项目组的变更消息只需要推给这个组里的人跨组消息属于越权。所以第三张表roomMap做的事就是把连接归组。// room.js —— 房间管理基于 Set 做连接维度分组 const roomMap new Map(); // roomId - SetconnectionId function joinRoom(connId, roomId) { if (!roomMap.has(roomId)) roomMap.set(roomId, new Set()); roomMap.get(roomId).add(connId); } function leaveRoom(connId, roomId) { roomMap.get(roomId)?.delete(connId); } function broadcastToRoom(roomId, message) { const conns roomMap.get(roomId); if (!conns) return; const raw JSON.stringify(message); for (const connId of conns) { const ws connMap.get(connId); // readyState 1 表示连接处于 OPEN 状态防止往 CLOSED 连接上写 if (ws ws.readyState 1) ws.send(raw); } }这里有一个需要想清楚的点房间维度到底按clientId还是connectionId。我上面是按connectionId存的好处是一个用户多个端在不同房间时互不干扰代价是用户换房间要做两次leaveRoomjoinRoom。如果你确定一个用户所有端永远在同一个房间就按clientId存房间每次广播时先展开成connectionId列表再发送省掉一部分重复消息。两个方案都能跑选择标准只有一个你的业务允不允许同一个人的两个端处在不同项目组。3. WebSocket心跳机制实现服务端如何判断「对方还活着」连接建立不等于连接健康。LAS多端互通里最常见的翻车现场是客户端突然从4G切到Wi-FiTCP连接已经死了但服务端和客户端都没有立刻感知服务端还往这个死连接上发消息客户端一直收不到也不重连。要解决这个问题必须有一套心跳机制。这也是WebSocket长连接工程里最值得抠细节的部分。3.1 心跳机制实现为什么是服务端主动 ping 而不是让客户端表态网上很多方案是客户端定时发一个{type:heartbeat}给服务端服务端收到就更新lastSeen。这种做法能用但它有个隐患客户端的定时器和网络栈是独立的即使链路已经半断开客户端的setInterval照样触发并调用ws.sendsend不报错并不代表数据真的到了对端。标准做法是服务端用WebSocket协议层的ping/pong控制帧。ws库的底层会自动响应协议层的pong帧所以服务端只要定时ping然后统计这个周期内有没有收到pong就能精确知道TCP链路是否通着。控制帧不走业务消息队列比应用层心跳更省资源、判定更准。3.2 心跳代码与参数间隔、误判阈值、重连退避// heartbeat.js —— 基于 ws 库的协议层心跳间隔 30s容忍 2 个周期无响应 const aliveSet new Set(); // 记录“本周期内回过 pong”的连接 wss.on(connection, (ws) { aliveSet.add(ws); ws.on(pong, () aliveSet.add(ws)); ws.on(close, () aliveSet.delete(ws)); }); const HEARTBEAT_INTERVAL 30_000; // 每 30s 检查一轮 const HEARTBEAT_TIMEOUT 60_000; // 距离上一次 pong 超过 60s 视为死亡 setInterval(() { for (const ws of wss.clients) { if (ws.readyState ! ws.OPEN) { aliveSet.delete(ws); ws.terminate(); // 直接掐断触发客户端重连 continue; } if (aliveSet.delete(ws)) { ws.ping(); // 本周期有 pong继续探活 } else { // 上一周期没收到 pong说明链路已经断了 ws.terminate(); } } }, HEARTBEAT_INTERVAL);这套逻辑的关键在aliveSet.delete(ws)的返回值每轮进入定时器时如果这个连接在上一个周期内回过pongdelete会返回true然后重新ping如果返回false说明这个连接已经整整一个心跳周期没有回应直接terminate。terminate和close的区别值得注意close是礼貌地走完关闭握手但TCP层可能已经死了close发不出去terminate是直接销毁底层socket立刻生效。服务端探活发现死连接就一律用terminate。参数别拍脑袋。30秒的检查间隔和60秒的容忍阈值适合绝大多数内网和公网场景但如果你在弱网环境比如移动端经常进出电梯建议把间隔调到15秒容忍阈值保持2个周期不变。间隔太短会增加无谓的包量和CPU开销太长则会让用户感知到「已断线但重连迟迟不来」。另外服务端terminate之后不要马上重连——客户端收到onclose再发起重连这才是合理链路服务端不要替客户端做重连决定。参数建议值说明HEARTBEAT_INTERVAL30s弱网 15s两次 ping 的间隔HEARTBEAT_TIMEOUT2 × interval连续两个周期无 pong 判定死亡服务端断开方式terminate不依赖 TCP 层状态直接销毁客户端重连退避1s → 5s → 15s → 30s 封顶指数退避避免断网恢复时打爆服务端3.3 浏览器端的取舍原生 ping/pong 不可控走应用层心跳浏览器里的WebSocket API没有暴露ping/pong的控制能力你拿不到底层的pong事件。所以网页端通常退而求其次采用应用层心跳前端定时发一条业务心跳服务端更新lastSeen并用服务端的定时扫描兜底。这不算违反协议层心跳原则而是平台限制下的务实选择。应用层心跳的收发两端格式要对齐我常驻的字段是{ type: heartbeat, ts: 1717300000000 }前端每25秒发一次比服务端30秒的判定窗口略短服务端收到后只更新时间戳不往业务消息队列里塞。要注意应用层心跳消息不要把数据写进Redis之类的存储里否则线上几十万连接每分钟会产生几百万次写入纯属浪费。// 前端浏览器端心跳LAS 网页端 const HEARTBEAT_APP_LEVEL 25_000; let heartbeatTimer null; function startHeartbeat(ws) { stopHeartbeat(); heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: heartbeat, ts: Date.now() })); } }, HEARTBEAT_APP_LEVEL); } function stopHeartbeat() { if (heartbeatTimer) clearInterval(heartbeatTimer); } // 页面卸载时一定要清定时器否则页面关了还在空发 window.addEventListener(beforeunload, stopHeartbeat);前端还有一个容易被忽略的点onclose事件的延迟。网络断开时浏览器不一定立刻触发onclose可能需要几十秒甚至更久。所以前端不能只依赖onclose来启动重连更稳的做法是同时监听onerror和onclose并在每次发送消息失败时也触发一次重连检查。重连时要把连接重新注册一遍这一点后面第5章会专门讲它是最常见的掉线恢复翻车点。4. 多端消息的路由从 A 端发起到 B/C 端到达连接和心跳都就绪后核心业务逻辑才上场一条消息从某个端进来服务端决定往哪几个端转发。这个章节说的不是某个具体业务而是LAS多端互通里通用的消息分发骨架。协议定得好不好直接决定后面接多少个业务类型都不慌。4.1 消息信封与消息类型先定协议再写代码我习惯给所有WebSocket消息统一包一个信封而不是各业务发各的裸JSON。信封字段如下字段类型说明msgIdstring全局唯一用于去重与回执typestring业务类型如 las.template.update / las.task.statusfromstring发送者 clientIdtostring目标clientId / roomId / broadcastpayloadobject业务数据tsnumber客户端时间戳这个信封的好处是路由层只认msgId、type、to三个字段完全不用关心payload里的业务结构。后面每接一个新业务只需要新增一个type路由逻辑一行不用改。LAS业务里我至少会分成三类实时同步类模板变更、状态流转、指令类强制刷新、踢下线、文件类二进制分片。一个type字段就可以区分这些不要让路由层靠识别payload里的字段名做判断。4.2 路由与回执目标离线时消息怎么办路由层要做的事是解析to然后到userMap或roomMap里查出目标连接逐个发送。如果目标端不在线LAS消息必须有个落地方案否则这条变更就丢了。离线消息的策略我推荐「Redis List暂存 登录后主动拉取」而不是服务端无限期给每个离线用户堆消息。// router.js —— 消息分发与离线暂存假设 Redis 已通过 ioredis 初始化 async function dispatchMessage(raw) { const msg JSON.parse(raw); const { msgId, type, from, to } msg; const targets resolveTargets(to); let anyDelivered false; for (const connId of targets) { const ws connMap.get(connId); if (ws ws.readyState 1) { ws.send(JSON.stringify(msg)); anyDelivered true; } } if (!anyDelivered) { // 目标连接全不在线按 clientId 维度暂存最近 50 条 const key las:offline:${to}; await redis.lpush(key, JSON.stringify(msg)); await redis.ltrim(key, 0, 49); // 只保留最新 50 条防止堆爆 await redis.expire(key, 7 * 24 * 3600); // 7 天有效期兜底 } return anyDelivered; }resolveTargets需要处理三种to值clientId就展开成userMap里的Set roomId就展开roomMapbroadcast就直接返回所有在线连接。注意anyDelivered只代表「发出去了」不代表对端业务处理成功。如果需要端到端确认由对端回一条ack消息服务端再更新消息状态。这个ack环节在LAS里很重要尤其是桌面端改模板后手机端必须确认收到光靠「发出去了」是不够的。还有一点ws.send在底层socket缓冲满时可能抛出异常。大文件消息尤其常见需要在send外面包try/catchcatch到就用terminate处理这个连接别让异常影响消息循环里的其他连接。4.3 去重与顺序同一个用户的多个端别互相打架一个用户三个端同时在线的时候最容易出现两类问题。第一类是消息重复桌面端发起模板更新服务端广播给三个端手机端和网页端各收到一次前端如果都做了弹窗提示用户会看到两条一样的信息。第二类是顺序错乱桌面端先发了「开始同步」又发了「同步完成」但由于两条消息走了不同的实例或线程服务端可能把后面那条先发出去。去重方案是在服务端维护一份msgId的最近缓存。每收一条消息就把msgId塞进Redis的SET并设置过期时间比如10分钟重复的msgId直接丢弃。注意这里去重的是「同一事件」而不是「同一事件的多次广播」广播给三个端是业务需要的不冲突。顺序问题更头疼单实例里可用一个简单的自增seq保证同连接的消息有序多实例场景下需要让同一个clientId的消息始终进同一个消息队列或分片才能严格保序。对LAS这种实时协作场景我通常只在同一连接维度保序跨端全局强一致投入产出比不高。// dedup.js —— 最近 10 分钟的 msgId 去重 async function isDuplicate(msgId) { const key las:msgdedup:${msgId}; // setnx 成功说明第一次见失败说明重复 const ok await redis.set(key, 1, EX, 600, NX); return !ok; }去重放在路由之前。收到消息先查重复再走dispatch。一个看似小但实际很关键的细节msgId的生成不能在服务端统一生成而要由发起端生成。原因很简单用户可能在桌面端先发出了消息但因为网络没到达服务端随后手机端又发起一次同样操作如果msgId是服务端生成的这两条消息永远无法被识别为同一条。5. 多端互通排查5 个常见坑与现场处理办法WebSocket踩坑的路径高度重复以下五条每一个我都实打实遇到过现象、原因和解决办法按顺序写你可以对照着手里的日志排查。5.1 Nginx 静默掐断空闲连接现象客户端连接建立后隔一段时间恰好是60秒左右就收到onclose服务端日志里没有任何close记录。原因Nginx作为反向代理时默认proxy_read_timeout是60秒在这段时间内如果后端没有数据返回Nginx会主动断开连接。WebSocket的连接恰恰大部分时间没有数据流动于是被当成空闲超时掐断。解决在Nginx的location里显式关闭代理超时。location /ws/ { proxy_pass http://las_ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }注意proxy_set_header Connection那行必须写成upgrade否则WebSocket握手在Nginx这一层就失败。这个坑的症状是连得上但马上断开和心跳无关。5.2 心跳间隔不一致导致误杀现象客户端明明在线却频繁被服务端断线重连。原因服务端心跳判定阈值设的30秒客户端的应用层心跳发的倒是挺勤但客户端所在的网络环境有丢包pong帧偶尔没回来连续两个周期没收到pong就被service端误杀。解决把服务端的容忍阈值从2个周期放宽到3个周期同时让客户端的应用层心跳间隔比服务端探活间隔短至少5秒留出余量。误杀重连不是大事但每次误杀都会带来一次重连风暴连接数多了会产生雪崩效应这个参数值得多调几轮。5.3 断线重连后消息继续发给旧连接现象用户手机切了Wi-Fi再切回来连接断了又重连成功但之后服务端发的消息他总是收不到。原因前端重连后只建立了新的TCP连接没有重新发起注册握手服务端userMap里用户的connectionId还是旧的消息全发给了已经死掉的旧连接。解决把「连接建立」和「连接注册」做成两个明确的阶段前端必须在onopen之后等待注册响应服务端注册成功再收业务消息。最常见做法是重连后第一条消息一定是register。// 客户端重连后必须重新 register不能只 new WebSocket() function connectWithRegister() { const ws new WebSocket(wss://las.example.com/ws?clientIdU10086); ws.onopen () { ws.send(JSON.stringify({ type: register, clientId: U10086 })); }; }服务端把register消息放进用户白名单没注册的连接拒绝转发业务消息。这个约束能直接避免重连后消息丢失的黑匣子问题。5.4 多端在线时的消息重复与回显问题现象A端发一条消息B端、C端都收到了但A端自己也收到了一份服务端回显前端没有过滤界面上出现两条自己发的消息。原因广播逻辑没有排除发送者连接或者前端没有对本地发送的消息做ack去重。解决服务端广播时排除from的connId前端也最好把「自己发出的消息」直接渲染成pending状态收到ack再变成已送达而不是等广播回来再渲染。LAS里桌面端和网页端经常共用一个账号回显去重尤其要注意。5.5 Sending on closed socket 异常现象服务端日志频繁出现Error: Sending on closed socket偶发进程崩溃。原因连接在ws.send之前刚被关闭但connMap里还残留引用代码直接往已关闭的连接上写数据。解决发消息前检查readyState 1只是第一道保险还要在send外面包try/catchcatch住就直接清理连接索引。不要小看这个错误流量高峰时它会拖垮整个消息循环属于高发事故源。// safeSend.ts —— 带兜底的发送封装 function safeSend(ws, raw) { try { if (ws ws.readyState 1) ws.send(raw); } catch (e) { // 连接已死清索引并终止 connMap.delete(ws.connId); ws.terminate(); } }6. 进阶多实例扩展与端到端验证单机跑通只是开始LAS多端互通要上生产单实例撑不住所有在线连接横向扩展是绕不开的问题。一个用户连在实例A另一个用户连在实例B两个实例之间的连接互相不知道对方消息就断在中间。常见做法是引入Redis Pub/Sub作为实例间消息总线本地路由直接发本机连接跨实例消息通过Redis发布所有实例都订阅同一个频道收到后检查目标连接是否在自己这里。// cluster.js —— 多实例桥接本地直接路由跨实例走 Redis 广播 const sub new Redis(); // 订阅连接 const pub new Redis(); // 发布连接 sub.subscribe(las:ws:cluster); sub.on(message, (_channel, raw) { const envelope JSON.parse(raw); // 只有目标连接在本实例才处理避免 A 实例收到又转发回 B 实例 dispatchMessage(envelope); }); function sendCrossInstance(targetConnId, msg) { pub.publish(las:ws:cluster, JSON.stringify({ target: targetConnId, msg })); }端到端验证我习惯用Node脚本模拟多端同时在线而不是靠手工开几个浏览器窗口戳来戳去。用ws库起一个测试客户端同时模拟桌面端、网页端、手机端三个身份连到同一个clientId下然后让一端发消息断言另外两端都能收到再手动调低心跳阈值验证断线重连。// test.js —— 模拟 100 个并发端做联调 const WebSocket require(ws); function createTestClient(clientId, port 8080) { const ws new WebSocket(ws://127.0.0.1:${port}/ws?clientId${clientId}); ws.on(message, (data) { const msg JSON.parse(data.toString()); if (msg.type las.template.update) { console.log([${clientId}] 收到模板更新:, msg.payload.version); } }); return ws; } // 同时模拟 100 个用户在线 const clients Array.from({ length: 100 }, (_, i) createTestClient(U${10000 i}) ); // 等 2 秒连接全部建立再从 U10000 广播一条模板更新 setTimeout(() { clients[0].send(JSON.stringify({ type: las.template.update, to: broadcast, payload: { version: v2.3.1 } })); }, 2000);跑这个脚本时重点观察两个指标一是100个连接同时注册时服务端有没有内存突增或报错二是广播后是否每个端都收到了且只收到一次——重复也说明路由有问题。我自己的教训是多端互通上线前一定要专门做一次「杀掉服务端」的演练看客户端重连能不能在30秒内全部恢复账号信息会不会因为重连而丢。这个演练花钱最少、救急最多。LAS多端互通的实现链条就是这样连接注册、心跳判定、消息路由、多实例桥接每层都守住边界端和端之间才能安静地实时同步。希望这些踩出来的经验帮到你。本文还有配套的精品资源点击获取
返回列表