Spring Boot自建在线客服系统:WebSocket实时通讯实战
企业官网有个功能看着不起眼但真到要用的时候没有它又不行——在线客服。访客点开网页右下角的“在线咨询”弹出一个聊天窗能实时跟客服对话还能把当前浏览的页面、订单信息一起带过去。这套东西做大平台有成熟SaaS但做企业官网、垂直行业站点自研一套Java版在线客服系统反而更划算、更可控。这篇就来聊聊基于Java Spring生态自建在线客服系统的完整思路、核心代码逻辑以及我实际部署过程中踩过的坑。这套系统说白了就两个端访客端网页上的聊天窗口和客服端客服人员用的工作台中间由服务端做消息转发、会话管理、状态同步。开发环境主选Spring Boot如果项目里还集成了Spring Cloud那多节点部署时的消息路由要额外处理这一点后面会详细讲。适合谁看想在企业官网里快速接入自研客服模块的Java工程师、做私域运营但不想被第三方SaaS绑架的团队都可以参考这套设计。全文不会只贴空泛的架构图而是把消息推送、心跳保活、会话分配这些核心环节的实操细节都掰开了说。1. 项目定位这套在线客服系统解决的到底是什么问题1.1 站在业务角度看客服系统的核心诉求企业官网的访客流量很珍贵可能10个访客里只有1个有真实意向。如果这1个人找不到即时咨询入口关掉页面可能就再也不回来了。我在实际运营中发现在线客服的核心场景有几种售前咨询访客对产品功能、报价有疑问希望即时得到答复而不是等“稍后回复”的邮件。售后支持已购用户遇到使用问题需要在网站里直接发起会话甚至要在会话里附带订单号、账号信息。销售线索收集即使客服不在线也要能留下访客的留言、联系方式方便后续跟单。这些诉求背后系统需要解决的是“会话生命周期管理”访客发起会话 - 排队/分配 - 客服接起 - 实时聊天 - 结束会话 - 生成记录。把这条链路跑通了客服系统就完成了80%的功能。1.2 为什么选择自研而非直接用第三方SaaS第三方客服SaaS确实成熟接入快但企业官网场景下有几个很现实的问题数据不出域。访客的聊天内容、行为轨迹、联系方式这些都是企业的核心资产。走SaaS意味着这些数据要落到第三方平台很多企业法务直接不通过。深度定制难。官网可能需要把客服窗口做成特定品牌风格需要跟会员体系打通比如自动带上访客的用户ID、历史订单这些需求在SaaS上要么做不了要么要额外付费定制。成本长期看并不低。SaaS按坐席收费一个坐席一年几千块10个坐席就是几万块。自研一套源码部署到自己服务器上一次开发多次复用尤其是有多个官网站点的团队边际成本几乎为零。当然自研最大的代价是时间成本。所以选择技术栈时一定要选自己团队最熟的Java Spring Boot就是国内很多团队的主力栈生态成熟、招人容易、出问题好排查。1.3 技术底座Java Spring生态做实时通讯的底气很多人觉得Java做实时通讯笨重其实恰恰相反。Spring生态里的WebSocket支持非常成熟Spring Boot起步依赖里加一个spring-boot-starter-websocket就能启动一个WebSocket端点。配合Spring的依赖注入和事件机制消息路由、会话管理的代码写起来很顺手。这套系统的后端技术骨架大致如下Spring Boot 2.x/3.x基础框架负责HTTP接口、静态资源托管客服工作台前端可以直接打成静态资源放在Spring Boot里。Spring WebSocket负责实时双向通讯兼容ws://和wss://。MyBatis-Plus 或 Spring Data JPA持久化层存会话记录、消息记录、客服账号。MySQL RedisMySQL存历史数据Redis存在线状态、会话队列、未读计数等高频读写数据。消息中间件可选单机部署时直接用内存队列就够了多节点部署时建议引入MQ如RabbitMQ做消息分发避免客服端连的节点和访客端连的节点不一致导致消息收不到。我实际做的项目里单机部署时完全没上MQ就是WebSocket ConcurrentHashMap维护会话日活几千人毫无压力。但如果客服系统要支撑多机房、多节点那MQ就是刚需。2. 技术方案选型实时通讯原理拆解与核心理由2.1 WebSocket为什么是首选而不是轮询网页客服最早的技术方案是前端每隔2秒轮询一次服务端问“有人给我发消息吗”服务端有就返回没有就返回空。这个方案实现简单但缺点很明显延迟高最多差2秒、服务端压力大大量无效请求、消息多了容易丢。WebSocket解决了这些问题。它是一次HTTP握手后升级成TCP长连接服务端可以在任意时刻主动往客户端推数据客户端也能随时发数据给服务端延迟只受网络本身影响。理论上讲聊天这种场景就是WebSocket的主场。在Spring Boot里开启WebSocket支持核心代码很简洁Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatWebSocketHandler(), /ws/chat) .setAllowedOriginPatterns(*); } Bean public WebSocketHandler chatWebSocketHandler() { return new ChatWebSocketHandler(); } }这里有几个容易踩的坑setAllowedOriginPatterns(*)要配置好不然后端会拦截跨域的WebSocket握手请求。但生产环境建议把*换成你的业务域名白名单。WebSocket握手走的是HTTP升级在此之前如果有登录鉴权比如必须登录才能咨询要在握手拦截器里处理而不是在WebSocket处理器里判断。前端连接的URL是ws://域名/ws/chat如果是HTTPS站点要改成wss://域名/ws/chat同时反向代理要支持WebSocket协议升级。2.2 会话模型与消息流转设计客服系统里最核心的数据结构就是会话Session和消息Message。我设计的时候参考了IM软件的思路但做了简化毕竟企业客服不是社交聊天不需要那么复杂。会话模型public class ChatSession { private String sessionId; // 会话唯一ID通常用UUID private Long visitorId; // 访客ID匿名访客也有 private Long agentId; // 客服ID未接起时为null private Integer status; // 0排队中 1进行中 2已结束 3已转接 private String visitorName; // 访客名称/昵称 private String visitorAvatar; // 访客头像可能为空 private Long createTime; // 会话创建时间 private Long endTime; // 会话结束时间 private String sourcePage; // 来源页面URL private String extraData; // 扩展字段存订单号等业务数据 }消息模型public class ChatMessage { private Long id; // 消息ID自增或雪花算法 private String sessionId; // 所属会话ID private Integer msgType; // 1文本 2图片 3系统提示 4商品卡片 private String content; // 消息内容 private Integer direction; // 1访客发送 2客服发送 3系统发送 private Long senderId; // 发送者ID private Long receiverId; // 接收者ID private Long timestamp; // 消息时间戳 private Integer readStatus; // 0未读 1已读 }消息流转的核心逻辑是访客发消息 - 服务端WebSocket收到 - 解析消息体 - 找到会话对应的客服连接或队列 - 推送给客服端客服回复时流程反过来。注意消息一定要先落库再推送防止推送成功但数据库没记录导致历史记录缺失。2.3 状态管理在线、排队、接线的一整套状态机客服系统的状态比一般聊天软件复杂因为涉及到“客服不一定在线”这个业务事实。我梳理出一套最小可用的状态机访客视角的状态CONNECTED访客打开聊天窗WebSocket已连接但尚未发起会话。QUEUING访客点击“开始咨询”但还没有客服接起进入排队状态。这个状态下访客可以看到“当前排队人数”或“预计等待时间”。CHATTING客服接起会话双方实时聊天中。ENDED会话结束访客可以评价或离开。客服视角的状态OFFLINE客服离线不参与排队分配。ONLINE_IDLE在线且空闲新会话可以分配给他。ONLINE_BUSY在线但忙比如同时接待超过最大会话数新会话不会分配给他。IN_SESSION客服当前正在接待的会话可以同时有多个一般设置上限比如同时3个。状态切换的触发点要理清楚。访客建会话是一个触发点客服接起是一个触发点客服结束会话也是一个触发点。我建议用一个枚举类加一个状态机工具类来管理避免到处散落if else后面加“转接”“关闭”这种新状态时改起来方便。实际项目中我用的状态机没有用第三方的状态机框架直接写了个工具类逻辑也不算复杂。核心是状态流转必须有日志否则线上状态不一致时根本查不了原因。3. 核心功能落地访客端 / 客服工作台 / 运营管理3.1 访客聊天窗从弹窗到文本消息的全链路访客端最核心的体验就是“点一下就能聊”。我用的是vanilla JS加原生WebSocket实现聊天窗前端不引入重框架因为要嵌入到官网页面越轻量越好。关键步骤加载聊天窗JS官网页面引入一个kefu.js文件初始化客服按钮的浮窗位置和样式。建立WebSocket连接页面加载完成后立即连接WebSocket但此时不创建会话。这样访客点击“咨询”按钮时连接已就绪不会让访客等待。发起会话点击按钮前端发送CREATE_SESSION消息服务端创建一个新的会话对象放入排队队列并返回sessionId给前端。收发消息聊天窗文本域输入内容发送SEND_MESSAGE消息同时监听NEW_MESSAGE推送渲染消息气泡。断开与重连页面关闭或网络异常时WebSocket断开。要在前端实现指数退避重连比如1秒、2秒、4秒……最大30秒重连成功后自动恢复会话上下文。还有一个容易被忽略的点访客发起会话时要把来源页面URL、访客IP、UA等上下文数据一起带给服务端。这不仅是客服跟单时的参考信息也是运营分析的重要素材。我的实现是在握手阶段通过URL参数传服务端WebSocket握手拦截器里取出来存到会话属性里。3.2 客服工作台待接入队列与多路会话客服工作台我做了Web端技术栈用的Vue Element UI后来换过一批也用React做过都行看团队熟悉哪个。工作台的核心页面是待接入队列显示所有排队中的会话卡片每张卡片有访客来源、问题摘要、排队时长。客服点击卡片即可接起会话。进行中会话客服当前正在接待的多个会话列表点击可切换聊天窗口。这个列表设计时要考虑会话多了之后怎么让客服快速找到重点会话我的做法是给会话打标签如VIP、投诉、待处理并且未读消息的会话卡片加红点显示未读数量。历史会话已经结束的会话按时间倒序排列支持按访客ID、时间段、客服ID筛选。历史会话不仅仅是为了查询还可以做服务质量复盘——比如分析哪类问题的首次响应时间最长然后针对性优化话术。还有几个工作台里我强烈建议要有的小功能常用话术库客服回复时一键插入预设话术极大提升回复效率。这个功能看着小但客服用起来幸福感提升巨大。访客信息侧栏客服接起会话时右侧展示访客来源页面、IP归属地、访问次数、历史会话记录。这个侧栏需要对接数据服务技术上是一个异步初始化接口会影响接起体验建议做成懒加载。转接功能当前客服解决不了选择一个目标客服转接过去。转接要保留聊天上下文方便下一个客服快速了解情况。3.3 历史会话与统计报表客服系统的运营价值沉淀在历史数据里。我实际交付过几套系统后总结出最刚需的统计指标咨询量趋势按天/按小时统计访客发起会话的数量用来做人力排班依据。首次响应时长访客发第一条消息到客服回复第一条消息的时间差中位数和P90是核心指标。平均会话时长客服处理一个会话平均花费的时间用于衡量负载。满意度评分会话结束后访客打星这里我通常只在会话结束后弹一次评价避免打扰。实际好评率会受到弹窗时机影响最好是在客服手动结束会话后才弹评价。这些报表用简单的SQL聚合就能查出来不需要引入太重的大数据组件。注意一点统计报表的查询频率不高但数据量会持续增长建议给chat_message表按月份做分区否则一年以后查询会越来越慢。4. 实操过程WebSocket连接管理、消息格式与部署细节4.1 连接建立与心跳保活WebSocket连接最头疼的问题是“假死连接”——TCP连接还在但实际已经收不到数据了。常见场景访客的电脑休眠唤醒后网络变了但浏览器没触发断开事件或者公司网络出口的NAT超时把空闲连接清掉了。解决假死的标准手段就是心跳。服务端每隔30秒发一个PING消息客户端收到后回PONG如果服务端连续3次没收到某个连接的PONG就判定这条连接已死主动关闭并释放资源。心跳消息设计成JSON格式方便前端识别{ type: ping, timestamp: 1696000000000 }前端收到ping后回{ type: pong, timestamp: 1696000000100 }实际项目里我是用了一个ScheduledExecutorService定时扫描会话管理器里的所有连接最后活跃时间超过90秒的强制关闭。定时扫描要注意线程安全建议在扫描时用ConcurrentHashMap的迭代器不要边遍历边删除。4.2 消息协议设计JSON格式与消息类型访客端和客服端通过WebSocket收发消息消息格式必须统一。我用的协议外层是{ type: send_message, data: { sessionId: xxx, content: 你好请问你们支持定制开发吗, contentType: text } }type字段标识消息类型服务端根据type路由到不同的处理方法。常用type有create_session创建会话send_message发送消息agent_accept客服接起会话agent_end客服结束会话visitor_end访客结束会话system_notify系统推送通知服务端发起如“客服已接入”ping / pong心跳这套协议要注意的地方是消息ID和消息确认。访客发消息时前端生成一个clientMsgIdUUID服务端收到后存库并回复一条message_ack前端收到ack才把消息标记为“已发送”如果超时没收到ack前端提示发送失败或自动重发。这里就能发现完全不做确认机制的聊天工具在弱网环境下有多痛苦。消息序列化用Fastjson还是Jackson都行但保持全项目统一。我踩过一次坑两个服务一个用Fastjson一个用JacksonWebSocket消息带了泛型类型结果序列化时字段丢失查了半天。4.3 部署上线环境要求、反向代理与HTTPS配置部署层面这套系统本质上就是一个Spring Boot应用打包成jar直接跑。我在生产环境用Systemd管理的服务配置就几个关键点JVM内存分配给WebSocket应用的内存不要只按“这点业务量”估。WebSocket连接数上去后每个连接要占一些内存缓冲。我遇到过给512M内存跑客服系统连接数到300就频繁GC后来调到2G才稳定。建议起步1G连接数上千再加。反向代理Nginx做反向代理时必须配置proxy_set_header Upgrade $http_upgrade;和proxy_set_header Connection upgrade;否则WebSocket握手会失败。同时把proxy_read_timeout调大默认的60秒会导致空闲连接被Nginx切断我直接设为3600s。HTTPS/WSS如果站点是HTTPSWebSocket必须用wss://否则浏览器会拦截混合内容。WSS在Nginx层用ssl配置支持即可证书可以复用HTTP的证书不用额外购买。配置文件核心片段参考location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }5. 常见问题与排查实录5.1 WebSocket频繁断连如何定位根因这个是最常见的问题现象是客服或访客的聊天窗口频繁提示“连接断开”几秒后又自动重连。排查顺序我基本固定先看Nginx日志有没有大量upstream timed out或者client closed connection。如果有大概率是Nginx超时或WebSocket升级配置缺失。再看服务端日志有没有WebSocket session closed异常记录注意看关闭原因Spring WebSocket会输出类似1006异常关闭或1000正常关闭的状态码。1006是非正常关闭几乎都是网络层问题。检查心跳是否生效如果心跳实现有问题——比如前端没回PONG服务端就会把正常连接误杀。我用一个简单的技巧验证在服务端日志打印收到PONG的时间戳看看间隔是否稳定。开启WebSocket的DEBUG日志logging.level.org.springframework.web.socketDEBUG能看到握手和关闭的完整过程这里往往能直接发现握手参数丢失的问题。5.2 消息乱序与重复推送消息乱序的根源多数是前端处理消息时没有按timestamp或seq排序。WebSocket本身保证同一个连接上的消息顺序但当页面刷新后重新拉取历史消息会把历史消息和实时消息混在一起这时候没排序就乱了。我的解决方案是给每条消息加一个msgSeq会话内自增前端渲染前按msgSeq排序。重复推送则是消息确认机制设计不当导致的。访客端发送超时重发服务端如果没做幂等就会给客服端推两条一样的。解决思路是服务端把clientMsgId存进数据库加唯一索引收到重复消息时直接忽略并返回ack。5.3 服务器内存持续增长最终OOM客服系统长跑下来最容易OOM根因基本都在会话对象泄漏。比如客服把会话结束了但访客的连接还挂在某个ConcurrentHashMap里没人清理或者创建会话时往一个静态Map里塞了访客信息后来忘了删。排查手段JVM堆转储OOM前导出heap dump用MAT或VisualVM分析看哪个类的实例数量异常多。重点检查会话管理器看看closeSession方法是不是把visitorId - WebSocketSession和sessionId - ChatSession两个Map都清理了漏一个就会累积泄漏。设置连接数上限在WebSocket握手拦截器里判断当前连接数如果超过阈值直接拒绝建立新连接。这个保护措施可以避免极端流量下雪上加霜。5.4 客服端和访客端会话状态不一致状态不一致最常见的是客服已经结束会话但访客端还显示在聊天中访客发消息也没人回。这个问题的直接原因通常是结束会话的通知没有成功推送到访客端。我建议结束会话采用“双端确认”机制客服端点击结束服务端先把会话状态改为ENDED并落库。服务端向访客端推送agent_end通知访客端收到后关闭聊天窗并回一个visitor_end_ack。如果访客端30秒内没有回ack服务端重推一次超过重试次数就不再强推但保证数据库状态是对的访客下次刷新页面时会发现会话已结束。这套机制避免了“服务端改了状态但客户端不知道”的尴尬。5.5 常见问题速查表问题现象可能原因排查手段解决方案WebSocket连接一直连不上Nginx未升级协议看Nginx error log搜“upgrade”补全Upgrade和Connection配置头连接稳定但消息收不到服务端路由Map查找失败在推送逻辑打日志输出sessionId对应连接是否存在检查连接删除逻辑多节点用MQ分发心跳超时误杀连接前端未处理PING消息前端监听消息时只处理聊天消息漏了ping类型前端统一处理所有typeping必须回pong客服端收到重复消息消息发送无幂等看数据库里clientMsgId是否重复加唯一索引重复请求直接返回ack会话结束了访客还能发消息服务端未校验会话状态在send_message处理器里查数据库会话状态不是CHATTING时返回错误提示历史消息加载慢数据库未分区索引缺失看SQL执行计划按时间分区加(session_id, msg_seq)联合索引我在实际部署中还养成一个习惯把所有WebSocket相关的异常和连接状态变化都打进一个单独的日志文件里和业务日志分开。排查问题时效率高很多不用在一堆业务日志里捞WebSocket的信息。客服系统这种7x24小时跑着的服务把日志做清晰了运维幸福感直接翻倍。如果你正准备做一套企业官网的在线客服系统照着上面的思路做技术上是完全可行的。别被“实时通讯”四个字吓到Spring Boot WebSocket的组合在日活几千的企业站场景下完全够用。把会话模型、消息协议、状态管理、心跳保活这四个核心点吃透你的系统就已经能稳定上线了。至于多节点、MQ、集群化这些等业务量真到了需要的时候上面的架构也给你留好了扩展的口子。踩过几次坑之后我最深的体会是客服系统真正值钱的不全是实时通讯技术本身而是围绕会话和消息的那一套业务设计——谁接、怎么派、状态怎么转、数据怎么沉淀。这些想清楚了代码其实写起来很快。