做了几年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.level=advanced参数在测试环境开启内存泄漏检测,线上环境再关掉。
3. 通信层核心实现:channel管理与消息流转
3.1 连接与用户身份的绑定关系
netty服务端每接收到一个新连接,都会创建一个Channel对象。但是Channel本身只代表一条TCP连接,服务端需要知道这条连接对应的是访客还是客服,对应哪个用户ID,才能进行消息路由。
这套源码的设计是在ChannelHandler里维护了一个AttributeKey,用于存业务的用户身份对象:
public class UserChannelManager { // 全局通道管理:userId -> Channel private static final ConcurrentHashMap<Long, 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 SimpleChannelInboundHandler<TextWebSocketFrame> { 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一个ServerBootstrap,bind端口,不就行了吗?行,但有个问题:netty的handler里要注入Spring的Service(比如MessageService),而netty的handler是自己在代码里new出来的,不在Spring容器管理范围内,注入不了。
这套源码用了比较标准的方式来处理这个问题——把netty服务本身做成Spring的Bean,让Spring管理它的启动和关闭:
@Component public class NettyServer implements ApplicationRunner, ApplicationListener<ApplicationClosedEvent> { 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 ChannelInitializer<SocketChannel>() { @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参数中取token:
ws://localhost:8080/ws?token=xxxxxnetty的WebSocketServerProtocolHandler完成协议升级后,自定义的handler可以从HttpRequest里解析到query参数。如果参数校验不通过,直接关闭连接;校验通过,才允许后面的消息交换。
对token的解密和用户信息获取,是在handler里调Spring的AuthService完成的:
public class AuthHandler extends SimpleChannelInboundHandler<WebSocketFrame> { @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 Result<GuestVO> register(@RequestBody(required = false) GuestRegisterDTO dto) { // 生成访客ID,也可以绑定用户输入的昵称和联系方式 return Result.success(guestService.createGuest(dto)); } }页面拿到返回的guestId后,构造WebSocket连接URL:
function 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 List<MessageVO> 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-server(netty服务,监听端口默认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核8G,netty的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.level=paranoid,如果跑一段时间日志里出现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的运行机制有更直观的理解。