Workerman在线客服系统实战:从轮询到WebSocket长连接

发布时间:2026/10/12 19:31:16
Workerman在线客服系统实战:从轮询到WebSocket长连接 简介这是一套基于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-FPM2.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明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 连接串加charsetutf8mb4表字段用 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或浏览器控制台手动发几条畸形消息——缺字段的、类型不对的、超长的——确认服务端不崩、能返回错误码再交给前端联调。常驻进程最怕的就是一条脏消息把整个进程带走而这类问题在正常测试里根本暴露不出来。从那以后我每次上线前都强制走一遍畸形消息测试希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询