ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot+Netty实战:构建高并发在线客服系统

2026/9/9 9:33:49 拓冰建站 浏览量
SpringBoot+Netty实战:构建高并发在线客服系统 做了几年Java后端在线客服系统这类项目我前前后后接触过几套自己也动手写过一版。很多人一听到“客服系统”就觉得是聊天室套壳实际拆开看里面涉及的长连接管理、消息推送可靠性、会话分配策略、后端与前端的状态同步每一块都能单独拎出来写一篇技术总结。这篇就用一个典型的springboot netty实现的网页客服项目来聊标题里写的三个关键词——Java、springboot、netty正好对应了这个项目的技术骨架业务层用springboot搭通信层用netty扛并发整个工程以源码形式开放适合拿来学习、二次开发、或者直接部署做商用基础。这套源码系统的核心能力是访客在网页端发起咨询客服在管理端接收并回复双方消息通过netty建立的长连接实时收发同时在springboot侧完成会话记录、客服分配、离线留言、消息历史查询等业务闭环。对于想搞懂“netty在生产项目里到底怎么用”的Java开发者而言它的价值不只是能跑通一个demo而是能看到完整的接入层设计channel如何管理、消息如何编解码、客户端断线重连怎么做、springboot如何与netty生命周期共存。这篇文章我会把这个系统的设计思路、核心实现、我在部署和压测中踩过的坑按可复现的标准拆开讲。1. 在线客服系统的整体设计与技术选型思路1.1 从业务需求倒推技术方案在开始写代码之前先得想清楚在线客服系统要解决什么问题。从用户视角看访客点开网页上的“在线咨询”按钮希望能立刻跟客服说话消息发出去要马上有响应客服回复了访客能实时收到。从客服视角看客服要能同时接待多个访客能看到访客来源、历史会话、当前排队情况。从管理员视角看要能统计接待量、平均响应时长要能查看聊天记录。这些需求组合在一起就对技术方案提出了几个硬性要求实时性要求高。网页端和客服端之间是双向通信轮询方案延迟太高HTTP长轮询体验又差必须走WebSocket或Socket长连接。消息要可靠。客服系统里的聊天记录是业务资产消息发出去不能丢至少要保证消息落库和推送到客户端的一致性。连接数可能很高。一个客服坐席可能要同时维护几十条连接如果做成商业SaaS还要考虑多租户单机连接数能支撑到万级才算合格。需要区分用户身份。访客是匿名的客服是登录的系统要给每条连接绑定业务身份才能做消息路由。基于这些要求springboot负责HTTP接口和业务逻辑这是Java后端最成熟的组合通信层选netty是因为它在长连接领域的性能表现、生态成熟度、以及对WebSocket协议的原生支持都比较稳。我见过不少项目直接用spring的WebSocket模块做中小规模够用但一旦连接数上来或者遇到客户端频繁断连重连的场景netty在连接管理、内存控制、线程模型上的优势就体现出来了。1.2 这套源码的核心功能清单拿到这套源码之后我先把它能跑通的功能捋了一遍大致是下面这些网页端访客咨询匿名用户打开咨询窗口自动建立连接支持发送文本消息、表情、图片图片走附件上传接口。客服工作台客服登录后看到分配给自己的会话列表可以接待新访客、结束会话、转移会话。消息实时收发访客和客服之间的消息通过长连接实时推送不需要手动刷新页面。会话分配新访客进入时按负载均衡策略分配给在线客服支持平均分配和空闲优先两种模式。历史记录访客再次访问时能拉取上次会话记录客服端能按访客维度查看历史聊天。离线留言没有客服在线时访客可以提交留言留言进入待处理列表。数据统计基础版包含今日咨询量、消息量、客服接待量等统计。这个功能集覆盖了一个商用客服系统的最小可用闭环而且每一块对应的技术点都比较典型会话分配涉及队列和策略模式消息推送涉及netty的channel管理历史记录涉及分页查询和时间线同步。学习和二次开发的空间都比较大。1.3 源码的工程结构说明项目拿到手第一件事是看工程结构。这套源码用的是标准的maven多模块布局拆得还算清晰im-system ├── im-common // 公共模块实体类、工具类、常量定义 ├── im-server // netty通信服务长连接接入、消息编解码、channel管理 ├── im-admin // 客服管理端springboot vue处理业务API ├── im-client // 访客端sdk封装了前端建立连接和收发消息的逻辑 └── sql // 数据库初始化脚本通信服务和管理后台分离这个设计我很喜欢。好处是netty服务可以独立部署、独立扩容出问题时不会拖垮业务接口坏处是跨服务通信要额外处理这套源码用的是后端内部通过Redis发布订阅做消息转发的前置设计后面会细说。2. 为什么选择netty长连接通信层的核心考量2.1 从BIO到NIO再到netty的演进逻辑很多新手看netty源码容易一头雾水其实netty要解决的本质问题很简单在Java里高效地管理成千上万个网络连接。早期的BIO模型一个连接一个线程线程数量上去之后CPU光忙着上下文切换了后来有了NIO一个线程可以轮询多个连接的事件但直接用JDK原生NIO写业务代码非常痛苦——要处理缓冲区的分配释放、半包粘包、线程模型选型、异常处理工程量太大。netty的价值在于把NIO的复杂性封装好了同时保留了很高的灵活性。它把网络通信抽象成ChannelPipeline里一串ChannelHandler每个handler只负责一件事比如拆包、解码、心跳检测、业务处理。这样的设计让开发者可以像搭积木一样组装自己的通信逻辑也方便排查问题——每个环节都是独立的哪里出了问题就查哪个handler。回到客服系统这个场景我在设计通信层时最看重的几个能力是连接空闲检测客服端和访客端都可能长时间不说话但连接不能断或者断了要能感知、消息编解码的灵活性既要支持字符串消息又要能扩展二进制消息、以及高并发下的性能。这些正好都是netty的优势区。2.2 长连接方案对比WebSocket原生协议与netty自研有人会问浏览器端要连服务器直接用WebSocket协议就行为什么还要套一层netty这里有个容易混淆的点netty本身就支持WebSocket协议有专门的WebSocketServerProtocolHandler所以“用netty”和“走WebSocket”不冲突。netty里可以处理底层的WebSocket帧也可以自己定义一套基于Socket的私有协议。这套源码的做法是前端通过WebSocket协议连接netty服务端用WebSocketServerProtocolHandler完成协议升级之后在自定义handler里解析业务消息。这样做的优势是兼容浏览器原生WebSocket API前端不需要额外引SDK。协议升级前的HTTP握手可以由netty接管不需要额外的nginx代理配置。后续如果要支持App端原生Socket只需要在netty里再加一套协议解析handler复用上层的业务逻辑。如果完全不用netty直接用nginx代理到后端的WebSocket服务那连接管理和业务逻辑就得拆成两个服务多一层转发就多一层延迟和故障点。放到线上环境里用netty做接入层运维上更可控。2.3 心跳机制与空闲检测长连接最怕的事情是“假死”——客户端网络断了但服务端没有感知TCP连接还在只是收不到任何数据。如果不做处理这些死连接会一直占着内存和文件描述符时间长了服务就撑不住了。netty提供了IdleStateHandler来检测连接空闲这是我在代码里重点用的一个组件。配置方式很简单// 参数依次为读空闲秒数、写空闲秒数、读写全部空闲秒数 pipeline.addLast(new IdleStateHandler(120, 0, 0));这个配置的含义是如果120秒内没有收到客户端的任何数据就触发一次IdleStateEvent。在自定义的handler里收到这个事件后有两种处理策略先发一个心跳包给客户端探活如果接下来N秒还没收到客户端响应就直接关闭这条连接。这套源码的做法是“读空闲超过90秒发心跳包再等30秒无响应则断开”。为什么要设置读空闲而不是读写空闲因为服务端自身也会发消息比如推送客服回复如果按读写空闲来算只要服务端一直在发消息连接就不会判死但客户端可能早就断了。按读空闲检测是最稳妥的——只判断“我有没有收到对方的消息”。2.4 零拷贝与内存管理netty性能好的另一个原因是它在IO路径上做了很多优化最常被提到的就是零拷贝。传统的Socket读取数据要经过内核缓冲区到用户缓冲区的拷贝netty通过堆外内存Direct Memory和FileRegion等方式减少了这些拷贝流程。在实际开发里我不建议为了“性能”盲目使用堆外内存因为这会导致排查内存泄漏变得困难。netty自带的内存池PooledByteBufAllocator默认是开启的这套源码也没有额外调整。经验是用默认的分配器配合-Dio.netty.leakDetection.leveladvanced参数在测试环境开启内存泄漏检测线上环境再关掉。3. 通信层核心实现channel管理与消息流转3.1 连接与用户身份的绑定关系netty服务端每接收到一个新连接都会创建一个Channel对象。但是Channel本身只代表一条TCP连接服务端需要知道这条连接对应的是访客还是客服对应哪个用户ID才能进行消息路由。这套源码的设计是在ChannelHandler里维护了一个AttributeKey用于存业务的用户身份对象public class UserChannelManager { // 全局通道管理userId - Channel private static final ConcurrentHashMapLong, Channel USER_CHANNEL_MAP new ConcurrentHashMap(); // 绑定用户与通道 public static void bindUser(Long userId, Channel channel) { USER_CHANNEL_MAP.put(userId, channel); // 在channel上记录userId方便反向查找 channel.attr(AttributeKey.valueOf(userId)).set(userId); } // 根据userId获取通道 public static Channel getChannel(Long userId) { return USER_CHANNEL_MAP.get(userId); } // 连接断开时移除绑定 public static void unbindUser(Long userId) { USER_CHANNEL_MAP.remove(userId); } }这里有几个细节值得注意。第一ConcurrentHashMap是线程安全的netty的IO线程是多线程并发处理不同channel的事件全局map必然被多线程访问第二当用户重复登录时比如同一个客服账号在多个浏览器登录后来的连接会覆盖先前的绑定需要在bindUser里把旧channel关闭第三channel关闭时一定要执行unbind否则map里会积累大量失效连接。以访客为例完整流程是这样的访客打开网页调用后端接口创建访客身份得到一个guestId页面拿到guestId后建立WebSocket连接。netty的handler在握手完成后先从HTTP headers或query参数里解析出guestId调用bindUser绑定。访客发消息时消息体里包含自己的guestId、会话ID、消息内容。服务端解析后找到对应的客服channel把消息转发过去。客服首次上线也要做同样的绑定。由于客服与访客的连接都维护在一个map里客服端转发消息给访客时只需要查一次map就能拿到目标channel。3.2 消息协议设计消息协议是通信层最重要的设计决策之一。这套源码没有直接用复杂的Protobuf而是用JSON字符串作为传输格式——对于客服系统这种消息类型不复杂的场景JSON的可读性和调试方便程度远大于二进制协议性能也完全够用。消息体结构大致如下{ type: chat, from: guest_10001, to: agent_5, sessionId: S20240101001, content: 你好我想咨询一下物流问题, timestamp: 1704067200000 }type字段用于区分消息类型chat是聊天消息read是消息已读回执typing是正在输入状态heartbeat是心跳包。服务端的handler会先根据type做路由聊天消息才进入下一步的处理逻辑。协议设计上有两个坑值得提一是字段不要过度设计。我见过有人一上来就设计十几个字段的消息头结果前后端联调时一半字段都是空的。客服系统的消息最少只需要“发送者”、“接收者”、“会话ID”、“内容”、“时间戳”这五个字段。二是时间戳用客户端时间还是服务端时间要想清楚。消息展示时应该用服务端收到消息的时间而不是客户端上报的时间客户端时间不可信可能会被用户修改。所以这条协议里客户端传的timestamp在服务端只做参考最终落库的时间以服务端为准。3.3 拆包粘包处理只要是基于TCP的通信就必须面对拆包和粘包问题。TCP是流式协议它本身不知道消息的边界在哪。举个例子客户端连续发送两条JSON消息可能一次就收到了{type:chat,content:你好}{type:chat,content:在吗}连在一起的数据也可能一条大消息被拆成了多个TCP包分批发到服务端。netty里解决这个问题有现成的handler可以加。WebSocket协议自带消息边界定义所以WebSocket接入场景可以不操心拆包但这套源码的handler链在协议升级前依然加了LengthFieldBasedFrameDecoder逻辑是// 最大帧长度 65536长度字段偏移 0长度字段长度 4长度调整 0初始跳过 4 pipeline.addLast(new LengthFieldBasedFrameDecoder(65536, 0, 4, 0, 4));这个类的语义是每个消息头部的4个字节是int类型的长度标识表示消息体长度。解码器读完4字节长度后再等对应的消息体全部到达组装成一个完整的ByteBuf交给下一个handler。这样业务代码拿到的永远是一条完整消息不用自己处理半包。如果这套项目以后要对接原生Socket客户端这一层就非常重要如果只走WebSocket那可以把这段逻辑注释掉框架自带的WebSocket帧解析已经解决了边界问题。两种方式并存也不会有冲突LengthFieldBasedFrameDecoder只在HTTP升级前生效。3.4 消息推送从服务端主动发消息给指定用户客服系统的核心操作是客服A回复了访客B服务端要把这条消息推送到访客B的浏览器上。这就是典型的“服务端主动推送”基于的就是之前说的用户ID到Channel的映射关系。推送逻辑的核心代码如下public void sendMessageToUser(Long userId, String message) { Channel channel UserChannelManager.getChannel(userId); if (channel ! null channel.isActive()) { channel.writeAndFlush(new TextWebSocketFrame(message)); } else { // 用户不在线走离线消息流程 offlineMessageService.saveOfflineMessage(userId, message); } }这里要特别说一个新手很容易犯的错误在netty的handler线程里直接调用业务服务比如数据库操作。netty的IO线程是EventLoop它必须快速处理完当前的事件才能继续处理其他channel的事件。如果在IO线程里执行了耗时的数据库查询或者Redis操作会阻塞整个EventLoop直接拖垮所有连接。正确的姿势是像这样把耗时操作扔到业务线程池public class ChatHandler extends SimpleChannelInboundHandlerTextWebSocketFrame { private static final ExecutorService BIZ_EXECUTOR new ThreadPoolExecutor(8, 16, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(10000)); Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) { // 异步处理业务逻辑避免阻塞EventLoop BIZ_EXECUTOR.submit(() - { // 解析消息、落库、推送 }); } }当然这里为了举例简化了线程模型。实际生产中可以用一个独立的Spring管理的线程池来做异步消息处理或者直接把消息投递到消息队列由消费者异步处理。4. springboot如何与netty协同工作4.1 Spring容器管理netty服务的生命周期很多第一次写netty项目的人会想我直接在SpringApplication启动之后手动new一个ServerBootstrapbind端口不就行了吗行但有个问题netty的handler里要注入Spring的Service比如MessageService而netty的handler是自己在代码里new出来的不在Spring容器管理范围内注入不了。这套源码用了比较标准的方式来处理这个问题——把netty服务本身做成Spring的Bean让Spring管理它的启动和关闭Component public class NettyServer implements ApplicationRunner, ApplicationListenerApplicationClosedEvent { private Channel serverChannel; Autowired private ChatHandler chatHandler; Override public void run(ApplicationArguments args) throws Exception { EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new HttpServerCodec()); ch.pipeline().addLast(new HttpObjectAggregator(65536)); ch.pipeline().addLast(new WebSocketServerProtocolHandler(/ws)); // 注意这里的chatHandler是从Spring容器中注入的 ch.pipeline().addLast(chatHandler); } }); serverChannel bootstrap.bind(port).sync().channel(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } Override public void onApplicationEvent(ApplicationClosedEvent event) { if (serverChannel ! null) { serverChannel.close(); } } }这里的关键是chatHandler这个Bean它被Spring创建并管理netty初始化ChannelPipeline时直接从Spring容器里拿现成的Bean。ChatHandler本身是无状态的它不存消息内容只做转发和路由所以可以安全地作为单例Bean被所有channel共享。这里使用单例handler的前提是不能再定义有状态的成员变量尤其是不能保存某个连接特有的数据。另外bossGroup和workerGroup线程数的设置也要结合机器配置考虑。bossGroup一个线程就够了只负责accept连接workerGroup默认是CPU核数两倍。如果连接的channel里有大量的编解码和业务处理workerGroup线程数可以适当调大不能随意。4.2 用户认证在长连接中的处理HTTP接口的认证可以用session、token、JWT每种都有成熟的方案。长连接握手时怎么认证是个容易忽略的细节。这套源码的处理方式是在WebSocket握手阶段从URL的query参数中取tokenws://localhost:8080/ws?tokenxxxxxnetty的WebSocketServerProtocolHandler完成协议升级后自定义的handler可以从HttpRequest里解析到query参数。如果参数校验不通过直接关闭连接校验通过才允许后面的消息交换。对token的解密和用户信息获取是在handler里调Spring的AuthService完成的public class AuthHandler extends SimpleChannelInboundHandlerWebSocketFrame { Autowired private AuthService authService; Override public void channelActive(ChannelHandlerContext ctx) throws Exception { // WebSocket升级完成后的首次channelActive里无法直接拿到HTTP请求参数 // 所以需要在握手阶段提前解析并缓存到channel属性中 } }这里有个实现顺序的细节WebSocket的字段信息要拿到HttpRequest而HttpRequest在握手阶段就处理完了等channelActive再拿已经拿不到了。正确的做法是在WebSocketServerProtocolHandler之前的某个handler里拦截握手请求解析参数放到channel的attribute里后面的业务handler从attribute里取。4.3 Redis在消息路由中的角色如果netty服务和管理后台部署在同一台机器上消息路由用本地map就够了。但一旦要扩容成多节点或者要把推送事件解耦本地map就无能为力了。这套源码引入Redis做了一层中转思路很清晰客服端发消息 - 后端API收到 - 写入数据库 - 把消息发布到Redis频道 - netty服务订阅这个频道 - 收到消息后推送给目标用户的channel。在这里Redis扮演的是一个“轻量级消息总线”的角色。当netty服务是多实例部署时访客A连接在节点1客服B连接在节点2客服B的回复发到后端API后通过Redis广播节点1能收到事件推送并找到访客A的channel。用Redis的pub/sub而不是RocketMQ或Kafka核心考虑是轻量。客服系统的消息量级远没有达到需要消息队列堆积的程度Redis pub/sub的秒级投递能力完全够用而且运维成本几乎为零。当然如果以后要做消息的大数据分析和离线推送再引入专门的消息队列也不迟。这段设计的启发是技术选型不一定要最复杂的但要匹配业务阶段。先用Redis做总线等业务量证明需要更重的方案时再演进这个节奏是对的。5. 前端与后端的交互细节5.1 访客端怎么建立WebSocket连接访客端页面启动时先向后端发起一个HTTP请求创建访客身份拿到guestId之后再建立WebSocket长连接。这段逻辑在im-client模块里做了封装前端只需要调用一个初始化方法即可。创建访客身份的接口大致是RestController RequestMapping(/api/guest) public class GuestController { Autowired private GuestService guestService; PostMapping(/register) public ResultGuestVO register(RequestBody(required false) GuestRegisterDTO dto) { // 生成访客ID也可以绑定用户输入的昵称和联系方式 return Result.success(guestService.createGuest(dto)); } }页面拿到返回的guestId后构造WebSocket连接URLfunction initWebSocket(guestId) { const protocol location.protocol https: ? wss : ws; const wsUrl ${protocol}://${location.host}/ws?guestId${guestId}; const ws new WebSocket(wsUrl); ws.onopen function() { console.log(访客连接建立成功); // 连接建立后可以发送一条系统消息通知客服 sendMessage({ type: system, content: 访客进入会话 }); }; ws.onmessage function(event) { const msg JSON.parse(event.data); handleMessage(msg); }; ws.onclose function() { console.log(连接断开准备重连); setTimeout(initWebSocket, 3000); // 3秒后重连 }; }断线重连是长连接应用必须考虑的基本能力。网络抖动导致连接断开恢复后要能自动重连并且把断线期间丢失的消息补偿回来。这套源码的做法是前端断线重连成功后向后端发送一个sync消息后端把这段时间内产生的新消息全部下发前端本地不会丢消息。5.2 消息已读回执的实现做客服系统的人如果没有跟业务方确认“已读回执”的需求做完一定会被怼。客服发了消息到底有没有送达访客有没有看到这两个状态直接影响客服的工作节奏。这套源码支持两种回执送达回执netty服务把消息成功写入客户端channel后客户端会回一个delivered消息服务端更新消息状态为“已送达”。已读回执访客端看到消息并渲染到界面上之后发送一个read消息服务端把该会话的消息状态更新为“已读”。后端接收已读回执的代码在会话消息Service里public void markAsRead(Long sessionId, Long userId) { // 更新数据库中的消息状态 messageMapper.updateStatusBySessionId(sessionId, userId, MessageStatus.READ); // 广播给会话中的另一个参与者让对方看到“已读” notifyReadEvent(sessionId, userId); }已读回执在UI上的呈现就是“消息后方的已读/未读状态”。这个功能在客服系统里是个感知很强的小亮点很多商用客服系统都拿它作为专业性的标志。不过要注意的是已读回执的实现会增加消息量大约20%左右在压测时要考虑到这部分流量。5.3 客服工作台的消息处理流程客服端和访客端的连接方式一致都是WebSocket。区别在于客服登录后后端会把客服ID绑定到channel上并且把该客服负责的会话列表推送到客户端。客服工作台收到新消息时要做几件事更新会话列表中的最新消息和时间。判断当前是否正在查看该访客的会话窗口如果是则直接渲染消息如果不是则显示未读消息数。如果消息在同一个会话里要保持消息时间线顺序正确不能出现后发的消息先显示的情况。这里有一个前后端配合的细节多条消息同时到达时前端可能会乱序渲染。简单的方式是前端维护一个消息队列按消息的seq字段或timestamp字段排序后再插入DOM。如果消息是并发推送的timestamp相同时要以服务端的递增序号为准。这套源码的每条消息都带有一个数据库自增ID和创建时间前端排序用两个维度一起判断基本不会出错。6. 会话分配策略与离线消息处理6.1 怎么把访客分配给合适的客服会话分配是客服系统业务层的核心逻辑。这套源码的分配策略有几个关键点第一在线客服池维护。客服登录后系统维护一个在线客服集合客服离线或手动切换状态时更新集合。分配时只从在线集合中选人避免给离线客服派活。第二分配模式支持平均分配和空闲优先。平均分配是轮询——保证今天每个客服接待量差不多空闲优先是按当前会话数排序把新访客分给当前接待量最少的客服。实际运营中这两种模式各有适用场景小团队用平均分配更公平大团队用空闲优先能缩短访客等待时间。第三客服可以手动接待。客服工作台上有一个待接入访客列表客服可以从列表中主动点击接待此时该访客从排队列表中移除锁定给当前客服。如果所有客服都离线了访客进入系统后看到的不再是聊天窗口而是留言表单。留言会保存到数据库并在客服上线时生成一条待处理提醒。6.2 消息防丢落库与推送的顺序聊天的消息绝对不能丢。这套源码对“消息可靠性”做了一个比较严格的处理链路访客发消息前端先把消息放到本地队列UI上显示“发送中”。WebSocket把消息发给netty服务端服务端原样转发给客服端同时把消息写入数据库。客服端确认收到做了送达回执访客端收到回执后把消息状态从“发送中”改成“已发送”。如果访客端在收到送达回执前断开了连接重新连接后会发起同步请求把未知状态的消息重新查询一遍。这里有一个看似矛盾但实际上正确的设计——消息先推送再落库还是先落库再推送这套源码采用的是先落库再推送。理由是推送失败可以靠的同步和补偿机制把消息捞回来但如果消息没落库就推了一旦客户端没收到这条消息就彻底丢了。落库在前至少保证消息有据可查。落库操作的代价是增加了消息的端到端延迟但实际压测下来一次MySQL插入对整体延迟影响极小完全可以接受。在真正的工作中保证不丢消息比节省几毫秒延迟重要得多。6.3 历史消息的加载与分页访客打开聊天窗口首先加载最近的历史消息。这套源码用的是倒序分页先查最后20条滚动条拉到顶部时继续加载更早的消息。public ListMessageVO getHistoryMessages(Long sessionId, Long lastMessageId, Integer limit) { // 查询条件sessionId ? and id lastMessageId order by id desc limit ? // 拿到后再逆序返回保证前端消息是按时间正序展示 }这里用id lastMessageId而不是offset分页是因为客服系统的消息数据量会持续增长用偏移量分页在数据量大的时候会越来越慢而用id游标分页能稳定走索引性能不会衰减。还有一个细节历史消息里的图片和附件不能直接返回数据库的存储路径要做一次鉴权转发。这套源码中图片是上传到本地磁盘通过/api/file/{fileId}接口读取接口内部校验请求者是否为会话参与者。7. 部署运行与压测实践7.1 本地快速启动步骤项目拿到手怎么跑起来我直接列一下实操步骤环境准备JDK 8建议8或11、Maven 3.6、MySQL 5.7、Redis 5.0。初始化数据库执行sql目录下的init.sql脚本创建数据库和表结构。修改im-server和im-admin模块下的application.yml配置MySQL和Redis的连接地址。启动顺序先启动im-servernetty服务监听端口默认8081再启动im-admin业务API默认端口8080。访问客服工作台浏览器打开http://localhost:8080/admin使用初始化脚本里的管理员账号登录。访问访客端页面另一个浏览器窗口打开http://localhost:8080/client点击在线咨询按钮开始聊天。这里有一个容易踩的坑如果前端页面是放在nginx反向代理下的WebSocket的代理配置需要单独处理。nginx默认配置不会转发Upgrade头需要在location里显式配置location /ws { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 3600s; }proxy_read_timeout要设置得比netty的心跳间隔更长否则空闲一段时间后nginx会主动断开连接。7.2 压测时观察的核心指标我用JMeter配合WebSocket插件做了简单压测大致摸了一下这套代码的性能边界。压测结果受机器配置影响很大但有几个指标值得关注。先看线程配置。我这台压测机器是4核8Gnetty的workerGroup线程数默认是8管理后台的Tomcat线程池也是默认配置。在这种配置下能稳定支撑的在线连接数大约是5000左右消息吞吐量大约每秒3000条左右。再往上涨CPU开始出现明显瓶颈。压测时遇到过几个主要问题按频率排内存增长过快。原因是长时间运行后大量关闭的连接没有及时从USER_CHANNEL_MAP中清理。后来在channelInactive和exceptionCaught里都补了清理逻辑问题解决。偶发消息延迟。排查后发现是业务线程池队列满了消息在队列里排队等待消费。调整线程池大小和队列容量后延迟恢复正常。连接数虚高。压测脚本异常断开后服务端没有及时感知导致连接一直占着。心跳检测的判死时间从120秒缩短到90秒后虚假连接明显少了。7.3 部署到服务器的注意事项从本地跑通到部署到服务器还有一些环境层面的经验服务器上必须关闭防火墙对端口的限制或者放行对应端口尤其要检查云安全组。用nohup java -jar xxx.jar启动一定要加JVM参数-XX:HeapDumpOnOutOfMemoryError出问题时能拿到堆转储文件。netty服务建议单独部署不要和业务接口混在一起。因为netty对CPU的消耗比较大混在一起会影响接口响应速度。连接数超过5000时记得调整Linux的文件描述符限制ulimit -n默认1024肯定不够。数据库层面提前加索引。消息表默认只有主键索引如果数据量上来按sessionId查询历史消息时会慢。建议建一些联合索引比如(session_id, id)和(from_user_id, to_user_id, create_time)几条核心查询都要覆盖到。8. 常见问题与排查技巧8.1 前端连不上WebSocket这是部署后反馈最多的一个问题。按这个顺序排查基本能定位问题检查netty服务端口是否成功监听用netstat -anp | grep 8081看一下。检查浏览器控制台WebSocket连接URL是否用了wss如果是HTTPS页面WebSocket也要用wss。检查访问路径如果走nginx代理看/ws路径是否正确转发到netty端口看nginx错误日志。检查服务端日志有没有握手失败的记录。如果服务端没有日志输出就先确认netty的端口绑定是否成功再看网络链路。多数情况下是代理层的配置问题。8.2 消息发出去了但对方收不到消息发送方向是访客-netty-后端存储-Redis广播-客服端。链条一旦中断收不到消息的环节通常用日志就能定位。常见的坑有两个一是redis的pub/sub广播模式如果netty服务没有订阅对应的channel或者订阅的是不同频道名消息就会“丢”在广播环节。检查服务端启动日志确认订阅channel名和发布channel名一致。二是channel绑定失败。如果消息路由时找不到目标用户代码里会走离线消息逻辑。排查时打印一下UserChannelManager中的map大小和绑定信息看目标用户是否真实在线。8.3 客服端界面卡顿客服端渲染的消息越来越多界面越来越卡这是典型的性能问题。优化方向有几个一是消息列表虚拟滚动只渲染可视区域内的消息DOM节点而不是把所有消息都插入页面。几千条消息前不卡几万条卡成PPT。二是减少不必要的DOM操作消息插入时用DocumentFragment批量插入避免逐条append到DOM。三是图片懒加载历史消息里的图片不立即加载滚动到可视区域才加载。8.4 内存溢出问题我测试时遇到过一次OOM排查了一下原因是一个全局Map在并发操作时出现了死循环导致某个key对应的value无线增长。netty项目里内存泄漏经常来自这几个地方自定义Handler里保存了不该保存的上下文。使用堆外内存没有及时释放。全局Map只在put时写入了忘记在连接关闭时remove。Channel写入队列积压如果推送速度大于客户端读取速度channel.writeAndFlush会不断积压最终OOM。排查内存问题我一般先在测试环境加上-Dio.netty.leakDetection.levelparanoid如果跑一段时间日志里出现LeakDetection相关警告就说明有大对象泄漏。再用jmap导出堆转储用MAT分析找出占用最大的对象基本能定位到具体代码。9. 基于这套源码的二次开发建议如果要把这套系统用于真实业务有几个方向我觉得值得优先投入。第一个方向是接入已有的用户体系。访客身份目前是临时生成的guestId如果要做“游客-注册用户”的转化就需要把guestId与系统用户表做绑定同时处理好消息历史归属的迁移。第二个方向是增加智能机器人。在线客服永远会面临“客服忙不过来”的问题接入一个基于关键词匹配的基础机器人能分流很大一部分简单咨询。springboot生态里接入AI对话接口也很方便可以在消息处理逻辑中加一个“机器人优先回答”的分支。第三个方向是操作审计与质检。客服系统天然涉及客户沟通敏感信息识别、会话质检、客服绩效考核都是后续能挂上的管理功能。数据库里的消息记录是现成的数据基础做统计分析也方便。第四个方向是主动推送。客服系统不只可以用于“访客找客服”还能反过来了——在特定时间点主动向在线用户推送营销消息或服务通知。netty的channel管理能力完全可以支撑这类需求但要注意推送频率避免对用户的打扰。每个方向要想落地都需要在通信层和业务层同时做改造。这套源码作为基础骨架既不会限制扩展也不会在扩展时带来过多的历史包袱算是比较合适的切入点。10. 实操心得这套系统里最值得深入研究的四个细节项目跑通之后我从这套源码里挑出了四个值得反复推敲的技术点在这里分享一下我的看法。第一个是netty的线程模型。很多人只是知道netty分boss和worker但实际写代码时哪些逻辑放IO线程、哪些放业务线程边界很模糊。这套源码里比较清楚消息解码、channel读写、心跳检测放IO线程消息落库、推送路由、Redis交互放业务线程池。想搞懂netty光看不练是没效果的拿了这套源码把瓶颈模拟出来——比如把数据库查询直接放IO线程里跑观察整个服务端对连接的影响——比看十篇理论文章都直观。第二个是消息可靠性的取舍。“先落库再推送”这个设计虽然看起来简答但能验证对消息丢失的态度。任何声称高可靠的系统最终落地时都要回答“如果这一步失败怎么办”这样的问题。这套源码的方案虽然不是最优解但每一步失败都有对应的补偿机制这是真正的工程思维。第三个是协议扩展能力。不要小看了JSON协议里的type字段它是整个系统所有功能扩展的入口。新增图片消息、文件消息、语音消息本质上都是新增type值并添加对应的处理handler。设计好这个字段后续加功能会很顺畅。第四个是前端断线重连与消息同步的配合。WebSocket断线重连不是简单地在onclose里重新new一个WebSocket就行。要考虑重连频率限制、重连后的数据补偿、重连期间的本地状态管理。这套源码用了一个简单的sync机制虽然不复杂但概念已经被几个商用系统采用过。如果现在再让我从零写一套类似的系统我还是会选springboot netty的组合。springboot保证了业务开发的效率netty保证了长连接的高并发承载能力两者的边界清晰各司其职。这也是为什么多数成熟的开源客服系统都采用这个技术栈——它不追求花哨但每一步都踩在稳定可靠的点上。最后提个小建议运行这套源码时多关注一下日志每当连接建立和断开时观察一下对应的事件流转你会对netty的运行机制有更直观的理解。