Netty实战——即时通讯IM

之前做海外社交,日活数据还是挺好的;我们为了丰富、强健产品,做了各种实验,其中Netty是特别好的工具,很好解决了我们通讯的问题!当然Netty不只是用到社交上,同样的需求都是可以适应滴,做人嘛(特别是程序员)最重要的要灵活,脑子要活络,懂伐😂

系统目标与核心需求

需求 说明
✅ 百万级长连接 单机支持 50W+ 用户在线
✅ 低延迟消息投递 端到端 < 100ms
✅ 心跳保活 & 断线重连 自动清理死连接
✅ 私聊 + 群聊 消息精准路由
✅ 水平扩展 支持多节点部署(后续可加 Redis 广播)

二、技术选型

  • 网络层:Netty 4.1.x(NIO + EventLoop)重中之中,皇冠加冕
  • 协议: WebSocket(Web 兼容),这里的思想博大精深,不得不佩服大神的智慧
  • 序列化:Protobuf(紧凑、跨语言)这个应用很广泛,开始觉得费劲,但熟能生巧
  • 连接映射ConcurrentHashMap<UserId, Channel> Map这里真的很重要,数据结构设计真是的妙不可言呀,太绝啦
  • 心跳IdleStateHandler,没有人能一直摸鱼,除非这个人是我 呵呵呵呵
  • 部署:独立 Netty 服务(不依赖 Tomcat)

本文以 WebSocket + JSON 为例(便于 Web 前端对接),但核心思想适用于任何协议。

三、完整代码实现(Spring Boot + Netty)

1. Maven 依赖

复制代码
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.100.Final</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>

2. 消息协议设计(JSON 格式)

复制代码
{
  "type": "CHAT",        // 类型: CHAT, HEARTBEAT, LOGIN
  "from": "user123",
  "to": "user456",       // 私聊目标;群聊时为 groupId
  "content": "Hello!***......**",//加密对话,请付费观看
  "timestamp": 1700000000
}

3. 核心组件:用户-连接映射表(全局单例)

复制代码
@Component //单例
public class UserChannelMap {
    // 线程安全:用户ID -> Channel,把用户和channel绑定,从此一世一双人
    public static final ConcurrentHashMap<String, Channel> USER_CHANNEL_MAP = new ConcurrentHashMap<>();
    
    public static void bindUser(String userId, Channel channel) {
        //从此你就是我的channel啦
        USER_CHANNEL_MAP.put(userId, channel);
        // 绑定属性,方便后续获取,channel还带了嫁妆呢
        channel.attr(AttributeKey.valueOf("userId")).set(userId);
    }

    public static void unbindUser(Channel channel) {
        //既然你动了想离开我的念头
        String userId = channel.attr(AttributeKey.valueOf("userId")).get();
        if (userId != null) {
            //那我就送你离开,千里之外,你无声黑白
            USER_CHANNEL_MAP.remove(userId);
        }
    }

    public static Channel getChannel(String userId) {
        //给你 我的专属channel ,拿走拿走别客气
        return USER_CHANNEL_MAP.get(userId);
    }
}

4. Netty 服务端启动类

基于 Netty 实现的 WebSocket 即时通讯(IM)服务器。 组件是8081 端口(别问为什么:约定大于配置,你也可以自己指定,都是心腹大患,端口这种事情咱也不藏着): 提供 WebSocket 通信能力,支持消息处理、心跳检测等功能。

