☰
PHP实时通讯实战:基于Workerman的双模式聊天室架构解析
2026/10/13 13:44:53 网站建设 项目流程

作为一个常年拿 PHP 做业务系统的人,我太清楚“PHP 做不了实时通讯”这个偏见有多顽固了。早期我也信这个邪,直到某次给某公司的售后平台做在线客服系统,被问“能不能像网页版微信一样,消息来了不刷新页面就直接弹出来”,才被逼着去认真研究 PHP 在长连接、高并发这条路上的解法。折腾了大半个月,最终落地了一套双模式架构的聊天室系统,核心思路不算复杂,但里面的坑确实不少。

这篇文章就把这套东西完整拆开讲:从双模式架构的设计动机,到 WebSocket 协议底层,再到基于 Workerman 的完整实现,以及上线后必须处理的心跳、断线重连、多进程、消息积压这些问题。看完之后你会发现,PHP 做高实时性应用不是不行,而是要用对姿势。

1. 双模式架构的整体设计思路

1.1 为什么纯 WebSocket 方案在 PHP 生态里会翻车

先说结论:PHP 不是不能做 WebSocket 服务端,而是“传统 PHP 运行模式”做不了长连接。

一个常规 PHP-FPM 进程处理完请求后,所有资源(变量、连接、内存里的状态)全部销毁。而 WebSocket 要求服务端跟客户端之间维持一条可能是几小时甚至几天的 TCP 长连接,期间要不断收发消息、维护会话状态。你总不能每隔几秒就让 FPM 重新拉起一个进程来“续命”吧?所以业内做 PHP 实时通讯的常规路线就两条:

  1. 用第三方实时服务(比如各种云厂商的 IM 服务),PHP 只做后端逻辑。
  2. 用常驻内存的 PHP 框架(Workerman、Swoole 等),让 PHP 进程像 Node.js 一样长时间跑在内存里。

本文讲的是第二条路线。但实战中又冒出一个新问题:不是所有客户端环境都支持 WebSocket。有些公司内网的代理服务器会拦截 Upgrade 请求,有些老旧浏览器压根没实现 WebSocket,还有些用户开着防火墙导致长连接被周期性掐断。如果只做 WebSocket 通道,遇到这些环境直接歇菜。

所以我在设计时把系统拆成两条通道:一条走 WebSocket 长连接,给绝大多数现代浏览器用,实时性最好;另一条走 HTTP 短轮询或长轮询,作为降级方案,给那些 WebSocket 连不上或者连上后频繁掉线的用户兜底。这就是“双模式”架构的核心思路,不是炫技,而是被现实逼出来的容灾设计。

1.2 双模式的分工协作:数据通道与信令通道分离

架构上我把聊天室拆成两个层级:

  • 接入层:负责跟客户端维持连接。WebSocketChannel 和 HttpChannel 都实现同一个 ChannelInterface,上层不关心消息是从哪条通道来的。
  • 逻辑层:负责消息的接收、过滤、路由、广播,这一层跟通道无关,不管你是用 WebSocket 推还是用 HTTP 轮询拉,逻辑都一样。

客户端的行为是:优先尝试 WebSocket 连接,连接失败或者心跳连续不回复,自动切换到 HTTP 轮询模式。服务端看到 HTTP 轮询请求就知道这是降级客户端,不会给它推送(反正推了也收不到),而是把这段时间积压的消息一次性返回。

这样的好处很明显:一套业务代码,两条接入通道,兼容性直接拉到满。而且后续如果某天想接小程序、App 推送,只需要再实现一个 Channel 接口,逻辑层完全不用改。

实际项目中我用了一张表来管理通道状态:

维度WebSocket 模式HTTP 轮询模式
实时性毫秒级推送秒级(轮询间隔)
服务端资源长连接占用内存每次请求短连接,消耗小
客户端兼容性需支持 WebSocket几乎所有环境
适合场景默认优先降级兜底

