news 2026/9/15 19:09:18

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于ThinkPHP实现无限坐席在线客服系统的核心架构与实践

简介:基于ThinkPHP内核开发的无限坐席在线客服系统源码,为需要搭建在线客服的企业或开发者提供了完整可运行方案。系统支持不限数量的坐席接入,集成访客对话窗口、坐席工作台与管理后台,安装过程简单,配置好PHP5.6与MySQL5.5环境、设置运行目录及ThinkPHP伪静态规则,访问install.php即可部署,启动两个端口即可使用。资源包共2000个文件,以PHP业务代码、PNG界面素材、JavaScript交互脚本、HTML页面及CSS样式表为主,压缩包约36.73MB,结构清晰,便于学习路由、控制器、模板渲染等ThinkPHP开发要点,也方便替换前端样式或扩展客服业务逻辑。当前已有963人学习/下载,尤其适合具备PHP基础、希望快速获得一套可运营客服系统的技术用户。

1. 无限坐席在线客服系统,真正的瓶颈在坐席之外

很多人第一次看到“无限坐席”这个卖点,会下意识地把它理解成数据库里能塞多少行坐席账号,或者后台能创建多少个客服人员。但从实际交付的角度看,这两件事都不构成瓶颈——MySQL 存几十万行用户数据毫无压力,后台多做几个增删改查也只是时间问题。真正让客服系统崩掉的,是访客消息的到达方式、坐席状态的实时一致性、以及消息分发链路的吞吐能力,这三件事设计的不好,系统到 50 个坐席在线时就会开始丢消息或延迟飙升,和坐席总数的“无限”之间隔着一条巨大的鸿沟。

这篇博客要聊的,就是基于 ThinkPHP 内核构建无限坐席在线客服系统源码时,我会怎么拆解这个需求,并提供一套可以直接落地的思路。适合的读者是已经在用 ThinkPHP 做业务系统、打算自己写客服模块或类似 IM 功能的开发团队。我会把会话模型、坐席分配算法、轮询与长连接的取舍、以及从单机到集群的扩展改造逐一展开,全部用可复现的代码和表结构说话。

2. 会话模型与坐席分配策略,决定“无限坐席”的天花板

在线客服系统的数据流看起来非常简单:访客发消息,坐席收消息,坐席回消息。但如果直接按“消息表”来设计,系统很快就会失控——因为你需要知道一条消息属于哪一段连续对话、当前由谁负责、是否已经结束、访客是否还在等待。这一整段关系在客服系统里叫“会话”(session),先把这个模型立住,后面所有的实时性和扩展性问题才有讨论的基础。

2.1 三个核心实体:访客、坐席、会话

我一般会把客服系统的最小实体集划成三张表:访客表、坐席表、会话表。访客表存来源渠道、浏览器指纹、首次访问时间;坐席表存工号、昵称、技能组、状态和最后心跳时间;会话表则是整个系统最核心的一张表,它把一次完整的服务过程从开始到结束状态化。

会话表的关键字段设计如下:

CREATE TABLE `chat_session` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `session_no` varchar(32) NOT NULL COMMENT '会话编号,对外展示用', `visitor_id` bigint(20) unsigned NOT NULL COMMENT '访客ID', `agent_id` bigint(20) unsigned DEFAULT NULL COMMENT '当前接待坐席ID,NULL表示未分配', `group_id` int(11) unsigned DEFAULT NULL COMMENT '技能组ID', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0排队中 1服务中 2已结束 3已转接', `channel` varchar(20) NOT NULL DEFAULT 'web' COMMENT '渠道:web/app/wechat', `last_msg_time` datetime DEFAULT NULL COMMENT '最后一条消息时间', `created_at` datetime DEFAULT NULL COMMENT '创建时间', `ended_at` datetime DEFAULT NULL COMMENT '结束时间', PRIMARY KEY (`id`), KEY `idx_agent_status` (`agent_id`, `status`), KEY `idx_visitor_created` (`visitor_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话主表';

这个设计里有两个细节值得注意。第一,agent_id允许为空,空值代表会话还在排队等待分配,这是“无限坐席”系统里至关重要的一个状态;第二,索引idx_agent_status直接服务后面要讲的空闲坐席查询,避免全表扫描。消息表则单独存放,通过session_id关联会话,分页加载历史消息时只查消息表,不碰会话表。