复制代码
@Component
    public class ImServer {
        /**
         * Boss 线程组:老朋友了,咱们上篇也说到过!
         * 用于接收客户端的连接请求(accept 操作),通常只需 1 个线程,因为连接建立是轻量级操作。
         */
        private EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        /**
         * Worker 线程组:老演员了 尽心尽力 不摸鱼 简直是当代诸葛亮
         * 用于处理已建立连接的 I/O 读写操作(read/write)。
         * 默认使用 CPU 核心数 × 2 的线程数,适合高并发网络处理。
         */
        private EventLoopGroup workerGroup = new NioEventLoopGroup();

        /**
         * Spring 容器完成依赖注入后自动调用此方法(因为走了后门,嘿嘿)启动 IM 服务器
         * 使用 @PostConstruct 注解确保在 Bean 初始化完成后执行。spring加载那一套你会了吗?
         *
         * @throws InterruptedException 异常就是咱们的软肋:当等待服务器绑定或关闭时被中断
         */
        @PostConstruct
        public void start() {
            //独立线程,不卡主线程
            new Thread (() -> {
                try {
                    // 创建服务器启动辅助类
                    ServerBootstrap bootstrap = new ServerBootstrap();
                    // 配置 Netty 服务器参数
                    bootstrap.group(bossGroup, workerGroup)//朕正式任命boss和worker线程组
                            .channel(NioServerSocketChannel.class)//指定服务端Channel类型(NIO)
                            .childHandler(new ChannelInitializer<SocketChannel>() {
                                /**
                                 * 当有新客户端连接时,会调用此方法初始化通道的处理管道(Pipeline)。
                                 * Pipeline 是 Netty 处理入站/出站数据的核心机制,按顺序执行 Handler。
                                 */
                                @Override
                                protected void initChannel(SocketChannel ch) {
                                    ChannelPipeline pipeline = ch.pipeline();
                                    // WebSocket 握手支持:和皇家打交道 首先卫生第一位 洗头换面少不了
                                    // 1. HTTP 编解码器:将字节流解析为 HttpRequest/HttpResponse 对象
                                    pipeline.addLast(new HttpServerCodec());
                                    // 2.如果一口吃不成胖子,那就多吃几口: HTTP 消息聚合器:将多个 HTTP 分段聚合成完整消息(如大 WebSocket 握手请求)
                                    //最大内容长度设为 65536 字节(64KB)
                                    pipeline.addLast(new HttpObjectAggregator(65536));
                                    // 3. 设立亲信:WebSocket 协议处理器:自动处理握手,并将后续帧转换为 WebSocketFrame
                                    // 只接受路径为 "/im" 的 WebSocket 连接
                                    pipeline.addLast(new WebSocketServerProtocolHandler("/im"));
                                    // 心跳检测:没有人能在我的眼皮底下一直摸鱼,没有人!:60秒无读事件则触发IdleStateEvent(userEventTriggered(ctx, evt)可在业务ImMessageHandler中重写自定义处理断连)
                                    // 参数含义:readerIdleTime=60秒(读空闲),writerIdleTime=0(不检测写空闲),allIdleTime=0
                                    pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
                                    // 自定义业务处理器,开开心心搞点小事情:处理 WebSocket 文本/二进制消息、事件等
                                    pipeline.addLast(new ImMessageHandler());
                                }
                            })
                            //TCP参数:求人办事要懂事,排好队很重要:连接队列大小(系统 backlog),默认1024,可适当调大应对高并发
                            .option(ChannelOption.SO_BACKLOG, 1024)
                            // TCP参数(没有人能一直活着):启用 TCP Keep-Alive,定期探测连接是否存活
                            .childOption(ChannelOption.SO_KEEPALIVE, true);
                    //看到没:看不顺眼就自己看,这都自己人:绑定端口8081并同步等待启动完成
                    ChannelFuture future = bootstrap.bind(8081).sync();
                    System.out.println("IM 服务器启动在 ws://localhost:8081/im");
                    // 等待服务器 channel 关闭(阻塞当前线程,防止 Spring 容器立即退出)
                    future.channel().closeFuture().sync();
                }catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }, "im-server-thread").start();
        }

        /**
         * Spring 容器销毁 Bean 前调用,优雅关闭 Netty 资源。
         * 使用 @PreDestroy 注解确保在应用关闭时释放线程组。
         */
        @PreDestroy
        public void shutdown() {
            //做人最重要的是:优雅!优雅关闭 boss 线程组(先停止接收新连接)
            bossGroup.shutdownGracefully();
            //其他的小兵各自散去吧:优雅关闭 worker 线程组(处理完剩余任务后再退出)
            workerGroup.shutdownGracefully();
        }
    }
  • 为什么用 @Component

    让 Spring 管理这个类的生命周期,从而能使用 @PostConstruct / @PreDestroy

5. 核心业务处理器:ImMessageHandler

复制代码
/**
 * 贫困中农翻身把歌唱:自定义 WebSocket 消息处理器,继承自 SimpleChannelInboundHandler<WebSocketFrame>。
 * 专门用于处理客户端通过 WebSocket 发送的各类消息帧(如文本、二进制等)
 */
