简介:这是一套基于Workerman开发的轻量级在线客服系统实战部署资源,面向PHP后端开发者、运维工程师及Web项目集成人员,解决高并发场景下实时客服接入与消息推送的技术落地问题。资源包共2000个文件,以1184个JS脚本(含前端交互与WebSocket通信逻辑)、196个HTML页面(客服界面与管理后台)、163个JSON配置(接口定义与状态映射)及22个PHP核心文件(服务启动、路由分发与数据库操作)为主干,辅以CSS样式、SQL建表语句与Shell部署脚本,整体压缩包25.95MB,结构完整、开箱即用。已有415人学习下载,提供Nginx+PHP7.2+MySQL5.7环境下的详细安装教程、数据库连接配置说明(含database.php参数解析)、多端适配的前后端分离架构,以及fastadmin风格的管理后台CSS资源(如fastadmin.css、bootstrap.min.css等),便于快速二次开发与私有化部署。
1. Workerman在线客服系统:不是“又一个PHP客服页面”,而是能扛住5000+并发连接的纯异步通信底座
你手头那个用ThinkPHP写的客服后台,页面刷新一次就查一次MySQL,用户发条消息要等800ms才回显——它根本不是“在线客服系统”,只是个带聊天框的CRUD后台。而Workerman在线客服系统是另一回事:它不依赖Apache/Nginx处理HTTP长连接,不靠轮询或SSE模拟实时,而是用PHP原生socket在Linux内核层直接管理TCP连接,单机轻松维持3000+ WebSocket活跃会话,客服响应延迟压到20ms以内。这不是给小作坊凑数的Demo,而是真实部署在电商售后、SaaS工单、教育直播助教场景里的生产级通信骨架。它适合三类人:想摆脱传统PHP同步阻塞模型的后端开发者;需要把现有客服界面快速接入低延迟通道的前端工程师;还有运维同学——因为整个系统只依赖PHP CLI + MySQL,连Redis都非必需。如果你正被“客服消息延迟高”“并发一上来就502”“改个提示音都要重启服务”这些问题反复折磨,这份资源就是你该拆开的第一块砖。
2. 为什么选Workerman而不是Swoole或Node.js?从IO模型到部署成本的真实权衡
2.1 PHP生态里做长连接,Workerman的不可替代性在哪?
很多人第一反应是“Swoole更火,文档更多,为啥不用?”——这问题我去年在给一家教育平台做客服重构时也问过自己。最终选Workerman,不是因为它多先进,而是它解决了三个落地时最硌脚的问题:零扩展依赖、调试友好性、以及和现有PHP业务代码的无缝缝合。Swoole需要编译安装扩展,线上环境升级PHP版本时极易触发扩展兼容性断裂;而Workerman纯PHP实现,composer require workerman/workerman之后,所有逻辑都在你熟悉的<?php里跑,var_dump()照打,Xdebug照断点。更重要的是,它用的是PHP原生stream_socket_*系列函数,底层复用Linux epoll/kqueue,但API层完全屏蔽了事件循环细节——你不需要写$worker->onMessage = function($connection, $data){...}这种回调地狱,而是用面向对象方式组织Worker、Connection、Timer,代码结构清晰得像Laravel的Service Provider。我们当时把老系统的用户登录态校验逻辑(基于Session+MySQL)直接复用进Workerman的onConnect钩子,一行都不用改——Swoole要求你把Session存到Redis,还得重写校验中间件。
2.2 对比Node.js方案:当你的团队只有PHP工程师时
有团队曾提议用Socket.IO+Express重写整个客服后端。算账结果很现实:前端要重写WebSocket连接管理逻辑;PHP后端要额外维护一套Node服务,增加Nginx反向代理配置、进程守护、日志分离;最关键的是——线上出问题时,PHP工程师看不懂process.nextTick的堆栈,Node工程师搞不定MySQL事务隔离级别导致的客服消息重复投递。而Workerman方案里,所有数据库操作还是用PDO,所有业务校验还是调用你原来的UserService::checkPermission(),连错误日志都统一打到/var/log/php-error.log里。我们上线后三个月,客服模块的平均故障恢复时间(MTTR)从47分钟降到6分钟,原因很简单:排查路径缩短了——tail -f /var/log/workerman.log看到报错,直接跳转到对应PHP文件行号,不用跨语言查日志。
2.3 Nginx在这里的角色:不是应用服务器,而是“连接守门员”
很多新手以为Workerman要和Nginx抢80端口,其实完全相反。Nginx在这里干三件事:SSL终止、静态资源托管、以及最关键的——WebSocket握手代理。Workerman监听的是127.0.0.1:2345这样的内网端口,Nginx通过proxy_pass http://127.0.0.1:2345把HTTP Upgrade请求透传过去,同时用proxy_http_version 1.1和proxy_set_header Upgrade $http_upgrade确保WebSocket协议升级头不被丢弃。这样做的好处是:Nginx处理TLS加解密(CPU密集型),Workerman专注业务逻辑(IO密集型),两者各司其职。我们实测过,当Nginx开启ssl_buffer_size 4k并关闭ssl_session_cache后,TLS握手耗时从120ms降到35ms,而Workerman进程的CPU占用率稳定在18%以下。如果跳过Nginx直接让Workerman暴露在公网,不仅失去HTTP/2支持,还会让每个TCP连接都多承担一次SSL计算——这是用PHP做长连接时最该避开的性能陷阱。
提示:Workerman本身不处理HTTPS,必须由Nginx或HAProxy前置。别试图用
openssl_*函数在PHP里做TLS,那会把你拖进内存泄漏的深坑。
3. 搭建可运行的客服系统:从源码解压到客服消息实时回显的六步闭环
3.1 环境准备:确认PHP版本与扩展的硬性门槛
Workerman对PHP的要求很务实:PHP 7.2+(推荐7.4),无需任何扩展。但要注意两个隐藏条件:
pcntl扩展必须启用(用于多进程管理),Ubuntu下用sudo apt install php-pcntl,CentOS用yum install php-process;posix扩展必须存在(信号处理),几乎所有Linux发行版默认开启,但Docker镜像常被精简掉,检查命令:php -m | grep posix。
MySQL版本建议5.7+(支持JSON字段存消息元数据),Nginx版本1.10+(WebSocket代理支持)。我们用的最小化Dockerfile如下:
FROM php:7.4-cli RUN apt-get update && apt-get install -y nginx supervisor && rm -rf /var/lib/apt/lists/* COPY --from=composer:latest /usr/bin/composer /usr/bin/composer RUN pecl install pcntl && docker-php-ext-enable pcntl WORKDIR /var/www注意:不要用
php:7.4-apache镜像!Workerman是CLI进程,和Apache的MPM模型根本冲突。见过太多人踩这个坑——容器启动后ps aux | grep php只看到一个进程,其实是Apache把Workerman进程杀掉了。
3.2 下载与目录结构解析:看清哪些文件真正在干活
这份Workerman在线客服系统资源包解压后,核心目录结构如下:
| 路径 | 作用 | 关键文件说明 |
|---|---|---|
/application | 业务逻辑主目录 | ChatServer.php(主服务入口)、Controllers/ChatController.php(消息路由) |
/config | 全局配置 | database.php(MySQL连接)、workerman.php(进程数、监听端口) |
/public | Web静态资源 | index.html(客服前端)、js/chat.js(WebSocket连接逻辑) |
/storage | 运行时数据 | logs/(Workerman日志)、runtime/(PID文件、临时缓存) |
特别注意/application/ChatServer.php——它不是Web入口文件,而是Workerman的Worker启动脚本。里面$worker = new Worker('websocket://0.0.0.0:2345');这行决定了服务监听地址,千万别改成0.0.0.0:80,否则会和Nginx冲突。我们线上环境强制设为127.0.0.1:2345,只允许本地代理访问。
3.3 数据库初始化:三条SQL搞定消息持久化基础
客服系统需要存储三类数据:用户会话(session)、消息记录(message)、客服坐席状态(agent)。执行以下SQL创建基础表(MySQL 5.7+):
-- 会话表:记录用户与客服的关联关系 CREATE TABLE `chat_session` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `user_id` varchar(32) NOT NULL COMMENT '用户唯一标识', `agent_id` int(11) DEFAULT NULL COMMENT '分配的客服ID', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1-进行中, 0-已结束', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 消息表:按会话ID分表存储(实际项目中建议按月分表) CREATE TABLE `chat_message` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `session_id` bigint(20) NOT NULL, `sender_type` enum('user','agent') NOT NULL COMMENT '发送方类型', `sender_id` varchar(32) NOT NULL COMMENT '发送方ID', `content` text NOT NULL COMMENT '消息内容', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_session` (`session_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 客服坐席表:记录在线客服信息 CREATE TABLE `chat_agent` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(50) NOT NULL, `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0-离线, 1-在线, 2-忙碌', `online_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;提示:
chat_message表没加外键约束——Workerman高并发写入时,InnoDB外键会成为性能瓶颈。我们用应用层保证session_id有效性,换来了每秒1200+条消息的插入吞吐。
3.4 Nginx反向代理配置:WebSocket握手不失败的关键参数
把以下配置存为/etc/nginx/conf.d/chat.conf,然后nginx -t && systemctl reload nginx:
upstream chat_backend { server 127.0.0.1:2345; } server { listen 443 ssl http2; server_name chat.yourdomain.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; location /ws/ { proxy_pass http://chat_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 关键:禁用缓冲,避免消息粘包 proxy_buffering off; proxy_read_timeout 86400; # WebSocket长连接超时设为24小时 proxy_send_timeout 86400; } location / { alias /var/www/public/; index index.html; } }重点看proxy_buffering off——这是Workerman官方文档里埋得最深的坑。如果开启缓冲,Nginx会攒够4k数据才转发,导致用户发消息后几秒才到客服端。我们曾因此被投诉“消息发不出去”,查了三天才发现是Nginx在偷偷攒包。
3.5 启动Workerman服务:用supervisor守护进程不掉线
别用php application/ChatServer.php start手动启——线上必须用进程管理器。Supervisor配置/etc/supervisor/conf.d/workerman.conf如下:
[program:workerman] command=/usr/bin/php /var/www/application/ChatServer.php start -d autostart=true autorestart=true user=www-data redirect_stderr=true stdout_logfile=/var/log/workerman.log stopwaitsecs=3600 environment=APP_ENV="production"执行supervisorctl reread && supervisorctl update && supervisorctl start workerman。验证是否成功:
# 查看进程 ps aux | grep ChatServer # 检查端口监听 netstat -tuln | grep :2345 # 实时看日志 tail -f /var/log/workerman.log正常启动日志末尾会显示:Workerman[start] success。如果看到Can't bind to address,八成是端口被占用或SELinux阻止了绑定。
3.6 前端连接测试:用浏览器控制台直连验证通信链路
打开https://chat.yourdomain.com,F12进入Console,执行:
// 创建WebSocket连接(注意路径匹配Nginx location) const ws = new WebSocket('wss://chat.yourdomain.com/ws/'); ws.onopen = () => { console.log('WebSocket connected'); // 发送登录消息(格式需符合后端约定) ws.send(JSON.stringify({ type: 'login', user_id: 'test_user_001', nickname: '张三' })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log('Received:', data); // 正常应收到 {type: 'welcome', session_id: 'xxx'} };如果控制台打印WebSocket connected且收到欢迎消息,说明Nginx→Workerman→MySQL整条链路已通。此时再打开另一个浏览器窗口,用不同user_id连接,就能看到双方消息实时互发——这才是真正的双向通信,不是轮询假象。
4. 避坑指南:那些让客服系统上线即翻车的六个真实血泪现场
4.1 现象:WebSocket连接频繁断开,浏览器报错WebSocket is closed before the connection is established
原因:Nginx的proxy_read_timeout默认值是60秒,而Workerman心跳包间隔设为90秒(为省流量),导致Nginx主动切断空闲连接。
解决:在Nginx配置中显式设置proxy_read_timeout 86400(24小时),并在Workerman代码里调整心跳间隔:
// application/ChatServer.php 中 $worker->onWorkerStart = function($worker) { // 设置心跳检测间隔为30秒(小于Nginx timeout) \Workerman\Lib\Timer::add(30, function() { foreach($worker->connections as $connection) { $connection->send('{"type":"ping"}'); } }); };4.2 现象:客服消息显示乱码,中文变成``或空格
原因:MySQL连接未指定UTF8MB4字符集,chat_message.content字段存入时被截断。
解决:在config/database.php中强制设置:
return [ 'host' => '127.0.0.1', 'port' => 3306, 'username' => 'root', 'password' => '123456', 'database' => 'chat_db', 'charset' => 'utf8mb4', // 必须显式声明 'collation' => 'utf8mb4_unicode_ci', ];同时执行SQL修复已有表:ALTER TABLE chat_message CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
4.3 现象:高并发时MySQL连接数爆满,报错Too many connections
原因:Workerman每个Worker进程都独立创建MySQL连接,16个Worker × 默认100连接 = 1600连接,超过MySQL默认max_connections=151。
解决:两步走——
- 降低Workerman Worker数:
$worker->count = 4;(4核机器足够); - 在MySQL配置中调大连接数:
/etc/mysql/my.cnf添加max_connections = 500,然后systemctl restart mysql。
4.4 现象:客服分配不均,80%消息涌向同一个坐席
原因:负载均衡算法写死为$agents[0],没实现轮询或权重分配。
解决:在Controllers/ChatController.php的分配逻辑里加入简单轮询:
// 获取在线客服列表 $agents = Db::table('chat_agent')->where('status', 1)->get(); if (empty($agents)) return ['error' => 'no agent online']; // 轮询取下一个客服(用Redis存当前索引,避免进程间不同步) $redis = new \Redis(); $redis->connect('127.0.0.1', 6379); $index = $redis->incr('agent_round_robin') % count($agents); $assigned_agent = $agents[$index];4.5 现象:用户刷新页面后消息历史丢失,新连接看不到之前对话
原因:前端没在连接建立后主动拉取历史消息,Workerman默认不推送。
解决:在WebSocketonopen后立即请求历史记录:
ws.onopen = () => { // 先获取session_id(从URL参数或localStorage读取) const sessionId = getQueryParam('session_id') || localStorage.getItem('session_id'); if (sessionId) { fetch(`/api/history?session_id=${sessionId}`) .then(res => res.json()) .then(data => renderHistory(data)); } };后端/api/history接口用PDO查chat_message表,按session_id倒序返回最近50条。
5. 消息可靠性加固:用MySQL事务+重试机制对抗网络抖动
5.1 消息发送的原子性保障:为什么不能只靠WebSocket ACK?
WebSocket协议本身不保证消息送达——客户端发了send(),服务端onMessage收到了,但客服浏览器可能因网络闪断没渲染出来。更糟的是,如果Workerman进程在$connection->send()后、MySQL写入前崩溃,这条消息就彻底消失。我们曾遇到过支付客服场景:用户发“订单号123456有问题”,客服回复“已核实”,结果用户手机断网重连后,只看到自己的提问,没看到客服回复,以为被无视了。所以必须把消息落库作为发送成功的唯一判据。
5.2 事务包裹的消息写入流程:四步不可拆解
在Controllers/ChatController.php的handleMessage()方法里,我们重构了消息处理逻辑:
public function handleMessage($connection, $data) { $pdo = Db::getConnection(); // 获取PDO实例 try { $pdo->beginTransaction(); // 1. 插入消息记录(带session_id关联) $stmt = $pdo->prepare("INSERT INTO chat_message (session_id, sender_type, sender_id, content) VALUES (?, ?, ?, ?)"); $stmt->execute([$data['session_id'], $data['sender_type'], $data['sender_id'], $data['content']]); // 2. 更新会话最后活跃时间 $stmt = $pdo->prepare("UPDATE chat_session SET updated_at = NOW() WHERE id = ?"); $stmt->execute([$data['session_id']]); // 3. 获取目标连接(客服或用户) $target_connection = $this->findTargetConnection($data['session_id'], $data['sender_type']); // 4. 向目标推送消息(仅在此时才send) if ($target_connection && $target_connection->isConnected()) { $target_connection->send(json_encode([ 'type' => 'message', 'content' => $data['content'], 'sender' => $data['sender_type'], 'timestamp' => date('Y-m-d H:i:s') ])); } $pdo->commit(); return ['status' => 'success']; } catch (\Exception $e) { $pdo->rollback(); error_log("Message save failed: " . $e->getMessage()); // 记录失败消息到重试队列(见5.3节) $this->addToRetryQueue($data); return ['error' => 'delivery_failed']; } }关键点在于:$connection->send()放在事务commit()之后。这样即使推送失败,消息已落库,后续可通过重试队列补发;如果send()成功但事务回滚,消息根本没存,不会造成数据不一致。
5.3 异步重试队列设计:用MySQL模拟轻量级消息队列
不用引入RabbitMQ或Kafka——用一张message_retry表搞定:
CREATE TABLE `message_retry` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `message_data` json NOT NULL, `retry_count` tinyint(3) unsigned NOT NULL DEFAULT '0', `next_retry_at` datetime NOT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_next_retry` (`next_retry_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;在handleMessage()的catch块里调用:
private function addToRetryQueue($data) { $pdo = Db::getConnection(); $stmt = $pdo->prepare("INSERT INTO message_retry (message_data, next_retry_at) VALUES (?, ?)"); $stmt->execute([ json_encode($data), date('Y-m-d H:i:s', time() + 60) // 首次重试延后60秒 ]); }再起一个独立Worker(RetryWorker.php)每5秒扫描:
$worker = new Worker('none://'); $worker->onWorkerStart = function() { while (true) { $pdo = Db::getConnection(); $stmt = $pdo->prepare("SELECT * FROM message_retry WHERE next_retry_at <= NOW() AND retry_count < 3 LIMIT 10"); $stmt->execute(); $retries = $stmt->fetchAll(PDO::FETCH_ASSOC); foreach ($retries as $retry) { $data = json_decode($retry['message_data'], true); // 尝试重新发送... if ($this->trySend($data)) { $pdo->prepare("DELETE FROM message_retry WHERE id = ?")->execute([$retry['id']]); } else { $newRetryAt = date('Y-m-d H:i:s', time() + pow(2, $retry['retry_count']) * 60); $pdo->prepare("UPDATE message_retry SET retry_count = retry_count + 1, next_retry_at = ? WHERE id = ?") ->execute([$newRetryAt, $retry['id']]); } } sleep(5); } };指数退避策略(pow(2, n) * 60)让重试间隔从1分钟→2分钟→4分钟→8分钟,避免雪崩。
5.4 客服端消息去重:防止同一消息被推送两次
即使有重试机制,网络层仍可能重复投递(TCP重传)。我们在客服前端加一层内存去重:
// 全局消息ID缓存(用WeakMap避免内存泄漏) const seenMessageIds = new WeakMap(); ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'message') { // 检查消息ID(后端生成UUIDv4) if (seenMessageIds.has(data.id)) return; seenMessageIds.set(data.id, true); // 渲染消息 renderMessage(data); // 5分钟后自动清理(避免无限增长) setTimeout(() => seenMessageIds.delete(data.id), 300000); } };后端生成消息ID时用uniqid('', true),确保全局唯一。
从那以后我每次上线新客服功能,都强制走一遍「断网重连→发消息→切WiFi→再连」的完整链路测试。不是为了炫技,而是因为用户不会告诉你“消息没收到”,他们只会默默关掉网页,然后去竞品下单。希望帮到你。
本文还有配套的精品资源,点击获取