基于ThinkPHP的无限坐席在线客服系统架构与实现

📅 发布时间:2026/9/16 1:10:29
基于ThinkPHP的无限坐席在线客服系统架构与实现
简介一款基于ThinkPHP内核开发的无限坐席在线客服系统源码面向需要部署私有化客服系统的站长、企业运维人员以及希望学习客服系统架构与PHP二次开发的开发者。系统支持多坐席同时接入压缩包约36.73MB共2000个文件其中包含3469个PHP文件、1621个PNG图片、573个JS脚本、287个HTML页面及268个GIF动态图等PHP文件承载业务逻辑与接口JS与CSS用于前端交互和界面样式图片资源覆盖图标与场景素材整体目录结构清晰便于定位核心模块。已有963人学习下载可作为研究客服会话分配、消息推送、坐席管理、统计报表等功能的参考项目。源码包内附带完整的安装引导文件、配置示例及常用辅助脚本适合具备一定PHP基础的学习者直接部署体验并在此基础上进行功能扩展与界面定制。1. 基于ThinkPHP的无限坐席在线客服系统架构先于功能在线客服系统这个需求第一反应往往是“能做聊天就行”。但真正投入生产后瓶颈几乎都出现在同一个地方坐席数量上来之后连接、状态同步、消息分发全挤在一起数据库连接被占满PHP-FPM直接卡死。所谓“无限坐席”不是指一台服务器能挂无数连接而是架构上要支持坐席水平扩展、消息不丢、状态实时一致。ThinkPHP在这个场景里不是用来写聊天页面的而是承担业务内核坐席管理、会话路由、消息持久化、数据统计。把内核层与实时通信层拆开是这套系统能否扛住坐席增长的关键。本文面向需要自研客服系统的技术负责人和PHP工程师从选型、表结构、分配策略到压测验证给出可落地的完整路径。2. ThinkPHP选型与坐席体系的数据模型设计2.1 用哪个版本ThinkPHP 5.1还是6.0PHP 8兼容性怎么处理选ThinkPHP做内核主要看中它的ORM、验证器、中间件和命令行的成熟度。需要明确一点自研客服系统里ThinkPHP只处理业务API和管理后台WebSocket长连接由独立网关承载不走TP的请求生命周期。这样的话TP的性能瓶颈就被隔离在业务API一侧不至于影响实时消息吞吐。版本选型上新项目建议直接用ThinkPHP 6.0配合PHP 8.0以上。如果老系统是TP 3.2需要兼容PHP 8的话问题集中在mysql_*函数移除、each()语法废弃、以及魔术方法的兼容层。常见做法是写一个兼容扩展包把TP 3.2的DB类重写为基于PDO的实现但这属于老项目迁移的范畴新项目不必走这条路。提示如果你维护的是TP 3.2老项目PHP 8下最常见的报错是Call to undefined function mysql_connect()解决思路不是打补丁强撑而是把数据库操作层替换为think\db的PDO实现同时处理掉each、list在foreach中的语法变化。2.2 坐席模块的三张核心表关于“无限坐席”的表结构设计无限坐席在数据库层面的核心不是“不做限制”而是让坐席数据可以水平切分。以下是直接可用的三张表设计涵盖了坐席账号、状态记录和技能组关联。-- 坐席账号表 CREATE TABLE agent ( id int(11) unsigned NOT NULL AUTO_INCREMENT, agent_no varchar(32) NOT NULL COMMENT 坐席工号业务内唯一, real_name varchar(64) NOT NULL DEFAULT COMMENT 姓名, type tinyint(4) NOT NULL DEFAULT 1 COMMENT 1在线客服 2机器人, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0离线 1空闲 2忙碌 3小休, max_sessions int(11) NOT NULL DEFAULT 5 COMMENT 最大同时接待数, created_at int(11) NOT NULL DEFAULT 0, updated_at int(11) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_agent_no (agent_no), KEY idx_status_type (status, type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT坐席基础表; -- 坐席实时状态表按天分表或使用Redis替代 CREATE TABLE agent_status_log ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, agent_no varchar(32) NOT NULL, status tinyint(4) NOT NULL COMMENT 状态值, session_count int(11) NOT NULL DEFAULT 0 COMMENT 当前会话数, last_active_at int(11) NOT NULL DEFAULT 0 COMMENT 最后活跃时间, PRIMARY KEY (id), KEY idx_agent_no (agent_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT坐席状态流水可按月归档; -- 坐席-技能组关联表 CREATE TABLE agent_skill_group ( id int(11) unsigned NOT NULL AUTO_INCREMENT, agent_no varchar(32) NOT NULL, group_id int(11) NOT NULL COMMENT 技能组ID, priority tinyint(4) NOT NULL DEFAULT 0 COMMENT 优先级数值小优先, PRIMARY KEY (id), UNIQUE KEY uk_agent_group (agent_no, group_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT坐席技能组关联;agent表用来持久化坐席的静态信息status字段允许冗余真正的实时状态应该放到Redis因为数据库的读写粒度和频率跟不上坐席状态切换的速度。max_sessions是实现“无限坐席”的软性控制点不限制坐席总数但限制单个坐席的并发接待量避免一个坐席被会话打爆。这里的agent_status_log按天分表后坐席总数可以做到十万级以上查询历史状态时按月表路由不会因为单表数据膨胀拖慢写入。实际上当一个客服系统的坐席超过一万时实时状态基本全部走Redis这张流水表只用于管理员查看历史状态和排班报表。2.3 Redis在内核层承担的角色在线状态与坐席维度的数据隔离ThinkPHP内核算的是业务规则而实时状态的数据载体落在Redis。每个坐席上线时向Redis写入一个Hash结构use think\facade\Cache; // 坐席上线时写入状态 $key agent:online: . $agentNo; Cache::store(redis)-hSet($key, status, 1); // 1空闲 2忙碌 3小休 Cache::store(redis)-hSet($key, session_count, 0); Cache::store(redis)-hSet($key, last_active_at, time()); Cache::store(redis)-expire($key, 86400); // 24小时过期防止僵尸数据 // 坐席心跳续期TP命令行任务中执行 Cache::store(redis)-expire($key, 86400);用Hash而不是String的优势在于可以只更新last_active_at这一个字段不需要先读出整个JSON再写回减少并发写冲突。另外坐席的在线列表存为一个Set用于分配时快速过滤// 将空闲坐席加入可分配池 Cache::store(redis)-sAdd(agent:pool:group: . $groupId, $agentNo);agent:pool:group:*这个Set就是分配算法的输入源坐席数增加时不需要扫描全表直接从Set里按策略取值。当坐席状态变化时移除或加入对应的技能组池子。这种设计下单台Redis实例支撑数千坐席的状态读写没有压力瓶颈只会出现在消息推送层。3. 无限坐席的分配策略与会话路由3.1 坐席状态机与状态变更的事件驱动无限坐席系统的核心是状态机离线、空闲、忙碌、小休四种状态之间如何流转以及流转时同步哪些数据。状态流转不只是改一个字段还要触发分配池更新、会话转移、离线通知等动作。每次状态变更建议统一走同一个入口而不是在各处直接改库namespace app\service; use think\facade\Cache; class AgentStateService { public static function change(int $agentNo, int $newStatus): bool { $oldStatus Cache::store(redis)-hGet(agent:online: . $agentNo, status); if ($oldStatus $newStatus) { return true; } // 更新Redis Cache::store(redis)-hSet(agent:online: . $agentNo, status, $newStatus); // 同步分配池 $poolKey agent:pool:default; if ($newStatus 1) { Cache::store(redis)-sAdd($poolKey, $agentNo); } else { Cache::store(redis)-sRem($poolKey, $agentNo); } // 写入状态流水异步队列 \think\facade\Queue::push(AgentStatusJob::class, [ agent_no $agentNo, status $newStatus, ts time() ]); return true; } }这个服务的两个细节值得注意。第一分配池只用default组实际项目中应根据技能组拆成多个key比如agent:pool:group:10这样访客进线时只从对应技能组的池子取人。第二状态流水通过TP的Queue异步写入避免坐席频繁点“小休”时阻塞主流程。队列消费失败时要有重试机制否则管理人员会看到状态日志缺失。3.2 分配算法空闲优先、轮询与技能组匹配的组合策略分配是“无限坐席”的灵魂。经典的算法是空闲优先但这还不够因为客服场景里技能组和优先级往往比“谁最闲”更重要。一个处理售后的坐席不应该被分配到售前咨询。实际工程中我一般用两级筛选namespace app\service; use think\facade\Cache; class RouterService { /** * 根据技能组和访客ID分配坐席 * param int $groupId 技能组ID * param string $visitorId 访客会话标识 * return string|null 返回agent_no或null排队 */ public function dispatch(int $groupId, string $visitorId): ?string { $poolKey agent:pool:group: . $groupId; $agents Cache::store(redis)-sMembers($poolKey); if (empty($agents)) { return null; } // 第一轮过滤掉达到最大接待量的坐席 $candidates []; foreach ($agents as $agentNo) { $onlineKey agent:online: . $agentNo; $sessions (int) Cache::store(redis)-hGet($onlineKey, session_count); $maxSessions (int) Cache::store(redis)-hGet($onlineKey, max_sessions); if ($sessions $maxSessions) { // 找到坐席的当前接待数并作为排序依据 $candidates[$agentNo] $sessions; } } if (empty($candidates)) { return null; } // 第二轮接待数最少的优先同接待数时取最早空闲的 asort($candidates); $selected array_key_first($candidates); // 命中后立即递增session_count防止并发重复分配 Cache::store(redis)-hIncrBy(agent:online: . $selected, session_count, 1); return $selected; } }array_key_first在PHP 7.3可用这里取接待数最少的坐席。需要注意一个关键问题分配和递增不是原子的两个访客同时进线时可能选到同一个坐席。解决办法是在Redis里执行Lua脚本将“取候选递增计数”合并为一个原子操作或者在业务层加锁。生产环境必须用Lua否则高并发下必然出现超卖式的分配冲突。轮询算法相对简单但空闲优先在客服场景中通常体验更好因为客服喜欢一个一个处理完而不是同时挂着十个会话各聊一句。3.3 排队与溢出策略分配不到坐席时内核怎么兜底当候选池为空时访客进入排队队列。排队不是简单的FIFO而是带优先级的FIFOVIP访客可以插队重复来访的老客户比新访客优先级高。// Redis有序集合实现排队score为优先级*100000时间戳 $score $priority * 100000 time(); Cache::store(redis)-zAdd(queue:group: . $groupId, $score, $visitorId);坐席空闲时由TP的命令行任务或事件回调触发抽人操作从queue:group:*中取score最小的访客再走dispatch流程。溢出策略上队列超过阈值比如20人或排队超过3分钟时引导访客留言或转机器人。这里的阈值配置建议放到TP的配置文件中方便运营随时调整。4. 实时消息推送与会话保持TP内核之外的通信网关4.1 WebSocket网关与ThinkPHP的职责边界划分在线客服的实时消息不能靠HTTP轮询那是把数据库当消息队列用坐席一多就会拖垮业务库。工程上最稳妥的方案是独立网关负责WebSocket长连接ThinkPHP提供HTTP API和业务数据处理两者通过Redis消息队列通信。网关层存在多种选择常见的包括Workerman、Swoole等。如果团队PHP经验为主Workerman上手成本最低如果已有Java或Go团队也可以另外写网关。这里的关键不在于选哪个而在于把协议对齐。消息格式建议统一为JSON{ event: chat.message, data: { msg_id: 20240101120000123456, from: visitor_10001, to: agent_20001, content: 你好我想咨询一下退款流程, ts: 1704110400 } }网关收到消息后先写入Redis的待处理队列ThinkPHP的消费脚本从队列取出做敏感词过滤、落库、然后调用网关的HTTP接口把消息推送给对方。这个链路的好处是推送和落库解耦网关不需要知道MySQL的表结构ThinkPHP不需要维持长连接各自可以独立横向扩容。4.2 心跳与断线重连坐席掉线后如何不丢会话WebSocket连接的稳定性是客服系统的生命线。坐席网络抖动、浏览器切后台、公司断网都会导致连接断开。必须实现心跳机制与断线重连。网关层的心跳参数一般设25到30秒一次ping3次无响应判定断线。// Workerman GatewayWorker中设置心跳 $worker-pingInterval 25; // 秒 $worker-pingNotResponseLimit 3; // 连续3次未回应则断开ThinkPHP侧要做的不是维持心跳而是监听网关的断线回调。当网关通知某个坐席连接断开时TP调用AgentStateService::change()将该坐席状态置为离线并把它负责的会话转移到技能组池子重新分配。转移时要注意会话中原坐席的session_count要减掉否则状态计数会漂移。提示断线不等于会话结束。访客等待期内可以提示“坐席暂时离开”而不是立即显示“会话已结束”。重连后原坐席优先回收该会话超过5分钟才允许被其他坐席接管这个逻辑要在分配时加一个last_agent_no字段判断。4.3 消息去重与离线消息补偿网络重传会导致同一条消息被收到两次所以消息头必须带msg_id。TP在消费消息时用msg_id做幂等判断namespace app\service; use app\model\ChatMessage; use think\facade\Cache; class MessageService { public function handle(array $message): bool { $msgId $message[data][msg_id]; // 用Redis SETNX做幂等防止并发重复写入 $lock Cache::store(redis)-set(msg:dedup: . $msgId, 1, [nx true, ex 3600]); if (!$lock) { return true; // 已处理过直接丢弃 } ChatMessage::create([ msg_id $msgId, from $message[data][from], to $message[data][to], content $message[data][content], create_time $message[data][ts] ]); return true; } }离线消息补偿的逻辑是消息落库后如果推送目标不在线不抛异常而是把msg_id写入目标的离线消息列表。等目标上线时从离线列表拉取最近50条未读消息。实现上可以用Redis List存储上线时一次性弹出并推送。5. 压测方法、参数调优与常见瓶颈排查5.1 用脚本模拟并发进线验证分配接口的TPS在交付前建议做一个基础压测压测的目标不是证明系统能抗10万并发而是验证单实例的分配接口在500并发下不报错、响应在200ms内。可以用一个简单PHP脚本模拟// bench.php 并发模拟脚本 $url http://your-domain/api/router/dispatch; $requests []; for ($i 0; $i 500; $i) { $requests[] $url . ?group_id10visitor_idvisitor_ . $i; } $mh curl_multi_init(); foreach ($requests as $i $req) { $ch curl_init($req); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_multi_add_handle($mh, $ch); } $running null; $successCount 0; $totalTime microtime(true); do { curl_multi_exec($mh, $running); $result curl_multi_select($mh); } while ($running 0); // 统计结果 foreach ($requests as $i $req) { $output curl_multi_getcontent($ch); if ($output ! false json_decode($output)-code 200) { $successCount; } }压测最直接的观察指标有两个成功率和平均响应时间。如果成功率低于98%先检查Redis连接数是否打满再看PHP-FPM的pm.max_children是否够用。多数情况下瓶颈在FPM进程数而不是代码本身。5.2 PHP-FPM、Redis与Nginx的参数联动调整坐席系统的请求特点是小而频每个请求的耗时通常低于100ms但每秒请求量大。针对这个特征FPM配置建议调整为动态模式pm dynamic pm.max_children 120 pm.start_servers 30 pm.min_spare_servers 20 pm.max_spare_servers 50Redis这边主要是确认最大连接数定义合理。默认的maxclients是10000但PHP-FPM每个进程会占用一个连接Redis的timeout要设置到300秒以上避免空闲连接被服务端切断。Nginx的keepalive_timeout建议设成65秒保持与WebSocket网关的错峰不强制复用HTTP长连接因为客服页面的消息走WebSocketHTTP接口只做业务操作。5.3 ThinkPHP框架层的SQL查询隐患客服系统最容易被忽视的性能杀手是N1查询。比如拉取会话列表时在foreach中查坐席姓名或访客信息坐席一多就慢。任何列表页都要用TP的with预加载关联模型$list SessionModel::with([agent, visitor]) -where(create_time, , $startTime) -order(last_message_time, desc) -limit(50) -select();排查SQL慢查询时开启TP的SQL日志把时间超过1秒的查询单独落文件。一般涉及三个字段的索引就够不要每个字段都加索引过多会拖慢写入。最后一格实战建议给session表的last_message_time和status建联合索引这是客服会话页加载最频繁的查询条件加上后即便会话表过百万行列表页也能稳定在200ms内返回。这套基于ThinkPHP搭建的内核结构配合网关转发和Redis状态层坐席从几十扩展到几千时只需要横向加网关节点业务代码可以保持不变。本文还有配套的精品资源点击获取