ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Workerman+ThinkPHP 5打造高可用长连接客服系统实践

Workerman+ThinkPHP 5打造高可用长连接客服系统实践 简介Workerman在线客服系统是一套基于PHP的实时客服部署源码面向需要快速搭建网页客服功能的开发者、中小企业站运营者及PHP学习者。资源围绕Nginx 1.21.4、PHP-7.2、MySQL 5.7.40组合展开提供完整的安装与配置说明重点覆盖上传解压、数据库连接设置等关键环节并明确标注application/database.php中数据库名、用户名、密码的修改位置压缩包内另附有详细文字教程可帮助使用者规避环境版本不一致、数据库连接失败等常见问题显著降低部署门槛。包内共2000个文件其中1184个js文件承担前端逻辑与交互196个html与106个css文件构成客服界面22个php文件完成服务端接口与Workerman进程管理4个sql脚本用于初始化数据库表结构另有163个json、159个md、142个txt等配置与文档类文件整体约25.95MB类型覆盖从前端展示、后端服务到数据库初始化的完整链路目录层级清晰便于按模块检索和二次开发。目前已有408人学习使用适合需要独立部署、调试或深入理解Workerman客服系统工作原理的技术人员也可作为课程设计或企业建站的参考方案。 搞这类“长连接IM”项目几乎每个做PHP的团队都会在某个阶段碰到一个坎Web端客服、工单、站内信、实时通知……业务方提出需求时第一反应是“这不就是WebSocket吗”但真到了落地上才发现在PHP的生态里从零写一套能抗住生产压力的长连接服务并不轻松。我之前在团队里负责过一版在线客服系统的选型和开发最终选择了Workerman ThinkPHP 5这套组合。不是因为别的框架不够好而是这套方案在“快速交付”和“长期可维护”之间找到了一个很实用的平衡点。这篇博文就把这个项目的完整拆解记录下来包括架构思路、核心代码、运行部署以及我在线上环境踩过的一些坑希望能给准备做同类项目的朋友省点时间。1. 项目做了什么事需求场景与结果1.1 表面需求与本质需求产品最初给的文档很简单在官网加一个“在线咨询”入口访客点开弹窗可以和客服实时聊天客服在后台看到会话列表点开就能回复。听起来就是个聊天室但拆解下来会发现真实的需求其实分好几层访客不需要登录打开页面就能发起咨询系统要能自动分配一个客服同一个访客刷新页面、切换设备后会话记录不能丢客服后台要能看到访客来源页面、当前访问地址、停留时间等信息这在客服场景里是刚需消息要有历史记录且可以翻查客服离线时消息要能落地避免丢失实时性要求高客服发出消息后访客要能秒收不能像轮询那样有明显滞后。这些需求叠加在一起决定了服务端不能只做一个简单的WebSocket转发还需要有会话管理、用户绑定、消息存储、离线消息补发这些业务逻辑。所以架构上必须拆成“长连接网关”和“业务处理层”两个部分这正是 Workerman 的 GatewayWorker 方案最擅长的事情。1.2 技术选型为什么是 Workerman ThinkPHP 5当时团队的技术栈是 ThinkPHP 5业务代码全部跑在PHP-FPM里如果为了客服系统单独引入Swoole或者Node.js意味着要么多维护一套技术栈要么就得把TP5的整个生命周期强行搬到Swoole常驻内存里坑会很多。选择 Workerman 主要有几个现实考量第一Workerman 是纯PHP实现的部署和维护成本极低。不用装额外的扩展只有可选的event扩展只要PHP环境支持pcntl、posix就能在Linux上跑起来。这对当时“不想为了一个客服系统改服务端架构”的团队来说很关键。第二Team 里大家对 PHP 比较熟用 Workerman 不需要换语言写业务逻辑时心智负担小。相比 Swoole 需要理解协程、理解常驻内存下变量生命周期的问题Workerman 的事件驱动模型更像是“你写一个回调函数框架帮你管连接”入门门槛低很多。第三GatewayWorker 这套现成的分布式通讯框架解决了最麻烦的连接管理问题。在线客服场景天然支持水平扩展——Gateway挂多台机器、BusinessWorker挂多台机器内部通过Register做服务发现。这个能力用原生Workerman裸写的话光靠自己和client_id拼消息路由就能写掉三分之一的工作量。第四和 ThinkPHP 5 的集成路径很干净。可以把TP5当做一个“业务库”挂在 BusinessWorker 进程里在事件回调里直接调用TP5的模型层和Service层事务、ORM、缓存全都能复用。这样在整个项目里客服系统的业务写法和其他模块保持一致新同事接手时也不会有割裂感。2. 整体架构与通信流程设计2.1 GatewayWorker 三层架构GatewayWorker 的架构值得展开说一下因为不搞懂这里后面代码写起来就是知其然不知其所以然。整个系统分为三层分别对应三个进程角色Register注册中心负责管理 Gateway 和 BusinessWorker 的注册与发现。Gateway 启动后向 Register 注册自己的内网IP和端口BusinessWorker 也向 Register 上报自己的进程信息。两边通过 Register 互相知道对方的位置才能形成一条完整的通信链路。Gateway网关层负责与客户端建立WebSocket长连接维护连接状态解析并转发消息。一个典型的客服系统可以部署多个 Gateway 进程每个进程对应一个对外端口。客户端只和 Gateway 直接通信。BusinessWorker业务层真正处理业务逻辑的地方。收到 Gateway 转发过来的用户消息后在这里落库、写缓存、做客服分配、调用TP5的Service然后把结果通过 Gateway 推回给指定客户端。调用关系如果用文字描述就是客户端连上 GatewayGateway 把消息发给 BusinessWorkerBusinessWorker 处理完后再通过 Gateway 推给目标客户端。消息不走 Redis 中转而是走内网TCP这也是为什么这套方案性能表现不错的原因之一。2.2 消息流转的关键路径以最核心的“访客发消息给客服”为例完整的数据流是这样的访客页面通过 WebSocket 连接到 Gateway 的地址如ws://chat.xxx.com:8282。连接建立后Gateway 生成一个全局唯一的client_id触发onConnect事件。访客在页面输入内容并点击发送前端 JS 通过 WebSocket 发送一条 JSON比如{type:chat,content:你好我想咨询...}。Gateway 接收到消息转发给任意一台空闲的 BusinessWorker触发onMessage事件。BusinessWorker 解析 JSON识别typechat调用ChatService::sendMessage()方法方法内部做消息落库、更新会话最后时间等操作。BusinessWorker 通过Gateway::sendToUid($toUid, $message)把消息推送给目标客服的客户端。这里的$toUid需要客户端在连接时先完成“登录绑定”即把client_id和用户ID关联起来。客服端收到消息后WebSocket 的onmessage回调触发渲染到聊天界面中。这套设计里最关键的一步是第6步的sendToUid它依赖 GatewayWorker 的uid绑定机制。在客服端和访客端建立连接后需要先发送一条login类型的消息把当前连接绑定到对应的用户ID上。之后无论这个用户连接在哪个 Gateway 进程上只要传uid就能准确推送到对应连接。2.3 前后端通信协议设计由于所有消息都走WebSocket必须设计一个统一的 JSON 协议格式否则前后端会越写越乱。我在项目里用的是这样的结构{ type: chat, data: { from_uid: 1001, to_uid: 2001, content: 你好请问这个商品的保修期是多久, timestamp: 1714000000 }, msg_id: 170e1d34-8a2c-4e18-9b9f-81c9a1a5f45c }type区分消息类型比如chat聊天消息、login登录绑定、ping心跳、read已读回执、close结束会话等data是业务数据msg_id是一个 UUID用于客户端做消息去重和发送状态确认。如果客户端在超时后重发同一条消息服务端可以根据msg_id判断是否已经处理过避免重复落库。协议要简单但也要留扩展余地后面如果想加表情、图片、文件消息只需要在data里增加msg_type字段即可不需要改动通信层的逻辑。3. 核心实现服务端与业务端3.1 服务进程启动脚本 start.php在项目根目录创建一个server/文件夹里面放 GatewayWorker 的启动入口。如果你的代码仓库已经用 Composer 管理了依赖直接安装workerman/gateway-worker即可。启动脚本server/start.php的完整代码如下?php use Workerman\Worker; use Workerman\Autoloader; use GatewayWorker\Gateway; use GatewayWorker\BusinessWorker; use GatewayWorker\Register; // 自动加载 require_once __DIR__ . /../vendor/autoload.php; // ---------- 注册中心 ---------- $register new Register(); $register-listen 0.0.0.0:1238; // ---------- 业务进程 ---------- $businessWorker new BusinessWorker(); $businessWorker-name ChatBusinessWorker; $businessWorker-count 4; // 按CPU核数调整一般设为CPU的1-2倍 $businessWorker-eventHandler \app\chat\worker\Events::class; $businessWorker-registerAddress 127.0.0.1:1238; // ---------- 网关进程 ---------- $gateway new Gateway(websocket://0.0.0.0:8282); $gateway-name ChatGateway; $gateway-count 4; $gateway-lanIp 127.0.0.1; $gateway-startPort 2900; // 内部通讯起始端口 $gateway-pingInterval 30; // 心跳检测间隔单位秒 $gateway-pingNotResponseLimit 2; // 连续2次未回应则断开 $gateway-pingData {type:ping}; // 服务端主动发送的心跳数据 $gateway-registerAddress 127.0.0.1:1238; // 运行 Worker::runAll();这里有几个参数值得单独说明$businessWorker-count业务进程数。因为是CPU密集型任务少、IO密集型任务多的场景主要是Redis、MySQL操作多开几个进程能有效提升并发处理能力。我线上用的8核机器开的是4个业务进程实测并发在2000连接时CPU占用率依然很低。$gateway-startPortGateway 进程启动后会在lanIp上开启一串监听端口用于内部通信。如果有多个 Gateway 进程端口会从startPort开始依次递增。这个端口段需要在防火墙里放行并且不能和其他服务冲突。$gateway-pingInterval和pingNotResponseLimit这两个参数配合实现心跳检测。服务端每隔30秒向客户端发送一个ping数据包如果连续2次客户端没有回应服务端主动断开这个连接。同时客户端也要有对应的心跳机制收到ping后回一个pong或者收到心跳后重置本地计数器。双端心跳是保证连接不假死的关键否则一天下来会积累大量死连接。3.2 业务事件处理 Events.php这是整个项目的核心所有业务逻辑都从这里分发。文件位置建议放在app/chat/worker/Events.php这样可以用 TP5 的自动加载规则直接加载需要在composer.json里配置对应的 PSR-4 映射。?php namespace app\chat\worker; use GatewayWorker\Lib\Gateway; use think\facade\Db; use app\chat\service\ChatService; class Events { /** * 客户端连接建立时触发 */ public static function onConnect($client_id) { // 连接刚建立时还没有绑定用户身份 // 这里只做日志记录不做业务处理 // 如果要做用户在线状态统计可以在这里给 client_id 对应的初始数据占位 } /** * 收到客户端消息时触发 */ public static function onMessage($client_id, $message) { $data json_decode($message, true); if (empty($data[type])) { return; } switch ($data[type]) { case login: // 登录绑定把 client_id 和用户ID绑定 $uid (int) $data[uid]; Gateway::bindUid($client_id, $uid); // 通知前端绑定成功 Gateway::sendToClient($client_id, json_encode([ type login_success, client_id $client_id, ])); break; case chat: self::handleChat($client_id, $data); break; case read: // 已读回执把会话中对方发的未读消息标记为已读 self::handleRead($client_id, $data); break; case close: // 结束会话 self::handleClose($client_id, $data); break; } } /** * 客户端断开连接时触发 */ public static function onClose($client_id) { // 获取该 client_id 绑定的 uid $uid Gateway::getUidByClientId($client_id); if ($uid) { // 处理离线逻辑标记客服下线、更新会话状态等 ChatService::handleOffline($uid, $client_id); } } /** * 处理聊天消息 */ protected static function handleChat($client_id, $data) { $fromUid (int) $data[from_uid]; $toUid (int) $data[to_uid]; $content trim(strip_tags((string) $data[content])); $msgId $data[msg_id] ?? ; if ($content || $toUid 0) { return; } // 通过TP5的Service层落库 $messageId ChatService::saveMessage([ from_uid $fromUid, to_uid $toUid, content $content, msg_id $msgId, create_time time(), ]); // 判断对方是否在线 $isOnline Gateway::isUidOnline($toUid); $message json_encode([ type chat, msg_id $msgId, message_id $messageId, from_uid $fromUid, content $content, timestamp time(), ], JSON_UNESCAPED_UNICODE); if ($isOnline) { // 在线则直接推送 Gateway::sendToUid($toUid, $message); } else { // 离线则走离线消息逻辑等对方上线后补发 ChatService::pushOfflineMessage($toUid, $message); } } }这段代码需要注意几个细节尽量用 TP5 的 Service 层封装业务处理逻辑Events 只做消息分发和转发不要把 SQL 写在这里。因为 BusinessWorker 是多进程常驻内存的所有被调用的类必须保证代码更新后能重新加载开发时用php start.php reload重载业务进程业务逻辑集中管理能减少踩坑。Gateway::getUidByClientId和Gateway::isUidOnline这两个API非常实用。前者可以在断开时排查用户身份解决一些边界场景比如用户关浏览器前没来得及发close消息后者可以用来决定消息是直接推还是走离线队列。注意strip_tags过滤。在线客服的访客端消息默认是纯文本但WebSocket消息经常被人拿去注入HTML或脚本。在入口处做一层过滤很必要。3.3 与 ThinkPHP 5 业务层的数据打通Events 类里用到了think\facade\Db和app\chat\service\ChatService这意味着 BusinessWorker 进程需要加载 TP5 的框架内核。具体做法是在start.php中引入vendor/autoload.php同时把 TP5 的app目录注册进 Composer 的自动加载。在composer.json中通常这样配置{ autoload: { psr-4: { app\\: application/ } }, require: { workerman/gateway-worker: ^3.0, topthink/framework: 5.1.* } }然后在start.php的最前面执行一次 TP5 的容器初始化让Db、Cache这些门面在 BusinessWorker 进程里可用// 在 Worker::runAll() 之前执行 $app new \think\App(); $http $app-http; $response $http-run();不过这个方案要小心Http-run()会解析路由并生成响应在 CLI 环境下并不适用。更稳妥的做法是只初始化容器和应用配置不调用 HTTP 相关的逻辑。实际操作中我的做法是在start.php里手动初始化App$app new \think\App(); $app-initialize();这样Db、Log、Config等核心服务都可用又不会进入HTTP请求流程。在Events中直接调用think\facade\Db就不会报“未初始化”的错误了。这一点是整个集成的关键很多第一次做 Workerman TP5 集成的朋友都卡在这里单独跑 Workerman 一切正常已进 TP5 模型就报错本质就是进程没初始化 TP 应用容器。3.4 前端 WebSocket 对接代码前端这块我用的是原生 WebSocket没有引入第三方库这样依赖最少、排查问题也最直接。核心代码如下class ChatClient { constructor(options) { this.wsUrl options.wsUrl; this.uid options.uid; this.onMessage options.onMessage || function() {}; this.ws null; this.heartbeatTimer null; this.connect(); } connect() { this.ws new WebSocket(this.wsUrl); this.ws.onopen () { // 连接建立后先把当前用户身份绑定到服务端 this.send({ type: login, uid: this.uid }); // 开启心跳 this.startHeartbeat(); }; this.ws.onmessage (event) { const data JSON.parse(event.data); // 如果收到ping重置心跳计数器也可以回复一个pong this.onMessage(data); }; this.ws.onclose () { clearInterval(this.heartbeatTimer); // 简单重连3秒后重连生产环境可以做指数退避 setTimeout(() { this.connect(); }, 3000); }; this.ws.onerror (err) { console.error(WebSocket error:, err); }; } send(data) { if (this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } startHeartbeat() { // 前端心跳30秒发送一次防止连接被网关静默回收 this.heartbeatTimer setInterval(() { this.send({ type: ping }); }, 30000); } close() { clearInterval(this.heartbeatTimer); this.ws.close(); } } // 使用示例 const chat new ChatClient({ wsUrl: ws://chat.example.com:8282, uid: 1001, onMessage: (data) { if (data.type chat) { // 渲染消息到聊天界面 console.log(收到消息:, data); } } });这里有几个细节要说明心跳不仅服务端要做前端也要做。因为有些网络环境比如企业防火墙、路由器NAT超时会静默断开长连接双端心跳是最稳妥的保活手段。前端这里是30秒一次ping服务端配置的pingInterval也是30秒两边节奏对齐。断线重连时要考虑消息补发。因为断线期间的消息可能已经通过离线消息逻辑写入数据库了重连后需要调一个 HTTP 接口拉取离线消息WebSocket本身只负责实时收发不负责历史消息的完整性。onclose里的重连逻辑在生产环境建议加随机退避避免大量客户端同时断线、同时重连造成服务端瞬间压力过大。4. 运行部署与实施细节4.1 环境准备与扩展安装Workerman 在 Linux 下运行需要pcntl和posix两个扩展。用 PHP7.2 的默认编译参数通常自带这两个扩展但还是要确认一下php -m | grep pcntl php -m | grep posix如果没有需要重新编译PHP或者在已有环境上安装扩展。另外建议安装event扩展它能提升 Workerman 的事件驱动性能特别是在高并发连接场景下event扩展可以显著降低 CPU 占用。安装方式可以用 peclpecl install event装好后在php.ini中添加extensionevent.so然后用php -m验证。对于连接数超过1024的场景还需要修改系统文件描述符限制# 临时生效 ulimit -n 102400 # 永久生效 echo * soft nofile 102400 /etc/security/limits.conf echo * hard nofile 102400 /etc/security/limits.conf这个操作很关键。我第一版部署时没改这个压测到800个连接就开始报Too many open files后来才发现是系统默认限制。在线客服系统的并发连接数很容易就上千提前改好能少踩很多坑。4.2 启动与守护进程运行启动脚本写好后可以用下面命令启动所有进程php server/start.php start开发调试时用start会输出实时日志适合观察报错。线上运行时建议改用start -d进入守护模式或者使用 systemd 来管理这样进程崩溃后能自动拉起。一个简单可用的 systemd 配置示例[Unit] DescriptionWorkerman Chat Server Afternetwork.target [Service] Typesimple WorkingDirectory/data/wwwroot/chat-server ExecStart/usr/bin/php /data/wwwroot/chat-server/server/start.php start ExecStop/usr/bin/php /data/wwwroot/chat-server/server/start.php stop Restartalways RestartSec3 [Install] WantedBymulti-user.target每次修改Events.php或业务代码后不用重启所有连接只需执行 reload 重载业务进程保持长连接不断php server/start.php reload这里要注意reload 只能重载 BusinessWorker 的代码Gateway 的配置改动比如端口、心跳间隔需要完整重启才能生效。4.3 Nginx 反向代理 WebSocket线上环境一般不直接把 8282 端口暴露给用户而是通过 Nginx 做反向代理顺便可以加一层 TLS 证书让客户端走 WSS 协议。关键配置片段map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 443 ssl http2; server_name chat.example.com; ssl_certificate /etc/nginx/ssl/chat.pem; ssl_certificate_key /etc/nginx/ssl/chat.key; location / { proxy_pass http://127.0.0.1:8282; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }proxy_read_timeout和proxy_send_timeout默认值是60秒如果不改长连接会在60秒后被Nginx断开。这对于需要长时间在线的客服系统来说是不能接受的必须要调大或者比心跳间隔大即可。前端连接地址也相应变成wss://chat.example.comWSS 的好处是把WebSocket流量混在443端口里不易被某些网络环境拦截同时也省去了给WebSocket单独开防火墙端口的麻烦。5. 常见问题与排查经验5.1 频繁掉线的排查路径线上遇到最头疼的问题就是用户反馈“聊几句就掉线”。通常我会按以下顺序排查检查Nginx代理超时时间。没设置proxy_read_timeout是最常见的原因长连接超过60秒就被掐断。检查心跳是否正常。用Gateway::$pingData和客户端的心跳逻辑是否闭合。可以打开 Workerman 的日志观察是否有大量close事件。检查防火墙/NAT。有些云环境的安全组限制了长连接的空闲时间客户端在空闲时收不到任何数据包就会被网络设备回收。解决方法是缩短心跳间隔比如从30秒改为15秒。检查连接数限制。确认ulimit -n是否足够以及 Gateway 进程数是否合理。单进程支持的连接数一般没问题但如果监听端口被占用会导致新连接全部失败。5.2 消息延迟与性能瓶颈分析消息出现延迟时先别急着加机器先看瓶颈在哪如果延迟集中在某些时间段多半是 MySQL 慢查询。查一下saveMessage的写入是否命中了索引事务锁是否造成排队。如果并发高时 CPU 飙升用top看是哪个进程占用高。如果是 PHP-FPM 的进程高说明 HTTP 接口有慢请求不是 Workerman 的问题如果是 BusinessWorker 进程高说明业务代码有死循环或大循环。如果 Redis 用了BLPOP之类的阻塞操作也要注意等待超时对消息链路的影响。我的经验是在线客服系统的瓶颈几乎不可能在 Workerman 本身而是在消息落库和会话分配的策略上。后来我把saveMessage改成了先写 Redis 队列再由单独的消费脚本批量落库吞吐量立刻上了一个台阶。如果你不需要强实时性可以考虑这个方案。5.3 业务数据与长连接状态不同步一个容易被忽视的坑是客服在后台被删除或封禁了但他在 Gateway 里绑定的 uid 还在线还能照常收发消息。解决办法是在删除操作后显式调用Gateway::closeClientByUid($uid)强制断开他的长连接。另外访客在客户端发起会话时可能会在短时间内重复连接、断开导致同一个 uid 对应多个 client_id。GatewayWorker 的默认行为是一个 uid 可以绑定多个 client_id推消息时会推给所有连接。这会带来两个问题一是消息重复展示二是离线状态判断不准确。解决方法是在绑定新client_id之前先用Gateway::getClientIdByUid($uid)获取旧连接判断是否还在线如果在线则先断开旧连接再绑定新连接$oldClientId Gateway::getClientIdByUid($uid); if ($oldClientId) { Gateway::closeClient($oldClientId[0]); } Gateway::bindUid($client_id, $uid);这样能最大程度保证“一个用户同一时刻只有一个活跃连接”避免消息串台。最后梳理一下整体维护的感受做完整套客服系统再回头看当初的选型我只想说一个结论Workerman 是 PHP 团队做长连接场景最平滑的过渡方案没有之一。它没有 Swoole 那种性能极致追求的学习成本也不需要引入 Node.js 这种跨语言的心智切换却能把 WebSocket、TCP 长连接、多进程分布式这些硬核概念落地到完全可运维的技术栈上。如果你正在用 ThinkPHP 5或者刚接手一个 PHP 的客服/聊天/通知项目完全可以按照这篇文章的思路先把 GatewayWorker 跑起来把Events.php里的四个回调方法理解透再逐步加业务。这套东西本身的原理并不复杂真正复杂的是在真实网络环境中如何把超时、重连、离线、并发这些边界情况都处理到位。最后分享一个调试小技巧开发时不要直接调 WebSocket可以先在 Linux 上用一个简单的命令行工具测试连接比如websocat或 Node 的wscat发送一段 JSON 观察服务端返回能快速定位问题出在前端还是后端。我就是靠着这个工具排查了好几起“前端以为后端没收到、后端以为前端没发”的乌龙事故。本文还有配套的精品资源点击获取
返回列表