简介:这是一套基于Workerman的在线客服系统源码及配套安装教程,面向需要快速搭建网页端客服功能的PHP开发者与运维人员,尤其适合熟悉Nginx、PHP、MySQL基础环境、希望自建即时通讯客服模块的中级技术人员。资源包共约2000个文件,压缩后25.95MB,以1184个js脚本、196个html页面、163个json配置、106个css样式及22个php后端文件为主,另含sql建表脚本、sh部署脚本与md说明文档,覆盖前端界面、后端逻辑与数据库结构。安装教程明确了Nginx 1.21.4、PHP 7.2、MySQL 5.7.40的运行环境,并给出/application/database.php中数据库名、用户名、密码的配置位置,便于按步骤完成部署。目前已有415人学习下载,读者可借此获得一套可直接运行的客服系统源码,理解Workerman常驻进程与Web端通信的整合方式,并参考目录结构进行二次开发或功能裁剪。
1. 从轮询到长连接:Workerman 在线客服系统到底解决了什么
很多做 PHP 在线客服的团队都经历过同一个尴尬:前端用setInterval每 3 秒拉一次消息接口,十个客服同时在线,MySQL 的SELECT就开始喘;访客一多,消息延迟肉眼可见,客服抱怨“客户发了我半天才看到”。这套 Workerman 在线客服系统,本质上是把“客户端反复问服务器有没有新消息”换成“服务器主动把消息推给客户端”,用常驻内存的 WebSocket 进程替掉短连接轮询。它适合已经会 PHP、能配 Nginx 和 MySQL,但没正经写过常驻进程的开发者——你不需要先啃完 Swoole 全家桶,只要理解 Workerman 的进程模型,就能把一套能跑、能改、能上线的客服系统搭起来。课程资源里给的是完整源码包,不是片段,下面按“先跑通、再改透、最后避坑”的顺序拆。
2. 环境搭建与 Workerman 进程模型:为什么不是普通 PHP-FPM
2.1 先看清 Workerman 和 PHP-FPM 的根本差别
普通 PHP 项目跑在 PHP-FPM 下,每个请求进来,PHP 重新加载一遍框架、重新连一次 MySQL,请求结束进程就回收。这套模式做网页没问题,做长连接就是灾难——客服要保持连接不断开,PHP-FPM 根本撑不住。Workerman 的做法是启动一个常驻内存的 PHP 进程,自己监听端口、自己管理连接,进程不退出,代码只加载一次。
这带来两个直接后果。第一,全局变量和静态变量在多个请求之间是共享的,你不能再像写 FPM 那样随手static $cache = []当请求级缓存用,它会跨连接累积。第二,任何未捕获的异常都可能让整个进程挂掉,所有在线客服瞬间掉线。所以 Workerman 项目里,try/catch不是可选项,是保命符。
常见做法是用 Workerman 起两个监听:一个 WebSocket 端口给浏览器和客服端,一个内部 Text 端口给后端业务系统推事件。课程资源里的start.php就是这个结构,下面拆开看。
2.2 启动文件与四个核心回调
Workerman 的入口文件通常长这样,我按客服系统的实际需要补了注释:
<?php use Workerman\Worker; require_once __DIR__ . '/vendor/autoload.php'; // 创建 WebSocket 服务,监听 0.0.0.0:8282 $ws = new Worker('websocket://0.0.0.0:8282'); // 进程数。客服系统连接数不大,4 个进程足够; // 注意:进程数 > 1 时,连接会分散到不同进程,跨进程通信必须靠 Gateway 或外部存储 $ws->count = 4; // 进程启动时触发,每个进程各触发一次 $ws->onWorkerStart = function ($worker) { // 这里一般用来初始化数据库连接池或 Redis 连接 // 千万不要在这里做只应该执行一次的全局初始化(比如建表) }; // 有客户端连接进来 $ws->onConnect = function ($connection) { // 可以在这里做 token 校验,但注意此时还没收到业务数据 }; // 收到客户端消息 $ws->onMessage = function ($connection, $data) { // $data 是原始字符串,客服系统里通常是 JSON $msg = json_decode($data, true); if (!$msg || !isset($msg['type'])) { $connection->send(json_encode(['code' => 400, 'msg' => '格式错误'])); return; } // 按 type 分发:登录、发消息、心跳、已读回执 }; // 连接断开 $ws->onClose = function ($connection) { // 必须在这里清理该连接对应的客服在线状态,否则会残留“幽灵客服” }; Worker::runAll();逻辑说明:onWorkerStart每个进程执行一次,所以初始化数据库连接池放这里合适,但建表、写种子数据这种全局只该做一次的事不能放。onMessage是业务主入口,客服系统所有动作都从这里分发。onClose最容易被忽略,但它是清理在线状态唯一可靠的时机。
参数说明:count设多少要看并发连接数,一般单进程撑几千连接没问题,客服系统几百个连接用 2 到 4 个进程足够。端口8282要和后面 Nginx 反代配置一致。websocket://协议决定了浏览器端必须用ws://或wss://连接。
2.3 Nginx 反向代理 WebSocket 的关键三行
Workerman 直接对外暴露端口不安全,也不方便和现有域名共用 80/443。标准做法是 Nginx 反代。这里有个血泪经验:WebSocket 升级握手必须显式转发Upgrade和Connection头,少一行就连不上,浏览器控制台只会报一个含糊的 400。
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_http_version 1.1是 WebSocket 升级的前提,HTTP/1.0 不支持。Upgrade和Connection两行负责把浏览器的升级请求透传给 Workerman。proxy_read_timeout默认 60 秒,长连接会被 Nginx 主动掐断,必须调大,否则客服每隔一分钟掉一次线,还找不到原因。
参数说明:proxy_read_timeout 3600s配合前端心跳间隔(一般 30 到 55 秒)使用,心跳间隔必须小于这个超时,否则连接照样断。
2.4 数据库与消息表的最小结构
客服系统的消息最终要落库,否则客服刷新页面记录就没了。课程资源里用的是 MySQL,最小可用结构如下:
CREATE TABLE `chat_message` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `session_id` VARCHAR(64) NOT NULL COMMENT '会话标识', `from_type` TINYINT NOT NULL COMMENT '1访客 2客服', `from_id` INT NOT NULL, `content` TEXT NOT NULL, `is_read` TINYINT DEFAULT 0, `created_at` INT NOT NULL, KEY `idx_session` (`session_id`, `id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:session_id把一次访客和客服的对话串起来,索引建在(session_id, id)上,因为拉历史记录永远是“按会话查、按时间排序”。created_at用 INT 存时间戳而不是 DATETIME,写入更快,前端转换也方便。
参数说明:content用 TEXT 而不是 VARCHAR,客服可能发长文本。is_read用于已读回执,别小看这个字段,没有它客服不知道客户看没看到回复,体验差一截。
3. 消息收发与在线状态:把“谁在线、发给谁”讲透
3.1 连接与用户身份的绑定
WebSocket 连接建立时,服务器只知道有个连接进来了,不知道这是哪个客服、哪个访客。所以第一件事是让客户端在连接成功后立刻发一条登录消息,把connection和业务身份绑定。常见做法是用一个内存映射表存起来:
// 全局连接表:uid => connection // 注意:count > 1 时这张表只在单个进程内有效,跨进程要用 Gateway 或 Redis global $uidConnMap; $uidConnMap = []; $ws->onMessage = function ($connection, $data) use ($ws) { global $uidConnMap; $msg = json_decode($data, true); switch ($msg['type']) { case 'login': // 绑定身份,uid 由前端从登录态带过来 $uid = (int)$msg['uid']; $connection->uid = $uid; $uidConnMap[$uid] = $connection; // 回执告诉前端绑定成功 $connection->send(json_encode(['type' => 'login_ok'])); break; case 'chat': // 找到目标用户的连接,推送 $toUid = (int)$msg['to_uid']; if (isset($uidConnMap[$toUid])) { $uidConnMap[$toUid]->send(json_encode([ 'type' => 'chat', 'from' => $connection->uid, 'content' => $msg['content'], ])); } // 无论对方在不在线,都先落库 saveMessage($connection->uid, $toUid, $msg['content']); break; } };逻辑说明:$connection->uid = $uid把身份挂在连接对象上,onClose时可以直接读出来清理映射表。消息先推送再落库还是先落库再推送,取决于业务对可靠性的要求——客服系统一般先落库,避免推送成功但入库失败导致记录丢失。
参数说明:uid必须由服务端从可信登录态推导,不能直接信前端传的值,否则任何人都能冒充客服。课程资源里前端传 uid 只是演示,真实项目要在login时校验 token。
3.2 单进程映射表的局限与 Gateway 方案
上面那张$uidConnMap有个致命问题:count = 4时,四个进程各有一张表,客服 A 连在进程 1,访客 B 连在进程 3,进程 1 的表里根本没有 B 的连接,消息发不过去。这就是新手最容易翻车的地方——本地测试count = 1一切正常,上线改成 4 就时好时坏。
解决办法有两个。简单的是把count设成 1,单进程扛几百连接没问题,但没法利用多核,也没法平滑重启。正规做法是引入 GatewayWorker,它把连接管理和业务逻辑拆成两组进程,连接层负责维护所有连接,业务层通过内部通信向连接层发指令,跨进程推送由框架处理。
课程资源如果给的是纯 Workerman 版本,通常会附带一个count = 1的说明;如果给的是 GatewayWorker 版本,目录里会有Gateway.php和BusinessWorker.php两个启动文件。判断方法很简单:看有没有GatewayWorker\Gateway这个类。
3.3 心跳机制:为什么连接会“莫名其妙”断掉
长连接不是永久的。中间任何一层——浏览器、Nginx、防火墙、运营商——都可能因为长时间无数据而回收连接。表现就是客服挂着页面去泡了杯咖啡,回来发现收不到消息了,刷新才好。这不是 bug,是长连接的常态,必须靠心跳维持。
// 前端心跳,每 30 秒发一次 let heartTimer = null; function startHeartbeat(ws) { heartTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', t: Date.now() })); } }, 30000); } // 收到 pong 说明链路正常,可以顺带做延迟统计逻辑说明:心跳间隔要小于所有中间层的空闲超时。Nginx 的proxy_read_timeout设了 3600 秒,但运营商网关可能 60 秒就回收,所以 30 秒是保险值。服务端收到ping回一个pong即可,不需要落库。
参数说明:30 秒是经验值,移动网络下可以缩到 20 秒。心跳太频繁会增加无效流量,太稀疏则断线发现不及时。前端还要监听onclose,触发自动重连,重连成功后重新走login绑定身份。
3.4 已读回执与消息顺序
客服系统里“已读”状态直接影响服务质量。实现方式是在消息表加is_read,客服端打开会话时,把该会话访客发来的消息批量置为已读,并通过 WebSocket 通知访客“客服已读”。这里有个坑:批量更新要用WHERE session_id = ? AND from_type = 1 AND is_read = 0,别全表更新。
消息顺序则依赖id自增,前端按id排序渲染。不要用created_at排序,同一秒内多条消息时间戳相同,顺序会乱。这是很多客服系统消息“跳来跳去”的根因。
4. 避坑与排查:上线后最容易炸的五个点
4.1 现象:本地正常,上线后消息时有时无
原因:count大于 1,连接分散在多个进程,单进程内的$uidConnMap找不到目标连接。 解决:要么把count改成 1,要么换 GatewayWorker 方案,要么用 Redis 存uid => 进程ID的映射,通过内部端口转发。别指望用文件或数据库存连接对象,连接对象没法序列化。
4.2 现象:客服每隔一分钟掉线一次
原因:Nginxproxy_read_timeout默认 60 秒,长连接空闲被掐断。 解决:在location里显式设置proxy_read_timeout 3600s,同时前端心跳间隔设为 30 秒,双保险。改完记得nginx -s reload,别只改配置不重载。
4.3 现象:进程跑一段时间就挂,日志报内存超限
原因:常驻进程里全局数组只增不减,比如$uidConnMap在onClose时没清理,断开的连接对象一直被引用,内存只涨不降。 解决:onClose里必须unset($uidConnMap[$connection->uid])。另外任何缓存都要设上限或过期,Workerman 进程不会自动回收全局变量。
4.4 现象:中文消息乱码或截断
原因:WebSocket 传输的是 UTF-8 字节流,但数据库连接没设utf8mb4,或者content字段长度不够。 解决:PDO 连接串加charset=utf8mb4,表字段用 TEXT 或更大。注意 emoji 是 4 字节,utf8存不下,必须utf8mb4。
4.5 现象:客服收到消息但访客没收到回复
原因:推送逻辑只处理了“对方在线”的情况,对方离线时消息没落库,或者落库了但重连后没拉历史。 解决:消息一律先落库,推送只是“锦上添花”。访客重连后主动拉一次该会话的历史消息,保证不丢。别把推送成功当成消息送达的唯一凭据。
5. 进阶:把客服系统做成能平滑重启、能查延迟的样子
跑通之后,真正决定这套系统能不能长期用的是两件事:平滑重启和可观测性。
平滑重启靠 Workerman 的reload能力。改完业务代码,执行php start.php reload,框架会启动新进程、等旧进程处理完当前请求再退出,客服连接不会断。但有个前提:你的代码里不能有阻塞很久的操作,比如同步调用一个慢接口。我一般会把耗时操作丢给独立的任务进程,WebSocket 进程只做收发和落库,保持轻量。
可观测性方面,至少加三个统计:当前在线连接数、消息平均延迟、每分钟消息量。在线连接数可以直接在onConnect和onClose里增减一个计数器,定时打到日志。消息延迟则在前端发消息时带一个客户端时间戳,服务端回执时带回,前端算差值。下面这个片段是我习惯加的延迟统计:
// 服务端回执时带上服务端时间,前端用 (now - clientTime) 估算往返延迟 $connection->send(json_encode([ 'type' => 'ack', 'client_t' => $msg['client_t'], // 前端发消息时的时间戳 'server_t' => microtime(true) * 1000, ]));逻辑说明:前端拿到ack后用当前时间减去client_t得到往返延迟,超过 500 毫秒就该查网络或数据库了。这个数字比任何“感觉卡”都靠谱。
另外,客服系统的会话分配也值得做一层。简单轮询会把访客随机丢给客服,忙闲不均。常见做法是维护一个“客服当前会话数”的计数,新访客进来分配给会话数最少的在线客服。这个计数存在 Redis 里,每次分配和会话结束各更新一次。别用数据库做这个,高频读写扛不住。
最后说个我自己的习惯:每次改完onMessage里的分发逻辑,我都会先用wscat或浏览器控制台手动发几条畸形消息——缺字段的、类型不对的、超长的——确认服务端不崩、能返回错误码,再交给前端联调。常驻进程最怕的就是一条脏消息把整个进程带走,而这类问题在正常测试里根本暴露不出来。从那以后我每次上线前都强制走一遍畸形消息测试,希望帮到你。
本文还有配套的精品资源,点击获取