这个设计不光解决兼容性问题,还有一个隐藏收益:压测的时候能对比两种通道的吞吐差异,帮助定位性能瓶颈到底是通道层还是逻辑层。

2. 核心细节:WebSocket 协议、消息格式与会话生命周期

2.1 WebSocket 握手到底做了什么事

很多人一提到 WebSocket 就发怵,觉得那是底层协议,写业务不用关心。但如果你想排查“连不上”“莫名其妙掉线”这类问题,不懂握手细节是寸步难行的。

WebSocket 连接本质上还是先走一次 HTTP 请求,只不过这个请求带了一些特殊头:

GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13

关键在Sec-WebSocket-Key,这是一个 Base64 编码的 16 字节随机值。服务端收到后,把这段字符串拼上一个固定的 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做一次 SHA-1 哈希,再把哈希结果 Base64 编码,通过Sec-WebSocket-Accept头返回给客户端。客户端校验这个值没问题,连接就正式建立了。

这个握手过程意味着:任何能处理 HTTP 请求的服务端,理论上都能实现 WebSocket 升级。Workerman 之所以能成为 PHP 做 WebSocket 的首选,就是因为它把握手、帧解析、掩码处理这些底层细节全封装好了,你只需要关注业务回调。这一点如果你用原生 PHP socket 去硬写,会痛不欲生。

2.2 数据帧格式:读懂这几个字节你就懂了长连接

握手完成后,后续数据传输不再走 HTTP 协议,而是走 WebSocket 协议自己的帧格式。一个数据帧由这几个部分组成:

  • FIN:1 位,标记这是不是消息的最后一帧(大消息会被分片)。
  • Opcode:4 位,0x1 表示文本帧,0x2 表示二进制帧,0x8 表示关闭帧,0x9 是 ping,0xA 是 pong。
  • Mask:1 位,客户端发往服务端的帧必须掩码。
  • Payload length:7 位或 7+16 位或 7+64 位,表示数据长度。

这里最容易踩的坑是“粘包”。TCP 是流式协议,一个 WebSocket 消息可能拆成多个 TCP 包到达,也可能多个消息合在一个包里到达。所以服务端必须做“帧缓冲”:把收到的字节先攒进缓冲区,然后按帧头声明的长度去切割,够一帧就解析一帧,不够就继续等。

Workerman 内部已经处理了粘包问题,但你要是用 workerman 之外的方案,比如自己写 TCP 服务端,这一块必须格外小心,否则就会出现“消息乱码”“消息丢失”这种鬼问题。

2.3 消息体设计:一个字段都不能少的教训

聊天室的消息格式我推荐用 JSON 的文本帧,结构长这样:

{ "type": "chat", "msgId": "20250325103012-xxx-0001", "roomId": "room_1024", "userId": "user_8899", "nickname": "老张", "content": "有人晚上一起开黑吗", "timestamp": 1742874612 }

为什么要单独加msgId?这里有个线上事故的教训:某开发者的第一版聊天室没做消息去重,客户端断线重连期间,服务端把未确认消息重发了一遍,结果用户看到的聊天记录全是重复的。加上msgId之后,客户端按 ID 去重,问题直接消失。

type字段也很重要,不只是用来说“这是一条聊天消息”,还承担了系统通知(用户上线、下线)、心跳回应、错误回执这些内部通信需求。比如用户进入房间时,服务端给所有在线用户广播一条sys类型消息:“老张进入了房间”,前端拿到这个类型就单独渲染成灰色提示文字。

2.4 会话生命周期:从连接到释放

聊天室场景下,用户关掉浏览器不是服务端能立刻感知的。TCP 连接断开通常要等 TCP 的超时重传机制触发,有时候要等好几分钟。如果你的服务端不及时清理这些“僵尸连接”,内存和文件描述符会一点点被吃光,最终服务器卡死。