public class ImMessageHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
    /**
     * 看看考虑多到位:Jackson 的 ObjectMapper 实例,用于 JSON 字符串与 Java 对象之间的解析与序列化。
     * 注意:ObjectMapper 是线程安全的,可作为单例复用。
     */
    private ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 当 Channel 中触发用户自定义事件时调用(例如 IdleStateHandler 触发的空闲事件)。
     * 此处主要用于处理心跳超时断连逻辑。
     *
     * @param ctx Channel 上下文,用于操作当前连接
     * @param evt 触发的事件对象
     * @throws Exception 处理过程中可能抛出的异常
     */
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        //是不是对家故意挑衅:判断是否为 IdleStateHandler 触发的空闲事件
        if (evt instanceof IdleStateEvent) {
            //直接鸡bi它:心跳超时,关闭连接
            //忍不了一点:客户端在指定时间内(如60秒)未发送任何数据,视为心跳超时
            // 主动关闭连接以释放资源,防止僵尸连接占用内存
            ctx.close();
        } else {
            //尊重创造者的主导权:非空闲事件,交由父类默认处理(保持扩展性)
            super.userEventTriggered(ctx, evt);
        }
    }

    /**
     * 找到那个后门的看门人啦:当客户端与服务器建立 TCP 连接并完成 WebSocket 握手后触发。
     * 此时通道处于活跃状态(active),但尚未进行业务登录。
     *
     * @param ctx Channel 上下文
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 打印新连接的远程地址(IP + 端口),便于调试和监控
        System.out.println("新连接: " + ctx.channel().remoteAddress());
    }

    /**
     * 师门不幸,清理门户,关门谢客:当客户端断开连接(主动关闭或网络异常)时触发。
     * 此时应清理该连接相关的业务状态,避免内存泄漏。
     *
     * @param ctx Channel 上下文
     */
    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        // 连接断开,清理用户映射,还记得咱们之前写的承诺吧?什么、你忘了了,不要你也罢!
        // 从全局用户-通道映射表中移除该连接对应的用户绑定关系
        // UserChannelMap 是一个静态工具类,维护 userId <-> Channel 的映射
        UserChannelMap.unbindUser(ctx.channel());
        System.out.println("连接断开: " + ctx.channel().remoteAddress());
    }

    /**
     * 咱们懂事又温柔可爱的Netty无敌在哪里呢?
     * 在很多地方:核心方法:处理接收到的 WebSocket 消息帧。
     * 由于父类是 SimpleChannelInboundHandler<WebSocketFrame>,
     * Netty 会自动将入站的 WebSocketFrame 类型消息传递给此方法。
     *
     * @param ctx   Channel 上下文
     * @param frame 接收到的 WebSocket 帧(可能是 TextWebSocketFrame、BinaryWebSocketFrame 等)
     */
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) {
        //自然要仅着自己人:只处理文本类型的消息帧(忽略 Ping/Pong、Close、二进制帧等)
        if (frame instanceof TextWebSocketFrame) {
            //原始股说了什么:获取原始文本内容
            String text = ((TextWebSocketFrame) frame).text();
            try {
                // 使用 Jackson 解析 JSON 字符串为 JsonNode 树形结构
                JsonNode msg = objectMapper.readTree(text);
                // 获取消息类型字段(约定:所有消息必须包含 "type" 字段)你听话加type我才承认你是自己人哒
                String type = msg.get("type").asText();
                //自己人也有个三六九等:要不然我得累死;根据消息类型分发处理逻辑
                switch (type) {
                    // 登录消息:绑定用户 ID 与当前 Channel
                    case "LOGIN":
                        String userId = msg.get("userId").asText();
                        //盖了章从此你就是我的人啦:将 userId 与当前 ctx.channel() 绑定到全局映射表
                        UserChannelMap.bindUser(userId, ctx.channel());
                        sendSuccess(ctx, "登录成功");
                        break;

                    case "CHAT":
                        //闺中蜜友:聊天消息:转发给目标用户
                        String to = msg.get("to").asText();//目标用户ID
                        //给你我的专属channel:从映射表中查找目标用户的 Channel
                        Channel target = UserChannelMap.getChannel(to);
                        if (target != null && target.isActive()) {
                            //如果咱们心有灵犀都在线的话,直接转发原始消息(含 from/to/content)
                            target.writeAndFlush(new TextWebSocketFrame(text));
                        } else {
                            //什么你不在线,看来是找别的龟了,算了算了,打入冷宫:返回错误提示
                            sendError(ctx, "用户不在线");
                        }
                        break;

                    case "HEARTBEAT":
                        // 我知道我的权利很迷人:心跳消息:客户端定期发送以维持连接
                        //好吧好吧,勉为其难 看你可怜见滴,回复你一下吧:可选择回复 PONG 表示服务端存活(部分前端框架需要)
                        // 回应心跳(可选)
                        ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"PONG\"}"));
                        break;
                    default:
                        //这是什么乱七八糟的东西:未知消息类型,可选择忽略或返回错误(此处未处理,可增强)
                        sendError(ctx, "不支持的消息类型: " + type);
                        break;
                }
            } catch (Exception e) {
                //皇宫大院终极是混进来了脏东西,JSON 解析失败或字段缺失等异常,统一返回错误
                sendError(ctx, "消息解析失败");
                // 建议记录日志以便排查问题(生产环境应添加)
                // e.printStackTrace();
            }
        }
        // 注意:非 TextWebSocketFrame(如 CloseWebSocketFrame)会被 Netty 自动处理,
        // 通常不需要在此手动处理,除非有特殊需求(如记录关闭原因)
    }

    /**
     * 向客户端发送系统成功消息。
     *
     * @param ctx Channel 上下文
     * @param msg 成功提示内容
     */
    private void sendSuccess(ChannelHandlerContext ctx, String msg) {
        // 构造标准格式的系统消息 JSON 字符串,并封装为 TextWebSocketFrame 发送
        ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"SYSTEM\",\"content\":\"" + msg + "\"}"));
    }

    /**
     * 我生气了,后果很严重:去西厂领罚吧,向客户端发送错误消息。
     *
     * @param ctx Channel 上下文
     * @param msg 错误提示内容
     */
    private void sendError(ChannelHandlerContext ctx, String msg) {
        // 构造标准格式的错误消息 JSON 字符串,并封装为 TextWebSocketFrame 发送
        ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":\"ERROR\",\"content\":\"" + msg + "\"}"));
    }
}

