作为一个常年拿 PHP 做业务系统的人我太清楚“PHP 做不了实时通讯”这个偏见有多顽固了。早期我也信这个邪直到某次给某公司的售后平台做在线客服系统被问“能不能像网页版微信一样消息来了不刷新页面就直接弹出来”才被逼着去认真研究 PHP 在长连接、高并发这条路上的解法。折腾了大半个月最终落地了一套双模式架构的聊天室系统核心思路不算复杂但里面的坑确实不少。这篇文章就把这套东西完整拆开讲从双模式架构的设计动机到 WebSocket 协议底层再到基于 Workerman 的完整实现以及上线后必须处理的心跳、断线重连、多进程、消息积压这些问题。看完之后你会发现PHP 做高实时性应用不是不行而是要用对姿势。1. 双模式架构的整体设计思路1.1 为什么纯 WebSocket 方案在 PHP 生态里会翻车先说结论PHP 不是不能做 WebSocket 服务端而是“传统 PHP 运行模式”做不了长连接。一个常规 PHP-FPM 进程处理完请求后所有资源变量、连接、内存里的状态全部销毁。而 WebSocket 要求服务端跟客户端之间维持一条可能是几小时甚至几天的 TCP 长连接期间要不断收发消息、维护会话状态。你总不能每隔几秒就让 FPM 重新拉起一个进程来“续命”吧所以业内做 PHP 实时通讯的常规路线就两条用第三方实时服务比如各种云厂商的 IM 服务PHP 只做后端逻辑。用常驻内存的 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 字节随机值。服务端收到后把这段字符串拼上一个固定的 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做一次 SHA-1 哈希再把哈希结果 Base64 编码通过Sec-WebSocket-Accept头返回给客户端。客户端校验这个值没问题连接就正式建立了。这个握手过程意味着任何能处理 HTTP 请求的服务端理论上都能实现 WebSocket 升级。Workerman 之所以能成为 PHP 做 WebSocket 的首选就是因为它把握手、帧解析、掩码处理这些底层细节全封装好了你只需要关注业务回调。这一点如果你用原生 PHP socket 去硬写会痛不欲生。2.2 数据帧格式读懂这几个字节你就懂了长连接握手完成后后续数据传输不再走 HTTP 协议而是走 WebSocket 协议自己的帧格式。一个数据帧由这几个部分组成FIN1 位标记这是不是消息的最后一帧大消息会被分片。Opcode4 位0x1 表示文本帧0x2 表示二进制帧0x8 表示关闭帧0x9 是 ping0xA 是 pong。Mask1 位客户端发往服务端的帧必须掩码。Payload length7 位或 716 位或 764 位表示数据长度。这里最容易踩的坑是“粘包”。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 的超时重传机制触发有时候要等好几分钟。如果你的服务端不及时清理这些“僵尸连接”内存和文件描述符会一点点被吃光最终服务器卡死。解决方案就是业界通用的三层清理机制WebSocket 协议层心跳客户端每隔 30 秒发一个 ping 帧服务端必须回 pong 帧。服务端每隔 60 秒扫一遍超过 120 秒没有任何通信的连接直接踢掉。应用层心跳客户端除了发协议层 ping还会在应用层发{type:heartbeat}消息。协议层 ping 是浏览器底层自动处理的应用层心跳则能让服务端顺便校验用户会话是否还有效、是否需要续期。连接清理定时器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这里补一个选型理由对比 SwooleWorkerman 是纯 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 的消息只发给了同进程的连接。解决办法有三种Redis 发布订阅推荐某个进程收到消息后往 Redis 的一个频道 publish 这条消息所有 Worker 进程都 subscribe 这个频道收到后各自向本进程维护的房间连接广播。这是最标准也最不挑环境的做法。Workerman Channel 组件官方提供的分布式通讯组件原理跟 Redis 发布订阅类似但它是基于长连接的内网通讯性能更好。内存表 / 共享内存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_timeout30 sysctl -w net.core.somaxconn65535 sysctl -w net.ipv4.tcp_max_syn_backlog65535这组参数配合 Workerman 的listen()设置能让服务端在瞬时大并发连接下不丢连接。4.3 消息积压与背压控制聊天室有一个特殊场景某个房间里突然涌入大量消息比如抢红包、抽奖、秒杀活动。这时候所有在线客户端每秒可能收到几十条消息有些客户端网络差消费速度跟不上消息就会在服务端的发送缓冲区里积压。如果不加控制缓冲区写满进程直接崩溃或者 OOM。Workerman 提供了发送缓冲区的反馈机制$connection-onBufferFull function ($connection) { // 缓冲区满了停止向这个连接发送并记录告警日志 $connection-isPaused true; }; $connection-onBufferDrain function ($connection) { // 缓冲区空了恢复发送 $connection-isPaused false; };广播的时候先检查连接是否被暂停如果暂停就不继续 sendfunction 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。排查思路看 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; }如果 101 都返回了但紧接着就断开多半是防火墙或者代理把长连接掐了除了降级机制没特别好的办法。如果是内网环境检查 WebSocket 是不是用了 wss 协议。只要页面是 HTTPSWebSocket 就必须走 wss否则混合内容会被浏览器直接拦截。wss 本质是 WebSocket over TLSNginx 用了 SSL 证书后需要把proxy_set_header X-Forwarded-Proto $scheme;加上否则可能出现“服务器检测到协议不一致”的问题。别忘了装 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 服务的LimitNOFILE65535。另外一个隐蔽的问题Worker 进程数。Workerman 的每个 Worker 进程默认最大连接数受listen backlog限制worker-listen时可以传 backlog 参数。高并发场景建议设成 1024 以上否则瞬间涌入大量握手请求时新连接会被拒绝。5.3 消息重复发送用户看到两条一模一样的记录这个问题的源头有两个WebSocket 协议没有 ACK 机制消息发送后客户端是否成功收到服务端是不知道的。客户端掉线重连后服务端补发消息时没法判断哪些已经到过。应用层重复提交。用户网络抖动时浏览器重试发送按钮可能触发多次相同内容的发送。我的解法每个消息都有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 数据库结果高峰时期数据库直接被打满。我的做法是消息先写 Redis。用RPUSH room:{roomId}:messages存原始消息顺便用EXPIRE设置过期时间防止内存无限增长。异步落库。用 Workerman 的Timer每隔 5 秒批量从 Redis 取出新消息攒够一批或者到时间了就批量插入 MySQL。批量插入 100 条消息的开销跟插入 1 条差不多但总耗时能降一个量级。历史消息查询走 MySQL实时增量走 Redis。用户进入房间时先查 MySQL 拉最近 50 条作为初始聊天记录然后走 WebSocket 管道收实时消息。这套方案跑下来MySQL 的写入压力大约只有实时写入的十分之一而且即使某一批写失败了Redis 里的原始消息还在可以重新补偿。6. 写在最后的一些个人体会这套双模式聊天室从开发到上线最深的感触是技术选型的核心从来不是“哪个技术更高级”而是“哪个方案能解决覆盖你所有用户场景的问题”。WebSocket 固然实时性拉满但没有降级通道的设计一旦遇到不支持的环境就是废的。而双模式架构看起来很“土”却是保证系统在极端网络条件下仍然可用的关键。如果你正在规划一个 PHP 实时通讯项目我的建议是先基于 Workerman 快速验证核心的 WebSocket 广播逻辑再把降级轮询通道补上最后把监控和日志做好每一条连接断开、每一次消息广播都留下日志逐步优化。千万别一上来就追求极致的并发性能先把“消息不丢、连接可复用、系统不崩”这三件最基本的事做扎实。在这个基础上后续要加在线状态、已读回执、历史消息翻页就都是顺手的事了。