资讯动态

基于Workerman与WebSocket的多端在线客服系统架构实践

发布时间:2026/10/11 5:06:43 来源:尧图企业网站定制
做在线客服这套系统我前后折腾了差不多一个季度。一开始公司给的需求很简单客户在网页上找我们咨询客服能实时回复就行。后来需求慢慢长成了四个端PC网页、手机H5、微信小程序、App。中间我纠结过要不要直接买第三方SaaS也纠结过用长轮询还是WebSocket最后敲定的方案是PHP端用Workerman做常驻内存的WebSocket服务四个端通过统一的消息协议接入。这篇文章把我从选型到落地、从踩坑到优化的完整过程梳理出来涉及的核心架构、协议设计、部署配置、常见排查手段都会拆开讲适合准备自研在线客服系统、或者正在做多端实时通信项目的朋友参考。我不讲虚的全是实际跑过的方案和代码。1. 系统整体架构与通信方案选型1.1 为什么用Workerman而不是买现成的SaaS先说说为什么最终没有买SaaS。市面上成熟的在线客服产品确实很多功能也远比自研丰富但有两个绕不开的问题一是数据全部过第三方客户信息、聊天记录相当于托管在别人那里我们这行对数据合规比较敏感二是定制空间有限比如我们要把客服工作台嵌进公司内部的管理后台要跟已有的订单系统、用户系统打通SaaS开放的接口不一定能完美覆盖这些场景。综合下来公司决定自研。自研就涉及选型。后端技术栈是PHPPHP做实时通信本来不算传统强项因为传统的PHP-FPM模式每个请求都要重新加载框架请求结束就释放所有资源没法维持长连接。Workerman恰恰解决了这个问题——它是一个基于事件循环的PHP异步框架进程常驻内存一个进程可以同时维持成千上万个TCP/WebSocket连接。对我来说能看懂、能改得动比性能天花板更高更重要。团队里没有Node.js或Go的熟手PHP大家都很熟Workerman学习成本很低生态也完整包括基于它二次开发的GatewayWorker分布式通讯框架天然就是为聊天、客服这类即时通讯场景设计的。当然如果团队里有Swoole的深度用户用Swoole也完全没问题。我选Workerman还有一个现实原因它的官方文档和示例代码非常详细尤其是GatewayWorker那套入门文档几乎把聊天室、消息推送这些场景的代码都贴出来了照着改很快。对于一个工期紧的项目来说能快速上手就是最大的优势。1.2 端整体架构GatewayWorker做底座四个端共用一条通道整个系统的架构不算复杂核心角色有三个客户端访客端、客服工作台坐席端、服务端GatewayWorker集群 业务后端。客户端分布在四个形态PC网页和H5页面通过浏览器WebSocket连接微信小程序用的是小程序原生的WebSocket API走wss协议App端我后来用了一套跨端方案统一封装WebSocket同时保留安卓和iOS的原生能力调用。四个端虽然运行环境不同但连接的都是同一个GatewayWorker网关消息都走同一套协议这就从根本上避免了一个端一套逻辑的后期维护噩梦。GatewayWorker的架构分两层Gateway层负责维持客户端长连接、收发消息、转发数据BusinessWorker层负责跑业务逻辑比如判断消息发给谁、更新会话状态、落库。两层之间通过TCP通信Gateway进程不写业务代码BusinessWorker里才是我们自己的逻辑。这样做的好处是网关可以水平扩展连接数不够了多开几个Gateway进程就行业务逻辑改动也不会影响连接层的稳定性。从一个访客进来开始整个消息流是这样的访客端连接上GatewayGateway把客户端的连接信息注册到GatewayWorker内部的分布式注册中心访客发消息后Gateway把消息打包推给某个空闲的BusinessWorkerBusinessWorker解析消息、查数据库、然后把回复消息通过Gateway推送给目标客服端。整个链路看起来长实际因为都是内网TCP通信加内存操作延迟极低实测体验跟本地开发环境差别不大。1.3 消息协议设计一条JSON走天下在线客服系统里客户端和服务端之间传递的消息类型其实不多但设计协议时一定要留好扩展余地。我采用的办法是统一的JSON格式每个消息都有固定的顶层结构{ type: message, data: { from: user_1001, to: agent_2003, content: 你好我想咨询一下售后政策, msg_id: msg_8f3a2b1c, timestamp: 1765123456, extra: {} } }type字段标记消息类型常见的有ping心跳、pong心跳回复、login登录认证、message聊天消息、read消息已读、typing正在输入、close会话结束。data字段存放消息具体内容。msg_id是客户端的消息唯一标识用于消息去重和超时重发判断。timestamp用Unix时间戳避免前后端时间格式化不一致的问题。这里有一个我后来才体会到的经验协议里一定要带上from和to两个字段而不是像最初那样只靠Gateway的连接ID来识别用户身份。因为连接ID是会变的——用户断线重连后Gateway会给它分配一个新的连接ID如果代码里到处用的是连接ID重连后消息就串了。业务层的ID比如用户ID、客服ID才是一切的基准。2. 核心功能模块拆解与关键技术实现2.1 客服分配策略别小看这个不起眼的逻辑在线客服系统的分配策略直接决定了用户体验也决定了客服工作强度是否均衡。我最初写的是最简单的轮询分配——谁空闲分给谁按顺序往下排。后来实际用了发现几个问题有的客服响应慢但一直闲着被分配新会话有的客服明明在线但用户量不均匀导致部分客服忙不过来。后来我把分配策略改成分层判断先按客服当前会话数排序取会话数最少的如果会话数相同再按最近一次响应时间排序选响应更快的。这其实就是一个带权重的最闲优先策略。实现上不用太复杂在BusinessWorker里每次需要分配客服时从Redis里拉取当前所有在线客服的会话状态列表排序后取第一个。客服上线、下线、会话结束时把这个列表同步更新到Redis保证分配时拿到的数据是实时的。还有一个细节容易被忽略访客二次进入时应该优先分配给他上次接待过的客服而不是重新分配一个人。这就需要把访客ID到客服ID的绑定关系存下来我直接存在Redis里Key是bind:user_{访客ID}过期时间设为7天。这样老客户再进来客服一眼就能看到历史沟通记录续聊体验好很多。2.2 会话生命周期管理从接入到关闭的完整状态机会话管理是客服系统的骨架。每个会话都有明确的状态等待接入、接待中、已结束。等待接入访客发起会话但还没有客服接入此时访客端显示正在排队中当前第X位。排队序号我会在Redis里用有序集合计算每秒刷新展示。接待中客服点击接入或系统自动分配此时会话绑定客服ID消息可以双向实时推送。已结束客服主动结束会话或访客超时未回复系统触发结束流程写入会话结束时间、统计消息条数、生成满意度评价入口。状态流转的核心代码集中在BusinessWorker里每个状态变化都广播给相关端。比如客服接入时工作台页面要弹出会话卡片访客端要提示客服已接入。这些提示都必须实时推送不是前端轮询出来的否则用户体验会慢半拍。会话超时结束这个场景踩过坑。最初我设了访客30分钟不回复就自动结束会话结果经常出现用户只是切出去看了个资料回来发现客服没了体验很差。后来改成30分钟未回复先给双方各发一条提醒消息再等10分钟仍无回复才结束友好很多。这种业务细节不实际上线跑一轮是根本意识不到的。2.3 消息可靠性从发送到已读的全链路追踪聊天系统最尴尬的场景就是用户明明发了消息客服那边没收到两边互相扯皮。所以消息可靠性必须从一开始就设计而不是出了问题才补。我采用的是客户端生成消息ID 服务端确认 已读回执三件套。流程是这样的客户端发送消息时生成一个msg_id消息先进入本地待确认队列同时发送到服务端。服务端收到消息后存储落库然后推送一条带有相同msg_id的ack回执给发送方。发送方收到ack后从待确认队列里移除这条消息如果超过5秒没等到ack自动重发最多重试3次。消息推送给接收方后接收方阅读时发送read回执发送方展示已读状态。这套机制看起来不复杂但确实把消息丢失的可能性降到了极低。需要特别提醒的是服务端在收到重复msg_id时要做一个幂等判断——比如客户端重发了一条服务端已经处理过的消息服务端不应该重复落库。我的做法是在Redis里记录最近处理过的msg_id有效期为10分钟重复消息直接返回ack但不重复处理。离线消息的处理也很重要。当访客离线时消息不能丢我统一把离线消息写入MySQL的消息表同时在Redis里维护一个该用户有未读消息的标记。用户下次上线时客户端主动拉取未读消息列表拉取完毕后清除标记。这里有个细节未读消息的拉取要一次性批量拉取并批量标记已读而不是一条条请求否则上线瞬间会打爆服务端接口。3. 各端从0到1的落地细节3.1 PC网页端WebSocket连接管理与消息渲染PC网页端是整个系统里开发最顺的一端因为浏览器环境成熟WebSocket API原生支持好。客户端核心逻辑可以拆成三块连接管理、消息队列、UI渲染。连接管理这块核心是一个reconnect机制。浏览器WebSocket经常因为网络波动、服务端重启、代理超时而断开但断开不等于用户要刷新页面所以断线后要自动重连。我采用的策略是指数退避重连间隔从1秒开始每次翻倍最大30秒如果连续重试5次都失败提示用户手动点击重新连接。前端代码的核心部分长这样function connect() { const ws new WebSocket(wsUrl); ws.onopen () { clearTimeout(reconnectTimer); sendLoginMessage(); }; ws.onclose () { const delay Math.min(30 * 1000, reconnectInterval * 2); reconnectTimer setTimeout(connect, delay); }; }消息渲染这里有一个容易翻车的点如果直接把每条消息append到页面DOM上消息量大起来页面会越来越卡。我最后用了虚拟列表的方案只渲染可视区域内的消息节点上下滚动时动态回收和创建节点。这个优化做完即使一个会话有几千条历史消息页面也能保持流畅。还有一个PC端的细节浏览器切换到后台时JavaScript的定时器会被降频甚至暂停导致心跳定时器不准。我通过监听visibilitychange事件在页面重新可见时立即检查WebSocket状态并发送一次心跳避免出现页面看起来还连着其实连接已经死了的问题。3.2 H5端适配移动端浏览器的那些坑H5端和PC网页端虽然都是浏览器但移动端的坑多了不少。最大的坑是移动端浏览器的锁屏和后台机制。用户在H5聊天页聊到一半切到微信、短信或者直接锁屏回来后WebSocket经常已经断了。这就是为什么H5端的断线重连要比PC端更激进。我实测下来H5端的重连间隔要从1秒起步但最大间隔不要超过15秒超过15秒用户回来看不到新消息会以为系统坏了。另一个坑是关于顶部安全区的。H5页面要跑在微信内置浏览器里也可能被用户用Safari、Chrome打开这种环境下页面顶部会有状态栏遮挡的问题。我的处理是使用CSS的env(safe-area-inset-top)来适配刘海屏和状态栏高度同时让聊天头部栏使用position: sticky固定。这属于UI层面的细节但做不好确实很掉价。H5端还有一个性能和体验相关的决策图片和语音消息的处理。图片需要先压缩再上传语音要用浏览器录音API录制并转码这些都要单独做上传进度指示。聊天时最怕用户以为发出去了实际还在上传所以我的实现是图片和语音消息先显示为本地发送中状态上传完成后替换为服务端返回的URL并置为已发送。3.3 微信小程序端wss接入与生命周期管理小程序端的开发最核心的三个关键词是合法域名、wss协议、生命周期。先说合法域名。小程序的WebSocket连接必须使用wss协议并且连接地址必须配置在小程序后台的服务器域名白名单里。开发调试时可以勾选不校验合法域名但真机预览和线上版本一定会被卡。这个问题我在联调阶段就遇到了第一次真机测试连接直接失败还以为是代码问题结果打开调试面板才发现是域名校验没过。上线前一定要把三个域名都配置好WebSocket域名wss、上传域名用于图片上传、普通HTTPS请求域名用于拉取历史消息。再说wss协议。这里的核心工作是Nginx反向代理WebSocket连接并在Nginx层终止SSL。配置并不复杂但有一个参数必须注意map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 443 ssl; server_name chat.example.com; ssl_certificate /etc/nginx/cert/chat.pem; ssl_certificate_key /etc/nginx/cert/chat.key; location /ws { proxy_pass http://127.0.0.1:8282; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; } }这里我额外强调一个参数proxy_read_timeout。如果这个值设置太小比如默认的60秒Nginx会在没有消息传输时主动断开WebSocket连接表现为小程序在线状态下莫名其妙掉线。我把超时统一设到3600秒同时依赖应用层的心跳机制来维持连接活性这才稳定。小程序的生命周期跟网页差别很大。用户在聊天页聊着聊着点了一下回到首页小程序并不会销毁WebSocket连接只是页面onHide了但用户侧滑关闭小程序、长时间后台驻留连接就会被系统杀掉。这时候最关键的是在onHide时记录连接状态在onShow时检查连接并立即重连。另外小程序WebSocket只能在App级别存在一条如果需要多页面共享一个连接必须做全局单例管理我用了小程序自定义的全局globalData来存WebSocket实例所有页面通过统一封装的方法收发消息。3.4 App端跨端方案与原生能力集成App端我调研过两条路一是用Flutter或React Native写一套独立的App二是用uniapp把已有的H5封装成App。考虑到我们最看重的是逻辑复用——App和H5要保持一致的聊天体验——我最后选择了H5代码基础之上套用uniapp壳的方案。uniapp的编译能力支持把同一套代码转成App和H5WebSocket连接层通过条件编译区分App端使用plus.websocket适配H5端使用普通WebSocket。App端和网页端最不一样的地方在于消息通知。用户把App切到后台后WebSocket连接还能维持一段时间但一旦系统杀进程聊天消息就只能靠推送来触达。我接入了厂商的消息推送通道后端在用户离线时把消息转为推送通知。不过这里面有个很实际的坑推送到达率和活跃状态强相关如果用户把App的推送权限关了那就彻底没法触达。所以App端的策略是在线时走WebSocket离线时走推送用户再次打开App时先清空离线推送、再通过WebSocket拉取未读消息确保最终一致。4. 部署优化与稳定性保障4.1 服务器部署GatewayWorker进程规划与Nginx反向代理部署这一块我直接给出一套经过压测验证的配置参考。假设是一台4核8G的云服务器WebSocket网关可以按CPU核心数来启动Gateway进程建议跑4个BusinessWorker跑4个Register进程1个。这个组合在2000个并发连接的情况下CPU占用率大约在60%上下响应延迟依然可控。启动GatewayWorker用的是Linux下常见的start.php脚本这里不多说。重要的是把start脚本交给systemd或者Supervisor管理保证进程崩溃后能自动拉起。我一开始图省事直接nohup启动结果某天凌晨网关进程因为内存泄漏崩了直到早上用户投诉才发现。后来老老实实配置了Supervisor守护设置了autorestarttrue和stopasgrouptrue现在再也不用半夜爬起来手动重启了。WebSocket对外一定不要直接暴露8282端口而是用Nginx做反向代理。除了SSL终结之外Nginx还能顺带做负载均衡——如果后面Gateway扩容成两台服务器Nginx配置多个upstream就行。这里要注意的是GatewayWorker分布式的节点之间要保证同一客户端的连接都落在同一台机器上或者让Gateway节点之间通过配置共享客户端连接数据否则会出现消息发到了A机器但用户连的是B机器的情况。我这边单机规模不大所以直接在单机部署后续扩容时再用GatewayWorker自带的集群配置来支持跨机器通信。4.2 心跳机制连接保活的定海神针心跳机制不做系统上线一个月内必然出问题。TCP连接本身并不自带对方是否还活着的感知客户端直接把WiFi关了、手机飞行模式开了这些场景下服务端是不知道连接已经废掉的。如果不做心跳服务端会积累大量僵尸连接内存越涨越高最终拖垮整个网关进程。我的心跳方案是这样的客户端每30秒发送一次ping消息服务端收到后立即回复pong服务端同时维护一个最近活跃时间记录如果90秒内没收到某个连接的任何消息就判定这条连接已失效主动断开并清理相关数据。这个90秒的判断时间是留足了网络抖动buffer的30秒心跳发3次都没收到回复基本可以确定物理链路已经断了。心跳还有一个容易被忽略的作用反向验证服务端是否还活着。如果客户端连续多次没收到服务端的pong回复客户端也应该主动断开本次WebSocket连接并触发重连逻辑。这样就不会出现客户端以为自己还连着、实际上服务端早已不在线的假活现象。两个方向都要判断缺一边都不算闭环。4.3 消息延迟优化与Redis的合理使用消息从访客发出到客服端展示用现在的架构实测同一台服务器上延迟通常在10到20毫秒左右基本感知不到。要保证这个延迟需要注意两点第一消息落库不能放在同步链路里。如果每发一条消息都等MySQL写成功后才会推送给对方高峰期数据库一抖动聊天就卡顿。我把落库操作投递到异步队列处理推送走实时链路落库晚几百毫秒完全没关系。第二在线客服系统的在线状态、会话状态、排队信息这类高频更新的数据不要每次都查MySQL。我统一用Redis维护客服在线状态存Redis Hash会话列表存Redis List未读数存Redis String。只有最终的消息记录和会话归档才写MySQL。Redis内存操作极快读写都在毫秒级还能避免频繁的数据库连接开销。Redis的持久化策略也要说一句。如果只是当缓存用可以不担心重启丢数据但会话绑定关系、排队信息这种数据丢了会让在线用户感知到异常。我把Redis的持久化策略设为RDBAOF混合模式尽量让重启后数据不丢。另外给Redis设置合理的maxmemory策略避免无限制的内存增长导致服务器OOM。5. 常见问题排查与避坑实录5.1 WebSocket频繁闪断的排查路径上线后的第一周我们收到最多的反馈就是聊着聊着就断了过几秒自己又好了。这类问题排查起来说难不难但需要按顺序来。第一步看Nginx的错误日志。如果大量出现upstream timed out或者client closed connection的记录基本可以确定是代理层超时导致的。按前面说的把proxy_read_timeout调大再观察。第二步看GatewayWorker的日志和连接状态。如果服务端记录的连接断开原因是heartbeat timeout说明确实是心跳丢了那就要看是不是中间链路不稳定。电信网络到云服务器之间的运营商链路抽风是很常见的这种属于客观环境能做的就是缩短心跳间隔、加快重连速度、提高容错。第三步检查客户端网络切换。手机从WiFi切到4G的瞬间WebSocket一定会断这是网络层决定的代码能做的只能是在visibilitychange和offline/online事件触发时主动触发重连。我还在客户端做了一层网络状态变化即重连的预设逻辑实测对移动端体验提升非常明显。5.2 微信小程序真机连接失败的高频原因小程序开发中常见的一个诡异现象开发工具里一切正常真机上WebSocket死活连不上。按我的经验优先级最高的是下面几个排查点确认小程序后台的socket合法域名已配置且域名是HTTPS或WSS开头不支持IP地址。确认线上是wss不是ws。小程序真机默认不允许明文WebSocket连接。确认Nginx的SSL证书链完整小程序对证书链校验比较严格有些证书在浏览器里没问题但在小程序里会报证书校验失败。确认小程序基础库版本。老版本基础库的WebSocket实现有已知Bug比如连接断开后不触发close事件导致前端无法感知掉线。这里没有别的办法只能建议用户更新微信版本和基础库。如果真的遇到开发工具正常、真机连不上最快的定位办法是把小程序的vConsole打开看具体的报错信息是wss://连接失败还是证书错误还是域名不在合法列表。这三种报错对应的解决路径完全不同千万别看到连接失败就去改代码。5.3 高并发下的消息积压与性能瓶颈压测阶段我们模拟了3000个并发连接同时发消息最初的结果并不理想消息延迟一度飙升到1秒多。排查下来瓶颈出现在两个地方。第一个是BusinessWorker出现了锁竞争。消息落库前要校验会话状态、更新Redis计数、写入消息队列这些操作里有大量Redis读写而Redis虽然是单线程模型但大量请求在峰值时依然会出现排队。我的优化是合并Redis操作把更新会话未读数和写入消息待发队列合并到一个Redis事务里执行减少一次网络往返。第二个是消息广播存在冗余推送。原本客服不在线时访客的消息也会尝试推送给客服端白白消耗一次网关转发。改成先查客服在线状态、不在线就直接走离线消息流程后无效推送少了约35%。这里也提醒一下所有推送操作在推送前先确认目标端是否在线是最基本也最容易被忽略的优化。注意GatewayWorker的进程数并不是越大越好。每个进程都会占用一定的内存和文件描述符进程数超过CPU核心数太多反而会导致上下文切换开销过大。建议Gateway和BusinessWorker的进程数都从CPU核数开始逐步加压测试后调整。结尾聊几句这套系统从立项到四个端全部上线我自己最满意的一点不是用了多高级的技术而是整个通信链路始终只有一条——所有的端都连同一个网关、走同一套协议后面每新增一个端只需要做客户端适配服务端几乎不用改动。这个收益在迭代过程中被反复验证后续我们加了一个新的客服入口渠道服务端零改动就直接接入了。如果现在让我重新做这个项目我会在以下方面做得更好一是更早地引入消息切片归档机制而不是等消息表膨胀了再回头迁移二是从一开始就把埋点和日志体系建好上线第一天就能看到消息延迟、连接存活率、掉线率这些核心指标而不是等问题暴露之后靠用户投诉来发现。最后分享一个小建议如果你也是第一次做在线客服系统别急着追求那些花哨的AI自动回复、智能路由先把消息能实时到达、不丢不乱序这个基本功打磨扎实。实时通信系统的信任感就是一条一条消息积累起来的用户说一句话能立刻看到回复比什么高级功能都重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