☰
Workerman在线客服系统实战:从进程模型到消息路由与避坑指南
2026/10/12 15:27:22 网站建设 项目流程

简介:这是一套基于Workerman的在线客服系统源码,面向需要搭建实时客服功能的PHP开发者与运维人员,尤其适合已掌握Nginx、PHP、MySQL基础、希望快速部署一套可用客服系统的中初级技术人员。资源包共约2000个文件,以1184个js脚本、196个html页面、163个json配置、106个css样式及22个php后端文件为主,另含sql建库脚本、md说明文档与少量vue组件,压缩包整体约25.95MB,结构完整、前后端资源齐备。安装教程覆盖环境搭建(Nginx 1.21.4、PHP 7.2、MySQL 5.7.40)、源码上传解压及数据库连接配置,重点说明application/database.php中数据库名、用户名与密码的填写方式,读者可据此完成从环境准备到系统跑通的完整流程。目前已有415人学习下载,适合作为在线客服类项目的部署参考与二次开发起点。

1. 从一次客服消息丢失说起:Workerman 在线客服系统到底解决什么问题

很多团队做在线客服,第一反应是上 HTTP 轮询:前端每 3 秒问一次服务器「有没有新消息」。用户少的时候没问题,一旦同时在线破千,服务器就被大量空请求拖垮,消息延迟还忽高忽低。更尴尬的是「正在输入」状态、访客实时轨迹、坐席转接这类功能,轮询根本做不优雅。Workerman 在线客服系统要解决的,就是把这套「请求-响应」模型换成常驻内存的长连接模型:用 PHP 起一个常驻进程,客户端和坐席都通过 WebSocket 挂上来,消息由服务端主动推送。

它适合谁?适合已经用 PHP 做业务、又不想为了一个客服模块单独引入 Java 或 Go 技术栈的中小团队。你不需要额外学一门语言,用熟悉的 PHP 就能把连接管理、消息路由、心跳保活全部握在手里。这一篇不讲空概念,从进程模型讲到消息协议、从单机跑通讲到多进程排错,最后落到几个能直接抄的参数和排查手法。

2. Workerman 的进程模型与在线客服的选型理由

2.1 为什么是常驻进程而不是 PHP-FPM

传统 PHP-FPM 的生命周期是「请求进来 → 初始化 → 执行 → 销毁」,每个请求都要重新加载框架、重建数据库连接。在线客服的核心诉求是「连接一直挂着、消息随时能推」,这跟 FPM 的短生命周期天然冲突。Workerman 把 PHP 跑成一个常驻的 Master + Worker 进程组:Master 负责监听和拉起 Worker,Worker 常驻内存处理连接和业务。连接对象、在线坐席表、房间关系都放在内存里,推送一条消息不需要查库、不需要走 HTTP,直接遍历连接对象写回 socket。

代价是你要自己管内存。常驻进程里一个没释放的数组、一个没断开的定时器,跑几天就会把内存吃满。这是选型时必须接受的交换:用「手动管理生命周期」换「实时推送能力」。

2.2 四种进程角色怎么分工

一个能上生产的客服系统,进程不是随便起的,常见做法是按职责拆成四类:

进程角色职责常见数量关键点
Gateway维持客户端 WebSocket 连接按连接数扩只做收发,不碰业务
BusinessWorker处理消息逻辑、路由按 CPU 核数无状态,可水平扩
Register内部服务注册与发现1Gateway 和 Worker 靠它互相找到
定时/推送进程心跳检测、离线消息补发1~2避免和业务 Worker 抢资源

Gateway 和 BusinessWorker 分离是 Workerman 生态里很关键的一步。Gateway 只管连接,BusinessWorker 只管逻辑,两者通过内部协议通信。好处是坐席逻辑改崩了不会把连接层带崩,扩容时也能分别扩。新手最容易犯的错是把所有逻辑塞进一个 Worker,连接一多,业务一卡,整个服务全挂。

2.3 最小可跑通的目录与启动脚本

先不接数据库,把「客户端连上、发消息、服务端回显」跑通,这是后面所有功能的地基。