之所以把会话从消息里独立出来,是因为会话的粒度是“一次服务过程”,它的状态变化频率低(排队、服务中、结束),适合用关系型数据库存;而消息的粒度是“一次交流动作”,频率高、写入量大,未来如果要拆分库或引入独立的 IM 存储,会话表可以留在业务库,消息表挪走,两侧通过session_id保持关联即可。

2.2 空闲坐席优先分配,一行 SQL 先解决 80% 需求

坐席分配策略有很多种——轮询分配保证公平、空闲优先保证响应速度、技能组匹配保证专业度。对于大多数中小型客服系统来说,最优解不是其中某一个,而是先按技能组过滤,再在组内按空闲时长排序取最长空闲者。这个策略实现成本极低,但效果最接近大厂客服系统的体感。

在 ThinkPHP 里的具体实现我一般这样写:

// app/service/AssignService.php namespace app\service; use think\facade\Db; class AssignService { /** * 为指定技能组分配一个最空闲的在线坐席 * @param int $groupId 技能组ID * @return int|null 返回坐席ID,无可用坐席时返回null */ public function pickFreeAgent(int $groupId): ?int { $agent = Db::name('agent') ->alias('a') ->leftJoin('chat_session cs', 'cs.agent_id = a.id AND cs.status = 1') ->where('a.group_id', $groupId) ->where('a.status', 1) // 1=在线 ->where('a.last_heartbeat', '>', time() - 30) // 30秒内有心跳 ->group('a.id') ->order('COUNT(cs.id) ASC, a.last_active_time ASC') ->find(); return $agent ? (int)$agent['id'] : null; } }

这段代码的逻辑是:先限定技能组和在线状态,再用左连接去统计每个坐席当前正在服务的会话数,GROUP BY之后按“正在服务的会话数从小到大、最近活跃时间从小到大”排序,取第一个。这样选出来的坐席,是同时满足“手头活最少”和“休息时间最长”的人。

这里有个常见的性能陷阱:如果坐席数量百万级,这种GROUP BY的查询会成为压力点。但在真实客服系统里,同时在线坐席数通常不会超过一万,而且这个查询只在“有新会话需要分配”时才触发,频率本身不高,配合last_heartbeat上的索引,完全够用。如果真要做到万人以上坐席在线,后面第四、五章会给出基于 Redis 的替代方案,这里先不展开。

2.3 悲观锁还是乐观锁,分配时别让两个访客抢到同一坐席

分配算法有一个容易被忽略的并发问题:假设坐席 A 目前空闲,两个访客同时发起会话请求,两个请求都执行了上面的查询,都返回坐席 A 的 ID,于是两个会话同时绑定到 A 身上。这在业务上叫“超卖”,解决思路和商品库存扣减完全一致。

ThinkPHP 里我有两个推荐做法。其一,在更新会话表时加上条件,只有坐席“仍然空闲”才允许更新结束:

$updated = Db::name('chat_session') ->where('id', $sessionId) ->where('agent_id', 'NULL') // 确保还没被其他人分配 ->update([ 'agent_id' => $agentId, 'status' => 1, 'last_msg_time' => time() ]); if ($updated === 0) { // 分配失败,说明坐席被其他请求抢先了,重新走分配逻辑 }

其二,在坐席表上使用UPDATE ... SET status = 1 WHERE id = ? AND status = 0这样的条件更新,利用数据库行锁保证同一时刻只有一个请求能占用该坐席。我建议两种方式结合使用:分配坐席时用条件更新占住坐席位,再更新会话表绑定关系,两步之间通过显式事务包裹。核心原则是——不要先查询再更新,把“查询结果是否有效”这个校验交给数据库的行锁去保证。

3. 基于 ThinkPHP 的最小通信链路实现,先跑通再谈优化

模型和分配策略立住之后,就要处理最实际的问题:访客和坐席之间到底怎么传消息。基于 ThinkPHP 开发时,常被问到的第一句话是“你们用的 WebSocket 还是轮询”。我的答案可能和很多技术博客不同——先把轮询实现跑通,再在需要的地方升级成长连接。轮询不丢消息、实现简单、天然适配 ThinkPHP 传统的请求响应模型,而且从最终的技术债角度看,轮询接口设计和 WebSocket 消息体设计几乎完全一致,后续切换成本没有想象中那么高。

3.1 两个必备接口,设计成前端好调用的样子

