尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

WebSocket聊天室实战:从服务端到Nginx反代部署

发布时间:2026/9/13 17:54:07

资讯中心
01
ARTICLE

WebSocket聊天室实战:从服务端到Nginx反代部署

WebSocket聊天室实战:从服务端到Nginx反代部署
简介基于WebSocket的网页聊天室学习源码包面向正在学习实时通信、Python后端与Web前端联动开发的初学者解决从零搭建双向通信应用的需求。项目虽小却涵盖了HTTP握手升级协议、持久连接上的全双工消息传递以及服务端向多个客户端广播等核心环节适合作为课程设计或简历项目的起点。压缩包共5个文件核心由Python后端脚本、HTML前端页面、依赖清单、说明文档及Git忽略文件组成整体仅3KB结构精简、便于逐行研读前端页面与后端脚本分离能直观对照前端接口与后端网络框架的配合方式。已有62人学习下载适合想快速上手WebSocket而无需阅读冗长教程的开发者。通过运行后端并打开前端页面可实际验证连接升级、消息收发与广播机制说明文档还提示了跨站脚本攻击、跨站请求伪造等安全防护及连接管理策略可在实验基础上继续完善也可作为加入用户认证、消息存储等功能的二次开发起点。1. 网页聊天室为什么必须用 WebSocket而不是轮询或 SSE一个网页聊天室消息能不能在发出后 1 秒内出现在别人屏幕上取决于你选的实时通道。HTTP 短轮询在聊天场景下会产生大量空请求服务端压力大、延迟还随轮询间隔放大SSE 解决了服务端推送但浏览器往服务端传数据还得另走 HTTP等于是两条链路。WebSocket 先用一次 HTTP Upgrade 完成握手随后就是一条双工长连接两端随时能互推。基于 WebSocket 做网页聊天室实质是三件事维护连接集合、做消息广播、用心跳把断了但没通知到的连接捞出来。接下来的代码从一组能直接跑的最小实现开始一直推进到鉴权、Nginx 反代和上线前要盯的参数。2. 先用 Python websockets 跑通最小聊天室服务端2.1 广播的本质是维护连接集合不是写一份聊天记录聊天室服务端和普通接口服务最大的区别是接口是一次请求一次响应聊天室是连接挂在那里等着收消息。所以服务端的核心状态就是「当前有哪些连接在线」。用 Python 做这件事最直接的是 websockets 库它把握手、帧解析、协议层心跳都封装好了开发者只需要管理连接集合和消息分发。常见做法是用一个 set 保存在线连接连接建立时 add断开时 discard广播时遍历这个集合逐个 send。为什么是 set 而不是 list因为 set 的 remove/discard 是常数时间连接频繁进出不会有碎片化问题而且同一连接不可能被重复加入。下面是完整可跑的广播服务端。import asyncio import websockets # 保存所有在线连接set 保证同一连接不会出现两次 clients set() async def chat_handler(websocket, pathNone): # 新连接进入聊天室 clients.add(websocket) print(fonline: {len(clients)}) try: # async for 会一直读到连接断开为止 async for raw in websocket: msg raw.strip() if not msg: continue # gather 并发广播慢连接不会拖累其他人 others [c for c in clients if c is not websocket] if others: await asyncio.gather( *(c.send(msg) for c in others), return_exceptionsTrue ) except websockets.exceptions.ConnectionClosed: # 客户端正常断开或网络异常都会走这里 pass finally: # finally 兜底连接无论怎么断都被清掉 clients.discard(websocket) print(foffline: {len(clients)}) async def main(): # ping_interval: 协议层 ping 的发送间隔 # ping_timeout: 发出 ping 后多久没收到 pong 就判定断开 async with websockets.serve( chat_handler, 0.0.0.0, 8765, ping_interval20, ping_timeout20, ): await asyncio.Future() # 挂起事件循环直到进程被终止 if __name__ __main__: asyncio.run(main())这段代码有两个容易踩的细节。第一others [c for c in clients if c is not websocket]是「免打扰」语义自己的消息不回显给本人如果希望自己发的消息也出现在自己的屏幕上直接广播给clients全集由前端判断是否渲染。第二asyncio.gather(..., return_exceptionsTrue)很关键如果某个客户端已经死了但还没来得及从集合里清掉send()会抛异常return_exceptionsTrue让它不打断其他连接的消息发送。参数上ping_interval和ping_timeout是配套出现的含义完全不同ping_interval是服务端主动探测的间隔ping_timeout是发出 ping 后等多长时间没收到 pong 就断开。聊天室建议把超时设得比间隔大一点给网络抖动留余地。websockets.serve还有其他在聊天场景值得调的参数见下表。参数默认值作用聊天室建议ping_interval20 秒服务端发协议层 ping 的间隔20~30 秒太短会频繁唤醒移动端ping_timeout20 秒收不到 pong 的判定时间大于 ping_interval避免一次丢包就判定离线max_size约 1MB单条消息最大字节数聊天消息设 16KB 左右防止有人猛发大帧max_queue16单个连接待发送队列长度保持默认超过后库会断开慢消费者compression默认开启对文本帧做压缩消息普遍很短时建议关闭省 CPU提示websockets 库较新版本里 handler 的签名从(websocket, path)变成只接收websocket一个参数。上面代码保留pathNone是为了兼容新老版本在只传一个参数的版本里默认值会自动兜住。需要读握手路径时较新版本用websocket.request.path。2.2 用 20 行的 Python 客户端验证广播链路服务端写完先别急着写页面先用一个最小客户端把「收得到、发得出」验证掉。下面的脚本连接本地 8765 端口发一条hi再打印收到的回复。import asyncio import websockets async def main(): async with websockets.connect(ws://127.0.0.1:8765) as ws: await ws.send(hi) reply await ws.recv() print(freceived: {reply}) asyncio.run(main())async with块退出时会自动发关闭帧并释放连接不需要手动 close。这段代码验证的是单向收发广播链路要再开两个「只收不发」的客户端让第 2 个连接发hello另外两个都打印出hello才说明广播通的是全链路。实际开发中我会把这个文件保存成smoke_client.py每次改完服务端跑一遍比打开浏览器调试快得多。2.3 先别急着加房间把连接可见性加上单线程聊天室能跑之后第一个要补的是可见性。至少在每次连接建立和断开时打印在线数再把len(clients)暴露成一个/stats接口或定时输出到日志。否则线上出现连接数不涨反跌、内存缓慢上升时你很难判断是客户端没重连还是服务端集合里残留了僵尸连接。前端接入之后出现的 onclose、onerror、reconnect 一系列问题全部建立在服务端这套连接管理逻辑上。协议层 ping/pong 负责探测finally负责清理这两件事做牢前端重连才有意义。3. 网页聊天室前端连接、心跳重连与 onclose 1006 的排查思路3.1 用原生 WebSocket API 封装一个带重试的连接对象浏览器原生WebSocket只负责收发不管断线重连和心跳。聊天室要做稳第一步是把这些逻辑封成一个类。下面这个ChatSocket是最常见的封装方式重连、心跳、事件分发都在里面业务层只需要接onmessage。class ChatSocket { constructor(url, { heartbeat 30000 } {}) { this.url url; this.heartbeat heartbeat; this.timer null; this.reconnectAttempts 0; this.connect(); } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.reconnectAttempts 0; this.startHeartbeat(); this.onopen?.(); }; this.ws.onmessage (e) { // 业务层心跳回包不交给上层业务处理 if (e.data pong) return; this.onmessage?.(e.data); }; this.ws.onclose () { this.stopHeartbeat(); this.scheduleReconnect(); }; this.ws.onerror () { // onerror 之后必然接 onclose这里不触发重连 this.ws.close(); }; } scheduleReconnect() { // 指数退避1s、2s、4s……封顶 15s const delay Math.min(1000 * 2 ** this.reconnectAttempts, 15000); this.reconnectAttempts 1; setTimeout(() this.connect(), delay); } startHeartbeat() { this.timer setInterval(() { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(ping); } }, this.heartbeat); } stopHeartbeat() { clearInterval(this.timer); this.timer null; } send(data) { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(data); } } }这段代码有三个设计点值得说。第一重连的触发入口只有一个onclose。onerror里不做任何重连只主动调ws.close()把状态收敛到onclose。如果两个事件各自触发一轮重连页面在断网瞬间会建立两条连接服务端弹 ping 后又把其中一条踢掉形成肉眼可见的连接抖动。第二reconnectAttempts在onopen时归零避免长时间抖动后一直用最大延迟重连。第三心跳用的是业务层文本ping不是协议层控制帧区别后面单独讲。调用方式也很直接new ChatSocket(ws://localhost:8765)然后给onmessage赋值。Vue 3 项目里通常会再包一层在onMounted里创建连接onUnmounted里调ws.close()并清掉定时器常见做法是封装成一个useChatSocket组合式函数内部维护status和messageList两个响应式状态。Vue 加这层封装原理和这个类完全一致。3.2 close code 1006 意味着什么以及下一步看哪里很多项目日志里反复出现[websocket] onclose, code: 1006, reason:, reconnect: true。1006 不是普通状态码它是浏览器内部保留值含义是这条连接关闭时没有收到任何关闭帧。正常的关闭流程会先发 close frame收到对方回应后连接才以 1000 结束1006 表示双方没来得及交换关闭帧TCP 就直接断了。看到 1006 的第一反应不应该是「前端哪里写错了」而是「这条链路在某个环节被硬生生切断了」。常见触发原因按概率排移动端网络切换Wi-Fi 切 4G、浏览器切后台后被系统或网关回收、服务端进程崩溃没有走优雅关闭、Nginx 或其他代理层超时断开、防火墙空闲会话回收。1006 之后继续重连是对的但如果服务端没有把死连接清掉客户端重建后会出现「服务端连接数匀速上涨、在线用户其实没变多」的现象。服务端的finally清理和ping_timeout就是防这个的。定位 1006 时我一般按下面的顺序检查开 Chrome DevTools 的 Network 面板找到这条 WebSocket切到 Messages 标签看最后一条帧是业务消息、ping 还是什么都没有。如果客户端最后发送的 ping 之后没有任何 pong说明链路在发送侧已经断了。接着看服务端日志Python 的 websockets 库在心跳超时后会记录ConnectionClosedError如果服务端完全没日志断点基本在中间链路重点查 Nginx 的 error log 里有没有 upstream 相关报错。常见 close code 的语义整理成表排查时对照着看。close code含义聊天室场景常见触发原因1000正常关闭用户退出或前端主动 close()1001端点离开服务端重启、灰度发布主动关连接1002协议错误收到非法帧或 Upgrade 不完整1003不支持的数据类型服务端只支持文本客户端发了二进制帧1006非正常关闭没有关闭帧断网、代理断开、进程被杀、网关超时1009消息超过限制触发了服务端 max_size 限制1011服务端内部错误服务端异常退出或升级被拒绝注意移动端还有两个容易误判为 1006 的坑。Android 9 之后默认禁止明文流量ws://请求会在握手前被系统拦掉表现是直接连接失败根本没有 close codeiOS 的 ATS 对非 TLS 的 WebSocket 也有默认限制。打包成 App 连不上、跑在 H5 里正常优先查这两项配置。浏览器端的onclose永远无法伪造出 1006 这个码它只能由底层连接状态决定。所以访谈里如果被问到 1006 和 1000 的区别核心就一句话1000 是双方握过手的体面分手1006 是没有告别的突然死亡。3.3 协议层 ping/pong 和业务层心跳是两回事这是最容易混的一点。WebSocket 协议本身定义了 Ping 和 Pong 控制帧但浏览器的 WebSocket API 不暴露收到协议层 ping 的事件浏览器收到 Ping 后会自动回 PongJS 完全感知不到。Python 的 websockets 库服务端每 20 秒发的是这种协议层 ping它验证的是「TCP 链路还活着」。业务层心跳是另一套机制前端每 30 秒发送字符串ping服务端收到后回一个pong文本。为什么协议层已经保活了还要业务层再发一遍因为协议层 ping 只证明链路通不证明业务应用层是好的。如果聊天服务端的消息分发逻辑卡死了协议层 ping 照样能收到 pong业务消息却一条都发不出去。两套心跳各司其职协议层验链路业务层验应用。配套设置可以这样给服务端ping_interval20、ping_timeout20前端业务心跳 30 秒一次。这个节奏下最坏情况是客户端已经断了 50 秒服务端才把连接踢掉如果心跳缩到 10 秒重连会快很多但移动端每 10 秒一次网络唤醒对耗电不友好。30 秒是聊天室场景比较平衡的值。服务端也可以做成「收到任何消息都当作活性证明」进一步减少心跳帧量但那样会干扰消息去重的统计我一般不太推荐。前端重连还有三个高频误用值得自查。第一onerror和onclose里各写一次重连断网瞬间产生双连接。第二重连定时器没有保存引用Vue 组件卸载后定时器还在跑页面切走再回来时出现两个 WebSocket 实例。第三浏览器把后台页面的定时器节流到 1 分钟一次页面恢复前台时心跳早就停了连接实际已死但readyState还是 OPEN正确做法是监听visibilitychange页面从 hidden 切回 visible 时主动检查readyState不是 OPEN 就立即重建。4. WebSocket 鉴权、消息协议与 Nginx 反代的工程化改造4.1 WebSocket 握手阶段就把 token 验掉别等连上再关很多聊天室项目把鉴权放在连接建立之后先连接再发一条登录消息服务端验完再踢。这个做法最大的问题是握手期间连接已经建立服务端等于给未认证用户开了一次连接配额WebSocket 长连接场景下这个配额成本比普通 HTTP 高得多。WebSocket 的握手本身就是 HTTP Upgrade鉴权应该发生在握手这一步。浏览器原生 WebSocket API 没法自定义请求头所以能用的 token 传输位置只有两个URL query 和子协议。query 方式最直接但 token 会出现在 Nginx access log 和浏览器历史里适合内网或短时效 token子协议方式更干净token 放在Sec-WebSocket-Protocol头里不污染 URL但前端取 token 时要多做一步拼接。import asyncio import websockets VALID_TOKENS {dev-token-123} async def auth_handler(websocket, pathNone): # 方式一URL query 带 token形如 ?tokendev-token-123 params websocket.query_string.decode() if websocket.query_string else token params.removeprefix(token) if params else # 方式二子协议带 token形如 [chat, token.xxx] subprotocol_token None if websocket.subprotocol: parts websocket.subprotocol.split(.) if parts[0] token: subprotocol_token parts[1] if token not in VALID_TOKENS and subprotocol_token not in VALID_TOKENS: await websocket.close(code1008, reasonunauthorized) return ...websocket.query_string的准确取法依赖库版本较新版本用websocket.request.path解析更稳这里表达的是「握手阶段读 token」的思路以实际安装版本的 API 为准。close code 1008 在 RFC 6455 里对应 policy violation专用于鉴权失败不要用 10001000 会让前端误以为是正常关闭不触发重连逻辑。鉴权放在握手阶段还有个附带好处Nginx 或网关层可以先做前置校验不带 token 的请求根本到不了后端。后端换成 netty、gin 或者 Spring WebSocket 时这套思路完全一致只是取 token 的入口换成了各自框架的握手拦截器。类似「netty websocket 怎么做鉴权」的疑问落到原理上都是「在 Upgrade 完成前做一次拦截」。4.2 用 JSON 定义消息协议广播从全量改成按房间第 2 章的广播是对全连接集合发真实聊天室要分频道大厅、战队频道、私聊。结构上和集合的区别只是多加一层把set[websocket]变成dict[room_name, set[websocket]]。消息格式如果继续用裸字符串等需求长到要区分系统消息、私聊、上麦通知时解析逻辑会变得不可维护。从第一天就用 JSON 定义消息协议是最省事的选择。一个最小但完整的消息协议长这样{ type: chat, room: lobby, from: u_1001, nick: 阿鱼, content: hello, ts: 1735000000000 }type字段用来路由消息join表示进房chat是聊天typing是输入中ping/pong是业务心跳system是系统广播。服务端收到消息后不该直接透传原文而是按type改写或补字段比如join处理完要回一条system消息带上当前房间人数。业务心跳统一用 JSON 里的type字段区分别再用裸字符串 ping/pong前端判断data.type pong过滤逻辑更清晰。按房间广播的代码只是在第 2 章的集合上套一层rooms: dict[str, set] {} async def broadcast(room: str, message: str, excludeNone): targets [c for c in rooms.get(room, set()) if c is not exclude] if targets: await asyncio.gather( *(c.send(message) for c in targets), return_exceptionsTrue ) async def join_room(ws, room: str): rooms.setdefault(room, set()).add(ws) async def leave_room(ws, room: str): rooms.get(room, set()).discard(ws)rooms.setdefault(room, set())会在房间不存在时自动创建空集合少一层手工判断。离开房间时务必discard否则客户端重连后旧连接还留在房间集合里广播会打到一条死连接上拖慢整次gather。如果聊天室要支持语音片段服务端判断isinstance(raw, bytes)二进制帧直接往房间转发文本和二进制双通道共用一套连接是 WebSocket 聊天室和普通轮询聊天室最大的协议差异之一。4.3 Nginx 反代配置Upgrade 头和 60 秒断连的真实原因浏览器连ws://地址生产环境通常由 Nginx 反代到后端服务。反代要做两件事把 Upgrade 头原样转发以及让连接保持在 HTTP/1.1。缺任何一环后端都会在握手阶段拒绝连接。下面是经过验证的最小配置。map $http_upgrade $connection_upgrade { default upgrade; close; } upstream chat_backend { server 127.0.0.1:8765; keepalive 32; } server { listen 80; location /ws { proxy_pass http://chat_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 60s; proxy_send_timeout 60s; proxy_buffering off; } }map指令是这里最容易漏的客户端的Connection头是Upgrade而 Nginx 向后端转发时必须动态设置成upgrade或close写死Connection: Upgrade反而不对。proxy_http_version 1.1要和Upgrade配套出现HTTP/1.0 不支持 Upgrade 语义。proxy_buffering off对纯文本聊天室不是必须但开着缓冲时服务端 push 的消息可能攒到 buffer 满了才吐出去实时性用肉眼看会有一点点延迟。关键是澄清「Nginx 60 秒断连接」这个流传很广的说法。proxy_read_timeout 60s的语义是「Nginx 读后端响应时两次读操作之间的最大空闲间隔」不是连接总存活时长。只要服务端每 20 秒发协议层 pingNginx 就会持续收到字节流这个计时器永远到不了 60 秒。真正导致 60 秒断连的通常是后端没有任何心跳、客户端也没发业务消息空闲会话被中间组件回收。调参数之前先在服务端抓一下 ping 帧是否真的在发比直接改proxy_read_timeout 3600s更接近真相。实际项目中Java 侧常见用 Spring WebSocket 或 NettyGo 侧用 gin 加 gorilla/websocket 一类的组合。框架换来换去反代层的参数是同一套Upgrade、Connection、proxy_read_timeout 三者对齐就能避开大部分「前端连不上」「连上又掉」的问题。把这条路径上的关键参数列全就是下面这张表。配置项作用建议值proxy_http_version反代必须用 HTTP/1.1Upgrade 才合法固定 1.1proxy_set_header Upgrade转发客户端的 Upgrade 头$http_upgradeproxy_set_header Connection动态设置连接升级语义用 map 映射不写死proxy_read_timeout两次读取之间的最大空闲间隔大于服务端 ping_intervalproxy_buffering是否缓冲后端响应纯文本可不关但要测首包延迟keepaliveupstream 空闲连接复用32 起步按并发调5. 用并发脚本测 WebSocket 广播容量上线前只看这 3 个指标5.1 用 100 个并发连接做一次广播冒烟部署之后的第一个动作不是调参是验证 fan-out。下面的脚本创建 100 个 WebSocket 客户端挂在那里每个客户端统计 5 秒内收到多少条消息再由第 101 个连接广播一条broadcast-test。理论上每个挂着的客户端都应该收到 1 条。这个脚本能一次性暴露两类问题服务端集合是否泄漏、Nginx 转发是否丢连接。import asyncio import websockets async def receiver(idx: int, results: list): async with websockets.connect(ws://localhost:8765) as ws: count 0 try: while True: await asyncio.wait_for(ws.recv(), timeout5) count 1 except asyncio.TimeoutError: results.append((idx, count)) async def main(): results [] recvs [asyncio.create_task(receiver(i, results)) for i in range(100)] await asyncio.sleep(1) # 粗略等 100 个连接建立 async with websockets.connect(ws://localhost:8765) as sender: await sender.send(broadcast-test) await asyncio.gather(*recvs) fail [r for r in results if r[1] ! 1] print(ffail: {len(fail)} / {len(results)}) asyncio.run(main())sleep(1)是这段脚本最粗略的部分连接建立得慢时广播会漏人。严格做容量测试要用专门的压测工具记录连接耗时和消息延迟分布这个脚本只负责冒烟验证连接成不立得住、广播是否穿透了整条链路。输出fail: 0说明基础链路是通的。5.2 上线前只看这 3 个指标第一连接数。把在线连接数、每秒新建连接数、每秒断开数打点监控。在线数匀速上涨而每秒新建数也是匀速的说明存在「连上就掉、掉了又连」的循环大概率是心跳或鉴权问题。每秒断开数突然抬高时看是不是服务端刚发完一批协议层 ping 后断的如果是说明链路健康状况已经在边缘。第二心跳超时的收敛速度。人为 kill 一个后端连接对应的进程看服务端集合多少秒能清干净。ping_timeout20时最坏 20 秒内连接集合应该回到 kill 前的数量如果一直清不掉说明清理逻辑没放在finally里改成「建立时登记、finally 统一 discard」收敛时间会立刻稳定。第三慢消费者的隔离。一个客户端网络很慢时接收队列会积压超过max_queue后服务端应当优先断开这个慢消费者而不是拖住整个广播。WebSocket 的 send 压力是共享的上线前最好在慢速网络环境模拟一次确认行为是「踢慢的」不是「全房间一起卡」。最后留一个调试技巧在浏览器 Console 里手动执行new WebSocket(ws://localhost:8765)就能绕过前端框架单独验证一条连接是否能建立。配合 Network 面板的 WS 过滤可以快速切分「是页面代码的问题还是网关或服务端的问题」。把这个裸连接跑通再套上鉴权、房间和心跳定位问题范围会小很多。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。