尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WebSocket实时聊天系统设计:心跳机制与断线重连实战

WebSocket实时聊天系统设计:心跳机制与断线重连实战 简介这是一套面向Python毕业设计与课程设计场景的实时在线聊天系统完整源码适合具备一定前后端基础、需要完成WebSocket即时通信项目的学生与开发者。项目以Vue.js构建聊天室单页界面后端基于Python搭建WebSocket服务涵盖连接管理、消息收发、事件驱动架构与wss安全通信等关键环节可帮助读者理解全双工通信在聊天场景中的落地方式。压缩包共31个文件约134KB以15个js脚本、2个vue组件、2个styl样式、2个json配置为主另含svg图标、html入口与README说明前端构建、路由与后端服务目录划分清晰便于按模块阅读与二次开发。目前已有35人学习下载。整体代码结构完整、体量轻巧适合作为课程设计参考模板也可用于梳理WebSocket握手、消息推送与前后端联调的实现思路。1. 从轮询到长连接为什么实时在线聊天系统非 WebSocket 不可做过在线客服或者 IM 功能的人大概都经历过这个场景前端用setInterval每两秒发一次 HTTP 请求拉新消息用户量一上来服务器日志里全是无效请求延迟还压不下去。这不是代码写得差是 HTTP 轮询模型本身的天花板——每次请求都要重新建连、带一堆头部、服务端无法主动推。基于 WebSocket 的实时在线聊天系统设计核心就是换掉这套「客户端不停问」的模式改成「一次握手、双向长连接、服务端随时推」。它解决的是消息延迟、连接复用、服务端主动下发这三件事适合正在做 IM、客服系统、协同工具、弹幕或者任何需要「对方一发我立刻看到」的开发者。下面按选型理由、最小可跑通实现、心跳与重连、避坑、进阶验证的顺序拆开讲代码可以直接抄。2. 协议选型与最小可跑通架构WebSocket 到底比轮询省在哪2.1 握手阶段发生了什么为什么它比轮询省资源WebSocket 复用的是 HTTP 的握手通道。客户端发一个带Upgrade: websocket和Sec-WebSocket-Key的 GET 请求服务端返回101 Switching Protocols之后这条 TCP 连接就不再走 HTTP 语义变成全双工帧协议。省的地方有三块一是连接建立成本只付一次轮询是每次请求都付二是帧头最小只有 2 字节而 HTTP 每次请求头部动辄几百字节三是服务端持有连接引用可以主动send轮询做不到。选型上常见的对比是短轮询、长轮询Comet、SSE 和 WebSocket。短轮询延迟取决于间隔长轮询每次消息后要重建连接SSE 只能服务端单向推WebSocket 是唯一原生双向的。如果你的场景只需要服务端推、客户端几乎不发消息SSE 更轻但聊天系统天然双向WebSocket 是默认答案。提示WebSocket 握手虽然借了 HTTP但握手完成后就不再是 HTTP 了。别指望在 Nginx 里用普通 HTTP 的 buffer 配置去调它后面避坑章会讲。2.2 用 Node.js 起一个能收发消息的最小服务端先跑通最小闭环别一上来就上集群。下面这段用ws库是 Node 生态里最常见的 WebSocket 服务端实现。// server.js const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); // 用 Map 保存 userId - socket方便定向推送 const clients new Map(); wss.on(connection, (ws, req) { // 从 URL query 里取 userId实际项目建议用 token 校验 const userId new URL(req.url, http://localhost).searchParams.get(userId); if (!userId) { ws.close(4001, missing userId); return; } clients.set(userId, ws); console.log(user ${userId} connected, online${clients.size}); ws.on(message, (raw) { let msg; try { msg JSON.parse(raw); } catch (e) { ws.send(JSON.stringify({ type: error, reason: bad json })); return; } // 简单路由to 存在则定向否则广播 if (msg.to clients.has(msg.to)) { clients.get(msg.to).send(JSON.stringify({ type: chat, from: userId, content: msg.content, ts: Date.now() })); } else { for (const [uid, sock] of clients) { if (uid ! userId sock.readyState WebSocket.OPEN) { sock.send(JSON.stringify({ type: chat, from: userId, content: msg.content, ts: Date.now() })); } } } }); ws.on(close, () { clients.delete(userId); console.log(user ${userId} disconnected, online${clients.size}); }); ws.on(error, (err) console.error(ws error ${userId}:, err.message)); }); console.log(ws server on :8080);逻辑说明clients这个 Map 是整个服务端的状态核心它把业务层的 userId 和传输层的 socket 绑定起来没有它就没法做定向推送。connection回调里先做身份提取和校验校验失败直接close并带自定义关闭码 4001方便前端区分是「没带身份」还是网络断了。message回调里做了 JSON 解析的容错解析失败回一条 error 而不是让异常冒泡把连接搞崩。广播时检查readyState OPEN因为 Map 里可能残留正在关闭的连接。参数说明port是监听端口生产环境一般放在反向代理后面这里先用 8080 直连调试。ws.close(code, reason)的 code 建议用 4000 以上自定义区间1000-2999 是协议保留的。JSON.parse外面必须包 try/catch这是新手最容易漏的一处一条脏消息就能让整个进程挂掉。2.3 浏览器端连接与消息渲染的最小实现前端用原生WebSocket就够了不需要引库。// client.js const userId u_ Math.random().toString(36).slice(2, 8); const ws new WebSocket(ws://localhost:8080?userId${userId}); ws.onopen () { console.log(connected as, userId); appendMsg({ from: system, content: 已连接 }); }; ws.onmessage (evt) { const msg JSON.parse(evt.data); if (msg.type chat) appendMsg(msg); }; ws.onclose (evt) { // 4001 是身份问题不该重连其他情况走重连逻辑 if (evt.code 4001) { appendMsg({ from: system, content: 身份校验失败请刷新 }); return; } appendMsg({ from: system, content: 连接断开(${evt.code})3s 后重连 }); setTimeout(connect, 3000); }; ws.onerror () appendMsg({ from: system, content: 连接出错 }); function appendMsg(msg) { const div document.createElement(div); div.textContent [${msg.from}] ${msg.content}; document.getElementById(list).appendChild(div); } function send(content) { if (ws.readyState ! WebSocket.OPEN) return; ws.send(JSON.stringify({ content })); }逻辑说明onclose里区分关闭码是关键设计。4001 是我们自己定义的身份错误重连也没用直接提示用户其他情况比如 1006 异常断开才走重连。send前检查readyState因为用户可能在连接还没建立或已经断开时点了发送按钮不检查就会抛异常。参数说明ws://是明文生产环境走wss://对应反向代理上的 TLS 终止。setTimeout(connect, 3000)这里是固定 3 秒实际项目要改成指数退避进阶章会讲。Math.random().toString(36).slice(2, 8)只是演示用的临时 ID真实项目里 userId 来自登录态。到这里最小闭环就跑通了两个浏览器标签页打开互相能收到消息。但只做到这一步上线撑不过半小时就会出问题接下来讲心跳和重连。3. WebSocket 心跳机制实现怎么判断连接是真活着还是假死3.1 为什么 TCP 层没断应用层却收不到消息这是 WebSocket 最反直觉的一点TCP 连接在不代表消息能通。中间经过 NAT、负载均衡、防火墙时这些设备会维护连接状态表长时间没有数据流动就会静默回收表项而两端的 socket 对象并不会立刻收到 FIN 或 RST。结果就是客户端以为连着服务端也以为连着但发出去的消息石沉大海。这就是所谓的「假死连接」。心跳机制的目的就是定期在连接上制造真实流量让中间设备知道这条连接还活着同时让两端能主动探测出已经失效的连接。心跳有两种方向客户端定时发 ping服务端回 pong或者服务端定时发 ping客户端回 pong。常见做法是客户端主动发因为客户端网络环境更复杂由它来感知自己是否掉线更合理。协议层其实有 ping/pong 控制帧但很多代理和浏览器对控制帧的处理不一致所以工程上更常见的是用业务层 JSON 消息做心跳可控性更强。3.2 客户端心跳定时器与超时判定// heartbeat.js let heartbeatTimer null; let pongTimer null; const HEARTBEAT_INTERVAL 25000; // 25s 发一次 const PONG_TIMEOUT 8000; // 8s 没收到 pong 判定掉线 function startHeartbeat(ws) { stopHeartbeat(); heartbeatTimer setInterval(() { if (ws.readyState ! WebSocket.OPEN) return; ws.send(JSON.stringify({ type: ping, ts: Date.now() })); // 发出 ping 后启动 pong 超时计时 pongTimer setTimeout(() { console.warn(pong timeout, force close); ws.close(4000, heartbeat timeout); }, PONG_TIMEOUT); }, HEARTBEAT_INTERVAL); } function onPong() { if (pongTimer) { clearTimeout(pongTimer); pongTimer null; } } function stopHeartbeat() { if (heartbeatTimer) clearInterval(heartbeatTimer); if (pongTimer) clearTimeout(pongTimer); heartbeatTimer null; pongTimer null; }逻辑说明startHeartbeat里每次发完 ping 就挂一个pongTimer如果在PONG_TIMEOUT内收到 pongonPong会把它清掉。如果没收到说明这条连接已经不通了主动close触发重连流程。这里用ws.close而不是直接不管是因为主动关闭能让onclose回调拿到明确的关闭码重连逻辑好写。参数说明HEARTBEAT_INTERVAL设 25 秒是有讲究的。大多数 NAT 设备的空闲超时在 30 到 60 秒之间心跳间隔必须小于这个值25 秒是比较安全的。设太短比如 5 秒会浪费流量和电量移动端尤其明显。PONG_TIMEOUT设 8 秒要留出网络抖动的余量设 1 秒会误杀正常连接。3.3 服务端响应心跳与连接清理// 在 server.js 的 message 回调里加分支 ws.on(message, (raw) { let msg; try { msg JSON.parse(raw); } catch (e) { return; } if (msg.type ping) { ws.send(JSON.stringify({ type: pong, ts: msg.ts })); return; } // ... 原有聊天逻辑 });逻辑说明服务端收到 ping 立刻回 pong把客户端发来的ts原样带回客户端可以用它算 RTT。服务端这边还应该加一个兜底清理定期扫描clients把readyState ! OPEN的条目删掉防止 Map 里堆积僵尸连接。参数说明服务端不需要自己发心跳回 pong 即可。但如果你的架构里服务端也要主动探测客户端可以对称地加一套服务端 ping。注意ws.send在连接已关闭时会抛错回 pong 前最好判断一下readyState。心跳跑通后连接稳定性会上一个台阶。但重连策略如果写得太粗暴用户会看到消息重复或者连接风暴下一章专门讲坑。4. 实时在线聊天系统避坑5 个上线后才会暴露的问题4.1 现象消息偶尔重复用户收到两条一样的原因客户端重连后服务端把离线期间的消息重新推了一遍但客户端本地已经渲染过其中一部分。或者客户端在onmessage里做了重试发送服务端没做幂等。解决每条消息带一个客户端生成的msgId可以用userId 时间戳 随机数服务端和客户端都维护一个最近 N 条的msgId集合收到重复的直接丢弃。离线消息拉取和实时推送之间要有明确的边界比如用服务端递增的seq号客户端记录已收到的最大seq拉取时只请求大于它的。4.2 现象Nginx 反代后连接几十秒就断原因Nginx 默认的proxy_read_timeout是 60 秒超过没有数据流动就断开。而且 WebSocket 需要显式配置Upgrade和Connection头透传否则握手直接失败。解决在 location 块里加这几行。# nginx.conf 片段 location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 300s; # 要大于心跳间隔 proxy_send_timeout 300s; }参数说明proxy_read_timeout必须大于心跳间隔否则心跳还没发连接就被 Nginx 掐了。proxy_http_version 1.1是 WebSocket 握手的前提HTTP/1.0 不支持 Upgrade。Connection upgrade这里用固定字符串而不是$connection_upgrade变量简单场景够用多协议混布时才需要变量映射。4.3 现象移动端切到后台再回来连接没了但没触发重连原因手机浏览器或 App 切后台时会挂起 JS 定时器心跳停了连接被中间设备回收。切回来时readyState可能还是 OPEN但实际已经不通。解决监听visibilitychange事件页面回到前台时主动发一次 ping 探测超时就强制重连。不要只依赖onclose因为假死连接不会触发它。document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { // 回前台先探测 if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping, ts: Date.now() })); } else { connect(); } } });4.4 现象重连风暴服务端瞬间被打满原因断线后所有客户端同时重连尤其是服务端重启的场景几千个连接在同一秒涌进来。解决重连间隔用指数退避加随机抖动。第一次 1 秒第二次 2 秒第三次 4 秒上限 30 秒每次再乘一个 0.5 到 1.5 的随机因子。这样能把重连请求打散到不同时间点。let retry 0; function connect() { const ws new WebSocket(url); ws.onopen () { retry 0; startHeartbeat(ws); }; ws.onclose (evt) { if (evt.code 4001) return; const base Math.min(1000 * Math.pow(2, retry), 30000); const jitter base * (0.5 Math.random()); retry; setTimeout(connect, jitter); }; }4.5 现象消息顺序错乱先发的后到原因WebSocket 本身在单条连接上是有序的但如果你在服务端用了多个 worker 或者消息走了不同的队列顺序就保不住。另外客户端并发send多条时如果中间有异步操作插入也可能乱序。解决单连接内的消息顺序由协议保证不要人为破坏。需要严格顺序的场景在消息里带服务端生成的单调递增seq客户端按seq排序后再渲染发现缺口就触发一次补拉。多 worker 场景下同一个会话的消息要路由到同一个 worker常见做法是按roomId或userId做一致性哈希。5. 进阶用 seq 号做消息可靠性验证与断线补拉前面把连接和心跳跑通了但「消息不丢」这件事还没验证过。真正上生产前我会做一件事给每条消息加服务端单调递增的seq然后写一个脚本模拟断线重连检查补拉逻辑能不能把缺口填上。这是判断一套实时在线聊天系统设计是否可靠的硬指标。服务端维护一个全局seq每条广播或定向消息都带上它。let globalSeq 0; function pushMessage(target, payload) { const packet { ...payload, seq: globalSeq, ts: Date.now() }; target.send(JSON.stringify(packet)); return packet.seq; }客户端记录lastSeq重连成功后发一条sync请求带上lastSeq服务端把大于它的消息补发回来。ws.onopen () { ws.send(JSON.stringify({ type: sync, from: lastSeq })); }; // 收到消息时更新 lastSeq并检测缺口 ws.onmessage (evt) { const msg JSON.parse(evt.data); if (msg.type chat) { if (msg.seq lastSeq 1) { // 有缺口主动补拉 ws.send(JSON.stringify({ type: sync, from: lastSeq })); } lastSeq Math.max(lastSeq, msg.seq); appendMsg(msg); } };服务端处理sync时从消息存储内存队列或 Redis List里取出seq from的部分回发。这里有个边界要注意补拉的消息和实时推送的消息可能重叠客户端靠seq去重即可不要假设两者互斥。验证方法很直接开两个客户端一个正常收另一个在收到第 10 条时手动ws.close()等它重连后看lastSeq能不能连续到最新。如果中间缺了或者重复了说明补拉逻辑有问题。我一般会把这个验证脚本跑 100 轮观察有没有偶发的 seq 跳跃。参数上seq用 64 位整数别用 32 位长时间运行会溢出。消息存储的保留窗口要覆盖最长可能的断线时间比如用户可能断网 10 分钟那存储至少保留 10 分钟以上的消息。内存吃紧就用 Redis List 加LTRIM控制长度。最后说个血泪经验别在onmessage里做重活。我见过有人在消息回调里同步写 IndexedDB结果消息一多主线程卡死心跳都发不出去连接被误判超时。消息先入内存队列渲染和持久化异步做。这套方案值不值得投入取决于你的场景对延迟和可靠性的要求——如果只是内部工具轮询也能凑合但只要面向真实用户WebSocket 加心跳加 seq 补拉这套组合是绕不过去的基本功。希望帮到你。本文还有配套的精品资源点击获取
返回列表