简介:面向需要部署在线客服系统的 PHP 开发者与运维人员,这是 Workerman 在线客服系统安装部署资源包,围绕 Nginx 1.21.4 + PHP 7.2 + MySQL 5.7.40 环境展开,以课程资源加软件插件形式整理,重点解决源码上传、解压、数据库连接配置等实际部署问题,也适合希望了解 Workerman 与 FastAdmin 后台结构的学员。整个压缩包共 2000 个文件,大小约 25.95 MB,其中 1184 个 JS、106 个 CSS、196 个 HTML 构成前台与后台界面,22 个 PHP、4 个 SQL 支撑业务逻辑和数据库结构,大量 JSON、MD、TXT 用来存放配置项与说明文档,另有 Vue、YAML 等文件,类型覆盖较全。FastAdmin 相关 CSS 文件也包含在内,便于二次开发时定位后台页面样式。压缩包内附详细安装教程,包括环境版本、上传解压、修改 application/database.php 中的数据库名、用户名和密码等关键提示,能帮助按步骤完成部署并排查常见问题。目前已有 408 人学习/下载,适合 PHP 项目部署和在线客服系统二次开发的入门参考。 手头正在做客服系统的朋友,大概率都遇到过这种情况:轮询接口撑不住并发,长连接又不知道从哪下手,后端写起来总觉得别扭。我之前接了个在线客服需求,后端是ThinkPHP 5,要求支持访客和客服实时聊天、未读消息推送、会话记录入库,当时就选了Workerman来做长连接层。这套方案跑下来,整体效果很稳,今天把完整实现过程拆开讲一遍,包括架构选型、核心代码、踩坑记录和优化思路,给打算入坑的朋友一份可以直接参考的实操笔记。
Workerman本身是一个PHP常驻内存的事件驱动框架,基于非阻塞IO实现,特点是能扛住高并发长连接,同时写起来又不像Swoole那样需要扩展编译,纯PHP就能跑。配合ThinkPHP 5这种传统Web框架,正好互补:TP5负责业务逻辑、后台管理、接口输出,Workerman负责长连接、实时推送、在线状态维护。这个“Web框架处理业务,Workerman处理长连接”的组合,是我认为最稳妥、也最容易上手的架构方式。
1. 项目定位与架构思路
1.1 为什么选Workerman而不是其他方案
做在线客服,最核心的技术问题就是实时消息通道。最初我也考虑过用前端轮询,但客服场景里消息频率高、并发集中,轮询的空请求会白白吃掉大量服务器资源,而且消息延迟不可控,访客体验很差。用Swoole也能做,但我当时的环境要装扩展、改PHP配置,而且团队对Swoole的协程模型不熟,出了问题排查成本高。Workerman的优势很明确:纯PHP实现,不需要额外编译扩展,Windows和Linux都能跑(生产环境建议Linux),文档细致、社区活跃,网上能搜到的问题基本都有解决方案。
还有一点很关键,Workerman自带的GatewayWorker框架,把“客户端连接管理”这件事做了高度封装。在线客服系统本质上就是一个多客户端连接、分组通信的场景——访客要发给客服,客服要回复访客,可能还要支持客服之间互相转接。GatewayWorker天然支持客户端分组、跨进程通讯、心跳检测、断线重连,几乎就是为这类IM场景设计的。我不用自己处理底层的连接池和消息路由,只需要关注业务消息怎么解析、怎么分发、怎么落库,开发效率提升非常明显。
1.2 整体架构:GatewayWorker + ThinkPHP 5结合
这套系统的架构,我分成了三层来看。
第一层是客户端,包括浏览器端的访客聊天窗口(Web页面里通过WebSocket连接)和客服端工作台(也是浏览器页面,同样走WebSocket)。两个端本质上是同一种连接类型,只是登录身份不同、数据权限不同。
第二层是GatewayWorker服务,也就是长连接网关层。它负责维护所有客户端的WebSocket连接,接收客户端发来的消息,解码后转发给Event处理逻辑,再把结果推送给目标客户端。
第三层是业务层和存储层。Workerman进程运行期间,通过PHP代码直接操作MySQL、Redis。登录校验、历史消息查询这类请求,由TP5的控制器处理,通过HTTP接口完成;Workerman则通过Redis订阅等方式和TP5通信。
实际开发中,我并没有强行让Workerman去加载TP5的整个框架,因为常驻内存进程和Web请求的生命周期完全不同,直接加载框架容易出现单例模式数据错乱、数据库连接时间长了断开等问题。我的做法是:Workerman独立运行,通过think\Db查询构造器或者原生PDO操作数据库,同时封装一个独立的工具类来处理加解密、数据格式化等逻辑。这样既复用了TP5的数据库配置和连接池能力,又不会因为框架过度耦合导致长连接进程不稳定。
2. 环境准备与基础服务搭建
2.1 安装Workerman与GatewayWorker
我的环境是CentOS 7 + PHP 7.2 + Nginx + MySQL 5.7,这个组合在中小项目里非常常见。安装Workerman有两种方式,我推荐用Composer,方便后续维护依赖版本。
composer require workerman/workerman composer require workerman/gatewayworker如果你还没安装Composer,先装Composer再执行上面两条命令。安装完成后,vendor/目录下会出现workerman和gatewayworker两个目录。我习惯在项目根目录下建一个workerman目录,专门放客服系统的服务端代码,和TP5的application目录分开,结构更清晰。
如果你是手动下载安装包的方式,那需要特别注意PHP版本。Workerman 4.x要求PHP 7.0以上,我最初用Workerman 3.x跑PHP 5.6也正常,但后来为了用PHP 7.2的标量类型声明和语法糖,统一升级到了4.x。还有一点,生产环境一定要确保PHP安装了posix和pcntl扩展,Workerman的多进程管理依赖这两个扩展,没装的话服务根本起不来。
2.2 启动GatewayWorker服务
GatewayWorker自带了一套完整的启动脚本,放在Applications/目录下。启动前需要修改Applications/your_app/start_gateway.php和start_business.php这两个配置文件。
// start_gateway.php // 设置Gateway监听的协议、IP和端口 $gateway = new Gateway("websocket://0.0.0.0:8282"); // 设置进程数,建议和CPU核数相同 $gateway->count = 4; // 设置LAN IP,也就是当前服务器的内网IP $gateway->lanIp = '127.0.0.1'; // 内部通讯起始端口 $gateaway->startPort = 4000; // 服务名称,方便日志区分 $gateway->name = 'KefuGateway';// start_business.php // BusinessWorker进程,专门处理业务逻辑 $worker = new BusinessWorker(); // worker名称 $worker->name = 'KefuBusinessWorker'; // 业务处理类,后面要实现的 $worker->eventHandler = 'KefuEvent'; // 进程数,根据业务压力调整 $worker->count = 2;启动命令很简单:
php start.php start看到进程起来后,用php start.php status可以查看各个进程的运行状态。这里有个关键点要提醒:websocket://0.0.0.0:8282是给浏览器端WebSocket连接的端口,而lanIp和startPort是GatewayWorker内部进程通信用的,这两个不能冲突。如果服务器上有防火墙,记得放行8282端口和4000起始的一段内部端口。
3. 核心功能实现
3.1 服务端消息处理逻辑
所有业务消息都集中在事件处理类中,也就是配置文件里指定的KefuEvent类。当有客户端连接、断开、发消息时,GatewayWorker会自动触发对应的方法。我实现了三个最核心的方法:onConnect(连接建立)、onMessage(收到消息)、onClose(连接断开)。
class KefuEvent { public static function onConnect($client_id) { // 连接建立,可以先不处理,等客户端发送登录认证消息 } public static function onMessage($client_id, $message) { $data = json_decode($message, true); switch ($data['type']) { case 'login': // 登录验证,绑定client_id和用户信息 break; case 'chat': // 聊天消息处理 break; case 'ping': // 心跳响应 break; } } public static function onClose($client_id) { // 清理连接绑定的用户信息,发送下线通知 } }这里重点说下login的实现。客户端建立WebSocket连接后,第一件事不是发聊天消息,而是发一条login消息,带上访客或客服的标识。我在Redis里维护一个映射关系:user_id -> client_id,同时用GatewayWorker的Gateway::bindUid方法把client_id和用户ID绑定。绑定后,我就能用Gateway::sendToUid($uid, $message)给指定用户推送消息,这是最核心的API。
chat消息的处理要做两件事:一是通过Gateway::sendToUid推送给目标用户,二是把消息内容写入MySQL,方便后续历史记录查询。这里有一条经验:不要在onMessage里直接做耗时的业务操作,比如发HTTP请求调用第三方接口,否则会阻塞当前事件循环,影响同进程其他用户的消息。如果确实需要,用异步任务或者把消息丢到Redis队列里,由另外的脚本去消费。
3.2 客服端与访客端JS对接
前端需要建立WebSocket连接并处理收发消息。我用原生JS写了一个简单的封装,方便在访客聊天窗口和客服工作台复用。
function KefuSocket(options) { this.ws = null; this.userId = options.userId; this.userType = options.userType; // visitor 或 agent this.url = options.url; } KefuSocket.prototype.connect = function() { var that = this; this.ws = new WebSocket(this.url); this.ws.onopen = function() { // 发送登录认证 that.ws.send(JSON.stringify({ type: 'login', userId: that.userId, userType: that.userType })); }; this.ws.onmessage = function(event) { var data = JSON.parse(event.data); that.handleMessage(data); }; this.ws.onclose = function() { // 断线自动重连 setTimeout(function() { that.connect(); }, 3000); }; };这个封装里,我保留了三个钩子函数:onMessage、onOpen、onClose,在实际业务页面里覆盖它们就行。比如说访客端页面里,收到客服回复后就渲染到聊天窗口里;客服工作台里,收到新访客发来的消息后就弹出提醒并刷新会话列表。
这里有个细节:WebSocket的URL是ws://域名:8282,如果你用了HTTPS,浏览器会强制要求使用wss://协议,否则会报安全错误。我当时在Nginx里做了SSL终结,把/ws路径代理到后端的8282端口,前端连接wss://域名/ws,这样既解决了加密问题,也避免了端口直接暴露。
3.3 后台历史消息与未读计数
在线客服除了实时聊天,还有一个非常重要的功能:历史消息记录。访客刷新页面后,要能拉取之前的聊天记录;客服也要能看到用户的历史咨询记录。这部分功能,我放在TP5的控制器里实现,走HTTP接口,不走WebSocket。
// application/api/controller/Chat.php public function history() { $userId = input('get.user_id'); $page = input('get.page', 1); $list = Db::name('chat_message') ->where('user_id', $userId) ->order('id desc') ->page($page, 20) ->select(); return json(['code' => 0, 'data' => $list]); }未读消息数量,我用Redis的INCR计数来实现。消息推送给离线用户时(通过Gateway::isUidOnline判断),给该用户的未读计数加一。用户打开聊天窗口时调一个HTTP接口,把未读清零。这个方案实现简单,成本低,在中小规模下完全够用。
4. 数据落地与业务集成
4.1 消息记录入库的设计方案
消息入库这块,我踩过一次坑。最开始我在每收到一条消息时立即写库,高峰期数据库连接频繁创建,压力很大。后来我优化成“批量入库 + 定时刷新”的策略。
具体做法是:在Workerman里增加一个BusinessWorker进程,专门从Redis的message_queue队列里取消息,攒够50条或者每3秒批量插入一次MySQL。这样数据库写入次数从每秒几十次降到了每秒几次,效果非常明显。
// 批量插入示例 $messages = $redis->lRange('message_queue', 0, 49); $redis->lTrim('message_queue', 50, -1); $data = []; foreach ($messages as $msg) { $data[] = json_decode($msg, true); } Db::name('chat_message')->insertAll($data);消息表结构我设计得比较简单,核心字段包括:消息ID、会话ID、发送者ID、接收者ID、消息类型(文本、图片、系统消息)、消息内容、发送时间。会话ID用来关联同一个访客和同一个客服之间的多次沟通记录。后端查询时按会话ID筛选即可。
4.2 与ThinkPHP 5业务系统的整合
GatewayWorker运行在常驻内存里,它要操作数据库,就需要读取TP5的数据库配置。我的做法是写一个单独的工具类KefuDb,在启动Worker时初始化一次数据库连接配置,后续所有查询都复用这个连接。
// KefuDb.php class KefuDb { public static function init() { $config = require_once __DIR__ . '/../config/think5_database.php'; // 这里用TP5的Db类初始化,或者用原生PDO连接 Db::setConfig($config); } }还有一个场景要注意:访客在网页上先提交表单,然后接入客服,这中间涉及TP5和Workerman的数据同步。我是用TP5的控制器写入一条“接入记录”到MySQL,同时通过Redispublish一条消息,Workerman里订阅了这个频道,收到消息后就向对应客服推送“有新访客接入”的提醒。这个通过Redis发布订阅实现跨进程通信的方案,比直接让TP5调用Workerman的接口要简单稳定得多。
5. 常见问题与排查技巧实录
5.1 端口占用与进程冲突
这是最常遇到的问题。Workerman启动时报错address already in use,通常是8282端口被之前的进程占用了。排查方法:
netstat -lnp | grep 8282找到占用进程的PID,用kill -9 PID强制结束,再重新启动。另一个隐蔽问题是开发阶段在Windows上调试,Workerman 3.x在Windows下只能单进程运行,start.php start后要注意看窗口里的提示,如果显示“只能运行一个进程”,是正常现象,部署到Linux就好。
5.2 WebSocket连接不稳定,频繁掉线
如果连接刚建立就断开,或者隔一段时间就掉线,大概率是网络层的问题。首先要确认客户端和服务端是否正常完成了WebSocket握手。可以在Nginx日志里查看/ws接口返回的状态码,如果是200说明握手成功。
其次是心跳机制。GatewayWorker默认有心跳检测时间,如果客户端长时间没有数据往来,服务端会主动断开连接。我的做法是前端每隔30秒发送一个ping消息,服务端收到后返回pong,这样就能保持连接不被误杀。
setInterval(function() { this.ws.send(JSON.stringify({type: 'ping'})); }, 30000);服务端onMessage里收到type为ping时,直接返回一个空消息即可,不要走业务逻辑。这里需要注意,心跳消息也要包含在onMessage里做判断,否则意外发到业务处理里会报错。
5.3 Nginx反向代理WebSocket的配置
如果WebSocket前端直连8282端口,通常没问题。但为了安全、复用80/443端口,很多人会选择用Nginx做反向代理。Nginx需要额外配置才能正确转发WebSocket的升级请求,核心配置如下:
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 X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout和proxy_send_timeout这两项特别重要,默认60秒超时,如果客户端长时间不发送数据,Nginx会自动断开连接。我之前就是漏了这两个配置,导致每过60秒客服端就掉线一次,排查了整整一个下午。
5.4 BusinessWorker进程内存持续增长
Workerman常驻内存,如果业务代码里有变量没有释放,内存会缓慢增长,最终导致进程崩溃。我遇到过的问题是:在循环处理消息时,把日志数据累加到一个静态变量里,没有及时清空,运行几天后内存直接打满。
解决方法是:每处理完一条消息后,检查一遍代码里是否有$GLOBALS、静态数组、单例对象等在持续积累数据。排查内存泄漏,可以用php start.php status查看进程内存占用,连续观察几个小时,如果内存只升不降,基本可以断定有泄漏。
6. 性能调优与扩展思路
6.1 进程数配置与系统限制调整
GatewayWorker的进程数不是越多越好。Gateway进程数建议和CPU核数一致,BusinessWorker进程数可以稍稍多配一点,但也不要超过CPU核数的两倍。我这里有个经验值:4核8G的服务器,Gateway配4个进程,BusinessWorker配2个进程,实测能稳定支撑3000+并发连接,消息延迟在毫秒级。
系统层还有一个限制要调整:单进程能打开的文件描述符数量。默认是1024,当连接数超过这个值,新连接会被拒绝。需要修改/etc/security/limits.conf,把nofile调大:
* soft nofile 65535 * hard nofile 65535修改后需要重新登录服务器生效。用ulimit -n验证是否生效。
6.2 平滑重启与多客服路由分发
上线后要改代码,最怕的就是重启服务导致在线用户掉线。GatewayWorker支持平滑重启,执行php start.php reload,它会逐个重启BusinessWorker进程,不影响已建立的连接。但注意,修改启动配置(比如端口、进程数)时,必须用php start.php restart完整重启,reload不会生效。
业务层面,如果以后要做多客服同时在线、自动分配访客的功能,可以在login时把这个客服标记为“在线”,访客发起咨询时,通过Redis里的客服状态列表,自动选一个空闲客服绑定会话。这里不细说实现代码,但思路就是:把“访客与客服的会话绑定关系”维护在Redis里,消息路由时先查绑定关系,再转发。这个设计可以大幅提升系统的扩展能力。
另外,如果要做聊天记录的全文搜索、多维度统计报表,可以在消息入库时同步把数据推送到Elasticsearch或者专门的统计系统,Workerman这边只需要保证消息推送和入库的稳定性,业务分析放到另外的系统去做,互不干扰。
这套系统从开发到上线,我最大的感受是:Workerman把长连接这块硬骨头啃下来之后,剩下的工作其实就是在它上面搭积木。如果你之前只用TP5做过传统的Web请求,第一次接触Worker时可能会不习惯“常驻内存、事件回调”的写法,但只要理解了连接、事件、消息分发这几个核心概念,写起来并不难。尤其是遇到问题的时候,多看看GatewayWorker自带的示例代码,里面的聊天室demo其实就是一个简化的客服系统,值得好好研究。照着这个demo扩展,比自己从零摸索要快得多。
本文还有配套的精品资源,点击获取