Workerman在线客服系统实战:WebSocket长连接与多进程消息路由
发布时间:2026/9/26 21:10:27 作者:尧图编辑部 阅读量:1,286

简介这是一套基于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 实现。安装 Channelcomposer 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是客服的连接 IDfrom是客户的连接 ID前端收到后根据这两个字段渲染对话窗口。注意Channel 服务端必须和 Worker 在同一个网段内生产环境建议监听内网 IP不要暴露到公网。3. 通信协议与消息可靠性别让消息在长连接里“玄学丢失”3.1 自定义 JSON 协议字段设计WebSocket 只负责传输字节流具体发什么内容、怎么解析完全由你自己定。我见过不少团队直接用纯文本传消息结果后面要加已读回执、正在输入、图片消息时代码里全是字符串切割维护起来非常痛苦。比较稳妥的做法是从一开始就定义一套 JSON 协议每个消息都有type字段标识类型其他字段按类型扩展。下面是一个我常用的协议格式字段名类型说明typestring消息类型如 customer_to_service、service_to_customer、heartbeat、ackmsg_idstring客户端生成的唯一消息 ID用于去重和回执fromint发送方连接 IDtoint接收方连接 IDcontentstring消息正文文本消息直接放图片放 URLtimestampint毫秒级时间戳用于排序和超时判断前端每次发消息前生成一个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 内存。假设每个连接 20KB4 个进程一共 4GB 内存那理论上能撑 20 万连接但实际受限于文件描述符。Linux 默认单进程文件描述符上限是 1024Workerman 启动时会提示你调整。需要改两个地方# 临时生效 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 反代 WebSocketNginx 配置里必须加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 条消息确认没有丢失和乱序再上生产。这个笨办法帮我省了很多次半夜回滚。希望帮到你。本文还有配套的精品资源点击获取