简介这是一套基于ThinkPHP6与Swoole4构建的企业级开源客服系统面向中小电商、SaaS服务商及技术团队解决多渠道客户咨询接入难、商家跨端接待不统一、客户分层管理缺失等实际问题。系统完整支持微信网页、H5、PC端访客接入商家侧覆盖PC管理后台、H5轻量接待页及App端含Java客户端源码并提供客户标签/分组、会话状态跟踪等精细化运营能力。资源包共2000个文件以294个PHP后端逻辑、171个Vue前端组件、347个JS交互脚本、279个PNG图标资源及108个Java移动端代码为主辅以SQL建表、配置文档与样式文件整体90.77MB结构清晰、模块解耦度高。已有261人学习下载开发者可直接部署运行亦可基于全量开源代码进行定制化扩展、性能调优或对接自有CRM系统快速落地私有化客服中台。1. 项目概述一个基于现代技术栈的实时客服解决方案最近在做一个客服系统的项目核心目标很明确要能覆盖微信网页、H5、PC这些常见的用户端同时商家那边也得有PC管理后台、H5和App来接待客户还得支持用户主动添加客服。听起来需求挺全的对吧市面上虽然有成品但要么太贵要么定制化程度不够要么性能跟不上。所以我们决定基于ThinkPHP 6TP6和Swoole 4这套组合自己动手从零搭建一个开源版本。为什么选TP6Swoole4这背后有挺多考量的。TP6是PHP领域一个非常成熟、优雅的现代框架它的设计哲学和代码结构让开发大型应用变得清晰可控依赖注入、中间件、门面这些特性用好了能极大提升代码质量和可维护性。但传统的PHP-FPM模式是“请求-响应-断开”的对于客服这种需要长连接、实时双向通信的场景简直是噩梦每次消息都要重新建立HTTP连接开销大延迟高根本做不到真正的“即时”。Swoole就完美地解决了这个问题。它是一个用C语言编写的PHP协程高性能网络通信引擎让PHP可以常驻内存轻松处理成千上万的并发TCP长连接。用Swoole 4提供的WebSocket服务器我们就能在客服和用户之间建立一条持久的、全双工的通信管道消息可以毫秒级推送这才是“在线客服”该有的样子。这个系统本质上是一个高并发的实时消息中台。它要处理的不仅仅是简单的文本聊天还包括用户上下线状态同步、消息的可靠投递与离线存储、客服的会话分配与负载均衡、以及可能的多媒体消息如图片、文件传输。商家端的多端接待意味着状态和数据需要在PC、H5、App之间实时同步这对后端架构的实时数据分发能力提出了很高要求。而支持用户添加客服则涉及到一套完整的客服管理、分组、二维码生成与绑定逻辑。接下来我就把这几个月从架构设计到踩坑填坑的全过程拆开了揉碎了跟大家分享一下。2. 核心架构设计与技术选型背后的思考2.1 为什么是TP6 Swoole 4而不是其他组合在做技术选型时我们对比过几种方案。比如纯Node.jsSocket.io性能固然强悍但对于团队主要成员是PHP背景的我们来说学习成本和原有业务代码的迁移成本太高。也考虑过Go语言其并发模型确实优雅但同样面临技术栈切换的问题。而TP6Swoole4的组合让我们能在熟悉的PHP生态内直接获得媲美Node.js/Go的并发处理能力这是最大的吸引力。TP6负责的是整个应用的HTTP API部分、业务逻辑、数据库ORM、缓存管理、权限控制RBAC、后台管理模板等。它就像一个稳重的大管家处理所有不需要长连接的请求比如客服登录、历史记录查询、数据报表生成、系统配置管理等。这些功能利用TP6丰富的组件和清晰的MVC结构可以开发得非常快。Swoole 4则扮演了实时通信引擎的角色。我们主要用它来创建WebSocket服务器处理所有实时消息的收发、连接管理、心跳维持。Swoole 4的协程特性是关键它可以用同步的代码写法实现异步IO大大降低了开发复杂并发程序的难度。比如在一个协程里等待数据库查询结果时CPU会自动切换到其他协程去处理新的WebSocket消息资源利用率极高。两者的分工与协作是这样的用户通过HTTP请求访问客服链接页面TP6处理页面加载后建立WebSocket连接连接到Swoole服务。当用户发送消息时消息通过WebSocket到达Swoole服务器Swoole服务器解析后会通过协程调用TP6业务逻辑层的方法这里需要一些进程间通信设计下文详述将消息存入数据库、进行客服路由然后再通过Swoole推送给对应的客服端。整个流程中TP6和Swoole各司其职通过一个高效的桥梁如Redis进行数据和指令的交换。2.2 多端支持与状态同步的架构挑战“支持微信网页、H5端、PC端客服接入”这句话落实到架构上意味着我们的WebSocket服务必须能接受来自不同平台、不同浏览器环境的连接。微信网页端有自己独特的环境如JSSDK权限、域名安全限制H5端可能被嵌入在各种App的WebView中PC端则相对标准。这就要求我们的连接层Swoole WebSocket Server不能对客户端环境做太多假设协议要标准同时要做好跨域CORS等配置。更大的挑战在于“商家端有PC端管理、H5端、App端接待”。一个客服人员可能同时用PC浏览器开着管理后台手机上也登录着H5或原生App接待页面。当有一个新用户接入时这个会话应该分配给谁分配给哪个设备如何保证客服在其中一个设备上回复了消息所有他登录的设备都能实时看到会话列表更新和消息已读状态我们的解决方案是引入“客服会话状态中心”。每个客服有一个唯一的UID用户ID。他在每个设备上登录时都会建立独立的WebSocket连接但所有这些连接都会在服务端Redis中与这个UID关联起来。当有新会话需要分配时负载均衡逻辑如最少接待数优先会决定分配给哪个UID然后系统会向这个UID关联的所有活跃连接广播“新会话”事件。同理当客服在任一设备上发送消息或阅读消息时事件也会广播到其他设备从而实现状态同步。对于原生App端如果希望更省电和稳定可以采用WebSocket为主辅以APNs/FCM苹果/谷歌推送服务做离线通知的混合模式。2.3 数据库与缓存设计为实时性与可靠性服务数据库我们选择了MySQL 8.0存储所有持久化数据用户信息、客服信息、聊天记录、系统日志等。这里有几个关键设计点聊天记录表的分表策略聊天记录量会非常大必须分表。我们没有按时间分表而是按会话IDsession_id进行哈希分表。因为查询最多的场景是“拉取某个会话的历史记录”这样能保证一次查询只落在一个分表上效率最高。表结构大致包含id,session_id,from_uid,to_uid,msg_type,content,send_time,read_status等字段。在线状态与路由信息存Redis所有需要快速访问、频繁变更、且允许暂时丢失的数据一律放在Redis。这包括online:user:{uid}存储用户连接的服务端Worker ID和Fd文件描述符用于直接推送消息。online:cs:{uid}存储客服的多设备连接信息可能是一个集合Set里面存放多个设备连接标识。session:cs:{uid}存储当前分配给该客服的会话列表。waiting_queue一个有序集合Sorted Set存储正在排队等待接入的用户会话分数为进入队列的时间戳用于实现公平队列。消息的可靠投递与离线存储这是客服系统的核心。我们采用“发送即存储”“接收方ACK”机制。消息一旦从发送方发出服务端会立即将其写入MySQL作为永久记录同时尝试投递给接收方。如果接收方在线则通过WebSocket推送并在收到客户端的ACK确认后更新消息状态为“已送达”。如果接收方不在线消息仍已存储待其上线后拉取。为了减轻上线时拉取大量离线消息的压力我们在Redis中也为每个用户维护了一个最近未读消息的列表。3. 核心模块实现与实操要点3.1 Swoole WebSocket服务器的搭建与优化首先Swoole服务不能和TP6的HTTP服务混在一个进程中。我们选择将Swoole服务作为一个独立的常驻进程来运行。创建一个swoole_server.php作为入口文件。?php // swoole_server.php use Swoole\WebSocket\Server; use Swoole\Http\Request; use Swoole\WebSocket\Frame; // 创建WebSocket服务器监听9501端口 $server new Server(0.0.0.0, 9501); // 设置运行时参数这部分调优很重要 $server-set([ worker_num 4, // 根据CPU核心数调整建议设置为2-4倍 task_worker_num 2, // 异步任务进程用于处理耗时的业务如写数据库 daemonize false, // 调试时设为false生产环境设为true max_request 10000, // 防止内存泄漏 dispatch_mode 2, // 固定模式保证同一个fd的数据分配给同一个worker heartbeat_check_interval 60, // 60秒检测一次 heartbeat_idle_time 600, // 连接最大空闲时间600秒 open_websocket_ping_frame true, // 开启WebSocket Ping帧由浏览器自动维持 buffer_output_size 32 * 1024 * 1024, // 32M应对大文件传输 ]); // 监听WebSocket连接打开事件 $server-on(Open, function (Server $server, Swoole\Http\Request $request) { $fd $request-fd; // 1. 从请求参数或Cookie中解析出用户身份token $token $request-get[token] ?? ; if (!$token) { $server-close($fd); return; } // 2. 验证token获取用户ID和类型是用户还是客服 $userInfo verifyToken($token); // 假设的验证函数 if (!$userInfo) { $server-close($fd); return; } // 3. 将连接信息绑定到uid $server-bind($fd, $userInfo[uid]); // 4. 将在线信息存入Redis $redis getRedis(); // 获取Redis连接实例 $key online: . ($userInfo[type] user ? user : cs) . :{$userInfo[uid]}; // 存储结构hash包含fd, worker_id, login_time等 $redis-hSet($key, fd_ . $fd, json_encode([ fd $fd, worker_id $server-worker_id, login_time time() ])); // 5. 如果是客服上线通知其有新的待处理会话如果有 if ($userInfo[type] cs) { notifyCsNewSession($userInfo[uid]); } echo 客户端 {$userInfo[uid]} 通过 FD {$fd} 连接成功\n; }); // 监听WebSocket消息事件 $server-on(Message, function (Server $server, Frame $frame) { $fd $frame-fd; $data json_decode($frame-data, true); if (!$data || !isset($data[type])) { return; } switch ($data[type]) { case chat: // 处理聊天消息 handleChatMessage($server, $fd, $data); break; case heartbeat: // 心跳包更新连接活性 $server-push($fd, json_encode([type heartbeat_ack])); break; case ack: // 消息送达回执 handleMessageAck($data[msg_id]); break; // ... 其他业务类型 } }); // 监听连接关闭事件 $server-on(Close, function (Server $server, int $fd) { // 1. 根据fd获取绑定的uid $uid $server-getClientInfo($fd)[uid] ?? 0; if ($uid) { // 2. 从Redis中清理该fd的在线信息 $redis getRedis(); // 需要遍历可能的key进行清理这里简化处理 // 实际应根据绑定信息知道用户类型 $redis-del(online:user:{$uid}); // 简化示例实际应更精确 } echo 连接 {$fd} 已关闭\n; }); // 启动服务器 $server-start();注意上面的verifyToken、handleChatMessage等函数需要你具体实现。其中涉及到的TP6业务逻辑不能直接在Swoole进程中调用因为TP6框架加载和数据库连接不是协程安全的。通常的做法是通过Redis队列、TCP连接或者更优雅的Swoole\TableTask进程来进行进程间通信。实操心得Worker与Task进程的运用我们把耗时的、阻塞的操作比如写入MySQL、调用外部HTTP API都投递到Task进程中去异步执行。例如在handleChatMessage函数中收到消息后立即通过$server-task()投递一个任务去持久化消息而主Worker进程只负责快速响应客户端和进行消息转发这样即使数据库偶尔慢一点也不会阻塞消息的实时传递。3.2 TP6与Swoole的进程间通信IPC方案这是整合TP6和Swoole的关键也是容易踩坑的地方。我们放弃了直接在Swoole Worker中include TP6框架文件的做法因为会有严重的协程安全问题。最终采用了“Redis发布订阅Pub/Sub 异步任务”的混合模式。业务逻辑触发通信当Swoole服务需要执行业务逻辑如根据客服分组规则分配会话时它不直接调用TP6的代码而是向一个特定的Redis频道例如channel:task发布一个任务消息消息体包含任务类型和参数。TP6常驻进程消费我们编写一个独立的TP6命令行指令作为一个常驻进程运行。这个进程使用think\console\Command基类内部通过subscribe监听channel:task频道。执行并返回TP6进程收到任务后在其完整的框架环境下安全地执行数据库操作、复杂业务计算等然后将结果写入另一个Redis键如result:task:{task_id}或者直接通过Swoole的$server-sendMessage()方法如果知道目标Worker ID和Fd将结果推回。Swoole侧监听结果Swoole服务在投递任务时可以启动一个协程去定时监听结果键或者由TP6进程在完成后直接反向通知Swoole通过Swoole的sendMessage给指定的Worker。// 示例在Swoole的onMessage中投递任务 $taskId uniqid(); $taskData [ type assign_cs, session_id $sessionId, user_id $userId, task_id $taskId ]; // 投递到Redis任务队列 $redis-lPush(task_queue, json_encode($taskData)); // 或者发布到频道 // $redis-publish(channel:task, json_encode($taskData)); // 异步等待结果协程方式 go(function () use ($server, $fd, $taskId, $redis) { $resultKey task_result:{$taskId}; // 超时设置比如5秒 $timeout 5; $start time(); while (time() - $start $timeout) { $result $redis-get($resultKey); if ($result ! false) { $resultData json_decode($result, true); // 根据结果通过$server-push推送消息给客服 $server-push($resultData[cs_fd], json_encode([typenew_session, data...])); $redis-del($resultKey); break; } \Swoole\Coroutine::sleep(0.1); // 协程睡眠100毫秒 } });3.3 微信网页与H5端接入的特殊处理微信网页端接入主要需要处理JSSDK的签名。我们在TP6端提供一个API前端通过当前页面的URL来获取微信JSSDK配置所需的appId、timestamp、nonceStr、signature等参数。然后利用JSSDK的wx.config进行配置成功后才能调用WebSocket通常使用wx.invoke或标准的WebSocket对象取决于微信版本。关键点WebSocket连接地址。微信网页对wss://WebSocket Secure的支持更好且域名必须是在公众号后台配置的JS安全域名。因此我们的Swoole服务器必须配置SSL证书提供wss://yourdomain.com:9501这样的连接地址。Nginx也可以作为WebSocket代理将wss://yourdomain.com/wss/路径的请求反向代理到本地的Swoole服务器这样可以利用Nginx管理SSL和负载均衡。对于H5端包括嵌入App的WebView主要挑战是后退刷新与连接恢复。用户可能在聊天页面按了后退键然后又点进来此时需要自动重连并恢复之前的会话。我们的做法是在本地存储LocalStorage中保存当前会话的session_id和最后一条消息的ID。当页面加载时先尝试用本地保存的session_id去恢复连接如果服务端告知会话已过期或不存在则再发起创建新会话的请求。3.4 客服会话分配与负载均衡策略会话分配逻辑是客服系统的“大脑”。我们实现了一个可插拔的策略模式。最少接待优先这是最常用的策略。系统维护每个客服的当前接待会话数。新用户进入时分配给当前接待数最少的、且在线的客服。实现上我们在Redis用一个有序集合cs_load来存储分数就是接待数。分配时用ZRANGE cs_load 0 0 WITHSCORES取出负载最小的客服。轮询分配按顺序依次分配给在线客服列表中的客服保证绝对平均。技能组分配根据用户的问题类型如“售前”、“售后”、“技术”分配给对应技能组的客服。这需要在客服信息和会话创建时都打上标签。分配逻辑在TP6的常驻任务进程中执行因为它需要查询复杂的数据库关系。执行流程如下Swoole收到新用户会话请求发布“分配客服”任务。TP6任务进程消费任务根据策略从数据库和Redis中查询符合条件的客服列表。选中客服后TP6进程做两件事a) 更新数据库建立会话关系b) 向Redis中该客服的待接收会话队列插入一条记录。同时TP6进程通过Swoole的sendMessage或Redis发布订阅通知Swoole服务器“客服已分配”。Swoole服务器收到通知立即向该客服所有在线的设备推送新会话通知。避坑技巧在“最少接待优先”策略中要处理好客服“挂起”或“离开”状态。一个客服虽然在线但可能设置了“忙碌”或“离开”。分配时必须过滤掉非“空闲”状态的客服。我们在Redis中为每个客服维护了一个状态键cs_status:{uid}分配逻辑必须先检查这个状态。4. 数据库表结构设计与核心业务逻辑4.1 核心表结构详解这里列出几个最核心的表字段已做简化聚焦于思路。客服表customer_service字段名类型说明idint主键usernamevarchar登录账号nicknamevarchar显示名称avatarvarchar头像group_idint所属技能组max_sessionint最大同时接待数statustinyint状态0离线1在线2忙碌3离开last_heartbeatint最后心跳时间用户会话表user_session字段名类型说明idchar(32)主键会话唯一IDuser_idvarchar用户标识可以是OpenID或临时IDcs_idint分配的客服ID0表示未分配/排队中statustinyint状态0排队中1进行中2已结束channelvarchar来源渠道wechat, h5, pccreated_atint创建时间updated_atint最后活动时间聊天记录表chat_message_%(分表)字段名类型说明idbigint自增主键session_idchar(32)所属会话IDmsg_idchar(32)消息唯一ID用于去重和ACKfrom_uidvarchar发送方IDto_uidvarchar接收方IDmsg_typevarchar消息类型text, image, filecontenttext消息内容文本或文件URLsend_timeint发送时间戳read_statustinyint阅读状态0未送达1已送达2已读客服-会话关联表cs_session这个表用于快速查询客服当前负责的会话是user_session表的冗余和缓存主要存在Redis中结构为集合cs_session:{cs_id}里面存储session_id。当会话结束时从集合中移除。4.2 消息发送与接收的完整流程让我们跟踪一条用户发送的“你好”消息看看它如何在系统中旅行。前端发送用户A在H5页面输入“你好”点击发送。前端WebSocket客户端将消息封装成JSON{type:chat, session_id:sess_abc123, content:你好, msg_id:msg_xyz789}通过ws.send()发出。Swoole接收消息到达Swoole服务器的onMessage回调。解析出session_id和msg_id。投递异步任务立即通过$server-task()投递一个“存储消息”的异步任务。Task进程会根据session_id查询数据库获得接收方客服的ID。将消息写入对应的chat_message分表状态为“未送达”。将消息的简要信息msg_id,content,send_time推入Redis列表offline_msg:{cs_id}如果客服不在线的话。实时推送与此同时主Worker进程根据session_id从Redis中查询当前负责此会话的客服ID假设是客服B然后查询客服B的所有在线设备连接信息存储在online:cs:{cs_b_id}中。多端广播遍历客服B的所有在线设备连接通过$server-push($fd, $message)将消息推送给每一个设备。推送的消息格式同样为JSON包含msg_id以便客服端发送ACK。客服端ACK客服B的PC端收到消息并渲染到界面后向前端发送一个ACK消息{type:ack, msg_id:msg_xyz789}。更新状态Swoole收到ACK后在Task进程中更新数据库中该条消息的read_status为“已送达”。如果客服在对话框内阅读了消息还会触发一个“已读”回执将状态更新为“已读”。用户端状态同步可选当消息状态更新后服务端可以主动推送一个状态更新事件给用户A让其界面上的消息状态从“发送中”变为“已送达”或“已读”。这个流程保证了消息的不丢失先存数据库、低延迟异步存储同步推送和状态可追踪ACK机制。5. 部署、性能调优与问题排查实录5.1 生产环境部署架构单机部署肯定不行我们需要一个高可用的架构。下面是一个简单的多机部署方案[用户] - [负载均衡器 (Nginx)] - [TP6 HTTP服务器集群 (多台)] - [WebSocket连接] - [Swoole服务器集群 (多台)] | -- [中心化 Redis (哨兵或集群模式)] | -- [主从 MySQL 数据库]Nginx同时作为HTTP反向代理和WebSocket代理。HTTP请求分发到TP6服务器集群wss://请求分发到Swoole服务器集群。需要配置proxy_pass和proxy_set_header以支持WebSocket协议升级。TP6集群无状态可以水平扩展。通过Nginx的upstream做负载均衡。Swoole集群这是有状态的因为每个连接绑定在特定的Worker进程上。我们需要一个“连接路由中心”通常就是Redis。所有Swoole服务器实例都连接到同一个Redis集群。当需要向某个UID推送消息时先到Redis查这个UID当前连接到了哪个服务器的哪个Worker和Fd然后通过服务器间的内部RPC如Swoole的sendMessage配合Node.js或Go写的消息中转服务或者直接用Redis Pub/Sub将消息转发到目标服务器再由目标服务器推送给具体的Fd。更简单的做法是使用Nginx的ip_hash策略让同一个客户端的WebSocket连接始终落在同一台Swoole服务器上但这不利于负载均衡。Redis作为连接信息、会话状态、离线消息、任务队列的中央存储必须高可用建议使用哨兵或集群模式。MySQL主从读写分离。TP6处理写操作和核心读Swoole的Task进程异步写复杂的报表查询走从库。5.2 Swoole参数调优与监控Swoole的配置参数对性能影响巨大以下是一些生产环境经验worker_num设置为CPU核数的1-4倍。太多会导致进程切换开销增大。可以通过top命令观察CPU利用率来调整。task_worker_num根据异步任务的多少来设置通常是worker_num的0.5到1倍。max_request必须设置例如10000防止Worker进程内存泄漏。Task进程也可以设置task_max_request。dispatch_mode我们选择模式2固定模式保证同一个fd的数据包由同一个Worker处理这对于连接绑定是必要的。log_file一定要设置并且定期切割和清理。buffer_output_size/package_max_length根据业务需要调整如果支持发送大文件需要调大。监控是保证稳定性的眼睛。我们做了以下几件事Swoole Server状态编写一个HTTP接口使用$server-stats()获取服务器状态信息连接数、任务队列长度等集成到监控系统如Prometheus。Redis监控监控连接数、内存使用率、命令延迟。自定义业务指标在代码中打点记录消息吞吐量、平均延迟、会话分配耗时等写入时序数据库。5.3 常见问题与排查技巧在实际开发和运维中我们遇到了不少问题这里记录几个典型的问题一消息偶尔重复或丢失现象用户反映有时消息发了两遍有时客服没收到。排查检查前端WebSocket发送逻辑是否在发送成功回调前就更新了UI导致用户重复点击。检查Swoole的onMessage回调确保消息处理是幂等的。特别是msg_id去重逻辑。检查Task进程处理消息存储时是否因为网络波动或超时导致了重试机制误触发。解决在前端发送消息时添加“发送中”状态禁用按钮在服务端用Redis为每个msg_id设置一个短期锁setnx防止同一消息被处理多次确保Task进程的数据库操作有重试机制但也要有唯一索引防止重复插入。问题二客服端连接频繁断开现象客服H5端在手机息屏一段时间后连接断开。排查这是移动网络和浏览器策略导致的。运营商可能回收长连接浏览器在后台也可能限制WebSocket活动。解决实现一个健壮的心跳机制断线重连。前端定时如每30秒发送{type:heartbeat}心跳包。服务端在heartbeat_idle_time内没收到任何数据包括心跳则断开连接。前端监听onclose事件尝试指数退避重连如断开后1秒、2秒、4秒...重试。对于H5还可以结合visibilitychange事件在页面从后台切换到前台时主动检查连接状态。问题三会话分配不均衡有的客服忙死有的闲死现象监控发现客服负载不均。排查检查负载均衡策略cs_load有序集合的更新是否及时。客服结束一个会话后是否立即从集合中减去了负载客服状态变为“忙碌”或“离开”时是否从待分配集合中移除了解决确保所有状态变更接待开始/结束、客服状态改变都原子性地更新Redis中的负载数据。使用Redis的WATCH/MULTI/EXEC事务或者Lua脚本来保证一致性。问题四上线后内存缓慢增长现象Swoole Worker进程内存占用随时间缓慢增加。排查这是典型的内存泄漏。使用Swoole\Coroutine::stats()查看协程数量是否异常堆积。检查代码中是否有在全局或静态变量中不断追加数据而未清理的情况。特别是连接信息缓存。解决设置max_request让Worker定期重启。使用Valgrind或PHP memleak工具进行排查。确保所有通过$server-bind()绑定的连接在onClose时都被正确清理。避免在协程内使用静态变量缓存大量数据。开发这样一个系统就像在搭建一个精密的数字通信网络每一个环节都要考虑并发、状态和一致性。从TP6优雅的HTTP处理到Swoole狂暴的并发能力再到Redis穿针引线般的状态管理整个过程充满了挑战但当你看到消息在不同终端间流畅地跳动时那种成就感也是实实在在的。这套架构已经能支撑起相当规模的并发并且代码结构清晰后续要加机器人客服、消息撤回、会话转移这些功能都有很好的扩展空间。本文还有配套的精品资源点击获取