
聊到“PHP 实时聊天室源码 WebSocket”这个标题我估计不少人脑子里第一时间冒出来的问题是PHP 不是处理请求就退出吗它怎么做长连接怎么实时推消息这问题我当年也问过自己。先说结论PHP 完全可以做 WebSocket 服务端关键是你得跳出“每次请求跑完就结束”的思维定式进入“常驻内存 事件回调”的模式。这篇文章会把这个项目的原理、服务端源码、前端对接、部署和排错全部拆开讲适合那种已经会写 PHP 增删改查、但第一次接触长连接实时通信的开发者看。我自己做这个聊天室项目时一开始也迷信过“AJAX 轮询”这种老路子后来被现实教育之后才彻底转向 WebSocket。为了让你少走弯路我会把踩过的坑、调过的参数、验证过的思路都写出来。放心不是那种贴一段代码就跑的教程而是真正能落到生产环境的东西。1. 先把问题想清楚HTTP 轮询到底卡在哪1.1 轮询的痛点和长连接的出现聊 WebSocket 之前得先明白它解决了什么问题。传统的 HTTP 请求是“客户端问、服务端答”一问一答之后连接基本就断了。聊天室要求服务端把消息主动推给所有在线的人这就跟 HTTP 的模型拧着来。早年大家用轮询硬撑前端每隔一秒发一个 AJAX 请求问“有没有新消息”服务端查一下数据库再返回。消息少的时候还能用用户一多每个在线用户每秒都来请求服务器压力成倍往上翻。我当时测试 50 个人在线一台低配服务器就被打满了而且轮到别人的消息要想被新进来的人看到还得额外做补偿查询逻辑越添越乱。轮询的本质问题是“拉”而不是“推”服务端不知道用户什么时候走开客户端也不知道服务端什么时候有货两边都靠频繁试探来碰运气。更麻烦的是实时性没法保证一秒轮询一次高峰期排队的请求多了消息延迟能到两三秒这对聊天场景来说基本不能用。长连接方案的核心思路是连接建立后不马上断开服务端可以随时往这条连接里写数据客户端也可以随时发数据双方都处于“随时可以说话”的状态。这就是 WebSocket 解决的痛点。1.2 WebSocket、SSE、轮询到底选谁很多人只听说过 WebSocket但实际做实时推送时还有“SSE服务器发送事件”这个选项它跟 WebSocket 理念很像但走的还是 HTTP 协议。几个方案放一起对比会更直观方案连接方式服务端推消息浏览器兼容性适用场景短轮询每隔一段时间发普通 HTTP 请求不支持主动推靠客户端反复问全兼容消息频率极低的管理后台长轮询请求挂住服务端有数据再返回勉强算半推半拉其实还是请求-响应全兼容兼容性要求极高且并发不大的场景SSE一条 HTTP 长连接服务端单向写支持但只能服务端推给客户端现代浏览器均可老 Edge 不支持服务端推送通知、股价行情等单向场景WebSocket独立的全双工长连接支持客户端和服务端双向推现代浏览器全覆盖聊天、协同编辑、实时互动游戏聊天室天然是双向的你要发消息给别人也要接收别人的消息所以 WebSocket 是最合适的选择没有之一。它在建立连接时用的是 HTTP 的握手握手成功后协议升级后续数据帧不再走 HTTP 报文而是走长度小得多的 WebSocket 数据帧省掉了大量 HTTP 头部开销这也是它能承载高频率消息的原因之一。1.3 聊天室项目的核心技术挑战这个项目的标题看着简单真做起来有几道坎。第一道坎是“连接管理”“在线的连接放哪、掉线了怎么清理、同一用户多端登录怎么处理”。第二道坎是“消息分发”一条消息怎么高效地发给所有人而不是写个 for 循环一个个 push 导致同步阻塞。第三道坎是“稳定性”长连接挂几个小时是常态内存泄露、fd 泄漏、心跳失联任何一个没处理好服务跑两三天就崩。我在做这个项目时光心跳机制就调整了三个版本后面会专门拆开讲。2. 技术选型为什么 PHP 也能担起实时通信的活2.1 核心认知PHP 的常驻内存模式传统 PHP 开发中PHP-FPM 模式下每个请求进来脚本从头到尾执行一遍执行完所有变量、连接、资源全部释放这叫“生命周期随请求结束”。WebSocket 服务端得 7x24 小时运行连接建立后要一直在内存里等消息所以你要用的不是 PHP-FPM 那一套而是“常驻内存模式”PHP 进程启动后初始化一次然后进入事件循环不断处理新连接、新消息、断开事件直到进程被手动停止或崩溃重启。用生活化的类比来说PHP-FPM 像是银行柜台每个客户来了临时开个窗口办业务办完关窗而常驻模式像是呼叫中心坐席一直坐在工位上等电话进来电话进来就接挂了继续等下一个。做 WebSocket 服务端你需要的就是后者。目前实现方式主要有两类一类是事件驱动框架把底层的轮询与网络事件封装好你在回调里写业务另一类是直接用扩展提供的原生接口。方式不同事件模型的核心思想是一致的。实现时只需要安装好对应扩展并确认 CLI 模式可用。服务端代码以 CLI 方式启动而不是跑在 Web 目录下这点新手最容易搞混——把 WebSocket 服务端代码当普通 PHP 文件丢进 Apache然后访问它当然什么都不会发生。2.2 一个能跑的 WebSocket 服务需要哪些零件拆开看一个最小可用的 PHP WebSocket 服务端包含这么几个部件网络服务器监听端口接受 TCP 连接完成 WebSocket 握手协议。事件循环监听客户端连接上的数据到达、断开等事件并回调业务逻辑。连接表记录每个连接的唯一标识fd即文件描述符、用户信息用适当的容器存起来。协议处理器解析 WebSocket 数据帧因为客户端发来的数据是分帧的可能拆包、粘包需要按协议重组。业务回调对外暴露“连接打开”“收到消息”“连接关闭”等事件供我们填充聊天逻辑。这些零件如果全从零手写光数据帧的掩码处理和分片重组就够折腾一天。所以我强烈建议直接用成熟框架的封装只关注业务回调部分。PHP 生态里成熟的长连接框架已经帮你把这些底层细节处理得很干净你写的代码量会少一个量级而且踩坑概率低很多。2.3 项目架构总览与数据流我最终采用的结构是前端浏览器 → Nginx负责静态资源和反向代理→ WebSocket 服务端PHP 常驻进程→ 可选的 Redis多机扩展时做消息中转。数据流走两段。第一条路径是“用户进入聊天室”浏览器发起 WebSocket 连接Nginx 把带有 Upgrade 头的请求转发给 PHP 服务端PHP 完成握手后触发 Open 回调此时把连接记录到连接表并广播“某某进入了聊天室”。第二条路径是“消息传播”用户 A 在页面输入文字前端把消息包装成 JSON 通过 WebSocket 发送PHP 服务端收到后触发 Message 回调对这个 JSON 做解析和校验然后遍历连接表把消息广播给所有在线连接包括排除发送者自己或者包含发送者看产品需要。如果用户 A 发的是私聊则只推给目标 fd。整个过程用的是 TCP 长连接消息实时性几乎无延迟。这个架构的好处是简洁、单机就能跑。等用户量大了单机进程扛不住再把“遍历连接表广播”改成“推给 Redis 频道多台机器订阅同一个频道分别推送给自己机器上的用户”这部分我在后面部署章节展开讲。3. 原理先行WebSocket 握手和 PHP 事件回调3.1 一次完整的握手从 Upgrade 到 101WebSocket 建立连接并不是直接拉起一条神秘通道而是先走一次 HTTP 握手。客户端发个特殊格式的请求服务端确认后返回 101 状态码协议才算切换成功。正常浏览器会自动完成握手但在调试时你得能看懂这个过程。客户端发起握手时请求头长这样GET /chat HTTP/1.1 Host: your-domain.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13 Origin: http://your-domain.com其中Sec-WebSocket-Key是客户端生成的随机 Base64 字符串用它来防止代理缓存导致的连接复用。服务端收到后把这个 Key 拼上一个固定的 GUID 字符串协议规定好的258EAFA5-E914-47DA-95CA-C5AB0DC85B11做一次 SHA1 散列再 Base64 编码作为Sec-WebSocket-Accept返回。服务端的响应头长这样HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo拿到这个 101 响应之后连接正式切换为 WebSocket 协议。这之后传输的数据就是二进制数据帧或文本帧不再有 HTTP 头那一大堆冗余信息。了解握手过程有什么实际价值呢排错时非常有价值。比如你看到客户端报Unexpected response code: 200说明请求没有打到 WebSocket 服务端而是被某个中间层拦截并当普通 HTTP 请求处理了这通常是 Nginx 没配好Upgrade头导致的排查方向一下子就能确定。3.2 连接生命周期Open、Message、Close框架的封装方式一般会暴露三个核心事件连接打开Open、收到消息Message、连接关闭Close。这三个事件覆盖了一条 WebSocket 连接从生到死的完整生命周期。Open 事件在握手成功后触发此时可以拿到客户端信息比如来源 IP、请求头、URL 参数。在这里做的典型事情是生成连接唯一标识、把它加入连接池、标记连接建立时间。需要注意一点不要在 Open 里做耗时操作比如查数据库、请求外部接口因为事件循环是单线程的某个回调卡住了其他所有连接的消息都会跟着排队等待。Message 事件是业务核心收到消息后要解析协议我们约定 JSON、校验数据合法性、执行逻辑存库、广播、私聊。Close 事件在连接断开时触发这里必须清理连接表里的残留数据。很多人忽略 Close 的重要性结果用户下线了连接表里还留着脏数据广播时给一个已经断开的 fd 推送报错不说还白白浪费资源。还有一类隐藏事件是“连接异常中断”比如客户端直接拔网线、断电TCP 层可能不会立即感知服务端这个连接会一直挂在连接表里。这种情况一般靠心跳超时兜底后面专门讲。3.3 消息广播模型谁负责把话带给所有人单机场景下广播的朴素写法是收到一条消息后循环所有在线 fd逐个 push。这个思路没错但它有两个坑。第一如果某个客户端接收缓冲已满比如网速极慢消息堆积push 操作可能会阻塞拖慢整个事件循环。第二如果在线人数上千循环一千次虽然不快但每次都做 JSON 编码、协议组装高频消息下 CPU 开销还是能叠上去。我的做法是广播前先做一次“可达性检查”只维护一个在线连接表记录每个 fd 的最近活跃时间心跳超时的先剔除再广播。这样广播的循环对象永远是“大概率还活着的连接”稳定性好很多。至于高并发下的优化一般框架会支持分批发送与缓冲控制底层细节框架已经处理。你只需要理解模型WebSocket 服务端天然是“中央分发器”所有消息都经它中转它既是服务器又是路由器。这也是为什么聊天室的瓶颈总会出现在消息分发层而不是数据库层。4. 实操落地从零搭一个能用的实时聊天室4.1 服务端连接管理 登录 广播现在进入正题。我以事件驱动模型为例写服务端代码框架名不重要重点是回调里写的这套业务逻辑你换到同类框架上也完全适用。先定义全局状态连接表$fdToUser映射 fd 到用户名$userToFd映射用户名到 fd。双映射的好处是收到广播需求时按 fd 遍历收到私聊需求时按名字查 fd用户退出时两边都能干净地清理。代码如下?php // 全局连接状态 $fdToUser []; // fd username $userToFd []; // username fd // 连接打开先记录基本状态但此时还没登录 $wsServer-on(Open, function ($server, $request) { echo 新连接进入fd{$request-fd}\n; }); // 收到消息按约定的 JSON 协议分发处理 $wsServer-on(Message, function ($server, $frame) use ($fdToUser, $userToFd) { $fd $frame-fd; $payload json_decode($frame-data, true); if (!is_array($payload)) { return; // 非法报文直接丢弃 } switch ($payload[type] ?? ) { case login: handleLogin($server, $fd, $payload, $fdToUser, $userToFd); break; case chat: handleChat($server, $fd, $payload, $fdToUser); break; case ping: // 心跳包只需要回一个 pong不广播 $server-push($fd, json_encode([type pong])); break; } }); function handleLogin($server, $fd, $payload, $fdToUser, $userToFd) { $username trim($payload[username] ?? ); if ($username || isset($userToFd[$username])) { $server-push($fd, json_encode([ type error, message 昵称无效或已被占用 ])); $server-close($fd); return; } $fdToUser[$fd] $username; $userToFd[$username] $fd; // 告诉所有人有人进来了 broadcast($server, [ type system, message {$username} 加入了聊天室, online count($userToFd) ]); } function handleChat($server, $fd, $payload, $fdToUser) { $username $fdToUser[$fd] ?? ; $content trim($payload[content] ?? ); if ($username || $content ) { return; } // 简单校验长度防止有人拿超长文刷屏 if (mb_strlen($content) 500) { $server-push($fd, json_encode([ type error, message 消息长度不能超过 500 字 ])); return; } broadcast($server, [ type chat, from $username, content $content, time date(H:i:s) ]); } // 广播排除指定 fd默认排除发送者自己 function broadcast($server, $data, $exceptFd null, $fdToUser null) { $message json_encode($data, JSON_UNESCAPED_UNICODE); foreach ($fdToUser as $targetFd $u) { if ($exceptFd ! null $targetFd $exceptFd) { continue; } if ($server-isEstablished($targetFd)) { $server-push($targetFd, $message); } } }这些代码里的几个细节值得单独说。登录失败我直接close了连接而不是只回一个错误原因是连接没登录就闲置着既不参与聊天也不触发心跳清理很容易变成僵尸连接直接关闭更干净。消息长度限制很多人不做但聊天室是最容易被刷屏攻击的场景一个 10MB 的消息如果广播给所有人服务端内存瞬间暴涨所以限制长度不是体验问题是安全问题。4.2 服务端私聊与“正在输入”状态公共聊天室能跑起来之后用户很快就会想要私聊。私聊的逻辑其实比广播还简单根据目标用户名从$userToFd查到 fd只推给对方就行。这里有个边界要注意目标用户不在线时可以给发送者回一条“对方不在线”的系统消息也可以把消息暂存到 Redis等对方上线再拉取。我第一版没做离线暂存后来发现用户经常问“我发给他的消息他怎么没收到”第二版加了 Redis 离线消息队列体验才正常。“正在输入”状态是另一个容易被忽略的高频功能。实现方式是前端监听到用户在输入框打字就发一个type: typing的包服务端收到后转发给房间里的其他人其他人展示“某某正在输入”。这里必须加上限流不然每敲一个字符发一次消息量会非常大。我用的策略是同一个用户两次typing包的间隔不低于 2 秒低于间隔的包直接丢弃同时前端 2 秒没再输入就发一个type: stop_typing清除状态。这样既保住了实时感又不会把服务端压垮。case typing: $username $fdToUser[$fd] ?? ; if ($username ) return; // 用当前时间做简单限流 if (time() - ($lastTypingTime[$username] ?? 0) 2) return; $lastTypingTime[$username] time(); broadcast($server, [ type typing, from $username ], null, $fdToUser); break;这个$lastTypingTime数组会随着时间不断变大你得定期清理掉已经离线的用户名否则又是内存泄露。常见的做法是复用 Close 事件在用户退出时把它的lastTypingTime一并删掉。4.3 前端WebSocket 客户端与心跳重连前端代码也不复杂但容易写漏。一个健壮的客户端必须包含三块建立连接、心跳保活、断线重连。// chat-client.js let ws null; let heartbeatTimer null; let reconnectCount 0; function connect() { ws new WebSocket(ws://your-domain.com/chat); ws.onopen function () { console.log(连接已建立); reconnectCount 0; // 登录 ws.send(JSON.stringify({ type: login, username: currentUsername })); // 开启心跳每 30 秒发一次 ping heartbeatTimer setInterval(function () { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: ping })); } }, 30000); }; ws.onmessage function (event) { const data JSON.parse(event.data); if (data.type chat) { renderMessage(data); } else if (data.type system) { renderSystemMessage(data.message, data.online); } else if (data.type typing) { showTyping(data.from); } else if (data.type pong) { // 心跳回包什么都不用做 } }; ws.onclose function () { clearInterval(heartbeatTimer); // 指数退避重连 const delay Math.min(30000, 1000 * Math.pow(2, reconnectCount)); reconnectCount; setTimeout(connect, delay); }; ws.onerror function () { // 触发 onclose做统一处理 ws.close(); }; } connect();重连这个细节我要多说一句不要用固定 3 秒重连如果服务端重启需要一分钟客户端在这段时间内每 3 秒疯狂重连一次服务端一恢复就会收到几十个连接请求很容易被打懵。指数退避重连是标准做法第一次 1 秒、第二次 2 秒、第三次 4 秒封顶 30 秒重连成功后重置计数。这个策略是我在项目上线后遇到“服务重启后大量连接同时涌入”的问题才加上的加完之后重启服务再也没出过问题。心跳时间间隔的选择也有讲究太短浪费带宽太长容易导致连接被中间层断开。我的经验是前端 30 秒发一次 ping服务端 60 秒内没收到任何数据就主动关闭该连接。这个“服务端超时比客户端心跳周期长一倍”的配置能保证正常用户永远不被误杀又能在网络异常时及时清理僵尸连接。4.4 前端消息渲染与性能消息渲染这块新手常犯的错误是把所有历史消息都渲染在 DOM 里。聊天室里消息会无限增长几千条 DOM 节点之后页面会明显变卡。我第一版就是 push 一条渲染一条聊了半小时后整个页面滚动都掉帧。后来改成只保留最近 100 条 DOM 节点超过 100 条就把最老的从 DOM 中移除历史消息从服务端接口按需加载。这个改动对页面流畅度的提升非常明显。渲染时还有一个安全问题必须处理消息内容是用户输入的原始文本直接拼进innerHTML等于把 XSS 漏洞亲手打开。我的做法是所有聊天内容渲染时都用textContent而不是innerHTML如果确实需要支持表情和链接也要先做转义只放行白名单标签。这个项目里我甚至为了保险做了两层校验前端渲染时转义服务端存储时也转义一遍。5. 部署与扩展从单机到多机的实战经验5.1 Nginx 反向代理 WebSocket 的配置要点本地跑通之后部署到服务器时大概率会遇到第一个门槛怎么让 Nginx 把 WebSocket 流量转发到 PHP 服务端。直接暴露 WebSocket 端口给公网不是不行但你会失去 SSL、限流、日志这些能力而且 80/443 之外的高端口在防火墙上还要多开规则。所以标准做法是 Nginx 反向代理。server { listen 443 ssl; server_name your-domain.com; # SSL 证书配置省略 location /chat { proxy_pass http://127.0.0.1:9502; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }这个配置里最关键的就是proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;。少了这两行Nginx 默认会用 HTTP/1.0 转发请求WebSocket 握手必然失败前端会报 400 或 200 错误。另外proxy_read_timeout默认是 60 秒如果服务端和客户端之间的空闲时间超过这个值Nginx 会自作主张把长连接掐断。因为我们的客户端每 30 秒有心跳理论上游永远不会超时但为了保险我把它设成了 3600 秒防止客户端心跳逻辑意外失效时连接被提前断开。5.2 多机部署Redis 发布订阅做消息中转单机能支撑的连接数取决于机器性能一般几千连接是没问题的但再往上就想也别想了。当你需要拆成两台、三台机器时最朴素的做法会立刻遇到一个问题用户 A 连在机器 1 上用户 B 连在机器 2 上A 发的消息只在机器 1 的进程里广播B 永远收不到。解法是用 Redis 发布订阅做消息总线。机器 1 收到消息后除了广播给本机的连接还把消息publish到 Redis 的一个固定频道机器 2、机器 3 都在订阅这个频道收到后各自广播给本机的连接。这样消息就能跨机器传播。架构变成用户 A → 机器1 WebSocket 服务 → publish Redis 频道 ↓ 订阅 用户 B ← 机器2 WebSocket 服务 ← 收到频道消息后广播这个方案的优点是不需要维护一个中心化的连接表每台机器只管自己的连接缺点是消息至少经过一次 Redis 转发延迟多一跳实测局域网内增加不到 1 毫秒完全可以接受。我实际部署时用的是两台机器加一个 Redis用户分布不均——有人全挤在机器 1 上但跨机消息依然正常这个方案就够用了。如果以后用户量再上一个量级需要更精细的路由、离线消息、消息序号那就得引入专业的消息中间件来负责广播路由但那是另一个量级的复杂度了这里不展开。5.3 性能与内存长连接服务最容易被忽视的坑WebSocket 服务和传统 PHP 项目最大的不同是进程不退出所以每一处内存使用都要自己负责。我列举几个我实际踩过的内存相关坑希望能帮你提前避开。第一个是日志无脑打印。开发时在 Message 回调里echo每个消息没问题生产环境如果还开着详细日志每个聊天消息都会触发磁盘写入高频消息下进程直接卡顿。上线时把日志级别调到“连接建立、连接关闭、异常”这个粒度业务消息不落日志需要排查时再临时开。第二个是数组无限增长。连接表和lastTypingTime这类缓存数组如果不在 Close 事件里清理跑几天内存就爆了。这个在 4.2 节已经强调过但值得再说一遍所有全局状态都必须有对应的清理路径。第三个是单条消息大小限制。消息内容如果不限制恶意客户端可以一次发 1GB 文本服务端解码就会内存爆炸。服务端在Message回调入口就要检查frame-data的长度超过阈值直接断开连接不给后续逻辑处理的机会。性能方面的另一个建议是如果服务器有多核 CPU单进程只能吃满一个核想提升单机吞吐就得多进程部署每个进程绑定一个 CPU 核。但多进程带来的问题是不同进程的连接表互相隔离广播时不知道别的进程有哪些连接。解决方式是要么用 Redis 做多进程间的消息同步要么用框架自带的进程间通信能力。如果对这块不熟先老老实实单进程跑撑不住再上多进程。6. 常见问题与排查技巧实录6.1 连接总是三秒断心跳和代理超时我接手过一个同事的项目现象是客户端连上后大约 60 秒必断重连之后又是 60 秒断非常有规律。排查了半天发现是 Nginx 没有配proxy_read_timeout默认 60 秒超时而客户端的心跳逻辑写得有问题只在onopen之后瞬间发了一次心跳后面就不再发了。服务端那边也没做超时清理两边都以为自己是“被动方”结果 Nginx 先动手把连接掐了。定位这种问题的方法是看抓包。WebSocket 的调试不用抓包工具也能做浏览器开发者工具的 Network 面板里能看到 WebSocket 帧Nginx 的 error.log 里也会留下upstream timed out或client closed connection之类的记录。解决思路是客户端确认心跳周期小于任何一层代理的超时时间服务端确认自己会按期响应心跳Nginx 确认超时时间远大于心跳周期。三端对齐断连问题基本根治。6.2 消息丢失与乱序消息丢失最常见的原因是“发送时连接已断开但代码没检查”。你在handleChat里拿到$fd后如果对端已经断了push调用会抛异常或返回失败如果代码没处理消息就悄悄丢了。标准做法是每次 push 前检查isEstablishedpush 后检查返回值发送失败就把这条消息写入一个待重试队列并触发连接清理。乱序问题则多半出在重连逻辑上客户端断线重连后紧接着用新连接发送消息但服务端还没把旧连接的资源清理完两条连接同时发消息顺序就乱了。解决方式是在客户端加一个简单的序号每条消息带一个递增的 seq服务端按 seq 排序后广播。这个小改动对聊天室体验提升非常明显尤其是移动端网络切换场景下。6.3 内存暴涨与 fd 泄漏内存暴涨的排除路径我有一个经验顺序先看连接表大小对不对再看有没有缓存数组只增不减最后看是不是日志写太多。fd 泄漏的典型症状是系统报too many open files进程明明没多少连接但耗着几千个句柄。原因往往是 TCP 连接已经断开服务端没有及时触发 Close 事件连接表里残留了大量已死的 fd。这种问题要两手抓服务端除了依赖 Close 事件还要做周期性的心跳超时扫描每隔一段时间遍历连接表把超过 N 秒未活跃的连接主动 close。这个扫描机制是长连接服务的最后一道保险必须有。6.4 排错速查表症状可能原因快速定位方法解决方案握手失败返回 200Nginx 没传 Upgrade 头看响应头是否含101 Switching Protocols检查 Nginx 的proxy_set_header配置连接 60 秒左右断开代理层空闲超时看客户端心跳帧是否持续发送配置proxy_read_timeout或客户端加心跳消息能发但收不到广播连接表未正确记录或清理服务端打印当前连接数检查登录回调是否写入fdToUserClose 是否清理内存持续上涨全局数组只增不减输出内存占用和连接数对比所有缓存数组绑定清理路径做心跳超时扫描重启后大量客户端卡死客户端重连风暴看服务端启动瞬间的连接日志客户端用指数退避重连封顶 30 秒多人同时说话消息错乱本机时间戳不可靠打印收到消息的先后顺序客户端加 seq 序号服务端按序广播6.5 一个实用工具在线调试 WebSocket 接口最后分享一个很实用的调试技巧。做 WebSocket 开发时浏览器控制台的调试验证效率其实不高我一直推荐先用现成的在线 WebSocket 调试工具做接口验证连上地址、手动发送 JSON 帧、查看返回帧。这样可以快速区分问题是服务端还是前端。我曾经排查一个“前端收不到消息”的 bug先用调试工具连接服务端手动发登录和聊天消息结果服务端立刻返回了正确广播问题一下子就锁定了在前端省了大量时间。我个人在实际调整这个项目时最深的体会是WebSocket 服务端的核心不是“代码跑通”而是“稳定地长期跑”。一开始代码能通很简单但真正决定它能不能上线的是心跳策略、内存管理、断线重连、代理配置这一堆周边细节。另外这个项目的源码结构适合继续扩展比如把 JSON 协议换成带二进制协议、增加房间分组、接一个基于 Redis 的在线状态服务改动的思路都是现成的。如果你正准备上手从这套逻辑开始比直接抄完整项目代码要有用得多。