ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

qchat聊天插件部署实战:Workerman+GatewayWorker+Vue环境搭建与性能调优

qchat聊天插件部署实战:Workerman+GatewayWorker+Vue环境搭建与性能调优

1. 项目整体设计与技术选型拆解

1.1 这个聊天插件到底解决了什么问题

第一次接触 qchat 是在一个内部协作工具的需求里,当时团队需要一个能快速嵌入现有后台管理系统的实时聊天模块。市面上的方案要么太重——动辄需要独立部署一套微服务集群,要么太轻——只给了一个 WebSocket 的 demo,消息持久化、离线消息、多端同步这些全得自己写。qchat 的定位恰好卡在中间:它是一套基于 Workerman、GatewayWorker 和 Vue 的开源实时聊天插件,把实时通信里最麻烦的几块——长连接管理、消息路由、前端交互——都封装好了,你只需要把它挂到自己的业务系统上就能跑起来。

从架构上看,qchat 的核心思路是“后端常驻进程 + 前端组件化”。后端用 Workerman 做 TCP/WebSocket 服务容器,GatewayWorker 负责连接网关和业务逻辑的分离,前端用 Vue 做聊天界面和状态管理。这种分层的好处是,连接层和业务层解耦,你改业务逻辑的时候不用动网关配置,扩容的时候也只需要加 Gateway 进程。

适合谁来参考这套方案?我总结了三类:一是中小团队的后端开发,需要在现有系统里快速加聊天功能,不想从零造轮子;二是全栈开发者,想找一个完整的实时通信项目来学习 GatewayWorker 的进程模型;三是运维人员,需要理解这套东西部署时到底开了哪些端口、占了哪些资源、怎么排查连不上的问题。

1.2 为什么选 Workerman + GatewayWorker 而不是其他方案

实时通信的后端选型,本质上是在选“连接怎么管”和“消息怎么发”。常见的方案有几种:Node.js 的 Socket.io、Go 的 Gorilla WebSocket、PHP 的 Swoole 或 Workerman。qchat 选 Workerman 系,我认为有几个很实际的考量。

第一是部署门槛。Workerman 是纯 PHP 写的,不需要额外装扩展(Swoole 需要编译扩展),只要 PHP 版本够就能跑。对于大量已经用 PHP 做业务系统的团队来说,这意味着不需要引入新的运行时环境,运维成本几乎为零。你可以在同一台机器上跑 Nginx + PHP-FPM 处理 HTTP 业务,同时用命令行启动 Workerman 进程处理 WebSocket,两者互不干扰。

第二是 GatewayWorker 的进程模型。它把服务拆成 Gateway(负责维持客户端连接)、Worker(负责业务逻辑)、Register(负责服务注册和发现)三种进程。这个设计的精妙之处在于:Gateway 进程只做网络 IO,不碰业务;Worker 进程只处理业务,不关心连接从哪来。当你的在线人数上涨时,你只需要增加 Gateway 进程数;当业务逻辑变复杂时,你只需要增加 Worker 进程数。这种水平扩展能力在 PHP 生态里是比较少见的。

第三是消息推送的可靠性。GatewayWorker 内部用 Register 服务做进程间通信,Worker 给某个客户端发消息时,不需要知道这个客户端连在哪个 Gateway 上,Register 会帮你路由。这个机制让“给指定用户推送消息”变得很简单,你只需要拿到他的 client_id,调Gateway::sendToClient()就行。

1.3 前端为什么用 Vue 而不是原生 DOM

聊天界面看起来简单,实际上状态很多:消息列表、未读计数、在线状态、输入中提示、滚动位置、历史消息加载。用原生 DOM 操作这些状态,代码会迅速变成一团乱麻。Vue 的响应式系统天然适合这种场景——消息数组一变,界面自动更新;在线状态一变,头像旁边的绿点自动切换。

