做在线客服系统的时候,相信不少做PHP开发的朋友都碰到过这类需求:客户在微信里发来一句“在吗”,坐席后台必须马上有提示;坐席回复一句,客户那边也得像微信聊天一样立刻收到,而不是等个两三秒再去刷新页面。标题里写的“PHP实现在线微信客服源码系统消息的实时推送与处理”,其实拆开看就是两件事:第一,把微信侧的消息接进来;第二,让消息在客服系统和访客端之间实时流动。
这篇文章我会把整个链路的选型思路、架构设计、核心代码逻辑、以及我在实际部署中踩过的坑完整梳理一遍。不管你是正在做客服系统的项目负责人,还是刚接触WebSocket和消息队列的PHP开发,都应该能从里面找到可以直接抄作业的部分。
1. 需求拆解与技术选型分析
1.1 在线客服场景对“实时”的硬性要求
客服系统和普通网站后台有个本质区别:聊天消息具备强时效性。客户在微信里问问题,本质上期待的是“秒回”的体验。如果客服系统做不到实时,客户等了几分钟没反应,可能就直接流失了。
传统的HTTP请求响应模式天生不适合做这种推送场景。HTTP是“客户端问一次、服务端答一次”,服务端没法主动把新消息塞给浏览器。所以早期很多客服系统用的是前端轮询:页面每隔3秒发一个Ajax请求,问“有没有新消息?有就拉回来,没有就返回空”。
轮询的问题是显而易见的。消息密集的时段,比如大促期间同时几百个客户咨询,每个客户页面每3秒就请求一次,后台接口压力成倍增长。而大部分轮询请求其实是“空转”的,因为绝大多数时候根本没有新消息。如果为了省服务器资源把轮询间隔拉到10秒甚至15秒,消息延迟又上去了,客户体验立刻变差。这种“实时不灵敏、服务端压力大”的矛盾,在客服这种消息密度极不均匀的场景里非常明显。
1.2 三种方案对比:短轮询、长轮询与WebSocket
我早期一个项目用的就是短轮询,后来客户投诉消息来得太慢,才彻底转向WebSocket。当时我把三种方案放在一起做了个对比:
| 方案 | 实时性 | 服务端压力 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 取决于轮询间隔,一般秒级 | 高,大量无效请求 | 低 | 在线状态、低频通知 |
| 长轮询 | 准实时,但仍有延迟 | 中,每个请求长期占用连接 | 中 | 兼容旧浏览器的准实时场景 |
| WebSocket | 毫秒级,真正的双工通信 | 低,长连接按需推送 | 中高,需维护连接状态 | 聊天、客服、协同编辑 |
长轮询在当年浏览器还没全面支持WebSocket时是个不错的折中,它的思路是:浏览器发一个请求,服务端先hold住这个请求不返回,等有新消息再立刻返回;如果没有消息,就等超时后再重新发起。看起来挺聪明,但服务端为了hold住大量连接,需要消耗不少内存维护请求上下文;而且HTTP连接频繁建立销毁,代理层和Nginx层都会留下一些隐患,比如连接数超限、CLOSE_WAIT堆积。
WebSocket是真正的双工长连接,服务端可以随时主动推送,实时性和性能都最好。当时浏览器兼容性已经不是问题,移动端微信内置浏览器对WebSocket支持也很好。所以最终我选的是WebSocket作为实时推送主通道,同时保留了一层“兜底轮询”,只在WebSocket异常断开且重连失败时启用,避免极端情况下消息完全不可达。
关于PHP做WebSocket,我需要多说一句。原生PHP跑在PHP-FPM里是“请求来了才执行,执行完就释放”的模式,服务端没法持续维持一堆长连接。所以做WebSocket服务必须用常驻内存模式:进程启动后一直在内存里跑,不退出。PHP做这个的成熟方案有常驻内存框架、共享内存扩展等,核心原理都是让PHP进程像Node.js一样常驻。我下面的示例代码会围绕这种常驻进程的思路来写,不绑定特定框架,方便你迁移到自己熟悉的技术栈。
2. 微信客服消息对接与整体链路设计
2.1 消息从微信侧进入客服系统(回调链路)
先理清楚一个基本事实:微信里的访客和你客服系统里的坐席,两边不是直接连着的。访客在微信聊天窗口发消息,这条消息先到微信服务器,微信服务器再通过一个回调地址POST给你客服系统的接口。你的客服系统接收到这条消息,才把它转成内部数据存库,并实时推送给坐席端。
所以“消息进得来”这一步,核心是配置好回调URL和token校验逻辑。微信服务器在你第一次配置回调地址时,会往你的URL发一个GET请求,带上signature、timestamp、nonce、echostr这些参数,你要做的是把token和timestamp、nonce拼起来排序再SHA1,比对signature是否一致,一致就把echostr原样返回,校验才算通过。
// 回调校验入口 $token = 'your-token-here'; $signature = $_GET['signature'] ?? ''; $timestamp = $_GET['timestamp'] ?? ''; $nonce = $_GET['nonce'] ?? ''; $echostr = $_GET['echostr'] ?? ''; $tmpArr = [$token, $timestamp, $nonce]; sort($tmpArr, SORT_STRING); if (sha1(implode('', $tmpArr)) === $signature) { echo $echostr; exit; } exit('invalid signature');这个校验逻辑各家接口大同小异,核心就是排序拼接,防止别人伪造请求往你接口里灌数据。我当时在这个环节踩过一个坑:配置回调时token抄错了一位,校验老是不通过,我以为是自己排序算法有问题,排查了半天。后来直接拿微信文档上的示例值手动算了一遍SHA1,发现是token抄错了。所以如果你也碰到校验失败,先别怀疑算法,先怀疑你的token是不是原样复制对了。
2.2 坐席回复下行到微信(主动下发与token管理)
访客消息进来之后,下一步是坐席回复。坐席在客服工作台里敲一行字,你的PHP后台需要调微信的“发送消息”接口,把这个文本内容主动推送给微信那头对应的访客。
这里有一个高频坑:access_token的获取与缓存。微信接口的access_token有效期是7200秒,但官方明确要求你全局缓存它,不能每次调用都去重新拉取。一旦你每个坐席回复都调一次获取token的接口,很快就会触发微信侧的频率限制,接口直接报错,客户的回复发不出去。
我当时是用Redis来缓存token的,缓存时间设置的比官方有效期短一些,比如7000秒,这样可以在token真正过期前主动刷新一次,避免边缘时间点出现偶发失效。
// access_token读取逻辑(Redis缓存) $cacheKey = 'wechat:access_token'; $token = $redis->get($cacheKey); if (!$token) { $resp = httpPost('https://api.weixin.qq.com/cgi-bin/token', [ 'grant_type' => 'client_credential', 'appid' => $appId, 'secret' => $appSecret, ]); $token = $resp['access_token']; $redis->setex($cacheKey, 7000, $token); }下发消息的接口调用可以放在异步队列里执行,这一点后面细讲。简单说,坐席点“发送”按钮,你的接口只负责把消息存库、推送到坐席自己屏幕上、把发送任务塞进队列,然后立刻返回到前端。真正的微信接口调用放到消费者进程里慢慢发,可以明显降低坐席端的等待时间。
2.3 数据模型与连接关系设计
整个系统里除了常规的会话表、消息表,还有一个非常重要的Redis缓存,用来维护“在线坐席/访客”的WebSocket连接关系。
先看消息表的设计。我在项目里用的是这张表:
CREATE TABLE kf_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(64) NOT NULL UNIQUE, session_id INT NOT NULL, direction TINYINT NOT NULL DEFAULT 0, from_uid VARCHAR(64) NOT NULL, to_uid VARCHAR(64) NOT NULL, content_type VARCHAR(20) NOT NULL DEFAULT 'text', content TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, KEY idx_session_id (session_id), KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个容易忽略的设计点:
- msg_id必须唯一。微信回调有时候会因为网络原因重复推送同一条消息,这个字段是幂等判断的天然锚点。
- direction字段用来标记消息方向。0表示访客发给坐席,1表示坐席发给访客,后面查聊天记录、算未读数都会用到。
- 状态字段既要记录消息是否已发送,又要记录是否已读。我在实际项目里把它拆成了send_status和read_status两个字段,放在一张表里用位标记容易把自己绕晕。
连接关系比较简单,但很重要:访客在微信里打开客服聊天页时,你的H5页面会和推送服务建立一条WebSocket连接;坐席打开工作台时,也会建立一条WebSocket连接。每个连接都要和一个业务ID绑定,访客用openid,坐席用坐席ID。
需要理清的是,消息进来了,并不是所有连接都要收到。坐席端可以根据会话归属推送;访客端则根据openid精确推送。这个“谁该收到哪条消息”的过滤逻辑,直接用Redis里维护的消息参与者ID来做,比在数据库里查会话关系再推要快得多。
3. 实时推送通道的核心实现
3.1 连接管理:UID与fd的双向映射
WebSocket服务端进程在运行时,每来一个连接,系统都会分配一个文件描述符,也就是fd。你要实现“给某个用户推送”,光有fd不行,你得知道哪个fd对应哪个用户。所以最基础也是最重要的数据结构,是一个双向映射:通过uid能找到fd,通过fd也能找到uid。
// 常驻进程内维护的连接映射 $uidToFd = []; // uid => fd $fdToUid = []; // fd => uid $fdToInfo = []; // fd => 连接基础信息(会话id、角色等) function onConnect($connection) { // 连接建立后,暂时不分配任何业务身份 // 等待客户端发来的auth包进行身份绑定 } function onMessage($connection, $data) { $frame = json_decode($data, true); if (($frame['type'] ?? '') === 'auth') { $uid = $frame['uid']; $role = $frame['role']; // kefu 或 visitor $fd = $connection->fd; $uidToFd[$uid] = $fd; $fdToUid[$fd] = $uid; $fdToInfo[$fd] = ['role' => $role, 'session_id' => $frame['session_id'] ?? 0]; pushToUid($uid, ['type' => 'auth_ok', 'ts' => time()]); } } function pushToUid($uid, $payload) { if (!isset($uidToFd[$uid])) { return false; // 用户不在线,由别的机制兜底 } $fd = $uidToFd[$uid]; $connection = $connections[$fd] ?? null; if ($connection) { $connection->send(json_encode($payload, JSON_UNESCAPED_UNICODE)); return true; } return false; }这里有个细节值得单独说:为什么连接建立后不能马上给这个连接绑定用户,必须等auth包?
因为WebSocket握手过程和HTTP一样,是没有业务状态的。任何拿到你WebSocket地址的人都可以直接连上来。如果不校验身份就绑定uid,那别人伪造一个uid连上来,就能收到这个uid的所有实时消息,这就是消息泄漏事故。所以我的做法是:连接建立后先放进一个“未认证连接池”,等客户端发来auth包、服务端校验token有效后,才把它加入正式映射。客户端JS在onopen之后第一件事就是发送auth包。
3.2 心跳保活与死连接回收
WebSocket的一大幻觉是:连接建立起来好像就一直活着。但实际上,中间任何一层都可能把这条连接悄悄断掉。运营商网络、代理层、云服务商的负载均衡,普遍会回收“空闲”连接。判断空闲的标准,往往就是一段时间内没有数据包。
如果不做心跳,极端情况下会出现一种诡异现象:客户端看着是“已连接”,服务端也保存着这个fd,但这条连接其实已经是死链了,数据发出去石沉大海,服务端也不会得到任何错误通知。消息推送因此丢失的概率非常高。
心跳机制就是我主动发一个ping包,期望对方回一个pong包。如果连续多次没有响应,就认定这条连接已死,主动清理。
我当时的参数是这样定的:
- 客户端每30秒发一次ping。
- 服务端如果90秒内没收到这个连接的任何数据(包括ping、pong、消息),就主动断开。
为什么是30秒和90秒?大部分云负载均衡的空闲连接回收时间在60秒到300秒不等,30秒的心跳间隔显然小于常见回收阈值;90秒没收到任何数据再断,相当于允许连续丢掉两三个心跳才判定死亡,不会因为一次网络抖动就把正常用户踹下线。如果你把心跳压得太紧,比如5秒一次,那全系统每秒都有大量无意义的心跳包传过来,在高并发时也是一笔不小的开销。
// 服务端心跳检测逻辑(伪代码,在常驻进程的定时器中执行) function heartbeatCheck($connections) { $now = time(); foreach ($connections as $fd => $conn) { $lastActive = $conn->lastActiveTime ?? $now; if ($now - $lastActive > 90) { $conn->close(); unset($conn); } } }客户端JS里的心跳发送我是放在setInterval里的,并且只在连接onopen之后启动、onclose之后清除。一个常见的坑是:setInterval的定时器没有清掉,导致断线重连后出现多个心跳发送任务,每次发好几个ping,服务端容易被误判异常。这个细节我当时第一次做就踩了,后来统一用了一个独立的heartbeatTimer变量来管理。
3.3 事件帧设计与前端接收端实现
WebSocket通道建立之后,不光是传聊天消息,系统里还有各种事件需要推送:新消息提醒、消息已读、坐席上下线、会话转接、系统通知。如果不加区分地用一堆杂乱的JSON分发给前端,前端就只能靠约定“字段里有个type”来区分,代码很容易写成一团乱麻。
我推荐的做法是事件帧统一格式:
{ "type": "message", "event_id": "xxxx", "ts": 1690000000, "data": { "msg_id": "msg_123", "session_id": 1001, "direction": 0, "content": "在吗" } }type表示事件类型,event_id可以用来做前端的消息去重,比如一条消息如果通过WebSocket推过来又通过兜底轮询拉了一遍,前端靠event_id防止重复渲染。data是具体的业务数据。统一格式的好处是:前端可以只写一套消息分发器,按type路由到不同的处理函数。
// 前端WebSocket封装的关键代码 let ws = null; let retryCount = 0; let heartbeatTimer = null; function connect() { const protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'; ws = new WebSocket(protocol + location.host + '/ws?token=' + getToken()); ws.onopen = function () { ws.send(JSON.stringify({type: 'auth', uid: currentUid, role: currentRole})); // 正常连接后重置重试次数 retryCount = 0; // 启动心跳定时器 clearInterval(heartbeatTimer); heartbeatTimer = setInterval(function () { ws.send(JSON.stringify({type: 'ping', ts: Date.now()})); }, 30000); }; ws.onmessage = function (e) { const frame = JSON.parse(e.data); if (frame.type === 'ping' || frame.type === 'pong') return; dispatchEvent(frame); // 按 type 路由处理 }; ws.onclose = function () { clearInterval(heartbeatTimer); // 指数退避重连 const delay = Math.min(30000, retryCount * 5000 || 3000); setTimeout(connect, delay); retryCount++; }; ws.onerror = function () { // 打印日志后让 onclose 处理重连 }; } connect();重连策略我用了指数退避:第一次断了3秒后重连,第二次8秒,第三次13秒,依次递增,封顶30秒。这样既能尽快恢复连接,又不会在服务端故障时造成大量客户端同时疯狂重连打满服务端连接数。
还有一个前端细节很有用:监听页面可见性变化。用户切到别的标签页一段时间再切回来,WebSocket可能已经被系统回收了。此时用document.visibilitychange事件,在页面重新可见时主动检查连接状态并重连,能大大减少“看起来在线其实掉线”的情况。
4. 消息处理链路:队列、幂等与重试
4.1 回调与推送之间为什么要塞一个队列
微信回调接口有个硬性要求:收到请求后必须尽快响应,通常是5秒内返回success,否则微信会认为发送失败并重发。如果你在回调接口里同步做完所有事情——解析消息、存数据库、推送WebSocket、更新会话、计算未读数,那复杂度一高,响应时间很容易超阈值。
而且同步处理还会引入另一个问题:如果某个环节挂了,比如数据库连接超时,整个回调接口就会抛错。微信侧重试回调,你这边消息可能已经存上了,就出现重复入库。
所以我的做法是在回调接口里只做一件事:把原始消息原封不动地塞进Redis队列,然后立即返回success。所有真正的业务处理,全部放到消费进程里异步执行。这样回调接口响应时间稳定在几十毫秒,微信侧不会超时重试;即使消费进程出问题,消息还安全躺在队列里,不会丢。
这个模式其实和生活中点外卖差不多:你下单后商家先接单(快速响应),然后真正炒菜、打包、配送是后面慢慢做的。接单这个动作不能等炒完菜再做。
4.2 生产消费的核心代码结构
队列我用的Redis List,生产端用rPush,消费端用blPop阻塞弹出。选择Redis List的原因很直接:客服系统的消息量级远没到需要上专业消息队列的程度,Redis部署简单,运维成本低,吞吐完全够用。退一步说,真要以后量起来了,生产端和消费端的代码结构也不需要大改,换消息中间件只是替换接口的事。
// 生产端:微信回调接口 function wechatCallbackHandle($request) { // 校验签名(见2.1节) ... $msg = parseWechatMessage($request->rawBody); $redis->rPush('wechat_kf:msg_queue', json_encode([ 'msg_id' => $msg['MsgId'] ?? generateMsgId(), 'from' => $msg['FromUserName'], 'to' => $msg['ToUserName'], 'type' => $msg['MsgType'], // text, image ... 'content' => $msg['Content'] ?? '', 'time' => time(), ], JSON_UNESCAPED_UNICODE)); echo 'success'; exit; }// 消费端:独立的常驻消费进程 while (true) { $raw = $redis->blPop(['wechat_kf:msg_queue'], 30); if (!$raw) { continue; } $msg = json_decode($raw[1], true); if (!$msg) { continue; } // 1. 幂等判断 $done = $redis->set('wechat_kf:processed:' . $msg['msg_id'], 1, ['nx', 'ex' => 86400]); if (!$done) { // 这条消息已经处理过 continue; } // 2. 保存数据库 $messageId = saveMessage($msg); // 3. 根据会话查找当前应该收到这条消息的坐席 $kefuUid = getSessionKefu($msg['session_id']); pushToUid($kefuUid, [ 'type' => 'message', 'event_id' => $msg['msg_id'], 'data' => $msg, ]); // 4. 如果消息需要下发到访端的微信,在这个分支里调微信发送接口 if ($msg['direction'] === 1) { sendWechatMessage($msg); } }消费端用blPop而不是lPop,是为了避免进程在队列为空时疯狂空转,占满CPU。blPop会阻塞住等新消息进来,队列里有数据才弹出来处理,没数据就休眠等待。这个命令需要传入一个超时时间,我用的30秒,如果30秒还没数据就返回空,循环重新等。这样进程平时几乎不消耗CPU。
4.3 消息去重与幂等
消息重复是客服系统里最隐蔽、最烦人的问题。微信回调重试会重复,队列消费失败重新入队会重复,前端兜底轮询也可能和WebSocket推送撞上。
从源头看,微信侧给每条消息带了一个MsgId,我把它作为消息的唯一标识。处理时先用Redis的SETNX命令标记这个msg_id已经被处理过,标记成功才继续后续逻辑,标记失败说明之前处理过,直接跳过。同时数据库里msg_id字段也建了唯一索引,作为第二道防线,即使Redis标记因为某些原因失效,数据库层面的唯一约束也会把重复插入挡掉。
这个“Redis先判断 + 数据库唯一索引兜底”的双保险思路,在分布式场景下很实用。Redis判断速度快,挡住大部分重复;数据库唯一索引是最终防线,防的是Redis崩溃或键被误删这种极端情况。
4.4 失败重试与延迟队列
消费进程处理消息时会遇到各种临时性失败:数据库连接闪断、微信接口限流、下游服务超时。对于这些情况,不能直接丢弃消息,需要一个带重试机制的架构。
最简单的做法是:消费失败就把消息重新入队。但这有个隐患,如果某条消息一直失败,立刻重新入队会被立即消费,又失败又入队,形成死循环。比较好的方案是延迟队列:失败的消息先放进一个延迟结构,等一段时间后再放回主队列重试。重试次数超过上限,就转入死信队列人工排查。
Redis的Sorted Set很适合做这个延迟队列,score就是“下次可执行时间”。
// 消费失败时的重试入队逻辑 function retryMessage($msg, $reason) { $retryKey = 'wechat_kf:retry:' . $msg['msg_id']; $count = $redis->incr($retryKey); $redis->expire($retryKey, 86400); if ($count <= 5) { // 第N次重试,延迟 N * 30 秒 $score = time() + $count * 30; $redis->zAdd('wechat_kf:delay_queue', $score, json_encode($msg)); } else { // 超过5次进入死信,后续人工处理 $redis->rPush('wechat_kf:dead_queue', json_encode($msg + ['reason' => $reason])); } }// 延迟队列扫描进程 while (true) { $now = time(); // 取出所有到期的延迟消息 $items = $redis->zRangeByScore('wechat_kf:delay_queue', 0, $now); if ($items) { foreach ($items as $item) { $redis->zRem('wechat_kf:delay_queue', $item); $redis->rPush('wechat_kf:msg_queue', $item); } } usleep(500000); // 每0.5秒扫一次 }重试的间隔从30秒开始,每次翻倍,这对微信接口限流这类问题特别有效。因为限流通常是一段时间内频率过高,等几秒再发往往就恢复了。死信队列里的消息,我写了一个简单的后台管理页面,运营人员可以手动“重发”或“标记丢弃”,虽然这种功能用得不多,但关键时刻能救急。
5. 踩坑记录与排查技巧实录
5.1 高频问题速查表
这个表是我做完整个系统之后整理出来的,基本覆盖了从开发到上线最常见的故障。建议你直接收藏,碰到类似问题按图索骥:
| 现象 | 可能原因 | 排查思路 | 解决办法 |
|---|---|---|---|
| WebSocket连接一直建立失败 | 反向代理没有配置Upgrade头 | 查看代理日志,确认Upgrade请求是否被转发 | 在代理层开启WebSocket协议支持 |
| 消息重复入库 | 微信回调重试 | 查同一条msg_id是否出现多次 | Redis SETNX预判 + 数据库唯一索引 |
| 坐席A发的消息跑到坐席B那去了 | 连接映射用了fd而非uid | 看推送代码用的是uid还是连接句柄 | 统一用uid做业务映射,fd只做传输层句柄 |
| 重启服务后端口被占用 | 旧进程没有完全退出 | netstat查看监听端口对应PID | 进程守护工具配置自动重启,禁止裸跑nohup |
| 微信回调经常超时 | 接口里同步做了太多事 | 查看接口耗时日志 | 回调里只入队,返回success |
| 客户收到消息后没有已读回执 | 已读事件没走实时推送 | 排查前端有没有处理status事件 | 已读状态单独用status事件帧推送 |
5.2 “入库有消息但客户收不到”的排查路径
这类问题是最难排查的,因为“消息存上了”容易让人误认为整个链路没问题,但客户确实没看到。我的经验是沿着消息生命周期分段定位,不要上来就翻代码。
第一段,确认消息是否进了Redis队列。直接看队列长度,如果队列在增长,说明生产端没问题,问题在消费端。如果队列一直是空的,那就是回调接口压根没把消息推进来。
第二段,确认消费进程有没有把消息消费掉。这个要看消费日志,我建议在消费进程里把每次消息处理的完整状态打出来,包括msg_id、会话ID、处理耗时、推送目标。没有日志,排查看起来就是两眼一抹黑。
第三段,确认推送时用户是否在线。这是最常见的坑——目标用户根本没有建立WebSocket连接,或者连接已经掉线但Redis里的映射还没清掉。pushToUid函数返回false时,就得触发备用方案,比如把这部分消息改用服务端“离线消息”接口,等用户下次上线时拉取。
第四段,确认前端有没有正确渲染。用浏览器开发者工具的Network面板看WebSocket帧,看消息到底有没有推送到浏览器。如果推到了但页面没显示,那是前端渲染逻辑的问题。如果没推到,那就回到第三段继续查。
5.3 稳定性与容量规划建议
客服系统的连接量虽然不如大规模IM那么夸张,但也要提前规划好服务的承载能力。常驻进程的并发能力取决于fd数量、内存和单进程网络模型。一个常驻进程可以轻松扛住几千条WebSocket连接,前提是不要在里面做阻塞操作。
我强烈建议把“推送服务”和“业务处理”拆到独立进程组里。推送进程只管维持连接和收发帧,不碰数据库;消费进程负责业务逻辑,通过内部消息通道把“需要推给谁、推什么内容”告诉推送进程。拆开之后,消费进程可以放心调用数据库、Redis,甚至偶尔慢一点不影响推送进程的心跳和连接管理。
部署层面,常驻进程必须由守护工具托管,不能裸跑nohup。配置好自动重启机制,进程崩溃后几秒内拉起来。早期项目里我偷懒没配自动重启,某次凌晨推送进程崩了,直到早上才发现,那段时间的在线消息全部堵塞,事后被客户狠狠吐槽了一顿。
另外,灰度发布和重启策略也要想好。直接kill -9重启推送进程,所有在线连接都会断开重连,几百个客户端一起涌进来,反而可能把服务打挂。我现在的做法是:先通知推送进程进入“维护模式”,不再接受新业务推送,等存量连接自然排空后再平滑重启。这个细节对线上稳定性影响很大。
还有一个关于量的建议:如果单机连接数超过预期,比如到了上万级,可以考虑多开几个推送进程,每个进程负责一部分用户。分配逻辑可以用uid取模,实现简单,排查问题也直观。再往上就需要引入更复杂的网关组件了,不过对绝大多数客服系统来说,单机多进程已经足够。
做完整套之后回看,这个项目最大的收获不是会用WebSocket,而是想清楚了一条消息从微信侧进来,到坐席端显示,再到坐席回复、客户收到,中间每个环节的边界在哪。实时推送的本质,是把“谁在线上、谁该收这条消息、消息怎么保证不丢不重”这三件事管理好。
我最后悔的一件事是项目初期没有把连接映射里的uid和fd概念严格分开,导致有一次代码review发现推送目标写成了fd,直接影响到消息串线排查。如果一开始就统一按uid做业务映射,能少走不少弯路。还有一点强烈建议:消息推送的前后端约定里一定要带msg_id和时间戳,后面排查消息乱序、消息重复,这两个字段能救你的命。