解决方案就是业界通用的三层清理机制:

  1. WebSocket 协议层心跳:客户端每隔 30 秒发一个 ping 帧,服务端必须回 pong 帧。服务端每隔 60 秒扫一遍,超过 120 秒没有任何通信的连接直接踢掉。
  2. 应用层心跳:客户端除了发协议层 ping,还会在应用层发{"type":"heartbeat"}消息。协议层 ping 是浏览器底层自动处理的,应用层心跳则能让服务端顺便校验用户会话是否还有效、是否需要续期。
  3. 连接清理定时器:Workerman 里可以挂一个全局 Timer,每 10 秒遍历一次所有连接,判断lastMessageTime是否超时,超时就把连接 close 掉,并触发下线广播。

这套机制跑下来,僵尸连接基本能在两分钟内被清理干净,内存曲线非常平稳,稳如老狗。

3. 实操过程:基于 Workerman 从零搭一套实时聊天室

3.1 环境准备与安装:别在生产环境用开发服务器

我的运行环境是 Linux + PHP 7.4+,需要装 pcntl、posix 扩展(Workerman 做多进程管理和信号处理要用)。如果是自己本地 Mac 或 Windows 调试,装个 Docker 容器更方便。

安装 Workerman 用 Composer 一行搞定:

composer create-project workerman/webman

或者不要全家桶,直接用核心库:

composer require workerman/workerman

这里补一个选型理由:对比 Swoole,Workerman 是纯 PHP 实现,不依赖 C 扩展,部署门槛低很多。公司内网服务器上你不太方便随便装扩展,但 Composer 装个库没有任何阻碍。而且 Workerman 对 Windows 的开发调试支持也更好(虽然生产环境我强烈建议 Linux)。从项目实际出发,它是我见过的最省心的方案。

3.2 服务端核心代码:一个可用的聊天室 Demo

下面是一份精简但完整可跑的聊天室服务端代码,基于 Workerman 4.x:

<?php use Workerman\Worker; use Workerman\Timer; use Workerman\Connection\TcpConnection; require_once __DIR__ . '/vendor/autoload.php'; // WebSocket 服务,监听 8282 端口 $worker = new Worker('websocket://0.0.0.0:8282'); $worker->count = 4; // 4个进程,按 CPU 核数调整 // 全局房间映射表 $rooms = []; // 新连接建立 $worker->onConnect = function (TcpConnection $connection) { $connection->uid = ''; $connection->roomId = ''; $connection->lastMessageTime = time(); // 每10秒检查一次空闲连接并清理 $connection->heartbeatTimer = Timer::add(10, function () use ($connection) { if (time() - $connection->lastMessageTime > 60) { $connection->close(); } }); }; // 收到消息 $worker->onMessage = function (TcpConnection $connection, $data) { $connection->lastMessageTime = time(); $msg = json_decode($data, true); if (!$msg || !isset($msg['type'])) { $connection->send(json_encode(['type' => 'error', 'content' => 'invalid message'])); return; } switch ($msg['type']) { case 'join': // 进入房间 $connection->roomId = $msg['roomId']; $connection->uid = $msg['userId']; $rooms[$msg['roomId']][] = $connection; broadcast($msg['roomId'], [ 'type' => 'sys', 'content' => "用户 {$msg['userId']} 加入了房间", 'timestamp' => time() ]); break; case 'chat': // 聊天消息,转发给房间内所有用户 broadcast($connection->roomId, [ 'type' => 'chat', 'roomId' => $connection->roomId, 'userId' => $connection->uid, 'content' => $msg['content'], 'timestamp' => time() ]); break; case 'heartbeat': $connection->send(json_encode(['type' => 'heartbeat', 'timestamp' => time()])); break; default: $connection->send(json_encode(['type' => 'error', 'content' => 'unknown type'])); } }; // 连接断开 $worker->onClose = function (TcpConnection $connection) { // 从房间移除 if ($connection->roomId && isset($rooms[$connection->roomId])) { $rooms[$connection->roomId] = array_filter($rooms[$connection->roomId], function ($conn) use ($connection) { return $conn !== $connection; }); broadcast($connection->roomId, [ 'type' => 'sys', 'content' => "用户 {$connection->uid} 离开了房间", 'timestamp' => time() ]); } Timer::del($connection->heartbeatTimer); }; // 辅助函数:向房间内全部在线连接广播 function broadcast($roomId, $payload) { global $rooms; if (empty($rooms[$roomId])) return; $msg = json_encode($payload, JSON_UNESCAPED_UNICODE); foreach ($rooms[$roomId] as $conn) { $conn->send($msg); } } Worker::runAll();

