1. 整体思路:为什么选 Netty 做消息推送
先交代一个背景。很多团队一开始做消息推送,第一反应是上 WebSocket,浏览器里new WebSocket('ws://xxx')一把梭,后端用 Spring 的WebSocketHandler或者 Socket.io 接一下,感觉挺简单。但一旦消息量上来、客户端数量上千、需要做分组推送、离线补推、心跳保活、多节点扩展时,这些“快速方案”就开始露馅了——连接管理混乱、线程阻塞、内存暴涨、广播风暴,各种问题轮着来。
我选 Netty 的核心原因就三个字:可控性。
Netty 是一个基于 NIO 的网络框架,把 Java 原生 NIO 那套复杂又容易写错的 Selector、ByteBuffer、Channel 封装好了,让你只需要关心业务逻辑。它对高并发连接的支持、内存池化管理、零拷贝、灵活的 Pipeline 机制,非常适合做长连接推送场景。说白了,如果只是 Demo 级别,用啥都行;但如果你要做一个能扛住真实业务压力的推送服务,Netty 在可控性上比那些 “开箱即用” 的封装强太多。
这篇文章不会给你贴一大坨跑不通的代码,而是把从零搭建一个 Netty 消息推送服务的完整思路、关键代码、踩坑记录都讲清楚。适合这几类人看:
- 后端开发,想给现有的 Web 或 App 加一个实时推送能力
- 对 Netty 只停留在“看过教程”阶段,想真正动手写一个能跑的服务
- 已经写了个简单 Demo,但遇到连接泄露、消息乱序、性能上不去的问题
我尽量把每一步为什么这么做讲明白,而不是单纯给代码。毕竟网上代码一抓一大把,能跑的和能用的之间,隔着的就是这些“为什么”。
2. 核心方案设计:先定协议,再写代码
2.1 明确需求:你的推送场景到底要什么
动手写代码之前,先花十分钟想清楚你的推送场景。这决定了你的协议设计、消息格式、ACK 机制,差一点后面返工量就差很多。
常见的推送场景有这么几类:
- 服务端主动通知:比如用户下单后给商家推送新订单提醒,客户端不需要回包,推送完就行
- 请求响应型:客户端发一条消息,服务端处理后返回结果,类似 RPC
- 双向实时交互:比如聊天室、协同编辑,两边都要实时收发
- 指令下发:比如 IoT 场景下给设备下发控制指令,设备必须确认收到并执行
我这次做的项目是服务端主动通知 + 客户端确认的模式。也就是客户端连上服务端后,大部分时间是“挂”在那里等消息;服务端有消息要下发时,主动推送过去;客户端收到后回一个 ACK,服务端据此知道这条消息已经送达。这个模式覆盖了很多业务场景,而且复杂度适中,适合作为第一个 Netty 实战项目。
这里有个容易被新手忽略的点:消息推送最怕的不是“推不出去”,而是“不知道有没有推出去”。TCP 能保证字节流到达对端内核缓冲区,但保证不了业务层面对方真的处理了。比如客户端断网了但 TCP 还没感知到,服务端以为推送成功了,实际上用户啥也没收到。所以业务层的 ACK 机制不能省,这也是我在设计时坚持加客户端确认的原因。
2.2 自定义协议:别用字符串裸奔
很多初学 Netty 的人写 Demo 时喜欢直接channel.writeAndFlush("hello"),发字符串,收到也是字符串,感觉挺爽。但这个路子只能活在 Demo 里。真实场景下你至少要考虑:
- 消息怎么区分类型?是心跳、业务消息、还是 ACK?
- 一条消息多长?粘包半包怎么处理?
- 消息体是 JSON 还是二进制?长度字段放哪?
- 客户端怎么知道这条消息是发给自己的?
所以必须自定义一个协议。我这里的协议设计如下:
- 魔数(4 字节):用于快速校验是不是合法连接
- 版本号(1 字节):协议升级时方便兼容
- 消息类型(1 字节):区分心跳、业务消息、ACK 等
- 消息 ID(8 字节):全局唯一,用于追踪和 ACK 关联
- 数据长度(4 字节):表示消息体的字节数
- 消息体(变长):JSON 格式的字节数组
用大白话解释一下为什么这么设计。魔数就像门卫,先看一眼是不是自己人,不是就直接拒之门外;版本号是给协议升级留的后路,不然以后想加字段,老客户端就崩了;消息 ID 特别重要,它是 ACK 机制的核心标识——你推送了消息 ID 为 10086 的内容,客户端回 ACK 时带上 10086,你才知道这条确实送达了。
消息体我选择用 JSON,图的是可读性好、调试方便。如果要极致的性能或者传输大量二进制数据,可以考虑 Protobuf,那是后话,这里不展开。
2.3 技术选型:长连接还是短连接
推送场景,连接模型上强烈建议用长连接。原因很简单:推送是服务端随时可能发起的,如果每次推送都重新建连,TCP 握手 + 业务建连的开销比推送本身还大,而且客户端没法实时收到消息。长连接建立后一直保持,配合心跳机制维持活性,服务端随时可以往对应的 Channel 上写数据。
这个选择还带来一个后续影响:服务端必须管理好这些长连接。断开的要清理,不活跃的要踢掉,每个用户对应哪个 Channel 要维护好映射关系。这部分我放在后面的具体实现里讲。
3. 核心实现细节:从 Channel 到 Pipeline
3.1 Pipeline 机制:把处理流程拆成流水线
Netty 最核心也是最优雅的设计就是 Pipeline 机制。你可以把 Channel 理解为一条管道,消息从一头进来,经过一系列处理器,最终被业务逻辑消费;响应消息再走另一边出去。每个处理器只负责一件事,互不干扰,这就是职责链模式。
我的服务端 Pipeline 大致长这样:
- IdleStateHandler:处理空闲连接,超时没读写就触发事件
- NettyDecoder:拆包解码,把字节流变成自定义协议对象
- NettyEncoder:编码,把响应对象变成字节流发出去
- HeartbeatHandler:处理心跳消息,维持连接活性
- MessageHandler:核心业务处理器,处理真正的业务消息
每个 Handler 都是独立的类,你可以在任意环节插入新逻辑,比如加一个验签 Handler、加一个限流 Handler。这种可插拔的设计,在后期维护时幸福感极高。
一个常见的误区是:把业务逻辑全堆在一个 Handler 里,那 Pipeline 的优势就完全没了。我给你一个判断标准:如果某个 Handler 里出现了“如果不是 xxx 就怎么怎么样”的分支,大概率这个职责应该拆出去。
3.2 拆包粘包:新手必踩的坎
TCP 是流式协议,没有消息边界。你发了两条消息,对端可能一次收到,也可能收到半个。这就是粘包和半包。不处理这个问题,解析出来的数据全是乱码。
处理方法就是固定长度、分隔符、或者长度字段前置。我用的是带长度字段的方案,对应 Netty 里现成的LengthFieldBasedFrameDecoder。
需要解释一下这个类的参数含义,新手经常在这里卡住:
- maxFrameLength:最大帧长度,超过就报错,防止内存溢出攻击
- lengthFieldOffset:长度字段偏移量,也就是从第几个字节开始是长度字段
- lengthFieldLength:长度字段占几个字节
- lengthAdjustment:长度字段的值加多少等于实际消息体长度
- initialBytesToStrip:解码后跳过的字节数
我这套协议里,魔数 4 字节 + 版本 1 字节 + 类型 1 字节 + 消息 ID 8 字节,加起来是 14 字节,所以长度字段偏移量是 14,长度字段占 4 字节。这个参数组合是项目里最容易调错的地方,我一开始也栽在这,后面在问题排查部分细说。
3.3 用户与 Channel 的映射关系:推送的“路由表”
做一个推送服务,你必须能回答一个问题:要给用户 A 推送消息,该往哪个 Channel 写?
所以服务端需要一个类似路由表的东西。我这里是维护了一个 ConcurrentHashMap,key 是用户 ID,value 是 Channel。客户端连接上来时,先做鉴权,鉴权通过后把自己的用户 ID 和 Channel 注册进来;连接断开时,从 Map 里移除。
这里有几个细节要处理:
- Channel 的唯一性:一个用户可能有多个设备,比如手机和电脑同时在线。所以更准确的做法是 key 用
userId:deviceType这种复合键,或者用一个 Connection 对象包一层。 - 重复连接的覆盖:同一个 key 新连接上来,旧连接要不要踢掉?我当时的策略是直接关闭旧连接,保证一个用户一个设备只有一个活跃连接,避免消息重复推送。
- 并发安全:Netty 的 Channel 写操作是线程安全的,但 Map 的操作不是。ConcurrentHashMap 能解决大部分问题,但涉及“先检查后操作”这种复合逻辑,还是要加锁或者用原子操作。
我还额外加了一个 ChannelGroup,用来支持广播。比如系统公告要推给所有在线用户,直接channelGroup.writeAndFlush(msg)就完事了,比自己遍历 Map 省心。ChannelGroup 是 Netty 自带的能力,底层会处理并发遍历和生命周期问题,比手动维护安全得多。
3.4 心跳机制:让连接“活着”
TCP 长连接最大的问题就是假死。客户端网络切换、WiFi 断开又连上、服务端防火墙静默丢弃连接,这些情况下 TCP 连接本身可能还没断,但实际已经不可用了。
Netty 提供了IdleStateHandler来解决这个问题。我在服务端设置了读空闲超时:如果 60 秒没读到客户端任何数据,就认为这个连接可能已经死了,触发userEventTriggered方法。在这里我会先发一个心跳响应试探一下,如果再超时就直接关闭连接。
客户端的逻辑对应是:每 30 秒发送一个心跳包,服务端收到后重置空闲计时器。这样就形成了一个心跳交互闭环。
这里有个经验值供参考:心跳间隔是服务端读超时的一半比较合理。比如服务端 60 秒没数据就判定空闲,客户端就 30 秒发一次心跳。太快浪费带宽,太慢服务端不能及时感知死连接。实际项目中要结合网络环境和移动端的省电策略调整。
3.5 断线重连:生产环境必须考虑的事
不是客户端连上就完事了。网络抖动、服务端重启、空闲超时踢掉,都会导致连接断开。客户端必须能自动重连。
重连不是简单的while(true) { connect(); },那叫连接风暴。我这次项目的做法是指数退避重连:第一次重连等 1 秒,第二次等 2 秒,第三次 4 秒,最多等 60 秒就不再往上加了。每次重连失败后重置尝试次数。
指数退避的灵感来自 TCP 的重传机制,背后的道理是:如果服务端正好在重启,你每秒重连一次,服务端一恢复就要同时抗住所有客户端的连接请求,很容易又被打挂。拉开间隔,给服务端喘息的时间,反而更稳定。
还有一个细节:重连前要把旧的 Channel 关闭,把相关资源清理干净。否则旧的连接对象还挂在路由表里,消息推送到一个半死不活的 Channel 上,白白浪费资源。
4. 实操落地:从工程骨架到可以跑起来的服务
4.1 工程结构和依赖
我用的构建工具是 Maven,JDK 版本 8,Netty 版本 4.1.x。之所以固定版本,是因为 Netty 4.0 和 4.1 的 API 差异不小,很多教程混着写,新手照着敲经常编译不过。
依赖只需要一个:
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.100.Final</version> </dependency>工程结构上,我分包比较清晰,每个责任一层:
com.example.push ├── server # 服务端启动类、Channel 初始化 ├── protocol # 协议编解码器 ├── handler # 各种业务 Handler ├── session # 连接管理、路由表 ├── common # 消息类型常量、工具类 └── client # 客户端 Demo4.2 服务端启动类:主流程
服务端启动代码是整个项目的入口,核心就几行,但每一行都有讲究:
EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS)); pipeline.addLast(new NettyDecoder()); pipeline.addLast(new NettyEncoder()); pipeline.addLast(new HeartbeatHandler()); pipeline.addLast(new MessageHandler()); } }); ChannelFuture future = bootstrap.bind(8080).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }解释几个关键点:
- bossGroup 和 workerGroup 的数量:bossGroup 负责接受新连接,1 个线程就够,因为它的工作很轻;workerGroup 负责处理已建立连接上的 IO 事件,默认线程数是 CPU 核数乘以 2。对于长连接推送场景,worker 线程数是瓶颈,如果连接数特别大,要适当调大。
- SO_BACKLOG:服务器端接受连接的队列长度。连接来得太快,操作系统来不及处理的会放在这个队列里。设太小会丢连接。
- TCP_NODELAY:关闭 Nagle 算法。这个算法会把小包合并成大包再发,减少网络包数量,但会增加延迟。推送场景对实时性敏感,必须关掉。
4.3 协议解码器:最核心的代码片段
编解码器是整个协议层最需要注意的组件。解码器继承ByteToMessageDecoder,或者直接用框架自带的LengthFieldBasedFrameDecoder,我选择了后者来处理粘包拆包,再配合自定义解码器把字节转成协议对象。
public class NettyDecoder extends LengthFieldBasedFrameDecoder { public NettyDecoder() { super(1024 * 1024, 14, 4, 0, 0); } @Override protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception { ByteBuf frame = (ByteBuf) super.decode(ctx, in); if (frame == null) { return null; } // 魔数校验 int magic = frame.readInt(); if (magic != MagicNumber.MAGIC) { ctx.close(); return null; } // 解析消息头 byte version = frame.readByte(); byte type = frame.readByte(); long messageId = frame.readLong(); int length = frame.readInt(); byte[] content = new byte[length]; frame.readBytes(content); // 构造消息对象 PushMessage message = new PushMessage(); message.setMagic(magic); message.setVersion(version); message.setType(type); message.setMessageId(messageId); message.setContent(content); return message; } }这里继承LengthFieldBasedFrameDecoder其实已经完成了拆包工作,decode方法拿到的ByteBuf就是一整条完整的消息了。你可以在里面做魔数校验、协议解析、反序列化。注意一点:super.decode返回 null 代表数据不够一个完整包,这种情况下直接返回 null 即可,Netty 会自动缓存剩余数据等下一批到达。
4.4 业务 Handler:消息分发与推送
核心业务逻辑放在MessageHandler里。它做的事情是按照消息类型分发处理:
public class MessageHandler extends SimpleChannelInboundHandler<PushMessage> { @Override protected void channelRead0(ChannelHandlerContext ctx, PushMessage msg) { switch (msg.getType()) { case MessageType.BUSINESS: handleBusinessMessage(ctx, msg); break; case MessageType.ACK: handleAck(msg); break; case MessageType.HEARTBEAT: // 心跳在 HeartbeatHandler 已处理 break; default: LOGGER.warn("unknown message type: {}", msg.getType()); } } }推送的具体动作很简单,就是拿到目标用户 ID,从 SessionManager 里查 Channel,然后writeAndFlush。但这里有一个我踩过坑的细节:writeAndFlush 之后一定要加监听器检查是否成功。
Channel targetChannel = SessionManager.getChannel(userId); if (targetChannel != null && targetChannel.isActive()) { ChannelFuture future = targetChannel.writeAndFlush(message); future.addListener((ChannelFutureListener) f -> { if (!f.isSuccess()) { LOGGER.error("push failed, userId={}", userId, f.cause()); // 可以考虑重试或者标记离线消息 } }); }很多初学者写完writeAndFlush就不管了,结果消息没发出去也不知道。实际上网络是脆弱的,目标 Channel 虽然存在,但对端可能已经断网,写操作会失败。这个监听器是排查“消息莫名丢失”问题的关键。
4.5 客户端 Demo:验证服务端是否真的可用
没有客户端的服务端就是空中楼阁,没法验证。我写了一个简单的控制台客户端,模拟真实客户端的行为:
- 连接服务端,连接成功后发送登录消息,注册用户 ID
- 启动一个定时任务,每 30 秒发送心跳包
- 监听控制台输入,可以手动发送业务消息
- 收到服务端推送的消息后,自动回 ACK
- 如果连接断开,触发指数退避重连
public class PushClient { public static void main(String[] args) throws Exception { EventLoopGroup group = new NioEventLoopGroup(); Bootstrap bootstrap = new Bootstrap(); bootstrap.group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new NettyDecoder()); ch.pipeline().addLast(new NettyEncoder()); ch.pipeline().addLast(new ClientHandler()); } }); connectWithRetry(bootstrap, "127.0.0.1", 8080); } }客户端和服务端复用同一套协议编解码器,这是自定义协议的好处之一——只要规范定好了,两端可以各自独立实现,保证兼容性就行。实际上这个客户端 Demo 在联调阶段帮我节省了大量时间,因为可以直接模拟各种异常场景。
4.6 启动验证:从日志看链路是否通了
一个可靠的服务必须有完整的日志体系。我习惯在每个关键节点打印日志:
- 客户端连接建立时打一条,记录远端地址
- 收到消息时打一条,记录消息 ID 和类型
- 心跳超时准备踢掉连接时打一条,记录原因
- 推送失败时打一条,记录用户 ID 和异常信息
第一次把服务端和客户端跑起来,看到日志里出现连接建立、心跳收发、消息推送成功的记录时,这个架子基本就算通了。接下来才是真正的考验:各种异常场景的测试。
5. 常见问题与排查技巧实录
5.1 问题一:消息粘包导致解析失败
现象:服务端收到的消息内容混乱,有时两条消息拼在一起,有时消息残缺不全。
原因:TCP 的流式传输没有消息边界。发送方连续 write 两条消息,接收方可能一次 read 就拿到了全部数据,也可能 read 到半条。
解决:用LengthFieldBasedFrameDecoder按长度拆包。关键参数是lengthFieldOffset和lengthFieldLength,要严格对应你自己的协议定义。我一开始把偏移量算错了,导致解码出来的长度字段完全不对,数据全是乱码。调试时可以先写一个固定内容的测试消息,检查拆包后的字节是不是和预期一致。
5.2 问题二:连接建立后一段时间就被莫名断开
现象:客户端连接上服务端,过了一阵子,什么操作都没做,连接就被服务端关闭了。
原因:多半是心跳超时被踢了。IdleStateHandler如果设置了读空闲超时,而客户端又没有定时发心跳,服务端就会认为连接无效。
解决:确认客户端是否按预期发送心跳;确认心跳间隔是否大于服务端读空闲超时的一半。还有一个容易忽略的点:IdleStateHandler的触发事件是在userEventTriggered里接收的,如果你只在channelRead0里处理消息而忘了处理空闲事件,那超时踢人逻辑就不会生效,连接会一直“假活”着。
5.3 问题三:推送消息丢失,但 TCP 层面没报错
现象:服务端调用writeAndFlush,日志显示成功,但客户端就是没收到。
原因:这是最坑爹的场景之一。TCP 写成功只能说明数据进了内核缓冲区,不能说明对端应用层处理了。对端可能已经半开(网络断开但无感知),内核还在接收数据。
解决:加业务层 ACK。客户端收到消息后必须回确认,服务端通过一个定时任务检查:推送超过一定时间没收到 ACK 的消息,进行重推或者标记为离线消息。这个机制才是推送服务可靠性的根本保障。不要迷信 TCP 的可靠性,它在连接存在的时候是可靠的,但连接“假死”时它什么都保证不了。
5.4 问题四:内存不断增长,最终 OOM
现象:服务跑了一段时间后,内存占用持续升高,GC 越来越频繁,最终 OOM。
原因:常见的有两个。一个是路由表里堆积了断开的 Channel 没有清理;另一个是解码器没有限制最大帧长度,恶意或者病态客户端可以发送超大消息,把你的内存吃光。
解决:第一,连接关闭时一定要从 SessionManager 里移除对应映射关系,这个不能依赖 GC,必须显式清理。第二,LengthFieldBasedFrameDecoder的maxFrameLength设置一个合理上限,比如 1MB,超过直接拒绝并关闭连接。我见过的生产事故里,内存泄漏有相当一部分就是“忘了清理 Map”造成的。
5.5 问题五:收到消息后回包,但客户端收不到响应
现象:服务端根据请求类型做了处理,也writeAndFlush发了响应,但客户端就是收不到。
原因:忘了走回包通道。如果你在业务 Handler 里手动通过ctx.channel().writeAndFlush(...),注意ctx.writeAndFlush是从当前 Handler 开始的,而ctx.channel().writeAndFlush是从 Pipeline 末尾往前找。这两个 API 的差异在复杂的 Pipeline 里会带来完全不同的结果。我之前在某个 Handler 里用ctx.writeAndFlush写数据,结果跳过了后面加的一个编码器,导致发出去的是对象而不是字节,客户端那边直接解码失败。
这种问题排查技巧是:在编解码器里加日志,打印出经过的每一帧数据的前几个字节;或者在channelRead0里打印完整消息内容。看到哪些环节的数据“断了”,就能定位到具体是哪个 Handler 出了问题。
5.6 实战速查表
| 现象 | 可能原因 | 检查方向 |
|---|---|---|
| 粘包解析出错 | 长度参数配置不对 | 用固定消息测试拆包结果 |
| 连接无故断开 | 心跳超时被踢 | 确认心跳间隔和超时时间 |
| 消息丢失但 TCP 正常 | 连接半开或业务 ACK 缺失 | 检查 ACK 机制和离线补推 |
| 内存持续增长 | 路由表未清理 | 显式移除断开的 Channel |
| 响应收不到 | ctx 和 channel 写 API 混用 | 理清 Pipeline 出站方向 |
| 重连风暴 | 服务端重启期间客户端疯狂重连 | 使用指数退避重连 |
6. 扩展思考:从能用迈向生产可用
我做完这个项目后在复盘时发现,如果把它真正丢到生产环境,还有好几个问题要解决。
集群化。单机 Netty 服务能撑的连接数有限,真要抗住百万长连接,必然要多节点部署。这时候连接就分散在多台机器上,用户 A 连在节点 1,用户 B 连在节点 2,推送消息时要先知道目标用户在哪个节点上。一般会引入一层路由服务或者用分布式缓存来记录用户与节点的映射关系。
鉴权与安全。长连接一挂就是几小时甚至几天,建立连接时的鉴权能防住“非授权接入”,但防不住中间人窃听。如果消息内容敏感,要考虑 TLS 加密。更严格的做法是连接建立后服务端下发一个动态 token,后面的消息都带这个 token,防止连接劫持。
离线消息。推送服务的核心场景之一就是“用户不在线”。消息发不出去不能直接丢了,要存起来,等用户上线后再补推。这需要引入消息存储,并且要设计好补推的时机和顺序,不然用户上线时收到几十条过期消息,体验很糟。
监控告警。连接数、每分钟推送量、推送成功率、ACK 超时率、平均推送延迟,这些指标必须可视化。我在项目里做了简单的计数器,生产环境建议接上完整的监控体系,推送失败率一旦异常升高,第一时间报警,不然用户骂上门了你才知道消息根本没发出去。
拓展的方向很多,但核心思路是一样的:先把单体链路跑通,再把工程化的问题一个个补齐。这个 Netty 推送服务作为起点,最大的价值是让你亲手走完了从协议设计、编解码、连接管理到消息可靠投递的完整链路,以后再去看任何 RPC 框架、消息中间件的源码,理解起来都会有质的飞跃。
最后说一点个人体会。很多人觉得 Netty 门槛高,不敢上手,其实它的核心概念并不复杂,难的是把各种细节组合起来的工程能力。你只要动手写一个哪怕很小的推送 Demo,把协议、编解码、心跳、重连这几个环节真正跑通一遍,Netty 对你来说就不再是“听说过但不会用”的框架了。这个项目做下来,我最值钱的收获反而不是代码,而是对那些看不见的网络问题的感知——粘包、半包、连接假死、半开连接,这些教科书里一句话带过的概念,在实际排查中各个都是磨人的小妖精。趁早踩完这些坑,后面做任何网络相关的项目都会顺手很多。