简介:这是一套面向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内收到 pong,onPong会把它清掉。如果没收到,说明这条连接已经不通了,主动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 补拉这套组合,是绕不过去的基本功。希望帮到你。
本文还有配套的精品资源,点击获取