
之前接手一个内部客服系统的时候被消息延迟折磨得不轻。页面用的还是传统的AJAX轮询每两秒向服务器拉取一次新消息结果用户愣是能感觉出来对方正在输入了好几分钟还没发出来。那次之后我就明白了一个道理聊天场景里实时不是加分项是底线需求。而PHP WebSocket的组合正好能把这条底线一次性兜住。这篇文章就把当时从零搭建PHP实时消息聊天室的全过程包括协议细节、前后端联调、Nginx代理、压测踩坑完完整整拆开来讲适合已经在用PHP做业务、想把即时通信能力塞进项目的开发者做参考。1. 为什么实时聊天不能靠轮询硬撑1.1 HTTP请求-响应模型的先天限制HTTP协议从设计之初就是请求-响应模式客户端发一个请求服务器返回一个响应然后连接基本就晾在那了。对于普通网页浏览这个模型没问题但对于聊天室统统反过来——服务器得主动把消息推给客户端而不是等客户端来问。用轮询模拟推送等于让客户端每隔几秒问一次有消息吗没有那我待会儿再问。频率低了消息延迟频率高了服务器CPU和带宽全浪费在空转上。我曾经用一台低配服务器做过统计200个在线用户、每2秒轮询一次光空请求就吃掉了一半以上的带宽资源。真正的实时聊天需要的是服务端主动推送的通道。1.2 轮询、长轮询与WebSocket的取舍通信方案的取舍核心是三个指标的博弈实时性、资源消耗、实现复杂度。轮询实现最简单但实时性最差资源浪费最严重。长轮询Long Polling稍微聪明一点客户端发出请求后服务器如果有新消息就立刻返回没有就挂起等待等消息来了再返回然后客户端再次发起请求。这样消息一到就能被拿走实时性大幅提升但仍然有每次都要重新建立HTTP连接的额外开销消息密度高的时候HTTP头那点浪费会被放大得很明显。WebSocket则完全不同。它通过一次HTTP握手之后直接升级为TCP长连接双向任意发数据消息头只有几个字节。聊天室这种高频率、小消息量、双向通信的场景WebSocket几乎是为它量身定做的。1.3 聊天场景对协议的真实需求聊天室不是只有发消息这一个动作背后还包括上线通知、下线通知、正在输入状态、消息已读回执、系统公告、踢人、禁言。这些动作如果都靠HTTP轮询来做逻辑上会绕一大圈后端把状态变更存进数据库前端再定期查出来。而WebSocket把状态推送变成了直接发一条数据帧服务器端的业务逻辑瞬间简化成收到消息 - 处理 - 推给对应的人。另外WebSocket的路径和子协议可以自定义比如在一个服务端上同时跑/chat和/notify两个通道互不干扰。这个灵活性是轮询方案很难舒服地实现的。2. 技术方案选型PHP怎么跑起WebSocket服务端2.1 传统PHP-FPM模式为什么跑不了长连接很多人在一开始会问PHP不是处理完请求就释放进程吗怎么搞长连接问题的根源在PHP-FPM的生命周期。一个PHP请求经过index.php处理完返回响应进程就闲置或销毁连接也随之关闭。WebSocket需要连接保持几个小时走的不是请求-响应这条路而是常驻内存的事件循环。所以PHP做WebSocket服务端本质上要跳出PHP-FPM的管理用一个独立的进程跑一个事件循环这个进程自己监听端口、接收连接、维护连接列表。2.2 方案对比纯PHP自研还是用常驻框架技术上有两条路用PHP的sockets扩展自己写协议处理或者用社区成熟的常驻内存框架基于sockets/事件驱动。我自己实际做的时候走了先用原生写法吃透协议、再框架化做业务的路径。用sockets扩展自研能彻底搞明白WebSocket握手、数据帧到底长什么样而且一个最小可用的服务端核心代码其实就一百多行。缺点也很明显心跳检测、断线重连、多进程管理、异步任务调度都得自己造轮子业务量上来之后代码量会失控。用常驻内存框架就等于把底层事件循环、连接管理、定时器都交给框架自己只写业务回调。我最终在项目里也是这么做的拿原生实现做验证用框架做生产版本。但是不管哪种方案核心原理都是一样的下面第三部分的实现拆解用的是原生sockets写的极简版方便大家看清楚每一步在干什么。2.3 消息存储与在线状态管理聊天的消息并不是发完就完事至少要保证离线用户下次登录能拉到最近记录。这里需要区分两个存储轻量级的在线状态用内存存持久化的聊天记录用数据库或消息存储存。在线状态我用的方案是连接ID - 用户ID的映射表放在常驻进程内存里。用户上线时写入断开时删除。注意不能只靠close事件清理因为断网、进程被杀可能不触发close所以必须配合心跳超时来做清理。聊天记录按消息ID、发送者、接收者、时间、内容存到数据库里。单聊和群聊的存储结构不一样单聊可以用一张会话表 消息表群聊可以按群ID分表。这块我用的是最通用的两张表设计一条消息存一次不搞复杂的会话归档。3. 核心实现拆解从握手到广播的完整流程3.1 握手协议关键就在一个Sec-WebSocket-Accept所有WebSocket连接第一步都是HTTP升级握手。客户端会发一个带着Upgrade: websocket头部的HTTP请求服务端验证后返回101状态码连接就升级为WebSocket。这一步的计算逻辑固定核心公式是?php $secWebSocketKey $_GET[key] ?? ; // 实际从请求头取 $accept base64_encode(sha1($secWebSocketKey . 258EAFA5-E914-47DA-95CA-C5AB0DC85B11, true));这个固定字符串是WebSocket协议规范里写死的 GUID不能改。时间上很多初学实现握手没问题帧处理反而卡壳因为数据帧不是纯文本是二进制的帧结构。3.2 数据帧解析掩码、长度和小端字节序WebSocket收发消息的核心是数据帧。客户端发给服务端的帧按照协议规则必须做掩码Mask处理服务端发给客户端则不用掩码。一帧数据的结构大致是这样第一个字节高4位表示是否最后一帧FIN低4位表示操作码1文本2二进制8关闭连接9ping10pong第二个字节高1位表示是否掩码低7位表示payload长度如果payload长度等于126后续2个字节是小端字节序的16位长度如果等于127后续8个字节是小端字节序的64位长度如果是掩码帧再后面4个字节是掩码密钥最后才是真正的消息体我贴一段当时调通的PHP解析函数这段代码会把收到的二进制流压成UTF-8字符串?php function decodeFrame($buffer) { $len strlen($buffer); if ($len 2) return null; $second ord($buffer[1]); $masked ($second 0x80) 0x80; $payloadLen $second 0x7F; $offset 2; if ($payloadLen 126) { $payloadLen (ord($buffer[2]) 8) | ord($buffer[3]); $offset 4; } elseif ($payloadLen 127) { // 64位长度简化处理高32位必须为0 $payloadLen 0; for ($i 0; $i 8; $i) { $payloadLen ($payloadLen 8) | ord($buffer[$offset $i]); } $offset 10; } $maskKey ; if ($masked) { $maskKey substr($buffer, $offset, 4); $offset 4; } $payload substr($buffer, $offset, $payloadLen); if ($masked) { for ($i 0; $i $payloadLen; $i) { $payload[$i] $payload[$i] ^ $maskKey[$i % 4]; } } return [ fin (ord($buffer[0]) 0x80) 0x80, opcode ord($buffer[0]) 0x0F, data $payload, ]; }这里有两个容易踩的坑。第一如果客户端一次发来多个数据帧TCP包可能把它们粘在一起单次read拿到的可能不止一帧所以解析函数必须循环调用直到缓冲区里没有完整帧为止。第二大消息会分片传输一个完整的业务消息会被拆成多个帧每帧的FIN0最后一帧FIN1实现上必须用缓冲区累积分片内容。3.3 服务端消息广播连接池与回调分发消息广播的逻辑很朴素每个连接对应一个socket资源用一个数组$connections存起来。A发消息时服务端收到文本帧解析出JSON然后把消息遍历发给所有连接或者所有在同一个房间的连接。?php foreach ($connections as $fd $conn) { if ($fd $senderFd) continue; $response json_encode([ type message, from $userMap[$senderFd] ?? guest, content $data[content], time date(H:i:s), ]); writeFrame($conn, $response, 0x1); // 0x1 文本帧 }注意广播的时候一定要跳过发送者自己否则前端就得自己过滤我发出的消息不然会重复渲染。我最初没加这个判断结果每个用户都看到自己发的话出现了两次排查了半天。群聊的话连接池要升级成房间结构$rooms[roomId][] $fd。用户在某个房间只往该房间的连接列表里广播。这层的设计和数据库里的群聊表是对应起来的。3.4 心跳机制为什么必须服务端主动PingTCP连接断掉但没关闭的情况非常多用户网线拔了、手机切了Wi-Fi、笔记本合盖休眠服务器这边短时间根本察觉不到。如果不做心跳那些死连接会一直躺在连接池里广播给它的消息最终触发写入超时拖慢整个进程。我的做法是服务端每30秒向所有连接发送一个ping帧客户端收到后必须回pong帧如果服务端连续60秒没收到某个连接的pong就判定为死链强制关闭并清理。同时客户端也要有自己的保活机制——浏览器有时会把空闲连接回收所以客户端每20秒主动发一条ping两边互相兜底。?php function writeFrame($socket, $data, int $opcode 0x1): bool { $length strlen($data); if ($length 125) { $header chr(0x80 | $opcode) . chr($length); } elseif ($length 65535) { $header chr(0x80 | $opcode) . chr(126) . pack(n, $length); } else { $header chr(0x80 | $opcode) . chr(127) . pack(J, $length); } return fwrite($socket, $header . $data) ! false; }上面这个函数对应服务端发送帧注意服务端到客户端的帧不设掩码所以第二个字节最高位为0直接写长度就行。这个和客户端发来的帧不一样容易搞反。4. 前端联调与部署中的疑难杂症4.1 Nginx反向代理必须显式转发Upgrade头后端跑起来之后前端连的肯定是域名wss://chat.example.com这种路径而不是直连后端端口的ws://ip:8080。如果域名走Nginx那Nginx必须能识别并转发WebSocket升级请求。配置文件核心就是那三行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_set_header Host $host; proxy_read_timeout 300s; }proxy_http_version 1.1是必须的因为HTTP/1.0不支持Upgrade头。proxy_read_timeout如果没设Nginx默认60秒没数据就断开空闲连接你的聊天室就会莫名其妙每60秒掉一次线。我当时把这个值调到了300秒配上前端心跳基本稳定。4.2 前端连接逻辑重连退避与页面保活前端JS部分最核心的就是WebSocket对象的生命周期管理。打开连接、监听onmessage、处理onclose自动重连。重连不能无脑立刻连否则后端一重启几百个客户端会同时发起重连造成瞬间压力。我做了一个简单的指数退避重连let ws null; let retry 0; function connect() { const scheme location.protocol https: ? wss:// : ws://; ws new WebSocket(scheme location.host /ws); ws.onmessage (event) { const msg JSON.parse(event.data); // 渲染消息、更新在线列表等 }; ws.onclose () { const delay Math.min(30000, Math.pow(2, retry) * 1000); retry; setTimeout(connect, delay); }; ws.onopen () { retry 0; // 发送登录绑定信息 ws.send(JSON.stringify({ type: auth, token: userToken })); }; } connect();注意一点页面重新加载时旧连接会直接关闭新连接需要重新认证。所以登录态校验必须放在WebSocket连接建立之后的第一步也就是上面onopen里发的那条auth消息。后端收到auth后才把当前socket绑定到用户ID上。这里有个实际中很容易踩的坑socket已经连上了但用户还没绑定此时如果收到消息后端不知道往哪个用户推所以auth之前的消息一律丢弃。4.3 内存泄漏与连接未释放排查长连接服务最怕的就是内存只涨不降。我排查过好几次最终确认了几类常见诱因错误码和异常没有捕获。某个客户端发了非法JSON服务端解码失败直接抛异常进程崩溃重启所有连接全部断掉表现为全员掉线。连接关闭了但socket资源没释放。feof($socket)判断不准必须用socket_get_status判断更好的做法是捕获fread返回false或空串的情况及时清理。广播闭包持有无用引用。如果在循环里把$socket作为闭包变量传入回调可能意外保住连接引用。排查方法很土但有效先在进程里定期打印count($connections)和memory_get_usage()观察内存曲线是平稳还是持续上升。如果持续上升再用对象或数组的弱引用特性做进一步定位。5. 压测与上线前的稳定性优化5.1 连接数与消息吞吐量的测量方法上线之前不压测等于赌博。我用简单的压测脚本和WebSocket客户端脚本做了两轮测量第一轮测最大连接数。用脚本循环建立连接看服务端在哪个数量级开始拒绝连接或内存暴涨。结果发现瓶颈不在PHP进程而在文件句柄数Linux默认ulimit -n是1024跑到900多个连接就开始报Too many open files。第二轮测消息广播吞吐。模拟100个连接持续发消息观察服务端广播延迟和CPU占用。当时测出消息量达到每秒3000条左右PHP单进程的CPU占用已经接近70%意味着需要上多进程或者垂直扩容。调整措施也简单ulimit -n改成65535sockets扩展的socket_set_option设置SO_RCVBUF和SO_SNDBUF避免小包攒满缓冲区导致延迟。5.2 单进程瓶颈与多进程分发思路常驻进程做WebSocket单进程能扛的连接数在几千到一万不等取决于消息频率和服务器配置。再往上就得考虑多进程架构用一个Master进程管理、多个Worker进程各自持有连接进程间通过IPC通道转发消息广播。这个架构的复杂度比单进程高一个量级主要体现在广播时要决定这条消息该往哪个进程里的哪个连接发。我的建议是业务初期单进程够用不要过度设计等连接数确实上来了再根据当时压测的瓶颈指标做架构升级。5.3 消息积压与离线消息的兜底WebSocket通道本身没有重发功能消息发出去了客户端断线就是丢了。所以聊天室必须有一个兜底机制在线走WebSocket离线走数据库拉取。具体做法是每条消息都实时写入数据库用户登录时除了建立WebSocket还要先请求一次HTTP接口拿到上次离线时间点到现在的消息按时间顺序渲染在聊天记录里。这样即使WebSocket消息全丢历史记录也不丢只是实时性差一点点。对于内部工具、客服系统这类场景这个兜底已经足够不用上一套复杂消息队列。5.4 安全加固消息内容与服务端资源保护聊天室天然是脏话、恶意链接、超长消息的重灾区。上线前我做了四件基础防护消息长度限制单条文本帧超过2KB直接拒绝并关闭连接。不做的话恶意用户可以持续发大消息包把带宽和内存打满。频率限制单个连接每秒最多发5条消息超过的直接丢进冷却队列连续超限就强制断开。因为聊天室业务本身不可能高频发消息频率限制是防刷最有效的一招。内容过滤服务端必须再做一次敏感词过滤和链接清洗。前端过滤只是体验优化服务端过滤才是安全底线。业务鉴权不能用auth消息里带的明文ID作为用户身份必须换成签名后的票据每次建立连接后由后端服务校验。6. 一些基于实测的补充心得整个项目做完我印象最深的还是连接管理这一层。业务逻辑再复杂最终都是落在连接池的增删改查上。连接管理稳了聊天室就稳了八成。另外调试WebSocket的时候很多人一上来就抓瞎因为浏览器的开发者工具里看不到原始二进制帧。这里我推荐一个十分顺手的方法在服务端把收发的二进制帧都做一次bin2hex()打印然后对照RFC数据帧结构逐字节分析。当时我解Mask解不对就是靠一帧一帧十六进制对比才定位到问题——别迷信文档直接看字节最诚实。生产环境的日志也要单独做。不能把聊天服务的日志混在业务日志里因为长连接服务日志量很大连接建立、关闭、心跳超时、消息收发一天能刷出几百MB。我把这几类日志分文件存并按天切割保存14天既方便排查问题又不至于撑爆磁盘。最后分享一个小技巧服务端心跳超时的时间最好和Nginx的proxy_read_timeout、TCP层keepalive的设置统一思考。如果你在WebSocket层30秒ping一次但Nginx那边300秒没数据才断那中间断开的大部分场景都是客户端真的离线了心跳超时设为90秒左右比较合适——既能及时清理死链又不至于误伤网络波动但依然活跃的用户。这几层参数对齐之后线上再也没出现过全员掉线的事故。