之前做海外社交,日活数据还是挺好的;我们为了丰富、强健产品,做了各种实验,其中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. 安全性建议(生产环境)
- 对
userId、to等字段做合法性校验(防 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。
五、扩展方向------下一篇
-
集群部署:
- 多台 Netty 服务器 → 通过 Redis Pub/Sub 广播跨节点消息;
- 用户路由表存入 Redis(
userId -> serverId)。
-
消息持久化:
- 离线消息存入 MySQL/MongoDB;
- 上线后拉取未读消息。
-
安全加固:
- JWT 鉴权(登录时验证 token);
- WSS(WebSocket over TLS)。