qchat 的前端部分通常包含几个核心组件:消息列表组件、输入框组件、联系人列表组件、以及一个管理 WebSocket 连接的服务层。这个服务层很关键,它负责在 Vue 组件和 WebSocket 之间做桥接,把收到的消息转成 Vue 能响应的数据格式,同时把用户的操作转成 WebSocket 帧发出去。用 Vue 的另一个好处是,你可以很方便地把聊天窗口做成一个独立的组件,然后嵌入到任何现有的 Vue 项目里,不需要改动主应用的架构。

2. 部署前的环境准备与核心依赖解析

2.1 服务器环境的最低要求和推荐配置

部署 qchat 之前,先把环境底子打好。我踩过的坑里,至少有三成是环境问题导致的。下面这张表是我实测下来比较稳的配置组合:

组件最低要求推荐配置说明
操作系统Linux (CentOS 7+/Ubuntu 18+)Ubuntu 20.04 LTSWindows 下 Workerman 只能跑单进程,不推荐生产用
PHP7.27.4 或 8.0需要 posix 和 pcntl 扩展,8.1 以上部分旧版 Workerman 有兼容问题
MySQL5.65.7 或 8.0存消息记录和用户表
Redis3.05.0+可选,用于缓存在线状态和消息队列
Nginx1.141.20+做 WebSocket 反向代理和静态资源服务
Node.js1214 或 16只用于前端构建,不参与运行时

PHP 扩展这块要重点检查。pcntl和posix是 Workerman 做进程管理必须的,很多默认安装的 PHP 不带这两个。你可以用php -m | grep -E 'pcntl|posix'来确认,如果没有输出,就需要重新编译安装或者通过包管理器装对应的扩展包。另外event扩展不是必须的,但装了之后 Workerman 的性能会更好,因为它能用 epoll 而不是 select 来做事件循环。

2.2 端口规划与防火墙策略

qchat 跑起来会占用几个端口,提前规划好能省很多事。默认情况下,GatewayWorker 需要两个端口:一个给客户端连(WebSocket,比如 8282),一个给内部通信(比如 1236)。Register 服务也需要一个端口(比如 1238)。如果你用 Nginx 做反向代理,那对外只需要暴露 80 和 443,内部端口全部走本机回环。

我一般这样规划:

  • 对外端口:80(HTTP)、443(HTTPS/WSS)
  • WebSocket 内部端口:8282(Gateway 监听)
  • Gateway 内部通信端口:1236(Gateway 之间互相通信)
  • Register 端口:1238(服务注册与发现)
  • Worker 端口:不监听端口,通过 Register 通信

防火墙方面,如果用的是云服务器,安全组里只需要放行 80 和 443。内部端口不要对外开放,否则会有安全风险。我见过有人把 8282 直接暴露在公网上,结果被人扫到后疯狂发垃圾消息。正确的做法是让 Nginx 监听 443,然后把/socket路径的请求代理到127.0.0.1:8282。

2.3 源码获取与目录结构说明

qchat 的源码通常包含后端和前端两部分。后端是 PHP 项目,前端是 Vue 项目。拿到源码后,先别急着装依赖,花五分钟看一下目录结构,后面排查问题会快很多。

后端目录一般长这样:

qchat-server/ ├── Applications/ # 业务代码 │ └── Chat/ │ ├── Events.php # 事件回调(onConnect/onMessage/onClose) │ ├── Gateway.php # Gateway 进程配置 │ └── BusinessWorker.php # Worker 进程配置 ├── GatewayWorker/ # GatewayWorker 框架代码 ├── start.php # 启动入口 └── composer.json # 依赖声明

前端目录一般是标准的 Vue CLI 结构:

qchat-web/ ├── src/ │ ├── components/ # 聊天组件 │ ├── services/ # WebSocket 服务层 │ ├── store/ # Vuex 状态管理 │ └── main.js # 入口文件 ├── vue.config.js # 构建配置 └── package.json

重点看Events.php,这是整个后端的业务核心。onMessage回调里定义了收到消息后怎么处理——是转发给指定用户,还是广播给所有人,还是存库后推送。你后续要改的业务逻辑,八成都在这个文件里。

