简介:面向需要自建在线客服系统的开发者、中小企业技术团队与运维人员,这份压缩包提供一套开源在线客服管理系统源码,可用于网页端实时咨询、客服工作台、常见问题自动回复、服务记录与数据统计等场景,并支持按业务需求二次开发。压缩包约7.78MB,文件总数与主要文件类型明细上游暂未提供,具体目录和源码构成需解压后确认。已有472人浏览学习,说明其在开源客服系统选型与源码研究中受到一定关注。读者可了解客服系统前后端模块的组织方式,围绕多渠道对话管理、客户信息查看、工单处理、报表分析、CRM/ERP对接、多语言与权限安全等方向做改造;也可参考开源项目的定制、部署与维护思路,用于课程实践、企业内部客服平台原型或功能验证,适合具备一定开发基础、希望以较低成本自建客服系统并持续迭代的团队参考。
1. 开源客服系统源码不是装完就能用:先把三条链路摸清
拿到「源码-开源版在线客服管理系统.zip」这类包,很多人第一反应是找安装说明,把它当普通插件往站点里一塞。真跑起来才发现,它不是插件式小模块,而是一套完整软件:访客端、坐席工作台、会话分配、消息通道、权限和统计都在里面。适合已有 Web 业务、想自己掌控客服数据的人,也适合要二次开发工单和渠道接入的团队。
先别急着改界面。先把目录、数据表和长连接入口摸清,这三处决定这套源码能不能接手、上线后会不会掉消息。后面按拆包选型、最小闭环、长连接可靠性、避坑、压测五步走,每一步都能落到命令和参数上。
2. 拆包与选型:从目录结构判断这套源码能不能接手
解压后的第一件事不是找 install,而是确认它是独立软件还是插件式模块。独立软件一般有独立入口、独立数据库和常驻进程;插件式模块往往只有几个控制器和一个挂载点,消息通道依赖宿主系统。客服系统最怕的是“看着能装,实际长连接没入口”,所以先把目录翻一遍,比看宣传页有用。
2.1 先看入口和依赖:三分钟判断这套源码能不能接手
我一般先把压缩包解到独立目录,避免和现有站点混在一起。然后找依赖清单、SQL 文件、启动脚本和 README,这四样决定后面怎么配环境。没有依赖清单的包不一定不能用,但维护成本会高;没有 SQL 文件的包要重点看它是否依赖宿主库;没有启动脚本却声称支持实时聊天,就要怀疑消息通道是不是轮询。
| 位置 | 先看什么 | 判断点 |
|---|---|---|
| composer.json / package.json / pom.xml | 依赖和脚本 | 能否锁定依赖,有没有长连接服务 |
| database 或 sql 目录 | 建表脚本 | 是否有 message、session、agent_status |
| bin 或 server 目录 | 启动入口 | 网关是不是独立进程 |
| config 目录 | 数据库和缓存配置 | 是否硬编码账号密码 |
| public 目录 | 前端资源 | 是源码还是编译产物 |
mkdir -p /opt/chat-src && cd /opt/chat-src unzip -q 源码-开源版在线客服管理系统.zip -d app find app -maxdepth 3 -type f \( -name "composer.json" -o -name "package.json" -o -name "pom.xml" -o -name "*.sql" -o -name "README*" \) -print du -sh app这段脚本做三件事:把源码解到独立目录、找出依赖清单和 SQL、看压缩包解压后的大致体积。-d app指定目标目录,-maxdepth 3防止在深层依赖里翻太久,du -sh用来看前端资源是不是已经把编译产物打进来了。如果find只找到一个 README,没有依赖清单也没有 SQL,先别急着配环境,优先确认它是不是半成品。
接着看入口文件。客服系统通常有三类入口:后台管理入口、访客端入口、长连接网关入口。后台和访客端一般是 HTTP 入口,网关入口会在命令行启动脚本或独立端口中出现。把这三类入口对上,后面排查“消息为什么不推”才有落点。
cd /opt/chat-src/app grep -R "listen\|websocket\|socket\|ws://" -n --include="*.php" --include="*.js" --include="*.yaml" --include="*.conf" . | head -n 60grep -R递归找关键字,--include限定文件类型,head -n 60只看前 60 行,避免被前端依赖刷屏。这里不是让你改代码,而是确认网关监听端口、握手路径和前端连接地址是否在同一个配置体系里。常见翻车点是前端写死ws://,线上是 HTTPS,浏览器直接拦掉混合内容;或者网关监听0.0.0.0:9502,反向代理却转发到/ws,路径对不上。
2.2 技术栈匹配:PHP、Node、Java 三种常见形态的取舍
客服系统的读写比例很偏:会话和消息写得多,坐席列表和统计读得多,长连接还要维持大量空闲连接。PHP 常见形态是 Web 后台加一个常驻进程网关,优点是部署简单、招人容易,缺点是网关和业务代码容易分成两套;Node 做长连接更自然,事件循环适合大量 IO,但 CPU 密集的统计任务要另拆;Java 适合已有权限体系和组织架构的团队,代价是启动和调优更重。
判断技术形态不用读全量代码,看依赖清单和启动脚本就够。下面这段命令用来找“谁在监听端口”和“谁在维护连接”。
cd /opt/chat-src/app grep -R "new Server\|createServer\|listen(\|bind(\|swoole\|eventloop" -n --include="*.php" --include="*.js" --include="*.java" . | head -n 40 find . -maxdepth 2 -type f -name "*.sh" -o -name "*.bat" | sortgrep找服务端监听和事件循环关键字,find找启动脚本。参数上不用追求全量匹配,先看命中位置在bin/、server/还是普通控制器里。如果监听逻辑散落在多个控制器,说明消息通道和业务耦合较重,后面做多实例会麻烦;如果只有一个入口脚本,改造点相对集中。
选型时别只看语言,先看三个边界:消息是否落库、坐席状态是否有过期机制、网关是否支持多实例共享会话。三个都满足,技术栈顺眼就能用;缺一个,后面就要补。常见做法是 Web 层只管 HTTP 接口和后台页面,网关层只做连接维护和消息转发,消息落库由业务接口完成,网关通过队列或 Redis 订阅拿推送内容。这样即使网关重启,历史消息也不丢。
2.3 数据模型先画清楚:会话、消息、坐席状态三张核心表
界面可以后改,表结构一开始就要看明白。客服系统的最小数据模型绕不开三张表:会话表记录访客和坐席的绑定关系,消息表记录每条消息的归属和顺序,坐席状态表记录在线、忙碌、最大接待数和最后心跳。原包表名可能不一样,但字段职责跑不掉。
CREATE TABLE cs_session ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, visitor_id BIGINT UNSIGNED NOT NULL, agent_id BIGINT UNSIGNED DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0排队 1进行中 2已关闭', created_at DATETIME NOT NULL, closed_at DATETIME DEFAULT NULL, KEY idx_status_created (status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE cs_message ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, session_id BIGINT UNSIGNED NOT NULL, sender_type TINYINT NOT NULL COMMENT '1访客 2坐席 3系统', sender_id BIGINT UNSIGNED NOT NULL, client_msg_id VARCHAR(64) NOT NULL, seq BIGINT UNSIGNED NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uk_client_msg (client_msg_id), KEY idx_session_seq (session_id, seq) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE cs_agent_status ( agent_id BIGINT UNSIGNED PRIMARY KEY, status TINYINT NOT NULL COMMENT '0离线 1在线 2忙碌', max_sessions INT NOT NULL DEFAULT 5, last_heartbeat DATETIME NOT NULL, version INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;cs_session.status用 0、1、2 表示排队、进行中、已关闭,比字符串好索引;cs_message.client_msg_id加唯一索引,用来挡前端重发和网关重投;seq是会话内递增序号,客户端补拉时按它做游标,不要拿全局自增 ID 跨会话排序。cs_agent_status.last_heartbeat和version一起用,前者判断在线,后者处理多端登录覆盖。
如果原包表名不是这套,别直接改表名,先做映射层。常见误用是看到字段不够就加字段,结果升级源码时冲突。我的做法是保留原表,新增扩展表或视图,把差异收在一处。后面第 3 章的最小闭环也按这三张表跑,换包时只需替换映射。
3. 跑通最小闭环:数据库、坐席、会话、消息四条链路
最小闭环不是“后台能登录”,而是访客发消息、进入排队、分配到坐席、坐席回复、访客收到、会话关闭。这五步里任何一步只在前端模拟,上线后都会变成“看起来能用,实际掉消息”。先用命令行和接口把闭环跑通,再开浏览器调界面,排查范围会小很多。
3.1 环境与配置:把数据库和 Redis 连接先跑通
先建库、导表、建应用账号。不要用 root 跑应用,也不要把密码写进仓库。下面命令里的库名、用户名和密码都要替换成自己的,导入前先看 SQL 文件里有没有DROP TABLE,有的话确认是不是空库。
mysql -uroot -p -e "CREATE DATABASE chat DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p chat < database/install.sql mysql -uroot -p -e "CREATE USER 'chat_app'@'127.0.0.1' IDENTIFIED BY 'change_me'; GRANT SELECT,INSERT,UPDATE,DELETE ON chat.* TO 'chat_app'@'127.0.0.1'; FLUSH PRIVILEGES;"DEFAULT CHARACTER SET utf8mb4是为了存表情和特殊字符;应用账号只给增删改查,不给建表权限;127.0.0.1限制来源,比%安全。导入后别急着开页面,先用一段 PHP 脚本检查数据库和 Redis 是否真的连上。密码从环境变量读,不写死在代码里。
<?php $config = [ 'db' => ['host' => '127.0.0.1', 'port' => 3306, 'name' => 'chat', 'user' => 'chat_app', 'pass' => getenv('CHAT_DB_PASS')], 'redis' => ['host' => '127.0.0.1', 'port' => 6379, 'db' => 0], 'ws' => ['listen' => '0.0.0.0:9502', 'heartbeat' => 30], ]; $pdo = new PDO( "mysql:host={$config['db']['host']};port={$config['db']['port']};dbname={$config['db']['name']};charset=utf8mb4", $config['db']['user'], $config['db']['pass'], [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION] ); echo $pdo->query('SELECT 1')->fetchColumn() === '1' ? "db ok\n" : "db fail\n"; $redis = new Redis(); $redis->connect($config['redis']['host'], $config['redis']['port']); $redis->select($config['redis']['db']); echo $redis->ping() ? "redis ok\n" : "redis fail\n";这段脚本只做连通性检查:PDO 用异常模式,连不上会直接抛错;Redis 用ping看返回。ws.heartbeat先设 30 秒,后面第 4 章会按反向代理超时再调。如果这里就失败,先看端口、防火墙和账号权限,不要继续改业务代码。常见坑是 MySQL 只监听了127.0.0.1,应用在容器里却连localhost,两边看到的不是同一个地址。
3.2 坐席与访客两端:一个最小会话的创建、分配、关闭
会话分配最容易写成“查一个在线坐席就更新”,并发一上来就重复分配。下面这段 PHP 用事务和行锁把分配动作收在一个事务里,SKIP LOCKED让多个请求不会卡在同一行。旧版数据库不支持这个语法时,改用 Redis 原子队列或单独锁,别硬套。
<?php function assignAgent(PDO $pdo, int $sessionId): ?int { $pdo->beginTransaction(); try { $stmt = $pdo->prepare( "SELECT agent_id FROM cs_agent_status WHERE status = 1 AND last_heartbeat >= DATE_SUB(NOW(), INTERVAL 90 SECOND) ORDER BY max_sessions ASC, agent_id ASC LIMIT 1 FOR UPDATE SKIP LOCKED" ); $stmt->execute(); $agentId = $stmt->fetchColumn(); if (!$agentId) { $pdo->commit(); return null; } $pdo->prepare("UPDATE cs_session SET agent_id = ?, status = 1 WHERE id = ? AND status = 0") ->execute([$agentId, $sessionId]); $pdo->commit(); return (int)$agentId; } catch (Throwable $e) { $pdo->rollBack(); throw $e; } }分配条件里status = 1表示在线,last_heartbeat90 秒内算有效,max_sessions小的优先是为了让接待量低的坐席先接。FOR UPDATE SKIP LOCKED避免两个请求同时选中同一个坐席;UPDATE ... AND status = 0是第二道保险,只有排队中的会话才能被分配。返回null表示没有可用坐席,调用方要把会话留在排队状态,别把状态改成进行中。
创建会话和关闭会话可以先用 SQL 验证。创建时只写访客和排队状态,分配成功后再更新坐席;关闭时同时写closed_at,方便后面统计会话时长。
INSERT INTO cs_session (visitor_id, status, created_at) VALUES (1001, 0, NOW()); UPDATE cs_session SET status = 2, closed_at = NOW() WHERE id = 1 AND status = 1;第一条插入排队会话,第二条只允许进行中的会话关闭,重复关闭不会覆盖时间。参数上注意visitor_id要来自真实访客标识,不要用时间戳凑;关闭条件带status = 1,能挡住重复请求。跑完这两步,再用后台接口查一次会话详情,确认坐席、状态和时间都对得上。
3.3 消息落库与推送:先保证不丢,再谈实时
消息链路我一般按“先落库、再入队、后推送”的顺序写。先落库保证刷新页面能查到,入队保证网关能异步推,推送失败还能重试。下面这段函数把消息写入和 Redis 队列放在一个流程里,client_msg_id唯一索引挡重复,seq按会话递增。
<?php function appendMessage(PDO $pdo, Redis $redis, int $sessionId, int $senderType, int $senderId, string $clientMsgId, string $content): array { $pdo->beginTransaction(); try { $seqStmt = $pdo->prepare("SELECT COALESCE(MAX(seq), 0) + 1 FROM cs_message WHERE session_id = ? FOR UPDATE"); $seqStmt->execute([$sessionId]); $seq = (int)$seqStmt->fetchColumn(); $ins = $pdo->prepare( "INSERT INTO cs_message(session_id, sender_type, sender_id, client_msg_id, seq, content, created_at) VALUES(?, ?, ?, ?, ?, ?, NOW())" ); $ins->execute([$sessionId, $senderType, $senderId, $clientMsgId, $seq, $content]); $id = (int)$pdo->lastInsertId(); $pdo->commit(); $redis->rPush('chat:push:queue', json_encode([ 'session_id' => $sessionId, 'seq' => $seq, 'client_msg_id' => $clientMsgId, ])); return ['id' => $id, 'seq' => $seq]; } catch (Throwable $e) { $pdo->rollBack(); throw $e; } }client_msg_id由前端生成,同一消息重发时必须复用同一个值;sender_type用 1、2、3 区分访客、坐席、系统;FOR UPDATE锁住当前会话的序号计算,避免两条消息拿到同一个seq。Redis 队列只放推送需要的轻量字段,不放整段内容,网关消费时再按 ID 查库。这样即使队列丢一条,也能用补拉接口补回来。
验证时别只看页面。用同一个client_msg_id连发两次,第二次应该报唯一键冲突;再查seq是否连续:
SELECT session_id, seq, client_msg_id, created_at FROM cs_message WHERE session_id = 1 ORDER BY seq ASC;如果seq断了,先看事务是不是提交失败,再看是不是有别的写入路径没走这个函数。常见误用是后台“快捷回复”直接拼 SQL 插消息,绕过序号计算,最后补拉时顺序全乱。所有写消息的入口都收口到一个函数,后面加审计和敏感词也方便。
4. 长连接与消息可靠性:WebSocket 网关的心跳、重连与补拉
长连接是客服系统最容易变成黑匣子的部分:页面显示在线,消息却收不到;网关日志没报错,坐席状态却一直挂着。排查这类问题,先把网关启动、心跳、重连、补拉四件事拆开,每一件都用可观测的参数落下来,别靠“刷新一下试试”。
4.1 网关进程怎么起:端口、心跳、鉴权三件事
不同源码的网关入口不一样,常见在bin/或server/下。先看 README 和启动脚本,再用命令行起进程、查端口、看日志。下面命令里的入口文件要按实际包替换,-d表示常驻,端口 9502 只是示例。
cd /opt/chat-src/app php bin/ws-gateway.php start -d ss -lntp | grep 9502 tail -f runtime/ws.logstart -d让进程在后台跑;ss -lntp确认端口真的监听了;tail -f看握手和断开日志。如果端口没起来,先看入口文件是否存在、PHP 扩展是否装齐、日志目录有没有写权限。别在nohup和自带守护之间来回切,先固定一种启动方式,后面做进程守护才不混乱。
反向代理要把 WebSocket 的升级头带过去。下面配置放在站点配置里,路径/ws要和前端连接地址一致。
location /ws { proxy_pass http://127.0.0.1:9502; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 120s; proxy_send_timeout 120s; }proxy_http_version 1.1和Upgrade、Connection是 WebSocket 握手必需;proxy_read_timeout 120s要大于心跳间隔,否则代理会先断开;Host透传是为了网关做域名校验。鉴权别只放在前端,握手时带 token,网关校验后再允许订阅会话。token 放查询串容易进日志,常见做法是放握手头或首帧认证消息。
4.2 心跳与重连:把“在线”判断从玄学变成可计算
前端心跳负责告诉网关“我还活着”,服务端心跳负责把坐席状态写成可查询的数据。两边间隔要配合:心跳太密浪费连接,太疏会被代理判死。我一般让客户端 25 秒发一次 ping,服务端 TTL 设 90 秒,反向代理读超时 120 秒,三层依次放宽。
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'; let ws = null; let heartbeatTimer = null; let reconnectDelay = 1000; function connect() { ws = new WebSocket(wsUrl); ws.onopen = () => { reconnectDelay = 1000; clearInterval(heartbeatTimer); heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, 25000); }; ws.onmessage = (ev) => { const msg = JSON.parse(ev.data); if (msg.type === 'pong') return; if (msg.type === 'message') renderMessage(msg.data); }; ws.onclose = () => { clearInterval(heartbeatTimer); setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, 30000); }; } function renderMessage(data) { // 按 seq 去重后插入消息列表 const box = document.getElementById('chat-list'); if (!box || box.querySelector(`[data-seq="${data.seq}"]`)) return; const item = document.createElement('div'); item.dataset.seq = data.seq; item.textContent = data.content; box.appendChild(item); } connect();25000毫秒发一次 ping,小于代理的 120 秒;reconnectDelay从 1 秒开始翻倍,最多 30 秒,避免断网时疯狂重连;onmessage里按seq去重,防止补拉和实时推送重复渲染。重连成功后不要只等推送,要主动调用补拉接口,把断线期间的消息拿回来。
服务端收到 ping 后要同时刷新 Redis 和数据库。只写内存,进程重启就丢;只写数据库,高频心跳会把库压出热点。常见做法是 Redis 存短 TTL 在线状态,数据库按较低频率落last_heartbeat。
<?php // 收到 ping 后刷新坐席在线状态,TTL 要大于心跳间隔 $redis->setex("agent:online:{$agentId}", 90, json_encode(['ts' => time(), 'status' => 1])); $pdo->prepare("UPDATE cs_agent_status SET last_heartbeat = NOW(), version = version + 1 WHERE agent_id = ?") ->execute([$agentId]);setex的 90 秒是过期时间,超过这个时间没有新心跳就自动离线;数据库更新带version + 1,多端登录时可以比较版本决定谁生效。参数上别把 TTL 设得比心跳间隔还短,否则坐席会反复上下线;也别设几小时,否则人走了状态还挂着。
4.3 离线补偿:消息 seq、ack、补拉接口
实时推送只能保证“在线时收到”,断线、切网、网关重启都会漏。补拉接口按会话和seq取增量,客户端保存本地最大seq,重连后带上它请求。不要用时间戳做游标,同一秒多条消息会乱;也不要用全局自增 ID 跨会话比较。
-- 客户端带上本地最大 seq,只取增量 SELECT id, session_id, seq, sender_type, sender_id, content, created_at FROM cs_message WHERE session_id = ? AND seq > ? ORDER BY seq ASC LIMIT 100;session_id和seq走联合索引idx_session_seq,LIMIT 100防止一次拉太多。客户端收到后按seq排序去重,再把本地最大seq更新到返回结果的最大值。如果返回空,说明没有增量;如果返回满 100 条,继续用新的最大seq拉下一页。
ack 机制用来标记“客户端已确认收到”。服务端可以记录每个会话的最后 ackseq,超过一定时间没 ack 的消息进入重投队列。重投时仍然带原client_msg_id,客户端靠唯一键和seq去重。常见坑是服务端只重投不记录次数,网络差的时候同一条消息反复推,前端又没去重,用户看到满屏重复。补拉和重投共用一套seq体系,问题就能收敛。
5. 上线避坑:五个只在真实会话里暴露的故障
本地跑通不代表线上能用。客服系统的故障往往不在主流程,而在状态、并发和超时这些边角。下面五条是我在模拟项目里反复见到的,按“现象、原因、解决”拆开写,方便对照日志排查。
5.1 坐席状态永远在线:心跳写了内存却没落库
现象是后台显示某个坐席一直在线,实际人已经下班,新会话还往他身上分配。原因通常有两个:心跳只更新了 Redis,没有写数据库;或者数据库有last_heartbeat,但分配查询没带时间条件。还有一种情况是多端登录,旧连接的关闭事件把新连接的状态覆盖了。
解决分三层。写入层用 Redis TTL 加数据库低频落库,TTL 取心跳间隔的三倍左右;查询层分配坐席时必须带last_heartbeat时间条件;多端登录用version或连接 ID 做比较,旧连接关闭时先校验再更新。排查时直接查最近心跳和 Redis TTL,别只看页面上的绿点。
5.2 消息重复或乱序:前端重发撞上网关重投
现象是同一句话出现两次,或者坐席先看到第二条再看到第一条。原因可能是前端超时重发但没复用client_msg_id,也可能是网关推送失败后重投,消息表却没有唯一约束。乱序则多半是seq计算没加锁,两个请求拿到同一个序号,或者客户端按到达时间排序而不是按seq排序。
解决是把client_msg_id唯一索引作为最后一道闸,所有写消息入口收口到一个函数;seq在事务里按会话取最大值加一;客户端渲染前按seq排序并去重。排查时查cs_message里同一client_msg_id的记录数,再看seq是否有重复。重复不可怕,怕的是没有幂等键。
5.3 会话分配不均:轮询没排除忙线和离线
现象是某个坐席同时接十几个会话,另一个坐席空闲。原因常见于分配查询只按status = 1过滤,没看当前进行中会话数;或者用了轮询但没排除离线坐席;还有一种是锁粒度太大,并发时大家都抢到同一个坐席。
解决是把坐席状态、最大接待数、当前会话数放在一次查询里,按接待量升序选人;分配更新带status = 0条件,失败就重试或进排队;高并发下用 Redis 原子队列或数据库行锁,别在应用层用文件锁。排查时看分配日志里的候选坐席列表,确认忙线和离线有没有被排除。
5.4 历史消息越翻越慢:message 表缺联合索引
现象是刚开始聊天很快,历史消息到几千条后打开会话要等几秒。原因通常是cs_message只有主键索引,查询按session_id过滤再按时间排序时走了全表扫描;或者消息内容存在大字段里,列表查询把整段内容都拉出来。
解决是加(session_id, seq)联合索引,列表查询只取需要的字段,内容大的附件单独存表或对象存储;分页用seq > ?的游标,不要用大偏移量LIMIT 10000, 20。排查时开慢查询日志,看rows_examined是不是远大于返回行数。索引不是越多越好,消息表写多,索引多了插入也会慢。
5.5 WebSocket 频繁断连:反代超时和心跳间隔打架
现象是页面每隔一两分钟重连一次,日志里能看到 1006 关闭。原因可能是反向代理proxy_read_timeout小于心跳间隔,代理先把空闲连接掐了;也可能是负载均衡没有会话保持,重连落到没有该连接状态的实例;还有 HTTPS 页面连了ws://,浏览器直接拦截。
解决是让代理读超时大于心跳间隔,心跳间隔小于超时的一半;多实例时把连接状态和订阅关系放共享存储,或者做一致性路由;前端根据页面协议自动选ws://或wss://。排查时看代理错误日志和网关握手日志的时间差,能快速判断是谁先断的。
6. 压测与验收:从 50 并发到 500 并发的扩容技巧
压测我一般分两层:先压消息写入接口,再压长连接广播。两层混在一起压,只能看到总 QPS,看不出瓶颈在数据库还是在网关。写入层重点看唯一键冲突、事务等待和seq计算锁;长连接层重点看握手耗时、心跳丢失和内存增长。
# 200 并发压消息写入,先看落库和幂等,不经过网关 seq 1 200 | xargs -P 200 -I{} curl -s -o /dev/null -w "%{http_code}\n" \ -X POST http://127.0.0.1/api/message \ -H "Content-Type: application/json" \ -d '{"session_id":1,"client_msg_id":"bench-{}","content":"load-test"}'xargs -P 200控制并发,client_msg_id每条不同,避免把幂等冲突算成失败;输出只看状态码,200 是成功,409 可能是重复,500 要看应用日志。压完查一次seq是否连续、有没有重复client_msg_id,再决定要不要加锁或改队列。
| 并发量 | 先看指标 | 常见瓶颈 | 处理方向 |
|---|---|---|---|
| 50 | 写入耗时、错误率 | 单条 SQL 没索引 | 加联合索引,收口写入口 |
| 200 | 事务等待、连接数 | 行锁竞争、连接池不足 | 缩短事务,调连接池 |
| 500 | 网关内存、心跳丢失 | 广播放大、代理超时 | 拆频道,调心跳和超时 |
验收时别只看平均值,看 95 分位和错误率。我一般要求消息写入 95 分位在可接受范围内,重复消息为 0,断线重连后补拉不丢消息。压测数据要清库或打标记,别把测试消息混进真实会话。从那以后我每次拿到客服系统源码,都先把client_msg_id唯一索引和网关心跳参数单独验一遍,再谈界面。希望帮到你。
本文还有配套的精品资源,点击获取