# 目录结构(纯手工建,不依赖任何框架) workerman-kefu/ ├── start.php # 入口,注册所有 Worker ├── app/ │ ├── Gateway.php # 连接层 │ └── Business.php # 业务层 └── composer.json
# 安装 workerman(用 composer,版本以你本地拉到的为准) composer require workerman/workerman
<?php // start.php —— 入口文件,只做进程注册,不写业务 require_once __DIR__ . '/vendor/autoload.php'; use Workerman\Worker; // 1. 连接层:对外暴露 WebSocket $gateway = new Worker('websocket://0.0.0.0:8282'); $gateway->name = 'Gateway'; $gateway->count = 2; // 连接层进程数,按并发调 $gateway->reusePort = true; // 多进程共享端口,减少惊群 // 2. 业务层:处理消息逻辑 $business = new Worker(); $business->name = 'BusinessWorker'; $business->count = 4; // 一般等于 CPU 核数 $business->onWorkerStart = function ($worker) { // 这里注册消息处理回调,见下一章 }; // 3. 内部注册中心 $register = new Worker('text://0.0.0.0:1236'); $register->name = 'Register'; Worker::runAll();

逻辑说明:reusePort = true让多个 Gateway 进程各自持有同一个端口的监听,内核层面做负载,比单进程 accept 再分发要平滑。count不是越大越好,Gateway 进程数超过 CPU 核数太多,上下文切换反而拖慢。参数上,websocket://0.0.0.0:8282的 8282 是示例端口,生产环境记得在防火墙只放行必要来源。启动用php start.php start,调试用start,后台跑用start -d,改完代码要restart而不是reload(reload 不会重载入口文件里的进程定义)。

3. 消息协议设计与坐席路由的落地实现

3.1 自定义 JSON 协议:字段怎么定

WebSocket 只负责传字节,传什么格式是你自己定的。客服场景消息类型多:文本、图片、转接、已读回执、正在输入。常见做法是统一一个 JSON 信封,用type区分,避免每种消息一套解析逻辑。

{ "type": "chat.message", "msg_id": "c-20240101-0001", "from": "visitor_8821", "to": "agent_007", "ts": 1704067200, "payload": { "content": "你好,订单还没发货", "content_type": "text" } }

字段设计有几个血泪经验:msg_id必须客户端生成并回传,服务端用它做去重和回执关联,否则弱网重发会出现重复消息;ts用秒级时间戳,别用毫秒字符串,排序时容易踩坑;type用「域.动作」两段式,后面加类型不会和旧类型撞名。payload单独一层,是为了让文本、图片、订单卡片共用外层路由字段,解析时先看type再决定怎么读payload。

3.2 坐席分配:轮询、最少连接还是技能组

访客进来要分给哪个坐席,是客服系统最核心的一段逻辑。三种常见策略:

  • 轮询:实现最简单,但坐席忙闲不均,一个坐席手上压 20 个会话,另一个闲着。
  • 最少连接:按当前会话数排序取最小,均衡性好,实现也不复杂,是多数团队的首选。
  • 技能组:按业务线(售前/售后/技术)先分组,组内再按最少连接分,适合业务复杂的团队。

下面是最少连接的落地写法,坐席在线状态和会话数都放内存:

<?php // app/Business.php 片段:坐席分配 class AgentPool { // agent_id => ['conn' => 连接对象, 'sessions' => 当前会话数, 'group' => 技能组] public static $agents = []; // 为一个访客挑选坐席 public static function pick($group = 'default') { $best = null; foreach (self::$agents as $id => $info) { if ($info['group'] !== $group) { continue; // 技能组不匹配直接跳过 } if ($best === null || $info['sessions'] < $best['sessions']) { $best = ['id' => $id] + $info; } } return $best; // 返回 null 表示无可用坐席 } // 会话结束时务必调用,否则 sessions 只增不减 public static function release($agentId) { if (isset(self::$agents[$agentId])) { self::$agents[$agentId]['sessions']--; } } }

逻辑说明:pick遍历所有在线坐席,按sessions取最小。参数$group是技能组标识,默认组兜底。这里有个必须强调的点:sessions的增减必须成对,分配时加一、会话关闭时减一。我见过线上坐席数明明够,却一直提示「无可用坐席」,排查半天发现是会话关闭的回调里漏了release,计数只涨不跌,坐席全被「假占满」。所以release一定要挂在连接断开和会话主动结束两个地方。

3.3 心跳保活与断线重连

长连接最怕「假死」:网络断了但两端都没察觉,消息发出去石沉大海。Workerman 自带onClose,但移动网络下连接可能既不报错也不关闭。常见做法是应用层心跳:客户端每 30 秒发一个ping,服务端回pong,服务端超过 90 秒没收到任何数据就主动断开。

<?php // 服务端心跳检测,挂在 BusinessWorker 的 onWorkerStart 里 use Workerman\Timer; $business->onWorkerStart = function ($worker) { Timer::add(30, function () { $now = time(); foreach (AgentPool::$agents as $id => $info) { // last_active 在每次收到该连接消息时更新 if ($now - $info['last_active'] > 90) { $info['conn']->close(); // 触发 onClose,走清理逻辑 } } }); };

