1. 从零搭建一套实时聊天系统,为什么我选了这套组合
很多人第一次接触实时聊天功能时,脑子里冒出来的第一个方案往往是轮询——前端每隔几秒发一次请求问服务器“有没有新消息”。这个方案在用户量小的时候确实能跑,但只要在线人数稍微上去一点,服务器就会被大量无效请求压得喘不过气。我早期做过一个内部协作工具,用的就是轮询,结果二十来个人同时在线,接口响应就开始明显变慢,数据库连接数也一直居高不下。后来换成基于长连接的方案,同样的硬件配置,支撑的并发量直接翻了十几倍。
这次要聊的这套实时聊天系统,核心思路就是用Workerman做常驻内存的通信服务,用GatewayWorker做连接管理和消息路由,前端用VUE构建交互界面。整套东西是开源的,可以自己部署在自有服务器上,数据完全掌握在自己手里。它适合谁呢?如果你正在做一个需要即时通讯能力的项目——比如内部客服系统、团队协作工具、在线教育答疑、社区私信功能——又不想依赖第三方云服务,那这套方案值得认真看一看。
我先把这套架构的角色分工讲清楚,不然后面部署的时候容易迷糊。Workerman是底层的高性能 PHP socket 服务框架,它让 PHP 可以像 Node.js 那样常驻内存运行,不用每次请求都重新加载一遍代码。GatewayWorker是建立在 Workerman 之上的一个专门用于长连接的框架,它把进程分成了三类:Gateway 进程负责维持和客户端的连接,Worker 进程负责处理业务逻辑,Register 进程负责协调各进程之间的通信。VUE则是前端框架,负责渲染聊天界面、管理消息列表、处理用户交互。三者配合起来,就是一个完整的实时聊天系统。
注意:这套方案要求服务器上能运行 PHP 命令行进程,并且需要开放指定的 TCP 端口。如果你用的是共享虚拟主机,大概率跑不起来,建议用独立的云服务器或者物理机。
我见过不少人部署失败,根本原因不是技术多复杂,而是一开始没搞清楚“谁和谁说话”。客户端连的是 Gateway 的端口,Gateway 把消息转给 Worker,Worker 处理完再通过 Gateway 推回客户端。Register 不直接和客户端打交道,它只在内部做协调。把这个数据流向在脑子里画清楚,后面配置端口和进程数的时候就不会乱。
2. 部署前的环境准备:那些容易忽略的细节
2.1 PHP 版本与扩展的硬性要求
这套系统对 PHP 版本有明确要求,建议使用 PHP 7.4 或更高版本,PHP 8.x 系列也可以跑,但要注意部分扩展的兼容性。我实测下来,PHP 8.1 配合 Workerman 4.x 是比较稳的组合。低于 PHP 7.2 的版本就不要考虑了,Workerman 的新版本已经不支持了。
扩展方面,必须确保以下几个是开启状态:
- pcntl:这是 Workerman 实现多进程的基础,没有它就只能单进程跑,并发能力大打折扣。
- posix:用于进程管理和信号处理,和 pcntl 配合使用。
- event:这个扩展不是必须的,但强烈建议装上。它能让 Workerman 使用更高效的事件循环机制,在高并发场景下性能提升很明显。如果没有 event,Workerman 会退而使用 select 或 stream_select,连接数一多就容易碰到文件描述符限制。
- sockets:处理底层 socket 通信。
- json:消息传输格式离不开它,一般默认都是开的。
你可以用php -m命令查看当前已加载的扩展列表。如果发现 pcntl 或 posix 没开,需要根据你的操作系统和 PHP 安装方式来开启。在编译安装的 PHP 中,通常是在 configure 阶段加上--enable-pcntl和--enable-posix。如果用包管理器安装的 PHP,可能需要单独安装对应的扩展包。
提示:在命令行下执行
php --ri event可以确认 event 扩展是否正常工作。如果提示扩展不存在,先把它装上再继续。
2.2 Composer 依赖管理与目录规划
这套系统用Composer管理 PHP 侧的依赖。Composer 的安装这里不展开,网上教程很多。重点说一下项目目录的规划,因为后面配置文件的路径写错了,排查起来很费时间。
我习惯把项目分成两个独立的部分:一个放 PHP 服务端代码,一个放 VUE 前端代码。服务端目录大概长这样:
chat-server/ ├── composer.json ├── vendor/ ├── start.php # 启动入口 ├── Applications/ │ └── YourApp/ │ ├── Events.php # 业务逻辑 │ └── ... └── config/ └── ...前端目录则是标准的 VUE 项目结构。两个目录分开的好处是,前端构建和部署不会影响服务端进程,服务端重启也不会干扰前端资源。如果你把两者混在一起,后期维护会很头疼。
2.3 端口规划与防火墙策略
端口这块是部署时最容易出问题的地方。GatewayWorker 至少需要两个端口:一个给客户端连接用(比如 8282),一个给内部通信的 Register 用(比如 1236)。这两个端口的分工要搞清楚:
| 端口类型 | 作用 | 是否对外暴露 | 示例 |
|---|---|---|---|
| Gateway 端口 | 客户端连接入口 | 是,需要对外开放 | 8282 |
| Register 端口 | 内部进程间通信 | 否,仅本机访问 | 1236 |
| Worker 内部端口 | Gateway 与 Worker 通信 | 否,仅本机访问 | 动态分配 |
很多新手会把所有端口都对外开放,这是有安全风险的。Register 端口和内部通信端口只应该监听在 127.0.0.1 上,不要让外部能访问到。Gateway 端口才是需要对外暴露的那个。
防火墙方面,如果你用的是云服务器,除了系统防火墙(iptables 或 firewalld),还要检查云平台的安全组规则。我遇到过好几次,系统防火墙明明放行了,但云平台安全组没配,外部就是连不上。两边都要检查。
3. 服务端核心配置:GatewayWorker 的进程模型与参数调优
3.1 启动文件里到底写了什么
GatewayWorker 的启动入口通常是一个 start.php 文件,里面做的事情其实不复杂,但每一行都值得理解。核心逻辑大概是这样的:
<?php use Workerman\Worker; use GatewayWorker\Gateway; use GatewayWorker\Register; use GatewayWorker\BusinessWorker; // 自动加载 require_once __DIR__ . '/vendor/autoload.php'; // Register 进程,只监听本机 $register = new Register('text://0.0.0.0:1236'); // Gateway 进程,对外提供服务 $gateway = new Gateway('websocket://0.0.0.0:8282'); $gateway->name = 'ChatGateway'; $gateway->count = 4; $gateway->lanIp = '127.0.0.1'; $gateway->startPort = 2900; $gateway->registerAddress = '127.0.0.1:1236'; // BusinessWorker 进程,处理业务 $worker = new BusinessWorker(); $worker->name = 'ChatBusinessWorker'; $worker->count = 4; $worker->registerAddress = '127.0.0.1:1236'; $worker->eventHandler = 'Applications\YourApp\Events'; Worker::runAll();这段代码里,Gateway 的 count 参数决定了启动几个 Gateway 进程。每个 Gateway 进程会占用一个对外端口和若干个内部端口。如果你设置 count 为 4,那么实际监听的端口会从 8282 开始,依次占用 8282、8283、8284、8285。这一点很多人不知道,以为只开了一个端口,结果防火墙只放行了 8282,导致部分连接失败。
BusinessWorker 的 count决定了业务处理进程的数量。这个数字怎么定?我的经验是:如果业务逻辑以 CPU 计算为主,count 设置为 CPU 核心数或核心数加一;如果业务逻辑涉及大量数据库查询或外部接口调用(IO 密集型),可以适当调大,比如核心数的两到三倍。但也不是越大越好,进程太多会导致上下文切换开销增加。
3.2 Events.php 里该放什么、不该放什么
Events.php 是业务逻辑的核心文件,它定义了四个回调方法:onConnect、onMessage、onClose、onWorkerStart。我见过一些项目把大量业务代码直接塞进 onMessage 里,几百行堆在一起,后期根本没法维护。
正确的做法是:Events.php 只做消息的接收和分发,具体的业务处理交给独立的类或服务。比如收到一条聊天消息,onMessage 里只负责解析消息类型,然后调用对应的 Handler 类去处理。这样代码清晰,也方便单元测试。
还有一个容易踩的坑:不要在 onMessage 里做耗时操作。比如同步调用一个响应很慢的外部接口,这会阻塞当前 Worker 进程,导致其他消息也处理不了。如果确实有耗时任务,应该丢到异步队列里处理,或者用 Workerman 的异步客户端。
public static function onMessage($client_id, $message) { $data = json_decode($message, true); if (!$data || !isset($data['type'])) { return; } // 只做分发,不处理具体业务 switch ($data['type']) { case 'chat': ChatHandler::handle($client_id, $data); break; case 'heartbeat': // 心跳直接回复 Gateway::sendToClient($client_id, json_encode(['type' => 'pong'])); break; default: // 未知类型,记录日志 break; } }3.3 心跳机制与断线重连的服务端配合
长连接最大的敌人是“假死”——连接看起来还在,但实际上已经不通了。移动网络切换、路由器超时、运营商回收连接,都会导致这种情况。心跳机制就是解决这个问题的。
服务端需要配合前端做两件事:一是响应心跳请求,二是主动检测超时连接。GatewayWorker 本身提供了Gateway::getAllClientIdList()和Gateway::getClientInfo()等方法,可以定期检查客户端的最后活跃时间。如果超过一定时间没有收到心跳,就主动断开连接。
心跳间隔设置多少合适?太短会浪费流量和电量,太长则检测不及时。我的经验值是30 到 60 秒。移动端可以适当放宽到 60 秒,Web 端 30 秒就够。前端每次收到心跳响应后,重置一个本地定时器,如果超过两个心跳周期没收到响应,就主动断开并重连。
注意:服务端的超时检测时间应该比前端的心跳间隔长一些,比如前端 30 秒发一次心跳,服务端设置 90 秒超时。这样给网络波动留出缓冲,避免误杀正常连接。
4. VUE 前端对接:从连接建立到消息渲染的完整链路
4.1 WebSocket 连接的建立与生命周期管理
前端这块,核心就是管理好 WebSocket 连接的生命周期。我建议把连接逻辑封装成一个独立的模块或类,不要散落在各个组件里。这样连接状态、重连逻辑、消息队列都能集中管理。
连接建立的基本流程是:组件挂载时创建 WebSocket 实例,监听 open、message、close、error 四个事件。open 事件里启动心跳定时器,close 事件里触发重连逻辑。重连不能无脑立即重试,那样在服务端宕机时会疯狂发起连接。应该用指数退避策略:第一次断开后等 1 秒重连,失败等 2 秒,再失败等 4 秒,直到一个上限比如 30 秒。
class ChatSocket { constructor(url) { this.url = url; this.ws = null; this.reconnectDelay = 1000; this.maxDelay = 30000; this.heartbeatTimer = null; this.reconnectTimer = null; this.manualClose = false; } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.reconnectDelay = 1000; this.startHeartbeat(); }; this.ws.onmessage = (e) => { this.handleMessage(JSON.parse(e.data)); }; this.ws.onclose = () => { this.stopHeartbeat(); if (!this.manualClose) { this.scheduleReconnect(); } }; this.ws.onerror = () => { this.ws.close(); }; } scheduleReconnect() { clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => { this.connect(); this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay); }, this.reconnectDelay); } startHeartbeat() { this.heartbeatTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'heartbeat' })); } }, 30000); } stopHeartbeat() { clearInterval(this.heartbeatTimer); } }这段代码里有个细节值得说:manualClose标志。当用户主动关闭页面或退出登录时,我们设置这个标志为 true,这样 onclose 里就不会触发重连。如果不加这个判断,用户退出后还会不断重连,体验很差。
4.2 消息列表的渲染性能与滚动策略
聊天消息列表看起来简单,但消息一多,性能问题就出来了。我见过一个项目,聊天记录超过五百条之后,页面滚动明显卡顿。原因就是所有消息都渲染成 DOM 节点,浏览器负担太重。
优化思路有几个方向。第一是虚拟滚动,只渲染可视区域内的消息,上下用占位元素撑开高度。这个方案效果最好,但实现起来稍复杂,可以用现成的虚拟滚动组件。第二是分页加载,默认只加载最近五十条,用户往上滚动时再加载更多。这个方案实现简单,适合大多数场景。第三是消息合并,连续发送的短消息合并显示,减少 DOM 节点数量。
我一般推荐分页加载加消息合并的组合,实现难度适中,效果也够用。具体做法是:消息列表用一个数组维护,新消息 push 到数组末尾,渲染时用 v-for。往上滚动到顶部时,触发加载更多历史消息,把旧消息 unshift 到数组开头。注意 unshift 后要保持滚动位置不变,否则用户会看到页面跳动。
// 保持滚动位置 const container = this.$refs.messageList; const oldHeight = container.scrollHeight; // 加载历史消息后 this.$nextTick(() => { const newHeight = container.scrollHeight; container.scrollTop = newHeight - oldHeight; });4.3 消息去重与顺序保证
分布式环境下,消息的顺序和去重是需要认真对待的问题。GatewayWorker 的多进程模型意味着同一条消息可能被多个 Worker 处理,如果业务逻辑里有广播操作,客户端可能收到重复消息。
解决方案是在消息体里带一个唯一 ID,前端维护一个已接收 ID 的集合,收到消息先查重,重复的直接丢弃。唯一 ID 可以由服务端生成,比如用uniqid()加上进程 ID 和时间戳,保证全局唯一。
顺序问题更微妙一些。理论上 WebSocket 是保证消息顺序的,但在多进程架构下,不同 Worker 处理的消息到达客户端的顺序可能和发送顺序不一致。如果业务对顺序敏感(比如聊天记录必须按时间排列),前端应该根据消息里的时间戳排序,而不是依赖到达顺序。时间戳的精度要足够,建议用毫秒级。
5. 上线前必须验证的几件事:从功能测试到压力测试
5.1 功能自测清单
部署完成后,不要急着上线,先按这个清单过一遍:
- 单个客户端能否正常连接、发送消息、接收消息
- 两个客户端之间能否互相收发消息
- 客户端断开后重连,能否恢复之前的会话状态
- 心跳是否正常工作,长时间空闲后连接是否还活着
- 服务端重启后,客户端能否自动重连
- 消息中的特殊字符、表情符号能否正确传输和显示
- 大量消息快速发送时,是否有丢失或重复
我特别想强调服务端重启后的重连测试。很多人只测了正常流程,没测服务端重启的情况。实际上,服务端重启时,所有客户端都会断开,如果重连逻辑写得不好,客户端可能一直连不上或者疯狂重试。正确的做法是服务端重启后,客户端按指数退避重连,同时前端给用户一个“连接已断开,正在重连”的提示。
5.2 压力测试怎么做才有效
压力测试不是简单地开一堆客户端连上去就完事了。有效的压力测试需要模拟真实的使用场景:连接建立、消息发送、消息接收、心跳维持、断开重连,这些操作按一定比例混合。
我一般用两种方式结合。第一种是用脚本模拟大量客户端,可以用 PHP 或 Python 写一个压测脚本,创建几百上千个 WebSocket 连接,定期发送消息。第二种是用真实浏览器,开多个标签页或使用浏览器的多用户模式,手动操作验证交互体验。
压测时重点观察几个指标:服务端 CPU 和内存占用、连接建立的成功率、消息的平均延迟、消息丢失率。如果 CPU 占用过高,可能是业务逻辑太重或者进程数不合理;如果连接建立失败率高,可能是文件描述符限制或者端口不够用。
提示:Linux 系统默认的文件描述符限制通常是 1024,在高并发场景下需要调大。可以修改
/etc/security/limits.conf,把 nofile 调到 65535 甚至更高。同时 Workerman 启动时也可以设置Worker::$maxPackageSize等参数来优化。
5.3 日志与监控:出问题时怎么快速定位
线上出问题时,最怕的就是没有日志。GatewayWorker 支持把日志输出到文件,建议在启动配置里设置好日志路径和日志级别。关键的操作都要记日志:客户端连接、断开、消息发送、消息接收、异常错误。
日志格式要包含时间戳、进程 ID、客户端 ID、消息类型等关键信息。这样排查问题时,可以根据客户端 ID 追踪一个用户的完整操作链路。如果日志量太大,可以按天切割,保留最近七天的日志。
监控方面,至少要监控三个东西:服务进程是否存活、端口是否可连接、消息延迟是否正常。可以用简单的定时脚本检查进程和端口,也可以用更专业的监控系统。我见过一个项目,服务进程半夜挂了没人知道,第二天用户反馈才知道,这就是缺少监控的后果。
6. 踩过的坑与实战经验
6.1 端口占用导致的启动失败
这是最常见的问题。GatewayWorker 启动时会占用多个端口,如果这些端口被其他程序占用了,启动就会失败。错误信息通常是“Address already in use”。排查方法是先用netstat -tlnp或ss -tlnp查看端口占用情况,找到占用端口的进程,要么停掉它,要么换一个端口。
还有一种隐蔽的情况:上次启动的进程没有完全退出,残留的进程还占着端口。这时候需要先ps aux | grep php找到残留进程,kill 掉再重新启动。我建议在启动脚本里加一个清理逻辑,启动前先检查并清理残留进程。
6.2 跨域与 WebSocket 安全策略
如果前端页面和服务端不在同一个域名下,浏览器可能会因为安全策略阻止 WebSocket 连接。虽然 WebSocket 协议本身不受同源策略的严格限制,但有些浏览器或代理会做额外检查。
解决方案是在服务端配置允许的 Origin,或者在 Nginx 等反向代理层做转发。如果通过 Nginx 转发 WebSocket,需要配置Upgrade和Connection头:
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; }注意proxy_read_timeout要设置得足够长,否则 Nginx 会在空闲一段时间后主动断开 WebSocket 连接。我一般设置 3600 秒,配合前端的心跳机制,基本不会出现意外断开。
6.3 内存泄漏的排查与预防
PHP 常驻内存运行最大的风险就是内存泄漏。虽然 PHP 有垃圾回收机制,但在长生命周期进程中,循环引用、全局变量累积、未释放的资源都可能导致内存持续增长。
排查内存泄漏的方法是定期记录进程的内存占用,观察是否有持续上升的趋势。Workerman 提供了Worker::$statistics可以查看内存使用情况。如果发现某个进程的内存只增不减,就要检查代码里有没有不断往全局数组里塞数据、有没有忘记 unset 大变量、有没有循环引用。
预防措施包括:避免使用全局变量存储大量数据、及时释放不再使用的资源、定期重启 Worker 进程(Workerman 支持设置max_request参数,处理一定数量的请求后自动重启进程)。我一般设置max_request为 10000 左右,这样即使有轻微泄漏,也会在积累到危险程度之前被重启清理掉。
6.4 消息丢失的几种典型场景
消息丢失是实时聊天系统最让人头疼的问题。根据我的经验,主要有以下几种场景:
场景一:客户端未连接时发送消息。如果用户 A 给用户 B 发消息,但 B 当前不在线,消息直接丢弃了。解决方案是服务端把离线消息存起来,等 B 上线后再推送。存储可以用数据库或 Redis,根据消息量和时效性要求选择。
场景二:服务端进程崩溃。如果 Worker 进程在处理消息过程中崩溃,这条消息就丢了。解决方案是消息先落盘再处理,或者用消息队列做缓冲。对于聊天场景,我建议至少把重要消息(比如私信)先存数据库再推送。
场景三:网络抖动导致消息未到达。WebSocket 基于 TCP,正常情况下不会丢包,但如果连接断开,发送中的消息可能丢失。解决方案是前端维护一个待确认消息队列,发送后等待服务端确认,超时未确认则重发。
注意:消息确认机制会增加系统复杂度,不是所有场景都需要。如果只是普通的群聊,丢失一两条消息用户可能感知不到;但如果是订单通知、支付结果这类关键消息,就必须做确认和重试。
7. 性能优化的几个实用方向
7.1 连接数与进程数的平衡
Gateway 进程数和连接数之间有个平衡点。每个 Gateway 进程能维持的连接数是有限的,受文件描述符和内存限制。一般来说,单个 Gateway 进程维持几千到一万个连接是没问题的,具体取决于消息频率和消息大小。
如果连接数很多,可以增加 Gateway 进程数。但 Gateway 进程太多,Register 的协调开销也会增加。我的经验是:Gateway 进程数设置为 CPU 核心数左右,然后根据实际压测结果调整。BusinessWorker 进程数可以比 Gateway 多一些,因为业务处理通常比连接维持更耗时。
7.2 消息序列化的选择
消息在网络中传输需要序列化。JSON 是最常用的格式,可读性好,前后端都容易处理。但 JSON 的序列化和反序列化开销相对较大,消息体也偏大。如果对性能要求极高,可以考虑 MessagePack 或 Protobuf,它们更紧凑、解析更快。
不过对于大多数聊天场景,JSON 的性能已经足够了。我实测过,在每秒几千条消息的压力下,JSON 序列化的开销占比并不高,瓶颈往往在业务逻辑或数据库操作上。所以不要过早优化,先用 JSON 把功能跑通,真有性能问题再换。
7.3 数据库写入的批量优化
聊天消息通常需要持久化。如果每条消息都单独写一次数据库,数据库压力会很大。优化方法是批量写入:在内存里攒一批消息,达到一定数量或间隔一定时间后,一次性写入数据库。
批量写入要注意两个问题:一是攒批期间服务崩溃会丢消息,所以攒批时间不能太长,一般几百毫秒到一秒;二是批量插入的 SQL 语句长度有限制,一批不能太大,通常几百条一批比较合适。
// 批量插入示例 $values = []; foreach ($messages as $msg) { $values[] = "('{$msg['from']}', '{$msg['to']}', '{$msg['content']}', {$msg['time']})"; } $sql = "INSERT INTO messages (`from`, `to`, `content`, `time`) VALUES " . implode(',', $values);当然,实际项目中要用参数化查询防止 SQL 注入,这里只是示意批量插入的思路。
8. 写在最后:一些个人体会
这套基于 Workerman、GatewayWorker 和 VUE 的实时聊天方案,我从第一次接触到熟练部署,中间踩了不少坑。最大的感受是:理解进程模型比记住配置参数更重要。很多人照着教程配了一遍,能跑起来,但一遇到问题就懵了,因为不知道每个进程在干什么、消息是怎么流转的。
另外,实时聊天系统的复杂度往往不在“能聊”这个基本功能上,而在断线重连、消息可靠性、并发性能这些边缘场景上。把这些边缘场景处理好,系统才算真正可用。我建议在开发阶段就模拟各种异常情况——断网、服务重启、高并发——提前暴露问题,而不是等上线后让用户帮你发现。
最后说一个实用小技巧:部署完成后,用php start.php status命令可以查看当前所有进程的状态,包括连接数、内存占用、运行时间等。这个命令在排查问题时非常有用,建议养成定期查看的习惯。如果发现某个进程的内存持续增长或者连接数异常,就要及时介入处理。