3. 后端部署实操与核心配置详解

3.1 Composer 依赖安装与常见报错处理

后端依赖用 Composer 管理。进入qchat-server目录,执行:

composer install --no-dev

--no-dev是为了不装开发依赖,生产环境能省一点空间。如果 composer 下载很慢,可以换国内镜像源:

composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/

这一步常见的报错有几个。第一个是php not found,说明 PHP 没装或者没加到 PATH 里。第二个是composer: command not found,需要先装 composer。第三个是内存不足,Composer 解析依赖时比较吃内存,如果服务器内存小于 1G,可能会被 OOM Killer 杀掉。解决办法是临时调大内存限制:

php -d memory_limit=-1 /usr/local/bin/composer install --no-dev

还有一个坑是 PHP 版本不匹配。有些 qchat 版本要求 PHP 7.4 以上,如果你的服务器还是 7.2,Composer 会报一堆依赖冲突。这时候要么升级 PHP,要么在composer.json里手动放宽版本约束,但后者有风险,不推荐。

3.2 GatewayWorker 的进程配置与参数调优

GatewayWorker 的配置文件在Applications/Chat/Gateway.php和BusinessWorker.php里。默认配置能跑,但生产环境需要根据在线人数调整。

Gateway.php里几个关键参数:

$gateway = new Gateway("websocket://0.0.0.0:8282"); $gateway->count = 4; // Gateway 进程数 $gateway->name = 'ChatGateway';

count是 Gateway 进程数。每个进程能维持的连接数大概在 1000 到 3000 之间,取决于消息频率和服务器性能。如果你预计同时在线 5000 人,设 4 个进程比较稳妥。但要注意,Gateway 进程数一旦设定,客户端连接会均匀分布到这些进程上,后续扩容需要重启服务。

BusinessWorker.php里:

$worker = new BusinessWorker(); $worker->count = 8; // Worker 进程数 $worker->name = 'ChatBusinessWorker';

Worker 进程数建议设为 CPU 核心数的 2 到 4 倍。比如 4 核机器,设 8 到 16 个 Worker。Worker 进程负责处理业务逻辑,如果消息处理里有数据库操作,Worker 数可以适当多一些,避免阻塞。

还有一个容易忽略的参数是$gateway->pingInterval和$gateway->pingData。这是心跳检测配置,默认 30 秒发一次心跳。如果客户端网络环境复杂,可以调到 20 秒,但不要低于 10 秒,否则会产生大量无效心跳包,浪费带宽。

3.3 启动脚本与守护进程配置

Workerman 的启动方式有两种:调试模式和守护进程模式。调试模式用php start.php start,日志直接输出到终端,适合开发阶段。生产环境用php start.php start -d,进程会转入后台,日志写到文件里。

但-d模式有个问题:服务器重启后进程不会自动拉起。所以需要配一个守护进程。我一般用 systemd 来管理,比 supervisor 更轻量。创建一个/etc/systemd/system/qchat.service:

[Unit] Description=qchat GatewayWorker After=network.target [Service] Type=forking User=www-data WorkingDirectory=/var/www/qchat-server ExecStart=/usr/bin/php start.php start -d ExecStop=/usr/bin/php start.php stop ExecReload=/usr/bin/php start.php reload Restart=always [Install] WantedBy=multi-user.target

然后执行:

systemctl daemon-reload systemctl enable qchat systemctl start qchat

这样服务器重启后 qchat 会自动启动。Restart=always保证进程意外退出时 systemd 会把它拉起来。

注意:ExecStop和ExecReload里的命令路径要写绝对路径,systemd 不认相对路径。另外User要设成有权限读写日志目录的用户,否则启动会失败。

3.4 数据库表结构与消息持久化配置

qchat 的消息默认是存 MySQL 的。需要提前建好库和表。核心表一般有两张:用户表和消息表。