一个是访客侧的checkNewMessage,一个是坐席侧的pollTasks。这两个接口都是轮询性质的,核心目的不是“推消息”,而是“增量拉取当前会话的最新状态”。我把消息表读接口设计成基于时间戳增量的方式,前端每次请求带上自己本地的最新消息 ID 或时间戳,后端只返回比这个更大的记录。

// 路由文件里做如下绑定 // Route::post('api/visitor/check_new', 'api.VisitorMessage/checkNew'); // app/controller/api/VisitorMessage.php namespace app\controller\api; use think\facade\Db; use think\response\Json; class VisitorMessage { public function checkNew(): Json { $visitorId = (int) request()->param('visitor_id'); $sessionId = (int) request()->param('session_id'); $lastMsgId = (int) request()->param('last_msg_id', 0); $messages = Db::name('chat_message') ->where('session_id', $sessionId) ->where('id', '>', $lastMsgId) ->order('id ASC') ->limit(50) ->select() ->toArray(); return json([ 'code' => 0, 'data' => $messages, 'last_msg_id' => $messages ? end($messages)['id'] : $lastMsgId, ]); } }

前端拿到返回的last_msg_id后,下一次轮询带上这个值,服务端就只查增量数据。这样每次请求的开销非常小,数据库走主键范围查询,即使 1000 个访客同时轮询也不至于打满数据库。轮询间隔一般设为 3 到 5 秒,低于 2 秒会给服务器带来无意义的压力,高于 10 秒访客会明显感觉到消息延迟。

坐席侧的pollTasks接口类似,但多了一层“会话状态变更”的判断:坐席需要知道有没有新会话被分配过来、正在服务的会话有没有新消息、以及有没有会话被访客主动结束。这些状态变更我会合并成一个seq_id概念——在 Redis 里维护每个坐席的一个自增序列号,任何状态变更都让序列号加一,坐席轮询时带上自己本地记录的上次序列号,不一致就说明有变化,再拉取详细数据。这种设计能显著减少无效轮询时的数据库查询量。

3.2 定时任务的调度,让超时会话自动转接或结束

客服系统中有大量不依赖用户操作的后台动作:访客 30 秒没回复,系统要提示坐席跟进;坐席 5 分钟没响应,系统要把会话重新放回排队池;会话结束 24 小时后,要把相关记录归档。这些动作通常放在一个 ThinkPHP 命令行定时任务里处理,而不是依赖某个用户请求顺带触发。

# crontab 配置,每分钟执行一次 * * * * * cd /www/wwwroot/site && php think task:timeout_check >> /tmp/kefu_task.log 2>&1

对应的定时任务代码骨架:

// app/command/TimeoutCheck.php namespace app\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\facade\Db; class TimeoutCheck extends Command { protected function configure(): void { $this->setName('task:timeout_check')->setDescription('检查超时会话'); } protected function execute(Input $input, Output $output): void { // 访客超过60秒未回复,标记为“坐席可结束” Db::name('chat_session') ->where('status', 1) ->where('last_msg_time', '<', time() - 60) ->update(['status' => 5]); // 5=待结束确认 // 坐席超过300秒未回复,驳回排队池 Db::name('chat_session') ->where('status', 1) ->where('last_agent_reply_time', '<', time() - 300) ->update(['status' => 0, 'agent_id' => null]); } }

定时任务最需要关注的是重复执行问题——如果上一次执行还没跑完,下一次 crontab 又触发了,会对数据库造成双重压力。有经验的方案是引入php think命令的进程锁,或者直接依赖 MySQL 的行锁来实现幂等。ThinkPHP 的topthink/think-migrationsymfony/console都提供了命令注册机制,这里不多展开,但一定不要不加锁就跑删除或状态变更类任务。

4. 从单机到集群,把连接层和状态层拆开才能承载无限坐席

轮询方案能承载几百个并发访客,但如果要支撑“无限坐席”这个目标,必须在架构上考虑横向扩展。最常见的错误做法是:买更高配的服务器,把 PHP 进程数调大,以为就扩展了。实际上单台服务器能维持的 TCP 连接数、数据库连接池大小、以及 PHP-FPM 的进程数都有硬上限,真正的解法是把“接入层”和“状态层”拆开,ThinkPHP 专注于业务逻辑,Redis 专注于分布式状态,再接一个独立的常驻内存服务负责长连接推送。

4.1 坐席状态与分配索引,从 MySQL 搬到 Redis

坐席上下线、心跳、当前会话数、正在服务的会话 ID 列表,这些数据更新频繁但单个数据量极小,非常适合放到 Redis 里。基于 Redis 的坐席状态模型我一般这样设计:

// 坐席状态 hash,每个坐席一个 key,field 存属性 agent:online:{agent_id} -> { "last_heartbeat": 1710000000, "group_id": 3, "serving": 2 } // 技能组内的在线坐席有序集合,score 为当前服务的会话数 agent:group:online:{group_id} -> zset, member 是 agent_id, score 是当前会话数 // 坐席正在服务的会话列表 agent:serving:{agent_id} -> set, member 是 session_id

分配坐席时,不再查询数据库,而是直接从有序集合里取 score 最小的成员:

// app/service/AssignService.php public function pickFreeAgentByRedis(int $groupId): ?int { $redis = \think\facade\Cache::store('redis')->handler(); // 取 score 最小的坐席,score 即当前会话数 $members = $redis->zRangeByScore("agent:group:online:{$groupId}", 0, 9999, ['limit' => [0, 5]]); foreach ($members as $agentId) { // 二次确认心跳,避免取到掉线坐席 if ($redis->hGet("agent:online:{$agentId}", 'last_heartbeat') > time() - 30) { return (int) $agentId; } } return null; }

ZSet 在这里起到了两个作用:一是天然按会话数排序,分配时取score最小的;二是利用 Redis 的原子性来避免并发分配冲突——调用zIncrBy让坐席的会话数加一,如果返回值仍然在阈值内,说明分配成功;其他请求再取到这个坐席时会发现 score 已经变大,自然转向下一个。这比数据库的乐观锁更快,而且随着坐席数增长,Redis 的操作复杂度依然是 O(log N)。

4.2 Workerman 互补长连接,ThinkPHP 只做业务落库

如果项目要求消息延迟低于 1 秒,纯轮询就很难满足了。业界在 PHP 生态里最常见的做法是用 Workerman 做独立的 WebSocket 接入网关,前端和网关之间维持长连接,网关收到消息后通过 Redis 发布订阅通道转发给业务层,业务层(ThinkPHP)负责落库和触发推送事件。

这个架构里有几个需要明确的边界:Workerman 不直接操作 MySQL,它只做消息的接收与转发;ThinkPHP 不维护任何长连接,它只接收来自网关的“消息事件”,然后执行业务逻辑并把结果通过 Redis 推回给网关。两侧通过 Redis 的publish/subscribe解耦。

// 在 Workerman 的 onMessage 回调中 public function onMessage($connection, $data) { $payload = json_decode($data, true); // 网关只转发,不做业务判断 $redis = new \Redis(); $redis->connect('127.0.0.1', 6379); $redis->publish('chat:incoming', json_encode([ 'session_id' => $payload['session_id'], 'from' => $payload['from'], // visitor / agent 'content' => $payload['content'], 'timestamp' => time(), ])); } // ThinkPHP 侧监听 Redis 订阅 // 在命令行常驻进程中执行 public function listen(): void { $redis = new \Redis(); $redis->connect('127.0.0.1', 6379); $redis->subscribe(['chat:incoming'], function ($redis, $channel, $msg) { $data = json_decode($msg, true); // 落库 Db::name('chat_message')->insert($data); // 再发布给网关,推送对方 $redis->publish('chat:outgoing', json_encode($data)); }); }

这套设计的关键收益是:WebSocket 网关可以部署很多台,坐席连接被负载均衡器分散到不同网关;业务层 ThinkPHP 只依赖 Redis 做数据交割,自身完全无状态,随时可以横向扩容。整个系统里,唯一的单点就是 Redis,给 Redis 做哨兵或集群模式即可解决。跑通这个骨架之后,“无限坐席”才真正变成一个水平扩展的问题,而不是架构上限的问题。

5. 无限坐席客服系统的压测方法、常见坑与上线前检查清单

最后落在实操上:整套系统写完,怎么验证它确实能承载大规模坐席;上线之后又有哪些“平时没问题、一压就翻车”的坑。这一节的内容全部来自实际交付经验,可以直接拿去用。

5.1 用脚本模拟坐席和访客,压测分配的并发安全

压测第一步不是用压测工具打满接口,而是验证分配逻辑在并发下不会把两个访客分给同一个坐席。我一般直接写一个多进程脚本,同时发起 500 个访客分配请求,然后统计每个坐席被分配到的会话数是否均匀分布,以及是否出现同一个坐席在同一个时刻被分配了超过预期数量的会话。