这段代码的核心逻辑就三步:连接建立时注册心跳定时器;收到消息时按 type 路由处理;断开时从房间清掉并广播下线通知。这里有几个细节要强调:

  • 心跳定时器是在onConnect里给每个连接单独注册的,而不是一个全局 Timer 扫全部连接。这样做的好处是清理更精准,坏处是连接数很多时(比如 10 万在线)定时器对象会占用不少内存。如果在线量极大,可以改用全局扫描的方式,我后面在优化章节会说。
  • broadcast函数里用的是普通数组,如果房间人数过万,这种线性遍历会拖慢性能。更优的做法是用 SplObjectStorage 或者按 user_id 做哈希索引,但小规模聊天室完全够用。

3.3 客户端 JS 实现:WebSocket 连接与断线重连

前端用原生 JavaScript 就够,不需要任何框架:

class ChatClient { constructor(options) { this.wsUrl = options.wsUrl; this.roomId = options.roomId; this.userId = options.userId; this.ws = null; this.reconnectAttempts = 0; this.maxReconnect = 5; this.heartbeatTimer = null; this.messageHandlers = []; } connect() { this.ws = new WebSocket(this.wsUrl); this.ws.onopen = () => { this.reconnectAttempts = 0; this.send({ type: 'join', roomId: this.roomId, userId: this.userId }); this.startHeartbeat(); console.log('WebSocket 连接已建立'); }; this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); this.messageHandlers.forEach(handler => handler(msg)); }; this.ws.onclose = () => { this.stopHeartbeat(); if (this.reconnectAttempts < this.maxReconnect) { this.reconnectAttempts++; const delay = Math.pow(2, this.reconnectAttempts) * 1000; console.log(`连接断开,${delay / 1000} 秒后尝试重连...`); setTimeout(() => this.connect(), delay); } else { console.error('重连失败,请手动刷新页面'); this.fallbackToHttpPolling(); } }; this.ws.onerror = (error) => { console.error('WebSocket 错误:', error); }; } send(message) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } else { console.warn('连接未就绪,消息未发送'); } } startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.send({ type: 'heartbeat', timestamp: Date.now() }); }, 30000); } stopHeartbeat() { clearInterval(this.heartbeatTimer); } onMessage(handler) { this.messageHandlers.push(handler); } fallbackToHttpPolling() { // 切换为HTTP轮询模式,调用后端接口获取离线消息 console.log('切换到 HTTP 轮询模式'); setInterval(() => { fetch(`/api/poll?roomId=${this.roomId}&userId=${this.userId}&lastMsgId=${this.lastMsgId}`) .then(res => res.json()) .then(messages => { messages.forEach(msg => this.messageHandlers.forEach(handler => handler(msg))); }); }, 3000); } }

断线重连这里用了指数退避策略:第 1 次重连等 2 秒,第 2 次等 4 秒,第 3 次等 8 秒,避免重连风暴把服务端打垮。重连超过 5 次就主动降级到 HTTP 轮询,这个逻辑我在线上见过太多失败案例了,很多新手只会疯狂重连,结果服务端还没恢复,客户端自己先崩溃了。

3.4 降级通道的实现:HTTP 轮询和长轮询

HTTP 轮询模式其实就是一个普通的 POST/GET 请求,客户端每 3 秒问一次服务端:“有我的新消息吗?”有就返回消息列表,没有就返回空数组。

// 在原有的 PHP-FPM 应用里加一个接口 public function pollAction(Request $request) { $roomId = $request->get('roomId'); $userId = $request->get('userId'); $lastMsgId = $request->get('lastMsgId'); // 从 Redis 里取这个用户自 lastMsgId 之后的所有消息 $messages = Redis::client()->lrange("room:{$roomId}:user:{$userId}", 0, -1); $newMessages = array_filter($messages, function ($msg) use ($lastMsgId) { return $msg['msgId'] > $lastMsgId; }); // 返回增量消息 echo json_encode(array_values($newMessages), JSON_UNESCAPED_UNICODE); }

这里有个关键点:WebSocket 在线时,消息是服务端主动推给客户端的;而 HTTP 降级模式下,消息必须有个地方暂存,否则客户端轮询的时候消息已经丢了。我在架构里对这个问题的处理是:每条聊天消息除了广播给房间内在线连接,同时写入 Redis 列表,并设置 5 分钟过期。这样即使客户端切换到了轮询模式,也还能把掉线期间的消息补回来,不会出现消息断层。

如果你嫌轮询太频繁,可以使用 HTTP 长轮询(long polling):客户端发请求后,服务端挂起这个请求 25 秒,期间有消息就立即返回,没有就等到 25 秒返回空响应,客户端收到后再立刻发下一个请求。这样服务端的推送延迟从 3 秒降到了毫秒级。代价是每个长轮询请求会占用一个 PHP-FPM worker,并发一高就占满进程。所以我的建议是:长轮询只给 WebSocket 降级的用户用,人数不多,完全扛得住。

4. 高并发与系统稳定性优化

4.1 多进程模型:Workerman 是怎么扛高并发的

Workerman 默认是 Master 进程 + N 个 Worker 进程的模型。Master 进程负责监听端口、接收新连接,然后把连接分配给各个 Worker 进程处理。每个 Worker 进程是独立的 PHP 进程,内存空间相互隔离,这就带来一个问题:用户 A 连接在 Worker 1 进程上,用户 B 连接在 Worker 2 进程上,A 发一条消息想广播给 B,做不到,因为两个进程之间的 PHP 变量不共享。

我最初做广播时就踩过这个坑,查了半天发现 A 的消息只发给了同进程的连接。解决办法有三种:

  1. Redis 发布订阅(推荐):某个进程收到消息后,往 Redis 的一个频道 publish 这条消息;所有 Worker 进程都 subscribe 这个频道,收到后各自向本进程维护的房间连接广播。这是最标准也最不挑环境的做法。
  2. Workerman Channel 组件:官方提供的分布式通讯组件,原理跟 Redis 发布订阅类似,但它是基于长连接的内网通讯,性能更好。
  3. 内存表 / 共享内存:Swoole 有 Table 可以做跨进程数据共享,Workerman 没有原生实现,需要借助扩展或数据库。

以 Redis 发布订阅为例,核心逻辑是在 Worker 进程启动时订阅频道:

$worker->onWorkerStart = function () { $redis = new Redis(); $redis->connect('127.0.0.1', 6379); $redis->subscribe(['chat_broadcast'], function ($redis, $channel, $message) { // 收到广播消息,解码后转发给本进程内的连接 $data = json_decode($message, true); broadcast($data['roomId'], $data['payload']); }); };

注意:Redis 的 subscribe 是一个阻塞操作,在 Worker 进程内调用会占住整个进程,导致 onMessage 无法响应。所以这里的正确姿势是起一个单独的 Reader 进程专门做订阅,或者用 Workerman 的 AsyncTcpConnection 配合 Redis 的 RESP 协议做异步订阅。我实战中用的是后者,避免了阻塞问题。

4.2 连接数上去了,内存怎么控制

每个 WebSocket 连接在 Workerman 里都是一个连接对象,里面有 send 缓冲区、属性数组、定时器引用等。量上来之后内存占用很明显。我实测过:默认配置下一个空闲连接大约占 1~2 KB 内存,10 万连接就是 100~200 MB,这还是没算业务属性数据的情况。

优化方向有三个:

  • 精简连接属性:不要把大对象挂在连接上,比如用户头像、历史聊天记录这些放 Redis,需要时再取。
  • 调整缓冲区:TcpConnection::$maxSendBufferSize默认是 1 MB,如果消息体都不大,可以调低到 256 KB,减少内存预分配。
  • 开启 TCP 层面的优化:Linux 内核参数net.ipv4.tcp_fin_timeout调小,加快 TIME_WAIT 状态连接的回收;somaxconn调大,应对突发连接。

我自己常用的命令是:

sysctl -w net.ipv4.tcp_fin_timeout=30 sysctl -w net.core.somaxconn=65535 sysctl -w net.ipv4.tcp_max_syn_backlog=65535

这组参数配合 Workerman 的listen()设置,能让服务端在瞬时大并发连接下不丢连接。

4.3 消息积压与背压控制

聊天室有一个特殊场景:某个房间里突然涌入大量消息,比如抢红包、抽奖、秒杀活动。这时候所有在线客户端每秒可能收到几十条消息,有些客户端网络差,消费速度跟不上,消息就会在服务端的发送缓冲区里积压。如果不加控制,缓冲区写满,进程直接崩溃或者 OOM。

Workerman 提供了发送缓冲区的反馈机制:

$connection->onBufferFull = function ($connection) { // 缓冲区满了,停止向这个连接发送,并记录告警日志 $connection->isPaused = true; }; $connection->onBufferDrain = function ($connection) { // 缓冲区空了,恢复发送 $connection->isPaused = false; };

广播的时候先检查连接是否被暂停,如果暂停就不继续 send:

function broadcast($roomId, $payload) { global $rooms; if (empty($rooms[$roomId])) return; $msg = json_encode($payload, JSON_UNESCAPED_UNICODE); foreach ($rooms[$roomId] as $conn) { if ($conn->isPaused) continue; $conn->send($msg); } }

这个“宁可让慢客户端的消息丢失,也不能拖垮服务端”的思路,在实时系统里叫背压(backpressure),是保证系统稳定性的关键。慢客户端踢掉或者降级到轮询模式,都比重试和大流量冲击整个服务强。

5. 常见问题与排查技巧实录:上线半年踩过的坑

5.1 “WebSocket 握手老失败”怎么办

现象:客户端一直显示连接失败,浏览器控制台报WebSocket connection failed。

排查思路:

  1. 看 HTTP 状态码。如果握手返回 200 而不是 101,说明请求根本没到达 Workerman,是被前面的 Nginx 或者负载均衡挡了。Nginx 要配置 Upgrade 头透传:
location /ws { proxy_pass http://127.0.0.1:8282; 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; }
  1. 如果 101 都返回了但紧接着就断开,多半是防火墙或者代理把长连接掐了,除了降级机制,没特别好的办法。
  2. 如果是内网环境,检查 WebSocket 是不是用了 wss 协议。只要页面是 HTTPS,WebSocket 就必须走 wss,否则混合内容会被浏览器直接拦截。wss 本质是 WebSocket over TLS,Nginx 用了 SSL 证书后,需要把proxy_set_header X-Forwarded-Proto $scheme;加上,否则可能出现“服务器检测到协议不一致”的问题。
  3. 别忘了装 openssl 扩展。PHP 没装 openssl 的场合,Workerman 的 wss 服务会直接启动报错,这个问题排查起来最容易一头雾水。

5.2 连接数到一定程度后新连接进不来

现象:系统运行几天后,新用户连不上聊天室,老用户还在正常聊天。

排查过程:

  • ss -s看系统连接数状态,如果 TIME_WAIT 数量非常大,说明短连接释放不够快,调整内核参数。
  • netstat -antp | grep 8282 | wc -l数一下当前连接数。
  • 查看 Workerman 日志,如果有Resource temporarily unavailable的报错,通常是文件描述符不够了。用ulimit -n看当前限制,生产环境我一般调成 65535 或者更高。在启动 Workerman 的脚本里写ulimit -n 65535,或者放到 systemd 服务的LimitNOFILE=65535。
  • 另外一个隐蔽的问题:Worker 进程数。Workerman 的每个 Worker 进程默认最大连接数受listen backlog限制,worker->listen时可以传 backlog 参数。高并发场景建议设成 1024 以上,否则瞬间涌入大量握手请求时,新连接会被拒绝。

5.3 消息重复发送,用户看到两条一模一样的记录

这个问题的源头有两个:

  1. WebSocket 协议没有 ACK 机制,消息发送后客户端是否成功收到,服务端是不知道的。客户端掉线重连后,服务端补发消息时没法判断哪些已经到过。
  2. 应用层重复提交。用户网络抖动时,浏览器重试发送按钮可能触发多次相同内容的发送。

我的解法:

  • 每个消息都有msgId,服务端在 Redis 里记录每个客户端最近处理过的msgId,收到重复的就丢弃。
  • 客户端渲染前先做本地去重,相同msgId只渲染一次。

实践下来,去重逻辑必须放在客户端和服务端双向做,单靠一端总会有漏网之鱼。

5.4 高并发广播时 CPU 飙到 100%

这个问题的根源一般是 JSON 编解码和字符串复制。

一个优化就立竿见影:把广播消息的json_encode结果缓存一下。同一时刻发给同一房间所有人的消息其实是相同的字符串,不需要每条连接单独编码。我在 3.2 的代码里就是这么写的,$msg = json_encode($payload)只执行一次,后面循环里全是$conn->send($msg)。

还有个小细节:如果客户端数量大于 5000,把broadcast的遍历从foreach ($array as $conn)改成传统的 for 循环加索引访问,实测能省出 5%~10% 的 CPU。这个优化看起来微不足道,但在每次消息广播都要遍历几万条连接的场景下,积少成多能把 CPU 降下来一大截。

5.5 聊天数据要不要入库,怎么入

聊天室的数据落库是个容易被忽视的坑。有人图省事,每条消息都直接 insert 数据库,结果高峰时期数据库直接被打满。我的做法是:

  1. 消息先写 Redis。用RPUSH room:{roomId}:messages存原始消息,顺便用EXPIRE设置过期时间,防止内存无限增长。
  2. 异步落库。用 Workerman 的Timer每隔 5 秒批量从 Redis 取出新消息,攒够一批或者到时间了就批量插入 MySQL。批量插入 100 条消息的开销跟插入 1 条差不多,但总耗时能降一个量级。
  3. 历史消息查询走 MySQL,实时增量走 Redis。用户进入房间时,先查 MySQL 拉最近 50 条作为初始聊天记录,然后走 WebSocket 管道收实时消息。

这套方案跑下来,MySQL 的写入压力大约只有实时写入的十分之一,而且即使某一批写失败了,Redis 里的原始消息还在,可以重新补偿。

6. 写在最后的一些个人体会

这套双模式聊天室从开发到上线,最深的感触是:技术选型的核心从来不是“哪个技术更高级”,而是“哪个方案能解决覆盖你所有用户场景的问题”。WebSocket 固然实时性拉满,但没有降级通道的设计,一旦遇到不支持的环境就是废的。而双模式架构看起来很“土”,却是保证系统在极端网络条件下仍然可用的关键。

如果你正在规划一个 PHP 实时通讯项目,我的建议是:先基于 Workerman 快速验证核心的 WebSocket 广播逻辑,再把降级轮询通道补上,最后把监控和日志做好(每一条连接断开、每一次消息广播都留下日志),逐步优化。千万别一上来就追求极致的并发性能,先把“消息不丢、连接可复用、系统不崩”这三件最基本的事做扎实。在这个基础上,后续要加在线状态、已读回执、历史消息翻页,就都是顺手的事了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询