
做在线客服系统这件事我前前后后折腾了不少年。市面上现成的客服产品要么重得要命部署一台机器都嫌挤要么是商业SaaS功能是齐全但想改个欢迎语、接个内部工单系统处处受限制。所以我一直很推崇那种“轻量高效、源码开放、适合学习和二次开发”的客服聊天系统它不追求大而全而是把核心链路做扎实让开发者能看懂、能改动、能快速落地到自己的业务里。这篇文章要聊的系统正是这样一个方向一套基于PHP/MySQL/WebSocket技术栈的在线客服聊天源码系统。它的核心价值不在于界面多华丽、功能多花哨而在于“轻”和“可改”。对于想学习实时通信原理、想给自家网站加客服功能、或者想在这个基础上做企业级二次开发的团队这套系统都是一个非常合适的起点。我的建议是先顺着这篇文章把整体架构和核心代码逻辑捋清楚再动手去改会比直接拿到源码瞎改高效得多。1. 整体设计思路为什么说“轻量”才是核心竞争力1.1 轻量化架构背后的取舍我见过很多团队一上来就喊微服务、消息队列、分布式缓存结果一个日活几千的客服功能部署了六个节点维护成本比功能本身还高。真正合适的方案是回到业务的本质客服系统的核心就是“访客发消息、客服回消息、双方都能实时收到”把这个链路搞清楚其他的都是锦上添花。这个系统采用了非常务实的组合方案层级选型说明后端语言PHP 7.4上手门槛低二次开发劳动力充足通信方案WebSocket (Workerman)真正实现服务端主动推送实时性有保障数据存储MySQL 5.7结构化数据天然友好索引合理即可前端原生HTML/JavaScript不依赖重型框架打开即用改起来直观部署Nginx PHP-FPM经典组合运维资料多出问题容易排查选PHP不是因为它性能最好而是因为它在“二次开发”这个维度上有着独到的优势。国内大量开发者的第一门后端语言就是PHP你找一个能改Java的人可能得等一周但找一个能改PHP的人当天就能上手。轻量系统的核心逻辑是“让使用者能在这个代码库上快速工作”而不是“让代码库在技术上显得很酷”。WebSocket的选型也同理。很多人问为什么不用轮询、不用SSE。轮询的问题是延迟高且浪费资源访客每两秒刷一次接口服务器得白白扛一堆无效请求。SSE能做到服务器推流但它是单向的访客给客服发消息还得另外走HTTP接口链路就劈成两半了。WebSocket是一次握手、双向通信访客发消息和客服收消息在同一条连接上完成代码逻辑连贯实时性也最好。这个系统的核心通信就建立在这个机制上。1.2 模块划分与目录结构二次开发的第一印象拿到源码后第一件事就是看目录结构。一个适合二次开发的系统目录结构必须“一眼就能看懂”而不是把所有逻辑都堆在一个几百KB的index.php里。online-chat-system/ ├── public/ # Web根目录唯一对外暴露的目录 │ ├── index.php # 入口文件路由分发 │ ├── visitor.html # 访客端页面 │ └── agent.html # 客服端页面 ├── app/ │ ├── Controllers/ # 控制器层HTTP请求处理 │ │ ├── VisitorController.php │ │ └── AgentController.php │ ├── Models/ # 数据模型层数据库操作 │ │ ├── SessionModel.php │ │ └── MessageModel.php │ └── Services/ # 业务逻辑层核心规则 │ └── ChatService.php ├── socket/ │ └── server.php # WebSocket服务端Workerman ├── config/ │ └── config.php # 数据库、端口等配置 └── sql/ └── install.sql # 初始化建表脚本这个结构最重要的特点是把“业务规则”和“页面展示”分开。Controllers只负责接收请求、调用Service、返回结果不写任何业务逻辑Models只负责和数据库打交道真正的状态判断、会话分配、消息记录这些核心规则全部收拢在ChatService里。二次开发的时候改数据表就动Models改业务规则就动Services改页面就动HTML文件互不干扰。我见过很多“教不会”的源码本质问题就是三层混在一起想加个敏感词过滤得在十几个文件里各插一段代码。而这套系统的分层说白了就是让开发者知道“我该去哪里改”。对于一个以学习为目的的项目来说这种清晰度的价值远大于某些炫技的架构设计。1.3 为什么轻量系统反而更适合做“地基”很多团队问我既然要做二次开发为什么不直接买一套商业系统或者用那种功能全部拉满的开源项目我的回答是要看你的目标是什么。如果你的目标是“上线一个能用的客服系统”那商业SaaS确实省心但如果你的目标是“做一套符合自身业务形态的客服系统”那么一个清爽简洁的代码库远比功能繁多但逻辑缠绕的巨型系统有价值。轻量系统适合做地基有三个原因。第一复杂度可控。整个系统的代码量可能只有几千行一个开发者一两天就能通读完成这意味着后续任何修改都在你的掌控范围内。第二问题可追踪。出bug的时候你能很快定位到是通信问题、数据问题还是业务逻辑问题而不是在一个庞然大物里面大海捞针。第三扩展有空间。地基越是规整往上加盖楼层就越轻松。加机器人、加工单、加数据统计本质上都是在已有的“会话—消息”模型上做延伸而不是推翻重来。2. 核心功能与数据模型聊天系统的地基2.1 四张核心表的精妙设计在线客服系统的本质是“会话上下文中的消息流”。整个数据库设计其实就围绕着一个核心把“一次服务过程”抽象成“一条会话记录”再在会话下面挂上若干条消息。这个系统只用了四张核心表就把整个业务模型撑起来了非常值得学习。第一张表是访客表。访客第一次打开聊天窗口系统会给浏览器分配一个唯一ID通过Cookie存下来。这张表记录访客的基础信息最基本的两个字段是visitor_id和create_time。不要小看这张表它是后面所有统计功能的基础——你今天接待了多少新访客、多少老访客回访全靠它。第二张表是客服表。客服表里有个关键字段是status标识客服当前是“离线”“在线空闲”还是“在线忙碌”状态。这个字段更新非常频繁客服登录后置为在线接入会话后置为忙碌挂断后恢复空闲。轻量系统的做法是直接用数据库字段标记状态而不去搞独立的在线状态服务因为客服数量通常远小于访客数量数据库的读写压力完全扛得住。第三张表是会话表。每次服务事件产生一条记录包括访客ID、接待客服ID、会话状态、创建时间、关闭时间。这张表的精髓在于source字段它标明会话是从哪个页面发起的。很多公司做二次开发第一件事就是扩展这个字段因为通过它就能知道“携程过来的客户”和“App内来的客户”在服务质量上有没有差异。第四张表是消息表。这是整个系统数据量最大的表每一条聊天记录都落在这里。核心字段包括会话ID、发送方类型访客/客服/系统、发送方ID、消息类型文本/图片/系统通知、消息内容、发送时间以及是否已读。我在改这个系统的时候给消息表加过两个很有用的字段client_msg_id和read_at。前者是客户端生成的消息ID用来做消息去重后者记录客服“已读”的具体时间用来算首次响应时长。这两个字段虽然原版系统没有但加起来的改动量极小对于做服务质检来说价值非常大——这就是轻量系统二次开发的典型体验改动都在明处不绕弯子。建表SQL大致如下CREATE TABLE visitor ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, visit_code VARCHAR(32) NOT NULL COMMENT 访客唯一标识CID, nickname VARCHAR(50) DEFAULT , create_time DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE agent ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0离线 1空闲 2忙碌, last_login_time DATETIME DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE chat_session ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, visitor_id INT UNSIGNED NOT NULL, agent_id INT UNSIGNED DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT 0排队中 1服务中 2已结束, channel VARCHAR(20) DEFAULT , create_time DATETIME NOT NULL, close_time DATETIME DEFAULT NULL, KEY idx_visitor (visitor_id), KEY idx_agent_status (agent_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE chat_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id INT UNSIGNED NOT NULL, sender_type TINYINT NOT NULL COMMENT 0访客 1客服 2系统, sender_id INT UNSIGNED NOT NULL, message_type TINYINT DEFAULT 0 COMMENT 0文本 1图片 2系统提示, content TEXT NOT NULL, client_msg_id VARCHAR(36) NOT NULL, is_read TINYINT DEFAULT 0, create_time DATETIME NOT NULL, KEY idx_session_time (session_id, create_time), KEY idx_client_msg (client_msg_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;你说它简单吗确实简单。但四张表已经能完整回答在线客服系统最核心的几类问题现在有多少访客在等待每个客服手里挂了几个会话某次服务过程聊了哪些内容响应速度怎么样这些数据都是从这四张表里出来的。做二次开发的时候先别急着加一堆华丽字段把这几张表的索引和字段设计研究透彻反而更重要。2.2 会话状态机从排队到结束的完整流转在线客服和普通微信聊天的最大区别在于客服系统有“接待”的概念。访客发消息不是简单地发给某个固定的人而是需要被分配到合适的客服客服也不是同时和几百个人高频聊天而是一个一个处理。这就需要一个清晰的状态机来控制会话的流转。这套系统的会话流转用三个状态就够了排队中、服务中、已结束。规则如下。访客第一次打开聊天窗口发起第一条消息时系统创建一个新会话状态为“排队中”同时触发分配逻辑。分配逻辑有两种策略如果当前有状态为“空闲”的客服直接把会话分配给最早空闲的那位状态改为“服务中”如果没有空闲客服会话保持排队状态等有客服空闲时再自动分配。客服端获得新会话提示后点击“接入”按钮才算正式进入服务状态。接入后客服状态从“空闲”改为“忙碌”。这里有个细节很重要访客发消息时系统自动分配客服但客服真正接手有两种模式——自动接入和手动接入。自动接入适合消息量大的场景客服不需要任何操作会话直接弹出来手动接入适合客服需要准备时间的场景避免被突然弹出来的会话打断手头工作。两种模式就是一行配置的区别但对于使用体验的影响非常大二次开发时值得重点调整。结束会话有两种方式。客服点击“结束会话”或者访客直接关闭聊天窗口并触发断开事件。结束之后会话状态变为“已结束”同时系统往消息表写一条系统消息——“客服XXX已结束会话”或“访客已离开会话”。还有一条容易被忽略但实际上很关键的规则如果访客在会话结束后又发来新消息那么系统应该创建一个新会话而不是去复用那个已结束的旧会话。很多客服系统的体验很奇怪聊着聊着没反应其实就是复用了死会话导致的。逻辑上旧会话已经归档新问题就应该开新档来记录。这个状态机的核心价值在于“可预测”。无论发生什么情况——客服掉线、访客关闭浏览器、消息发送失败——会话总能落在这三个状态之一不会出现“既排队中又服务中”这种逻辑死角。二次开发和排障的时候只要盯着状态字段看就能推演出系统当前处于什么阶段。3. 实操过程与核心实现3.1 从零搭建运行环境纸上谈兵到此为止下面进入真正动手的阶段。我在本机用Linux虚拟机完整过了一遍从拉取源码到跑通的流程整个过程中最耗时间的其实不是写代码而是环境配置里那些细枝末节的问题。这里把完整的步骤和踩坑点一起放出来。环境准备其实很标准一台Linux服务器或者虚拟机装好PHP 7.4及以上、MySQL 5.7及以上、Nginx、Composer。PHP需要安装pcntl和posix扩展这是Workerman跑起来的前置条件。初始化步骤分五步走第一步把源码放到Nginx的网站目录比如/var/www/online-chat设置public为网站根目录。这一步的目的是隔离文件访问范围让外部只能触达入口文件和静态资源后端代码和配置目录都藏在Web根目录之外。第二步导入数据库。执行sql/install.sql生成四张核心表。然后用文本编辑器打开config/config.php把数据库地址、用户名、密码、库名改掉。第三步安装Composer依赖。在项目根目录执行composer install这一步会拉取Workerman等依赖包如果没装Composer先通过官方脚本装好。第四步启动WebSocket服务端php socket/server.php start出现“Workerman started successfully”字样说明服务端已经监听在0.0.0.0:2346端口。第五步启动Nginx和PHP-FPM然后访问http://你的服务器IP/visitor.html打开访客端页面再打开http://你的服务器IP/agent.html跳转到客服登录页用初始账号登录客服端。到这里系统就能用了。但这里有个关键的坑默认情况下访客端的ws连接地址是写死在配置里的ws://你的IP:2346如果IP不对或者端口没放行前端就会一直显示“正在连接”。这是新手第一次部署最容易卡住的地方建议先把服务器防火墙的2346端口放行再确认前端配置文件里的地址和实际IP一致。3.2 WebSocket连接与心跳保活看似简单但最要命的部分既然叫“实时聊天系统”通信就是命脉所在。这套系统用Workerman做WebSocket服务端前端的连接代码用原生JavaScript实现把这个通信链路彻底打通整个聊天系统就活了。先看服务端核心代码。Workerman提供了一个事件驱动的WebSocket服务器开发者注册几个回调函数即可?php require_once __DIR__ . /../vendor/autoload.php; use Workerman\Worker; use Workerman\Connection\TcpConnection; $ws_worker new Worker(websocket://0.0.0.0:2346); $ws_worker-count 4; // 开启4个进程处理连接 // 连接建立时每个连接保存一个clientId用于标识 $ws_worker-onConnect function (TcpConnection $connection) { $connection-clientId uniqid(); }; // 收到消息消息统一为JSON格式 $ws_worker-onMessage function (TcpConnection $connection, $data) { $msg json_decode($data, true); if (!$msg || empty($msg[type])) { return; } // 分发消息这里将业务处理交给ChatService \App\Services\ChatService::handleMessage($connection, $msg); }; // 连接关闭时需要告诉ChatService清理在线状态 $ws_worker-onClose function (TcpConnection $connection) { if (isset($connection-clientId)) { \App\Services\ChatService::disconnect($connection-clientId); } }; Worker::runAll();前端的连接逻辑也很直接const ws new WebSocket(ws://你的服务器IP:2346); ws.onopen function() { // 登录成功后给服务端发一条init消息绑定访客身份 ws.send(JSON.stringify({ type: init, visitorCode: getVisitorCode(), role: visitor })); }; ws.onmessage function(event) { const data JSON.parse(event.data); if (data.type chat) { appendMessage(data); } else if (data.type system) { showSystemMessage(data.content); } };这里有个非常关键的设计——消息层就只做转发业务逻辑全在ChatService里。服务端收到消息后先根据type字段判断是初始化、聊天还是心跳然后调用Service里的方法处理。这种设计避免了把业务代码塞在回调里否则二次开发的时候改成机器人对接、敏感词过滤全得在回调里层层叠加代码很快变成一锅粥。连接建立之后最核心的问题就来了WebSocket连接是长连接但网络环境并不总是稳定的。客户端掉线、服务器重启、Nginx空闲超时都可能导致连接悄无声息地断开。如果没有心跳机制服务端会一直认为某个访客还“在线”直到下次发消息才发现连接已经死了。这时候访客体验就是打开页面显示在线但发消息毫无反应。这套系统用心跳机制来解决客户端每30秒发送一个ping服务端收到后返回pong如果连续三次没收到客户端心跳服务端主动断开该连接并清理在线状态。let heartbeatCount 0; let heartbeatTimer null; function startHeartbeat() { heartbeatTimer setInterval(() { ws.send(JSON.stringify({ type: ping })); heartbeatCount; if (heartbeatCount 3) { ws.close(); reconnectWebSocket(); } }, 30000); } ws.onmessage function(event) { const data JSON.parse(event.data); if (data.type pong) { heartbeatCount 0; // 收到pong重置计数 } // 处理其他类型消息... };心跳机制的经验总结起来就三个字别偷懒。我见过有人把心跳间隔设置成5分钟理由是“省资源”结果一遇到运营商NAT超时就大量断线。更好的做法是缩短心跳周期同时配合客户端的自动重连逻辑。断线不可怕可怕的是断线了双方都不知道。对于在线客服这种实时性要求高的系统每30秒一次心跳是值得的。3.3 消息收发的完整链路从访客输入到客服端展示把通信链路搭好之后就要看消息在系统里是怎么流转的。我画了条线访客发一条消息背后发生的事比看着的要复杂一些。访客在输入框按回车前端把消息内容包装成JSON通过WebSocket发送到服务端。JSON格式是统一的规范这个规范在二次开发时最好不要轻易改动{ type: chat, sessionId: 10001, clientMsgId: a3f2e8f1-5c1b-4d7b-9bf3-8b3ff1bd3e21, content: 我想咨询一下你们的会员价格 }这里的clientMsgId是我的建议字段。原版系统里可能没有但加上它几乎零成本却能让消息去重、消息追踪变得无比轻松。服务端收到这条消息后按以下步骤处理第一步查会话表确认当前会话是否有效。如果会话状态是“排队中”或“服务中”继续下一步如果是“已结束”则创建新会话。第二步把消息写入消息表。写入时把clientMsgId原样保存这样万一客户端因为断线重发同一条消息服务端可以通过SELECT * FROM chat_message WHERE client_msg_id ?查出已经存在直接返回成功而不重复入库。第三步通过WebSocket把这条消息推送给客服端。这里就有个“连接映射”的问题服务端怎么知道这条消息要发给哪个客服答案是通过会话表里的agent_id字段找到对应客服再在服务端维护的“客服ID到WebSocket连接”的映射表里找到连接对象。第四步如果客服当前不在线——注意这里说的是“WebSocket连接不存在”不是“客服账号未登录”——则把消息标记为离线消息。客服下次上线时系统自动把离线消息批量推送过去。整个链路看起来简单真正容易出错的是第三步的“推送对象”判定。很多初学者会犯一个错误往会话里所有连接都推一遍消息导致访客收到自己发送的消息、客服收到重复消息。正确做法是在ChatService里维护一张在线连接映射表每个会话只推给对应的两个角色。这套系统的消息链路把“写入数据库”和“推送给对方”两个动作做成原子操作——先入库再推送。如果推送失败无非就是对方稍后刷新能看到但如果你先推送、再入库推送成功而数据库写入失败了那这条消息就彻底丢了怎么都找不回来。这个顺序在二次开发时一定要保持住。4. 二次开发的几个方向与实操建议4.1 最容易见效的三个扩展点很多做二次开发的同行一开始不知道从哪里下手。我的建议是先做那些“改动小、见效快、能直观感受到系统在变化”的功能建立信心之后再碰底层的架构改动。这套系统有这三个扩展点值得优先做。第一个是自动欢迎语。访客打开聊天窗口时系统自动推送一条欢迎语。实现方式非常简单在访客建立WebSocket连接并完成init握手后调用一次消息发送接口往会话里写入一条类型为system的消息。这段代码不会动底层逻辑只是在你现有的“会话创建”流程上挂一个钩子。第二个是快捷回复。客服端维护一个快捷回复模板列表客服点击模板系统把模板内容填入输入框或者直接发送。这个功能特别适合客服话术标准化的业务场景。实现上只需要在客服端页面加一个侧边栏从数据库读取模板列表点击后把内容回填到输入框即可服务端不用改一行代码。第三个是敏感词过滤这个功能每个商业客服系统都有但自己做起来也只是一层过滤逻辑。在ChatService::handleMessage()入口处对消息内容做一次字符串匹配命中敏感词就直接拦截返回一条系统提示给访客不进入消息表。注意敏感词过滤一定是“进系统的第一道关卡”而不是在客服端展示时才过滤。如果只在展示端过滤敏感词已经落库了排查的时候怎么都说不清。我调过很多客服系统的敏感词功能这个教训是反复被验证的。这三个扩展点的共同特征是都在原有模型上“加一层”不影响底层架构。做完这些你基本就对系统运行机制有了感觉可以有底气去做更复杂的改动。4.2 进阶改造机器人接入、多租户改造、数据统计当基础扩展做完后就可以挑战真正的“大活”了。这里聊三个我做过并且验证过的进阶方向任何一个都会让这套轻量系统具备商业级的能力。第一个是机器人接入。现在的客服系统如果不带机器人人力成本很难控制。但机器人的接入实际上不是“人工智能”的问题而是“事件分发”的问题。做法不复杂在消息处理链路里加一个钩子当消息的发送方是访客时先把消息复制一份发给机器人接口然后设定一个超时时间比如3秒。如果机器人返回了回复内容就把回复内容作为客服角色的消息写入并推送给访客如果机器人没回或者返回“转人工”指令则该消息的正常分配逻辑继续执行转给人工客服处理。这个改造里最容易出问题的是“超时控制”。机器人接口如果响应慢访客会以为系统卡了。我的建议是机器人回复的主导权交给前端。前端收到访客消息后先展示“AI正在输入...”的占位效果同时并行请求机器人接口3秒内没回就自动切换为人工排队提示。这个体验比让访客干等好得多。第二个是多租户改造这是把一套系统变成SaaS平台的关键一步。说白了就是给原有的表都加一个tenant_id字段然后在数据操作层统一加上租户过滤条件。听起来简单但会碰到一个绕不开的问题WebSocket的连接映射表也要按租户隔离。不然A公司的客服可能会收到B公司访客的消息推送请求。具体做法是在WebSocket连接初始化的时候把tenant_id作为连接的一个属性存下来消息路由时先校验会话所属租户和连接所属租户是否一致不一致直接拒绝。这个校验代码量不大但逻辑位置非常关键——必须在路由分发的最前面而不是到了ChatService内部才判断。越早拦截越不容易漏。第三个是数据统计。聊天数据是客服系统的金矿但光有数据没有统计金矿就是一堆沙子。我建议从最简单的指标做起每日会话数、平均首次响应时长、平均会话时长、满意度评价。这些指标不需要复杂的分析模型几条SQL就能跑出来。会话数就是COUNT(*)加GROUP BY date首次响应时长就用该会话第一条客服消息时间减去会话创建时间满意度评价则需要在会话结束前加一个评价入口把评分存到会话表里。说到多租户改造顺带提一嘴现在很多团队做二次开发喜欢先上复杂的东西比如对接大模型、做智能分配但基础的数据隔离和服务质量统计没跟上最后系统跑起来了业务方一问“今天哪条渠道咨询量最高”“哪个客服响应最慢”拿不出数据这个系统就很难真正落地。先把数据底座打好后面任何智能化改造都有了判断依据。4.3 二次开发中容易被忽略的细节做二次开发的过程中有两个细节我强烈建议多花一点时间处理不然上线后会很痛苦。第一个是消息内容的HTML转义。聊天消息在客户端展示时如果直接拼接HTML访客发一条img srcx onerroralert(1)那就是标准的存储型XSS漏洞客服端一打开页面就会中招。做在线客服系统这个防线必须要设。最简单的做法前端展示消息时全部用textContent而不是innerHTML需要支持图片的话对内容做白名单校验只允许特定的标签格式。第二个是操作日志。客服系统每天会产生大量的操作行为登录、接入会话、转接、结束会话、快捷回复。这些行为如果完全没有日志出了问题连“这个客服到底有没有接过这个客户”都说不清。轻量系统考虑到精简日志功能往往很薄但我的建议是至少记录两类登录日志和会话操作日志。前者记录谁在什么时间登录过后者记录会话的完整流转过程。不需要额外建很多表在现有表上增加日志字段或者一个简单的操作流水表就够了。5. 常见问题与排查技巧实录5.1 部署和运行中的高频问题速查我在部署和调试这套系统的过程中遇到了不少问题很多问题其实是同一个根源的不同表现。这里整理成一张速查表按“现象—原因—解法”的方式方便大家直接对标自己的情况。现象可能原因排查与解决访客端一直显示“正在连接”连接不上WebSocket服务器防火墙未放行2346端口前端ws地址IP写错用firewall-cmd --list-ports检查端口用telnet IP 2346测试连通性连接正常但聊天消息收不到Nginx反向代理未开启WebSocket升级消息路由被并发进程打乱配置Nginx代理头Upgrade: websocket确认Workerman启动的数个进程的连接映射同步正常客服端显示在线但访客发消息没有反应客服的WebSocket连接已断开但未感知会话分配逻辑未触发检查客服端心跳断开时重新拉取会话列表检查ChatService分配逻辑消息重复显示缺少有效的去重机制客户端断线重连后重发了未确认的消息启用client_msg_id去重前端加载历史消息时跳过已存在的ID一段时间没操作后连接自动断开Nginx的proxy_read_timeout默认60秒长连接被掐断调整proxy_read_timeout 300s客户端心跳周期保持在30秒以内消息写入数据库失败但界面没有任何错误提示消息接口返回值被前端忽略SQL字段长度超限前端统一处理错误返回显示“发送失败点击重试”调整消息表字段长度客服端页面白屏或卡死浏览器控制台有JS报错图片或特殊字符导致渲染异常打开开发者工具看Console报错检查消息渲染代码是否对特殊内容做了转义多开几个客服端后消息串线连接映射表按agent_id维护但多个标签页共用了同一个客服身份客户端连接时给每个标签页生成独立连接ID服务端以连接ID作为映射主键这里面最值得单独说的是“消息路由”那个问题。Workerman启动多进程后不同进程维护各自的连接映射表如果访客的连接和客服的连接落在不同进程消息就没法直接转发。解决办法有两种要么改成单进程模式适合学习阶段代码简单要么引入一个共享的内存通道生产环境必须做。很多二次开发的人在这个问题上卡了很久是因为他们以为问题出在“消息格式”上实际是进程隔离问题。5.2 性能、安全与底线的把握在线客服系统的性能压力相对可控但这不意味着可以完全不管性能。明确说一下底线在哪里。消息表的增长速度是最快的一个活跃的客服系统一天几千条消息很正常。如果不做任何归档策略一两年后消息表就会有几百万行导致查询越来越慢。轻量系统常用的策略是“冷热数据分离”热数据保留最近90天老数据定期导出归档到历史表或文件里。实现上就是一条定时任务把90天前的消息从主表搬到归档表改动量不大。安全方面有三道底线要守。第一道是SQL注入防线所有数据库操作必须走参数绑定不允许字符串拼接SQL。第二道是消息内容过滤入库之前做统一的过滤和转义防止存储型XSS。第三道是权限控制前后端都要做。前端隐藏按钮只是体验优化真正的权限校验必须放在服务端否则任何人构造一个请求就能冒充客服调用接口。压力测试这块我实测过这套系统在单台2核4G的云主机上开启4个Workerman进程保持500个WebSocket长连接同时在线消息延迟能控制在100毫秒以内。这台机器的配置在云服务商那儿是最低档位的因此作为学习用和中小业务场景已经足够。如果并发再往上走那就需要考虑负载均衡、多机部署方案属于另一个话题了。5.3 调试工具与日常运维心得最后分享几个调试这套系统时很有用的工具和心法。浏览器开发者工具是调试WebSocket的第一利器。打开Network面板找到WS标签可以看到建立连接、发送帧、接收帧的完整记录。前端发送了什么、服务端返回了什么在这上面一目了然。我调试消息重复、断线重连这类问题几乎全靠它。Workerman本身带了日志和状态查看功能。启动后在命令行执行php socket/server.php status能看到当前连接数、消息收发总数、进程状态。如果发现连接数一直在涨而不回落那多半是有连接没被正确关闭结合心跳机制检查一下。线上出问题先跑这个命令信息量比看一堆日志都要大。还有一个运维细节改代码之后重启Workermanphp socket/server.php restart这个命令会重启所有进程加载新代码。如果是新增了数据库表字段记得先执行对应的ALTER语句再重启否则新逻辑查到不存在的字段会直接报错。关于调试时效性我的体会是在线客服系统出问题最怕的是“复现不了”。因为涉及实时连接和多端交互很多问题在测试环境根本复现不出来。所以一定要在生产环境保留前端的错误日志和服务端的消息日志。前端把WebSocket的收发记录发送到日志接口服务端把每条收到和发出的消息记录到日志文件一旦出问题两边日志一对比问题在哪边立刻就清楚了。这套系统原版可能日志很薄但做二次开发时把它补上绝对是性价比最高的一笔投入。