参数说明:检测周期 30 秒、超时阈值 90 秒,是「3 次心跳丢失判定离线」的常见配比。周期太短浪费流量,太长断线感知迟钝。客户端侧要配指数退避重连:第一次 1 秒、第二次 2 秒、依次翻倍到 30 秒封顶,避免服务端刚重启就被海量重连打垮。重连成功后第一件事是重新拉取未读消息,而不是假设本地状态还对。

4. 避坑与排查:上线后最容易翻车的五个点

4.1 消息重复:现象是访客收到两条一样的回复

现象:弱网或客户端重连后,同一条消息被处理两次,访客看到重复回复。原因:客户端超时重发,服务端没有幂等判断,msg_id形同虚设。解决:服务端维护一个近期msg_id集合(用 Redis 或内存 LRU),处理前先查,命中就直接丢弃并回一个已处理的回执。集合要设过期时间,否则内存无限涨。

4.2 内存只涨不降:跑三天进程被 OOM 杀掉

现象:php start.php status看到某个 Worker 内存持续上涨,几天后被系统杀掉。原因:常驻进程里往静态数组塞数据却从不清理,比如把每条历史消息都存进$allMessages。解决:所有「按会话/按用户」累积的结构都要有清理时机,会话结束就删;排查时用status命令看各进程内存,定位到具体 Worker 再回代码里找全局变量。

4.3 坐席收不到转接消息:跨进程连接找不到

现象:访客被转接给坐席 B,但 B 的客户端没反应。原因:Gateway 是多进程的,访客连在进程 1、坐席 B 连在进程 2,业务代码只在当前进程的连接表里找,自然找不到。解决:连接寻址必须走 Gateway 提供的跨进程发送接口(按 client_id 发),不要自己维护「进程内连接数组」然后直接遍历。这是 Gateway/Worker 分离架构里最经典的坑。

4.4 定时器重复注册:一条消息被推了 N 次

现象:某个提醒消息被推送了多次,次数正好等于 Worker 进程数。原因:Timer::add写在了onWorkerStart里,而 Worker 有 4 个进程,每个进程都注册了一遍定时器。解决:定时任务要么只在一个进程里注册(用进程 id 判断),要么把定时逻辑独立成一个count = 1的 Worker。凡是「全局只该跑一次」的逻辑,都要显式限制进程数。

4.5 平滑重启丢消息:reload 期间连接被断

现象:发版reload时,部分在线访客的连接被断开,正在输入的内容丢失。原因:reload 会平滑重启 Worker,连接会短暂中断,如果客户端没做重连和消息补发,用户就感知到掉线。解决:发版尽量选低峰期;客户端必须实现重连 + 未读补拉;服务端在断开前可以给客户端发一个「服务维护中」的提示帧,让前端给出友好提示而不是白屏。

5. 把客服系统做稳的三个进阶习惯

第一个习惯是给消息加「可观测性」。别等用户投诉才发现问题,在消息进出两个关键点打结构化日志:msg_id、from、to、type、耗时。出问题时用msg_id一路 grep,能快速判断是没收到、没路由还是没推送。日志别写进常驻进程的标准输出就完事,要落到文件或日志服务,否则进程一重启全没了。

第二个习惯是把「在线状态」当成有生命周期的资源来管。坐席上线写进AgentPool,下线、断线、超时三种情况都要触发清理,且清理逻辑要幂等——重复清理不能把计数减成负数。我一般会在清理函数入口先判断这个坐席是否还在池子里,不在就直接返回。这个判断看着多余,但线上真能挡住不少重复回调引发的脏数据。

第三个习惯是压测要压「连接数」而不是只压「消息量」。客服系统的瓶颈往往在同时挂着的连接数上,而不是每秒消息条数。用脚本模拟几千个 WebSocket 连接挂着不发消息,观察内存和文件描述符;再模拟一部分连接高频发消息,观察 BusinessWorker 的 CPU。两个维度分开压,才能知道该扩 Gateway 还是扩 BusinessWorker。文件描述符上限(ulimit -n)是新手最容易忽略的,连接一多就报「too many open files」,改完系统限制记得重启进程才生效。

最后说个我自己的教训:早期我图省事,把坐席分配、消息存储、推送全塞进一个 Worker,本地测几十个连接一切正常,上线到几百连接就开始随机卡顿,排查了两天才意识到是单进程里一个慢查询把整个消息循环堵住了。后来拆成 Gateway + BusinessWorker,慢查询只影响业务层,连接层照常收发,问题立刻可控。做这类常驻服务,职责分离不是架构洁癖,是保命手段。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询