简介:这是一套基于Workerman构建的在线客服系统源码,面向需要快速搭建网页端实时客服功能的PHP开发者与运维人员,尤其适合中小型网站、后台管理系统集成即时通讯模块的场景。资源包共约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常驻内存通信、FastAdmin后台与客服前端整合的开发者参考,可借此理解长连接客服系统的目录组织与配置方式。
1. Workerman在线客服系统:从零到一,为什么它是中小团队最稳的落地路径
如果你正在找一个能扛住几百上千人同时在线咨询、部署简单、不依赖复杂中间件的客服系统方案,Workerman在线客服系统大概率是你绕不开的选项。它本质上是用 PHP 写的一套常驻内存的 Socket 服务框架,配合 WebSocket 协议,让浏览器和服务器之间保持长连接,消息可以毫秒级推送到客服和客户两端。和传统的 Ajax 轮询相比,它不会每几秒就发一堆无效请求把服务器压垮,也不会出现消息延迟三五秒才刷出来的尴尬。适合谁?适合那些不想为了一个客服功能就上 Java 微服务全家桶、也不想被 SaaS 客服按坐席数年年收费的中小研发团队。你只需要一台普通云服务器,装好 PHP 和 Workerman,就能跑起来一套属于自己的在线客服系统。接下来的内容,我会按实际落地顺序,把环境搭建、通信协议设计、多进程模型、消息可靠性和性能调优逐层拆开讲。
2. 环境搭建与最小可运行 Demo:把第一个 WebSocket 服务跑起来
2.1 为什么选 Workerman 而不是 Swoole 或 Node.js
在动手之前,先把选型逻辑说清楚,免得做到一半发现方向不对。Workerman 是纯 PHP 实现的,不需要安装额外的 C 扩展,composer require workerman/workerman就能用。Swoole 性能确实更强,但它要求你装扩展、改 php.ini,而且很多虚拟主机和共享环境根本不让装。Node.js 做 WebSocket 也很成熟,但你的业务代码如果本来就是 PHP 写的,引入 Node 意味着多维护一套运行时和进程管理,运维成本直接翻倍。Workerman 的定位很准:用 PHP 写常驻内存服务,性能比传统 FPM 高一个数量级,同时保持 PHP 的开发和部署习惯。我一般会跟团队说,如果你的并发连接数在 5000 以内,Workerman 完全够用,再往上才需要考虑 Swoole 或者加机器做分布式。
2.2 用 Composer 拉取 Workerman 并写一个回声服务
先确保你的 PHP 版本在 7.4 以上,然后建一个空目录,执行下面这几步。
mkdir workerman-chat && cd workerman-chat composer require workerman/workerman安装完成后,创建一个start.php文件,内容如下:
<?php // start.php require_once __DIR__ . '/vendor/autoload.php'; use Workerman\Worker; // 创建一个 WebSocket 服务,监听 0.0.0.0:8282 $ws_worker = new Worker("websocket://0.0.0.0:8282"); // 启动 4 个进程,利用多核 CPU $ws_worker->count = 4; // 当客户端连接上来时触发 $ws_worker->onConnect = function ($connection) { echo "新连接建立,连接 ID: {$connection->id}\n"; }; // 当收到客户端消息时触发 $ws_worker->onMessage = function ($connection, $data) { // 原样把消息推回给客户端,用于验证链路 $connection->send("服务端已收到: " . $data); }; // 当客户端断开时触发 $ws_worker->onClose = function ($connection) { echo "连接关闭,连接 ID: {$connection->id}\n"; }; // 运行所有 Worker Worker::runAll();这段代码的逻辑很直白:onConnect记录连接建立,onMessage把收到的消息回显,onClose记录断开。$connection->id是 Workerman 自动分配的递增整数,在单个进程内唯一,后面做客服和客户的绑定关系时会用到。count = 4表示启动 4 个进程,每个进程独立处理自己持有的连接,这是 Workerman 多进程模型的基础。
启动命令:
php start.php start如果你想让它后台常驻,用php start.php start -d。调试阶段建议前台运行,方便看输出。启动后,你可以用浏览器控制台或者在线 WebSocket 测试工具连ws://你的服务器IP:8282,发一条消息,应该能立刻收到回显。这一步跑通,说明环境没问题,接下来才是真正的业务逻辑。
2.3 把回声服务改造成客服消息路由
回声服务只是验证链路,真正的客服系统需要把消息从客户路由到对应客服,再把客服的回复推回客户。核心思路是维护两张映射表:客户连接ID => 客服连接ID和客服连接ID => 客户连接ID列表。在 Workerman 里,这些映射关系如果直接存在 PHP 数组里,多进程之间是不共享的。所以常见做法有两种:一是用count = 1单进程跑,简单但浪费 CPU;二是引入 Redis 或者 Workerman 自带的 Channel 组件做进程间通信。我一般会推荐 Channel 组件,因为它不依赖外部服务,纯 PHP 实现。
安装 Channel:
composer require workerman/channel然后改造start.php,加入 Channel 服务端和客户端逻辑。这里先给一个简化版的核心代码结构:
<?php require_once __DIR__ . '/vendor/autoload.php'; use Workerman\Worker; use Workerman\Connection\TcpConnection; use Channel\Server as ChannelServer; use Channel\Client as ChannelClient; // 启动 Channel 服务端,监听本地 2206 端口 $channel_server = new ChannelServer('0.0.0.0', 2206); $ws_worker = new Worker("websocket://0.0.0.0:8282"); $ws_worker->count = 4; $ws_worker->onWorkerStart = function ($worker) { // 每个 Worker 进程启动时连接 Channel 服务端 ChannelClient::connect('127.0.0.1', 2206); }; $ws_worker->onMessage = function ($connection, $data) { $msg = json_decode($data, true); if (!$msg || !isset($msg['type'])) { return; } // 客户发消息,转发给对应客服 if ($msg['type'] === 'customer_to_service') { $serviceConnectionId = $msg['service_id']; // 通过 Channel 发布事件,让持有该客服连接的进程处理 ChannelClient::publish('send_to_service', [ 'service_id' => $serviceConnectionId, 'content' => $msg['content'], 'from' => $connection->id, ]); } }; // 订阅事件,收到其他进程发来的消息后推送给对应连接 ChannelClient::on('send_to_service', function ($data) use ($ws_worker) { $serviceConnection = $ws_worker->connections[$data['service_id']] ?? null; if ($serviceConnection) { $serviceConnection->send(json_encode([ 'type' => 'customer_message', 'from' => $data['from'], 'content' => $data['content'], ])); } }); Worker::runAll();这里的关键点是:ChannelClient::publish会把消息广播到所有订阅了该事件的进程,每个进程检查自己是否持有目标客服的连接,有就推送,没有就忽略。这样即使客户和客服连接落在不同进程,消息也能正确送达。参数service_id是客服的连接 ID,from是客户的连接 ID,前端收到后根据这两个字段渲染对话窗口。
注意:Channel 服务端必须和 Worker 在同一个网段内,生产环境建议监听内网 IP,不要暴露到公网。
3. 通信协议与消息可靠性:别让消息在长连接里“玄学丢失”
3.1 自定义 JSON 协议字段设计
WebSocket 只负责传输字节流,具体发什么内容、怎么解析,完全由你自己定。我见过不少团队直接用纯文本传消息,结果后面要加已读回执、正在输入、图片消息时,代码里全是字符串切割,维护起来非常痛苦。比较稳妥的做法是从一开始就定义一套 JSON 协议,每个消息都有type字段标识类型,其他字段按类型扩展。下面是一个我常用的协议格式:
| 字段名 | 类型 | 说明 |
|---|---|---|
| type | string | 消息类型,如 customer_to_service、service_to_customer、heartbeat、ack |
| msg_id | string | 客户端生成的唯一消息 ID,用于去重和回执 |
| from | int | 发送方连接 ID |
| to | int | 接收方连接 ID |
| content | string | 消息正文,文本消息直接放,图片放 URL |
| timestamp | int | 毫秒级时间戳,用于排序和超时判断 |
前端每次发消息前生成一个msg_id,服务端收到后先回一个ack消息,带上同样的msg_id,前端收到ack才把消息标记为“已发送”。如果 3 秒内没收到ack,就重发一次。这个机制能解决大部分“消息发出去了但对方没收到”的问题。
3.2 心跳检测与断线重连
长连接最怕的是中间网络设备悄悄断开连接,而两端都不知道。Workerman 本身有onClose回调,但如果是网络中断,可能几分钟后才触发。所以需要应用层心跳:客户端每 30 秒发一个{"type":"heartbeat"},服务端收到后更新该连接的last_active_time。同时服务端起一个定时器,每 60 秒扫描所有连接,把超过 90 秒没心跳的连接主动关闭。
// 在 onWorkerStart 里加定时器 $ws_worker->onWorkerStart = function ($worker) { ChannelClient::connect('127.0.0.1', 2206); \Workerman\Timer::add(60, function () use ($worker) { $now = time(); foreach ($worker->connections as $connection) { if ($now - ($connection->lastActiveTime ?? $now) > 90) { $connection->close(); } } }); }; // 在 onMessage 里更新活跃时间 $ws_worker->onMessage = function ($connection, $data) { $connection->lastActiveTime = time(); // ... 其他逻辑 };客户端侧的重连逻辑用 JavaScript 写大概是这样:
let ws; let reconnectTimer; function connect() { ws = new WebSocket('ws://你的服务器IP:8282'); ws.onopen = () => { console.log('连接成功'); // 启动心跳 setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'heartbeat' })); } }, 30000); }; ws.onclose = () => { console.log('连接断开,3 秒后重连'); clearTimeout(reconnectTimer); reconnectTimer = setTimeout(connect, 3000); }; ws.onerror = () => { ws.close(); }; } connect();这段代码里,onclose触发后延迟 3 秒重连,避免频繁重试把服务器打满。心跳间隔 30 秒、服务端 90 秒超时,是经过多次踩坑后比较稳的参数组合。太短会增加无效流量,太长则断线发现不及时。
3.3 消息去重与顺序保证
即使有ack和重发,消息重复仍然可能发生。比如客户端发了消息,服务端处理了但ack在返回路上丢了,客户端重发,服务端就会收到两条一样的消息。解决办法是在服务端维护一个已处理msg_id的集合,可以用 Redis 的 Set,设置 5 分钟过期。收到消息先检查msg_id是否已存在,存在就直接回ack但不重复处理。
顺序问题更微妙。同一个客户连续发两条消息,由于多进程和网络抖动,到达服务端的顺序可能颠倒。我的做法是在服务端给每个会话维护一个递增序号,前端按序号排序渲染。序号可以用 Redis 的INCR命令生成,key 用会话 ID。这样即使消息到达顺序乱了,前端也能正确展示。
提示:Redis 的 Set 和 INCR 都是原子操作,多进程并发下不会出错。如果不想引入 Redis,用 Channel 组件也能实现类似效果,但代码会复杂一些。
4. 多进程模型与性能调优:连接数上去后先别急着加机器
4.1 Workerman 的进程模型到底怎么跑
Workerman 启动时,主进程负责监听端口,然后 fork 出count个 Worker 子进程,每个子进程独立接受连接、处理消息。这意味着同一个连接的所有事件都在同一个进程内处理,不需要加锁。但跨进程通信就必须走 Channel 或 Redis。很多新手会问:为什么我改了全局变量,另一个进程读不到?原因就在这里——每个进程有独立的内存空间。理解这一点,后面调优和排错会少走很多弯路。
4.2 连接数、文件描述符与内核参数
单机 Workerman 能扛多少连接,主要受三个限制:PHP 内存、文件描述符、内核网络参数。每个连接在 Workerman 里对应一个TcpConnection对象,大概占用几 KB 到几十 KB 内存。假设每个连接 20KB,4 个进程一共 4GB 内存,那理论上能撑 20 万连接,但实际受限于文件描述符。
Linux 默认单进程文件描述符上限是 1024,Workerman 启动时会提示你调整。需要改两个地方:
# 临时生效 ulimit -n 65535 # 永久生效,编辑 /etc/security/limits.conf * soft nofile 65535 * hard nofile 65535内核参数也要调:
# 编辑 /etc/sysctl.conf net.core.somaxconn = 65535 net.ipv4.tcp_max_syn_backlog = 65535 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_fin_timeout = 30然后sysctl -p生效。这些参数的含义分别是:somaxconn是监听队列最大长度,tcp_max_syn_backlog是半连接队列长度,tcp_tw_reuse允许复用 TIME_WAIT 状态的端口,tcp_fin_timeout缩短 FIN_WAIT2 超时。调完之后,单机 1 万到 2 万连接是比较稳的。
4.3 用压力测试找到瓶颈再优化
不要凭感觉猜瓶颈,用工具测。WebSocket 压测可以用websocket-bench或者自己写脚本。我一般会先用 100 个并发连接跑 5 分钟,观察 CPU 和内存曲线,然后逐步加到 500、1000、2000。重点看三个指标:CPU 使用率、内存增长趋势、消息延迟。如果 CPU 先到 80%,说明业务逻辑太重,需要优化代码或者加进程;如果内存持续增长不回落,大概率是连接没释放或者消息队列积压。
一个常见的性能陷阱是在onMessage里做同步阻塞操作,比如直接查 MySQL。数据库查询可能耗时几十毫秒,这期间该进程无法处理其他连接的消息。正确做法是把耗时操作丢给异步任务队列,Workerman 生态里有workerman/redis-queue或者workerman/rabbitmq可以用。消息先入队,立即返回ack,后台进程慢慢消费。这样即使数据库慢,也不会拖垮 WebSocket 服务。
5. 避坑与常见问题排查:那些让我半夜爬起来改代码的瞬间
5.1 现象:客户端连不上,服务端日志显示连接被拒绝
原因:Workerman 默认监听0.0.0.0,但如果服务器有防火墙或者安全组,8282 端口没放行,外部就连不上。另外,如果你用了 Nginx 反代 WebSocket,Nginx 配置里必须加Upgrade和Connection头。
解决:先telnet 服务器IP 8282确认端口通不通。不通就检查安全组和iptables -L。如果走 Nginx 反代,配置如下:
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; }5.2 现象:消息偶尔丢失,客户端没收到但服务端日志显示已发送
原因:$connection->send()返回 true 只代表数据写入了操作系统缓冲区,不代表对方一定收到。如果对方网络断开或者客户端处理不过来,消息可能在缓冲区里被丢弃。
解决:关键消息必须加应用层ack。服务端发送后等待客户端回ack,超时重发。同时检查$connection->getStatus(),如果连接已关闭就不要再发。
5.3 现象:多进程下客服和客户连接不在同一进程,消息发不过去
原因:前面说过,Workerman 多进程内存不共享,直接遍历$worker->connections只能看到当前进程的连接。
解决:用 Channel 或 Redis 做跨进程路由。Channel 更轻量,Redis 更通用。如果团队已经有 Redis,直接用 Redis 的发布订阅也行。
5.4 现象:服务运行几天后内存暴涨,重启后恢复
原因:常见的是$connection对象没有正确释放,或者某个数组只增不减。比如把消息历史存在进程内存里,时间长了就爆了。
解决:所有会话数据要么存 Redis,要么设过期时间。用Timer定期清理无用连接和过期数据。另外,Workerman 有maxSendBufferSize和maxPackageSize参数,设置合理值可以防止单个连接占用过多内存。
5.5 现象:心跳正常但消息延迟越来越高
原因:某个进程的消息队列积压了。可能是某个客服同时接待太多客户,消息处理不过来。
解决:在onMessage里加耗时统计,超过 100ms 就记日志。然后看是数据库慢还是业务逻辑复杂。如果是客服接待量问题,可以在业务层做限流,比如一个客服最多同时服务 20 个客户,超出的排队或转接。
6. 进阶技巧:用 Redis 做消息持久化与离线消息补推
前面讲的都是在线消息,但客户可能发完消息就关掉页面,客服回复时客户已经离线。这时候消息不能丢,需要存起来,等客户下次上线再推。我的做法是用 Redis 的 List 结构,key 用offline_msg:{客户ID},客服回复时如果检测到客户不在线,就LPUSH进去。客户重新连接时,先LRANGE取出所有离线消息推送给前端,然后DEL删除。
// 客服回复时检查客户是否在线 $customerConnection = $ws_worker->connections[$customerId] ?? null; if ($customerConnection) { $customerConnection->send(json_encode($replyMsg)); } else { // 客户离线,存入 Redis $redis->lPush("offline_msg:{$customerId}", json_encode($replyMsg)); $redis->expire("offline_msg:{$customerId}", 86400 * 7); // 保留 7 天 } // 客户上线时拉取离线消息 $ws_worker->onConnect = function ($connection) use ($redis) { $customerId = getCustomerIdFromToken($connection); // 从 token 解析客户 ID $offlineMsgs = $redis->lRange("offline_msg:{$customerId}", 0, -1); foreach (array_reverse($offlineMsgs) as $msg) { $connection->send($msg); } $redis->del("offline_msg:{$customerId}"); };这里expire设 7 天,是防止 Redis 被离线消息撑爆。array_reverse是因为LPUSH是头插,取出来顺序是反的,需要反转回正常时间顺序。另外,客户 ID 不能直接用连接 ID,因为连接 ID 每次重连都会变,必须用业务层的用户 ID,通过登录 token 解析。
还有一个细节:如果客户在多台设备同时登录,离线消息应该只推给第一个上线的设备,其他设备通过在线消息同步。这个逻辑可以在 Redis 里加一个online:{客户ID}的标记,上线时用SETNX抢占,抢到才拉离线消息。
最后说一个我自己的习惯:每次改完通信协议,先在一台测试机上用两个浏览器窗口互发 100 条消息,确认没有丢失和乱序,再上生产。这个笨办法帮我省了很多次半夜回滚。希望帮到你。
本文还有配套的精品资源,点击获取