⚠️ 注意 :实际项目中需考虑用户多端登录连接唯一性 等问题,可能需使用更复杂的结构(如 Map<userId, Set<Channel>>

2. 安全性建议(生产环境)
  • userIdto 等字段做合法性校验(防 XSS、SQL 注入等)
  • 消息内容应做长度限制,防止 OOM
  • 敏感操作(如登录)应增加Token 验证,而非仅靠前端传 userId
3. JSON 构造方式优化

当前使用字符串拼接构造 JSON,存在风险(如 msg 含引号会破坏结构)。

推荐改用 Jackson 构造

复制代码
ObjectNode node = objectMapper.createObjectNode();
node.put("type", "SYSTEM");
node.put("content", msg);
String json = node.toString();

6. 前端测试(HTML + JS)

复制代码
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script>
const socket = new WebSocket("ws://localhost:8081/im");

socket.onopen = () => {
  // 登录
  socket.send(JSON.stringify({type: "LOGIN", userId: "user123"}));
};

// 发送私聊消息
function sendMsg() {
  socket.send(JSON.stringify({
    type: "CHAT",
    from: "user123",
    to: "user456",
    content: "你好!"
  }));
}

socket.onmessage = (event) => {
  console.log("收到:", event.data);
};
</script>

四、性能优化关键点(支撑百万连接)

优化项 说明
内存池 ByteBufAllocator.DEFAULT = PooledByteBufAllocator.DEFAULT
TCP 参数 SO_SNDBUF/SO_RCVBUF 调整为 1MB,TCP_NODELAY=true
线程数 Worker 线程数 = CPU 核数 × 2(避免过多上下文切换)
GC 调优 使用 G1 GC,新生代调大,减少 Full GC
连接复用 客户端使用长连接,避免频繁握手

💡 实测:在 16C32G 机器上,Netty 可轻松维持 80W+ 空闲连接,内存占用 < 10GB。

五、扩展方向------下一篇

  1. 集群部署

    • 多台 Netty 服务器 → 通过 Redis Pub/Sub 广播跨节点消息;
    • 用户路由表存入 Redis(userId -> serverId)。
  2. 消息持久化

    • 离线消息存入 MySQL/MongoDB;
    • 上线后拉取未读消息。
  3. 安全加固

    • JWT 鉴权(登录时验证 token);
    • WSS(WebSocket over TLS)。
相关推荐
ps酷教程1 天前
HttpPostRequestEncoder源码浅析
http·netty
ps酷教程11 天前
netty模拟文件列表http服务器
http·netty
ps酷教程15 天前
HttpPostRequestEncoder使用示例
http·netty
猫吻鱼22 天前
【系列文章合集】【全部系列文章合集】
spring boot·dubbo·netty·langchain4j
ps酷教程22 天前
HttpPostRequestDecoder源码浅析
java·http·netty
J_liaty22 天前
Netty高频面试题及答案整理
java·面试·netty
daidaidaiyu1 个月前
一文学习和实践 当下互联网安全的基石 - TLS 和 SSL
java·netty
enjoy编程1 个月前
Spring boot 4 探究netty的关键知识点
spring boot·设计模式·reactor·netty·多线程
ps酷教程1 个月前
HttpData
http·netty