EasyChat-Netty:基于Netty与SpringBoot的仿微信聊天后端实战
简介EasyChat-Netty 是一套基于 Netty、SpringBoot、WebSocket 构建的仿微信聊天 APP 后端工程面向具备 Java 与网络编程基础、希望深入理解即时通讯服务端实现的学习者与开发者。项目涵盖聊天、通讯录、发现、个人四大模块支持最近聊天快照保存、A-Z 通讯录排序查找、朋友圈与扫一扫添加好友、头像昵称修改等功能并通过个推完成 APP 端消息推送借助前后端心跳维持 WebSocket 长连接是研究高并发长连接与消息路由的实用案例。资源包共 96 个文件以 68 个 java 源码与 17 个 xml 配置为主另含 1 个 sql 建表脚本、1 个 yml 配置、5 张 jpg 截图及说明文档压缩包约 209KB结构紧凑便于快速导入 IDE 运行调试。目前已有 1001 人学习下载读者可借此掌握 Netty 与 SpringBoot 整合、WebSocket 通信、FastDFS 文件存储及 tk.mapper 持久层等后端要点并对照前端工程完成联调。1. 从轮询到长连接EasyChat-Netty 到底解决了什么做过 IM 后端的人大概都经历过这个阶段前端用setInterval每两秒拉一次消息接口用户量一上来数据库 QPS 直接飙红消息延迟还忽高忽低。EasyChat-Netty 这个项目就是冲着这个痛点去的——它用 Netty 做长连接网关、SpringBoot 做业务编排、WebSocket 做传输协议再配一套 MUI HTML5 的前端壳子拼出一个仿微信聊天 APP 的完整后端。仓库本身只放后端系统核心就两件事Netty 扛住海量并发连接SpringBoot 把用户、好友、消息这些业务逻辑串起来。它适合谁如果你正在做一个需要「消息实时到达」的产品——不管是内部协作工具、客服系统还是社交 APP——又不想一上来就上 Kafka 微服务的重装备这套组合是个很务实的起点。单机几万连接、消息端到端延迟压到百毫秒级用 Netty 加 SpringBoot 完全够用。下面我按「先跑通、再调优、最后避坑」的顺序把这条链路拆开讲。2. 环境搭建与最小可运行链路让第一个 WebSocket 连接握手成功2.1 依赖选型为什么是 Netty 而不是原生 WebSocketSpringBoot 自带spring-boot-starter-websocket为什么还要引入 Netty原因在于原生 WebSocket 基于 Servlet 容器每个连接占用一个线程Tomcat 默认 200 个线程连接数一上去就排队。Netty 用 Reactor 模型少量 EventLoop 线程就能管理成千上万连接内存占用和上下文切换成本都低一个量级。我一般会在pom.xml里锁定这几个核心依赖版本用 SpringBoot 父 POM 管理的即可不手动指定避免冲突!-- Netty 核心提供 EventLoop、Channel、ByteBuf 等基础设施 -- dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId /dependency !-- WebSocket 协议编解码Netty 已内置在 netty-all 中 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 如果要用 Redis 存在线状态和路由信息 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency这里有个容易翻车的点netty-all是个聚合包体积大但省心如果你对启动速度敏感可以只引netty-handler、netty-codec-http等子模块。新手阶段建议直接用netty-all等压测出瓶颈再瘦身。2.2 启动类与 Netty 服务端骨架Netty 服务端不能像普通 SpringBoot 应用那样靠内嵌 Tomcat 启动需要手动创建ServerBootstrap并绑定端口。我通常把它写成一个CommandLineRunner让 Spring 容器启动完成后自动拉起 Netty。Component public class NettyServer implements CommandLineRunner { Value(${netty.port:8888}) private int port; Override public void run(String... args) throws Exception { // bossGroup 负责接收连接1 个线程足够 EventLoopGroup bossGroup new NioEventLoopGroup(1); // workerGroup 负责 IO 读写默认 CPU 核数 * 2 EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) // 连接等待队列长度高并发下适当调大 .option(ChannelOption.SO_BACKLOG, 1024) // 开启 TCP 底层心跳保活 .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); // HTTP 编解码用于 WebSocket 握手 pipeline.addLast(new HttpServerCodec()); // 把 HTTP 请求聚合成 FullHttpRequest pipeline.addLast(new HttpObjectAggregator(65536)); // WebSocket 协议处理路径 /ws pipeline.addLast(new WebSocketServerProtocolHandler(/ws)); // 自定义业务处理器 pipeline.addLast(new ChatHandler()); } }); ChannelFuture future bootstrap.bind(port).sync(); System.out.println(Netty 服务端启动端口 port); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }逻辑说明bossGroup只处理 accept 事件设 1 个线程就够workerGroup处理所有读写默认线程数是 CPU 核数乘 2IO 密集型场景可以调到核数乘 4。SO_BACKLOG是三次握手完成但还没被 accept 的队列长度压测时如果看到连接被拒先调这个参数。HttpObjectAggregator的 65536 是最大内容长度聊天消息一般远小于这个值但如果你要传图片 base64得往上加。2.3 前端最小连接测试后端跑起来后别急着写完整前端先用浏览器控制台验证握手是否成功。打开任意页面在 Console 里执行// 连接 Netty WebSocket 端点 const ws new WebSocket(ws://localhost:8888/ws); ws.onopen () { console.log(连接已建立); // 发送一条测试消息格式自定义为 JSON ws.send(JSON.stringify({ type: CHAT, content: hello, to: user2 })); }; ws.onmessage (event) { console.log(收到消息, event.data); }; ws.onerror (err) { console.error(连接出错, err); }; ws.onclose () { console.log(连接关闭); };如果控制台打印「连接已建立」说明 Netty 的 WebSocket 握手链路是通的。这一步看着简单但实际项目中我见过太多人卡在这里——要么是路径写错/ws和/websocket对不上要么是 Nginx 反代没配Upgrade头。先用最裸的方式验证再往上叠业务。3. 消息路由与在线状态管理让消息找到对的人3.1 Channel 与用户 ID 的绑定策略Netty 的Channel代表一条连接但业务层需要知道「这条连接属于哪个用户」。常见做法是在握手成功后客户端发一条认证消息服务端把userId和Channel的映射存进ChannelGroup或自定义的ConcurrentHashMap。Component public class SessionManager { // userId - Channel 的映射用 ConcurrentHashMap 保证线程安全 private static final MapString, Channel USER_CHANNEL_MAP new ConcurrentHashMap(); // 所有连接的集合用于广播 private static final ChannelGroup ALL_CHANNELS new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); public void bind(String userId, Channel channel) { Channel old USER_CHANNEL_MAP.put(userId, channel); // 同一账号重复登录时踢掉旧连接 if (old ! null old.isActive()) { old.writeAndFlush(new TextWebSocketFrame(您的账号在其他设备登录)); old.close(); } ALL_CHANNELS.add(channel); } public Channel getChannel(String userId) { return USER_CHANNEL_MAP.get(userId); } public void unbind(Channel channel) { USER_CHANNEL_MAP.entrySet().removeIf(e - e.getValue().equals(channel)); } }参数说明ConcurrentHashMap在 8 之后用 CAS synchronized 优化读多写少场景性能足够。ChannelGroup的writeAndFlush是批量操作广播时比遍历 Map 再逐个写要快但注意它不保证顺序。踢人逻辑里先写消息再 close给客户端一个提示体验上比直接断连好。3.2 单聊与群聊的消息分发消息分发核心就一句找到目标用户的 Channel写进去。但实际要考虑离线消息、多端登录、群成员遍历这些情况。public void dispatch(String fromUserId, String toUserId, String content) { Channel target SessionManager.getChannel(toUserId); if (target ! null target.isActive()) { // 在线直接推送 target.writeAndFlush(new TextWebSocketFrame( JSON.toJSONString(MessageVO.of(fromUserId, content)) )); } else { // 离线落库等对方上线后拉取 offlineMessageService.save(toUserId, fromUserId, content); } }这里有个设计取舍离线消息是存 Redis 还是 MySQL我的经验是最近 7 天的离线消息放 Redis List过期时间设 7 天历史消息落 MySQL。用户上线时先查 Redis没有再查库。这样既保证实时性又不会让 Redis 内存无限膨胀。3.3 心跳检测与空闲连接清理长连接最怕「假死」——TCP 连接还在但客户端已经断网。Netty 提供了IdleStateHandler来检测读写空闲。// 在 pipeline 中加入读空闲 60 秒、写空闲 30 秒、读写空闲 90 秒 pipeline.addLast(new IdleStateHandler(60, 30, 90, TimeUnit.SECONDS)); pipeline.addLast(new HeartbeatHandler()); // HeartbeatHandler 中处理空闲事件 Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { // 读空闲超时说明客户端没发心跳关闭连接 ctx.close(); } } else { super.userEventTriggered(ctx, evt); } }参数怎么设客户端心跳间隔一般 30 秒服务端读空闲设 60 秒留一个心跳周期的容错。写空闲设 30 秒是为了检测服务端自己是否卡住。读写空闲 90 秒是兜底。这三个值不要设得太激进否则移动端网络切换时会频繁断连重连反而增加服务端压力。4. 避坑与排查那些让我熬夜的瞬间4.1 消息乱序为什么对方收到的顺序和我发的不一样现象A 连续发三条消息B 收到的顺序是 1、3、2。原因Netty 的writeAndFlush是异步的如果多条消息在不同线程中提交EventLoop 的执行顺序不保证。解决对同一会话的消息用Channel的eventLoop().submit()串行化或者用MessageOrderHandler给消息加序列号接收端按序号排序。4.2 内存泄漏ByteBuf 没释放导致 OOM现象服务跑几小时后堆内存持续上涨Full GC 也降不下来。原因ByteBuf是引用计数对象如果用了PooledByteBufAllocator但没调用release()内存不会回收。解决继承SimpleChannelInboundHandler而不是ChannelInboundHandlerAdapter前者会自动释放如果必须手动处理用ReferenceCountUtil.release(msg)。4.3 跨域握手失败浏览器报 403现象前端new WebSocket()直接报错控制台显示握手被拒。原因Netty 的WebSocketServerProtocolHandler默认不校验 Origin但如果前面挂了 Nginx 或 Spring Security跨域头没放行。解决在WebSocketServerProtocolConfig中设置checkStartsWith(true)并在 Nginx 配置里加proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection upgrade。4.4 群聊消息风暴一个人发消息全群卡顿现象500 人群里发一条消息服务端 CPU 瞬间打满。原因遍历群成员逐个writeAndFlush每个写操作都触发一次网络 IO。解决用ChannelGroup批量写或者把群消息先投递到本地队列由单独的线程批量刷出。更彻底的做法是引入消息队列做削峰但单机场景下ChannelGroup够用。4.5 重连后消息重复客户端收到两条一样的现象网络抖动后客户端重连拉取离线消息时发现有的消息收了两遍。原因离线消息落库和推送之间没有幂等控制重连时既走了推送又走了拉取。解决给每条消息生成唯一msgId客户端本地去重服务端在推送离线消息后标记已读拉取时过滤已读 ID。5. 进阶技巧把单机压到极限的几个参数5.1 EventLoop 线程数不是越多越好默认NioEventLoopGroup()的线程数是CPU 核数 * 2。我实测过在 8 核机器上线程数从 16 调到 32QPS 反而下降 15%。原因是线程切换开销增加而 Netty 的 EventLoop 本身是单线程处理多个 Channel线程太多反而导致缓存失效。IO 密集型可以设核数 * 2计算密集型比如消息加解密设核数即可。5.2 写缓冲区水位线防止 OOM 的后悔药Netty 的ChannelOption.WRITE_BUFFER_WATER_MARK控制写缓冲区高低水位。当待写数据超过高水位Channel.isWritable()返回 false此时继续写会堆积在内存里。// 低水位 32KB高水位 64KB bootstrap.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(32 * 1024, 64 * 1024));在业务代码里判断ctx.channel().isWritable()如果为 false说明下游消费慢应该暂停读取或丢弃非关键消息。这个参数在推送大量离线消息时特别有用能避免一次性把内存打爆。5.3 用 JMeter 做一次真实压测别凭感觉调参用数据说话。JMeter 装 WebSocket 插件后建一个 5000 线程的测试计划每个线程循环发 100 条消息。观察三个指标连接建立成功率、消息端到端延迟 P99、服务端 GC 频率。我一般会跑三轮默认参数、调大 EventLoop、调大写水位对比数据再决定线上配置。5.4 一个容易被忽略的细节TCP_NODELAYNetty 默认开启TCP_NODELAY禁用 Nagle 算法。聊天消息都是小包如果关了TCP_NODELAY系统会等 200ms 攒够数据再发延迟直接爆炸。检查一下你的childOption里有没有误关这个选项bootstrap.childOption(ChannelOption.TCP_NODELAY, true);这个参数我踩过坑——某次为了「优化吞吐」把它关了结果消息延迟从 50ms 涨到 250ms查了一整天才定位到。血泪经验聊天场景永远开着TCP_NODELAY。最后说个习惯每次改完 Netty 参数别只看 QPS一定要看 P99 延迟和 GC 日志。吞吐上去了但延迟抖动变大用户体验反而更差。这套 EasyChat-Netty 的架构不复杂难的是把每个参数调到和你的业务场景匹配。希望帮到你。本文还有配套的精品资源点击获取