# 模拟 200 个访客同时进入,检查分配结果 for i in $(seq 1 200); do curl -s -X POST "http://127.0.0.1:8000/api/visitor/request_agent" \ -H "Content-Type: application/json" \ -d "{\"visitor_id\": \"$i\", \"group_id\": 1}" & done wait

然后用 SQL 验证:

SELECT agent_id, COUNT(*) cnt FROM chat_session WHERE status = 1 GROUP BY agent_id ORDER BY cnt DESC;

观察有没有某个坐席cnt特别大,同时有其他坐席为 0——这往往意味着分配算法没有真正按空闲优先执行,或者 Redis 的zIncrBy分数更新没有生效。

常见的一个隐蔽 bug 是:坐席心跳在 Redis 里正常更新,但数据库里的last_heartbeat字段却已经过期,导致基于 ORM 查询的分配逻辑把明明在线的坐席过滤掉了。排查技巧是写一个巡检脚本对比 Redis 和数据库里的在线状态差异,每 5 分钟跑一次,能快速暴露这种不一致。

5.2 线上最容易忽视的三个配置项

第一,php.inimax_execution_time。ThinkPHP 默认请求执行时间限制为 30 秒,但如果业务里接入了发送邮件、WebSocket 推送等外部调用,单个请求很容易超时。客服系统的接口建议单独设置,set_time_limit(0)只给长连接的入口用,其他接口一律保持 3 秒内的快速响应。

第二,MySQL 的max_connections。客服系统的连接特点是:PHP-FPM 进程数 x 每个进程的 DB 连接数。如果 PHP-FPM 开了 200 个进程,数据库连接池最大 50,那高峰期必然出现“连接数不够用”的报错。上线前必须算好:max_connections至少要大于 PHP-FPM 的最大进程数,并且对慢查询做好治理,避免一个慢 SQL 占住连接不释放。

第三,Redis 的maxmemory-policy。客服系统中 Redis 主要存坐席状态和未读计数,这些 key 如果设置了过期时间但坐席一直在线,会持续堆积。建议把maxmemory-policy设为allkeys-lru,同时定期清理超过两天没有心跳的坐席 key,防止内存被僵尸坐席占满。

5.3 上线前按这个清单过一遍

  • 会话分配接口用wrkab压测,目标值应达到每秒 50 次以上分配成功率 99.9%,失败时不产生脏会话数据
  • 模拟一个坐席断网 5 分钟,检查 Redis 心跳 key 过期后,该坐席不会被分配新会话,但已有会话不被强杀
  • 消息表按session_id分表或归档脚本是否就绪,避免一个月后消息表超过千万行导致查询变慢
  • 前端轮询接口是否带有缓存头,Cache-Control: no-store必须设置,否则浏览器或 CDN 缓存会导致消息延迟

5.4 最后的调试技巧,用日志链路定位消息丢失

消息丢失是客服系统最大的事故类型,定位时一定要依赖日志链路。我一般在每个消息事件里带上trace_id,从访客发出、网关接收、Redis 转发、业务落库、坐席收到推送,每个环节都打印一行带trace_id的日志。访客说“我发了消息对方没收到”,直接查这条trace_id,看卡在哪个环节——如果网关收到了但业务没落库,问题在订阅进程;如果业务落库了但坐席没收到推送,问题在 outbound 通道;如果全都正常但坐席端没显示,那就是前端渲染的 bug。没有链路追踪的客服系统,排障全靠猜,代价很高。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 19:05:39

HyperFrames 引擎底层解析:HeadlessChrome 精确帧捕获完全指南

HyperFrames 引擎底层解析&#xff1a;HeadlessChrome 精确帧捕获完全指南 【免费下载链接】hyperframes Write HTML. Render video. Built for agents. 项目地址: https://gitcode.com/GitHub_Trending/hy/hyperframes HyperFrames 是一个开源的视频渲染框架&#xff0…

作者头像 李华
网站建设 2026/9/15 19:02:25

洛克王国HTML5游戏源码实战:Canvas渲染、状态机与回合制战斗系统解析

简介&#xff1a;洛克王国HTML5游戏源码是一份基于HTML5技术构建的网页游戏学习工程&#xff0c;面向Web前端开发者、游戏爱好者及课堂教学场景&#xff0c;主要解决无需安装、跨平台运行的游戏原型演示与二次开发需求。完整压缩包共88个文件&#xff0c;涵盖73个PNG游戏素材、…

作者头像 李华