CREATE TABLE `chat_user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL, `avatar` varchar(255) DEFAULT '', `status` tinyint(1) DEFAULT 0 COMMENT '0离线 1在线', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `chat_message` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `from_id` int(11) NOT NULL, `to_id` int(11) NOT NULL, `content` text, `type` tinyint(1) DEFAULT 1 COMMENT '1文本 2图片', `is_read` tinyint(1) DEFAULT 0, `created_at` int(11) NOT NULL, PRIMARY KEY (`id`), KEY `idx_to_id` (`to_id`), KEY `idx_created_at` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

chat_message表的数据量会增长很快,建议按时间分表或者定期归档。我一般会加一个定时任务,把三个月前的消息移到历史表里,主表只保留近期数据,查询速度会快很多。

数据库连接配置在Applications/Chat/Events.php或者单独的配置文件里。Workerman 是常驻进程,数据库连接不能像 PHP-FPM 那样每次请求新建。正确的做法是在onWorkerStart回调里初始化连接,然后用单例模式复用。如果连接断了,要有重连机制,否则 Worker 进程会一直报错。

4. 前端构建与 Nginx 反向代理配置

4.1 Vue 项目构建与 WebSocket 地址配置

前端部分进入qchat-web目录,先装依赖:

npm install

如果 npm 速度慢,可以换淘宝源:

npm config set registry https://registry.npmmirror.com

装完依赖后,需要改 WebSocket 的连接地址。这个地址通常在src/services/socket.js或者环境变量文件里。开发环境连ws://127.0.0.1:8282,生产环境连wss://你的域名/socket。注意生产环境一定要用wss,也就是 WebSocket over TLS,否则浏览器会拦截混合内容。

改完地址后构建:

npm run build

构建产物在dist目录里,是一堆静态文件。把这些文件放到 Nginx 的站点目录下就行。

4.2 Nginx 反向代理 WebSocket 的关键配置

Nginx 代理 WebSocket 和普通 HTTP 请求不一样,需要额外设置Upgrade和Connection头。下面是我常用的配置模板:

server { listen 443 ssl http2; server_name chat.example.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; root /var/www/qchat-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /socket { 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_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }

几个关键点解释一下。proxy_http_version 1.1是必须的,WebSocket 握手需要 HTTP/1.1。Upgrade和Connection头是告诉 Nginx 把这个连接升级成 WebSocket。proxy_read_timeout默认是 60 秒,如果不改,连接空闲 60 秒后 Nginx 会主动断开,导致客户端频繁重连。设成 3600 秒比较合适。

注意:location /socket这个路径要和前端代码里 WebSocket 连接的路径一致。如果前端连的是wss://域名/ws,那这里就要改成location /ws。

4.3 跨域问题与 HTTPS 混合内容处理

前端和后端如果不在同一个域名下,会遇到跨域问题。WebSocket 本身不受同源策略限制,但握手阶段的 HTTP 请求会带 Origin 头,如果后端没做处理,可能会拒绝连接。GatewayWorker 里可以在onConnect回调里检查 Origin,或者直接在 Nginx 层加 CORS 头。

更常见的问题是 HTTPS 页面里连ws://会被浏览器拦截。解决办法就是前面说的,用 Nginx 做 TLS 终止,前端连wss://,Nginx 再转发到本机的ws://127.0.0.1:8282。这样浏览器看到的是加密连接,实际内部通信还是明文,性能损耗很小。

如果证书是自签的,浏览器会报证书错误,WebSocket 连接也会失败。生产环境一定要用受信任的证书,Let's Encrypt 的免费证书就够用。

5. 常见问题排查与性能调优实录

5.1 连接建立失败的五种典型场景

部署完 qchat 后,最常见的问题就是“连不上”。我整理了一个排查表,按出现频率排序:

现象可能原因排查方法解决方案
浏览器控制台报WebSocket connection failedNginx 没配 Upgrade 头检查 Nginx 配置加上proxy_set_header Upgrade
连接后立刻断开心跳超时或 Worker 报错看 Workerman 日志调大 pingInterval,修 Worker 错误
部分用户连不上Gateway 进程数不够netstat看连接数增加 Gateway count
连接成功但收不到消息Register 服务没启动ps aux看进程启动 Register 或检查端口
HTTPS 页面连不上混合内容拦截浏览器控制台改用 wss

其中“连接后立刻断开”最容易被误判。很多人以为是网络问题,实际上是 Worker 进程里抛了异常,导致连接被关闭。Workerman 的日志在runtime/logs目录下,workerman.log里会记录详细的错误堆栈。

5.2 消息丢失与重复的排查思路

消息丢失通常有三个原因:一是客户端没收到 ACK 就断了,二是 Worker 进程崩溃导致内存里的消息丢了,三是数据库写入失败但消息已经发出去了。

qchat 默认的消息流程是:客户端发消息 -> Gateway 转发给 Worker -> Worker 存库 -> Worker 推送给目标客户端。如果存库失败,Worker 应该返回错误给发送方,而不是继续推送。我见过有人把存库和推送写成异步的,结果数据库挂了消息还在推,重启后消息就丢了。

重复消息一般是客户端重连导致的。WebSocket 断开后客户端会自动重连,如果重连时把未确认的消息又发了一遍,接收方就会收到两条。解决办法是给每条消息加一个唯一 ID,接收方根据 ID 去重。这个逻辑可以在前端做,也可以在后端做。

5.3 高并发下的性能瓶颈与优化手段

当在线人数上千后,性能问题会逐渐暴露。我实测下来,瓶颈通常出现在三个地方:

第一是 Gateway 进程的 CPU。每个 Gateway 进程要处理所有连接的读写事件,如果消息频率很高,CPU 会跑满。优化手段是增加 Gateway 进程数,但要注意进程间通信也有开销,不是越多越好。一般 4 到 8 个进程能支撑几千人在线。

第二是 Worker 进程的数据库操作。如果每条消息都同步写库,Worker 会被数据库拖慢。优化手段是批量写入——把消息先攒在内存里,每 100 毫秒或每 100 条批量插入一次。这样能把数据库压力降低一个数量级。

第三是 Nginx 的代理开销。Nginx 代理 WebSocket 时,每个连接会占用一个文件描述符和一部分内存。如果并发连接数上万,需要调大worker_connections和worker_rlimit_nofile。另外可以开启proxy_buffering off,减少内存拷贝。

还有一个容易被忽略的点是 PHP 的memory_limit。Workerman 是常驻进程,内存不会自动释放。如果代码里有内存泄漏,跑几天后进程就会 OOM。建议在onWorkerStart里设置ini_set('memory_limit', '512M'),并定期重启 Worker 进程(比如每天凌晨低峰期 reload 一次)。

5.4 日志分析与日常运维要点

Workerman 的日志分几种:workerman.log记录启动、停止、错误;stdout.log记录var_dump和echo的输出;业务日志需要自己在代码里写。我一般会在Events.php的关键节点加日志,比如收到消息、存库成功、推送成功,方便出问题时追踪。

日志文件会越来越大,需要配 logrotate 定期切割。在/etc/logrotate.d/qchat里加:

/var/www/qchat-server/runtime/logs/*.log { daily rotate 7 compress missingok notifempty copytruncate }

copytruncate很重要,因为 Workerman 进程一直持有日志文件句柄,直接删除文件会导致日志丢失。copytruncate是先复制再清空,进程不需要重启。

日常运维还要监控几个指标:在线连接数、消息吞吐量、Worker 进程的内存占用、数据库连接数。这些可以用netstat、top、mysqladmin status来查,也可以接入监控系统做告警。我个人的经验是,只要内存和连接数在正常范围内波动,一般不会出大问题。一旦发现某个 Worker 进程内存持续上涨,就要怀疑是不是有内存泄漏,及时 reload 能避免服务崩溃。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进