
做物联网设备接入、IM、行情推送这类后端业务,你早晚得碰长连接网关。我之前做充电桩管理平台的时候,几千台设备连上来,要定时上报状态,服务器还得往下推控制指令。一开始图省事用 HTTP 轮询,结果延迟高、浪费带宽,后来老老实实换成了 Netty。今天这篇文章就把我在 Spring Boot 里集成 Netty 做长连接网关的经验梳理一下,代码都是基于实际项目简化过的,直接拿着改改能用。
长连接到底解决什么问题
先说为什么需要长连接。HTTP 短连接每次请求都要经历 TCP 三次握手、四次挥手,光握手开销就够喝一壶的。而且服务端没法主动往客户端发数据,只能轮询。长连接不一样,一条 TCP 通道一直保持,两边随时能说话,服务端推消息延迟能到毫秒级。
典型的场景就这么几个:
物联网设备,智能水表、充电桩、工业传感器,跑在弱网环境,一挂就是几个月。服务器要管成千上万个连接,设备认证、心跳保活、指令下发一个都不能少,还得防着设备频繁断线重连把服务器搞挂。
IM 即时通讯,核心就是消息实时收发。用户登录后建立长连接,别人发消息,服务器得立刻推给在线用户,顺带处理消息时序、离线缓存、多端同步。
股票行情推送更变态,买卖五档、逐笔成交,每秒好几百条更新。轮询肯定凉,只能长连接,配合二进制协议把数据压缩到最小。
这些场景对网关的共同要求就四条:连接数要够多,单机几十万是及格线;延迟要低,转发消息别磨蹭;协议得自定义,用二进制省带宽省解析开销;连接要稳定,坏连接得及时清理,别把文件描述符和内存耗光。
说实话,这些要求 Netty 几乎都帮你处理好了,你只要把业务逻辑填进去就行。
Netty 这几个概念必须搞明白
Netty 是一个 NIO 框架,底层是 Java NIO 那套 Selector 机制,但它把复杂度藏起来了,提供了一套很顺手的 API。我用下来觉得最关键的就三个概念:EventLoop、Pipeline、ChannelHandler。
EventLoop 就是那个不断循环的线程
EventLoop 说白了就是一个死循环线程,不停从任务队列拿任务执行。每一个 EventLoop 绑定了一个 Selector,负责一堆 Channel 的读写事件。一个 EventLoop 可以服务多个 Channel,但一个 Channel 一辈子只属于一个 EventLoop。这就意味着这个 Channel 上的所有操作都在同一个线程里执行,你不用操心并发同步的问题,省了一大堆脑子。
服务器这边一般用两个 EventLoopGroup。bossGroup 就一个线程,专门 accept 新连接,接回来之后把连接交给 workerGroup。workerGroup 负责跑每个连接的 I/O 读写和业务逻辑,默认线程数是 CPU 核数乘以 2。
java
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(); // 默认 CPU 核数 * 2
Pipeline 是处理消息的责任链
每个 Channel 都有一个属于自己的 ChannelPipeline,里面串了一堆 ChannelHandler。入站数据从 Pipeline 头部进来,按顺序往下传;出站数据从尾部往上传。编解码、业务逻辑、异常处理都可以拆成独立的 Handler,谁不干了随时可以摘掉。
比如我这个网关上,Pipeline 大致长这样:
java
pipeline.addLast("frameDecoder", new LengthFieldBasedFrameDecoder(...));
pipeline.addLast("binaryDecoder", new MsgDecoder());
pipeline.addLast("businessHandler", new BusinessHandler());
ChannelHandler 就是业务逻辑的容器
这个不用多解释,你所有自定逻辑都写在 Handler 里。常用的是 SimpleChannelInboundHandler<T>,它会在处理完消息后自动释放引用计数对象,还能按泛型自动分发。
java
public class EchoHandler extends SimpleChannelInboundHandler<Message> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Message msg) {
// 处理业务消息
}
}
Netty 自带了一批常用 Handler,比如 LengthFieldBasedFrameDecoder、IdleStateHandler、ByteToMessageDecoder,后面都会用到。
Spring Boot 里怎么把 Netty 启动起来
在 Spring Boot 里集成 Netty 其实没什么高级的,就是定义一个 Spring Bean,在 @PostConstruct 里启动服务,在 @PreDestroy 里关掉。同时把配置丢到 application.yml 里,方便环境区分。
依赖和配置
pom.xml 里加一条依赖:
xml
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.100.Final</version>
</dependency>
再写一个配置类,用 @ConfigurationProperties 把端口、线程数、超时时间这些外置出去:
java
@Data
@ConfigurationProperties(prefix = "netty.server")
public class NettyServerProperties {
private int port = 8000;
private int bossThreads = 1;
private int workerThreads = 0; // 0 表示使用默认值
private int maxFrameLength = 1024;
private int readerIdleTimeSeconds = 60;
private int writerIdleTimeSeconds = 30;
private int allIdleTimeSeconds = 90;
}
服务启动类
这个类负责创建 ServerBootstrap,绑定端口,并注册一个 ChannelInitializer 来装配各条连接的 Pipeline。
java
@Component
@RequiredArgsConstructor
public class NettyServer {
private final NettyServerProperties props;
private final ChannelInitializer<SocketChannel> channelInitializer;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
private Channel serverChannel;
@PostConstruct
public void start() throws InterruptedException {
bossGroup = new NioEventLoopGroup(props.getBossThreads());
workerGroup = new NioEventLoopGroup(props.getWorkerThreads());
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.option(ChannelOption.SO_REUSEADDR, true)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(channelInitializer);
serverChannel = bootstrap.bind(props.getPort()).sync().channel();
log.info("Netty server started on port {}", props.getPort());
}
@PreDestroy
public void shutdown() {
if (serverChannel != null) {
serverChannel.close();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
}
}
ChannelInitializer 里面别直接注入 Handler
这是我最想提醒你的一点。网上很多教程直接在 Spring 容器里把 ChannelHandler 注册成 @Component 然后注入 ChannelInitializer 里往 Pipeline 一加。这种做法是错的。因为 ChannelHandler 大多数是有状态的,比如 ByteToMessageDecoder 内部有缓存 Buffer,单例会被多个 Channel 共享,会出现数据串线的诡异问题。
正确姿势是让 Handler 在 initChannel 里 new 出来,但把 Spring 管理的依赖(比如 DeviceSessionManager)通过构造器传进去。这样每个 Channel 都是自己的 Handler,同时还能用上 Spring Bean。
java
@Component
@RequiredArgsConstructor
public class GatewayChannelInitializer extends ChannelInitializer<SocketChannel> {
private final NettyServerProperties props;
private final DeviceSessionManager sessionManager;
private final ChannelRegistry channelRegistry;
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// 空闲检测(心跳)
pipeline.addLast("idleStateHandler",
new IdleStateHandler(props.getReaderIdleTimeSeconds(),
props.getWriterIdleTimeSeconds(),
props.getAllIdleTimeSeconds()));
// 粘包/拆包处理器(基于长度字段)
pipeline.addLast("frameDecoder",
new LengthFieldBasedFrameDecoder(props.getMaxFrameLength(),
0, 4, 0, 4));
// 自定义编解码器
pipeline.addLast("protocolDecoder", new ProtocolDecoder());
pipeline.addLast("protocolEncoder", new ProtocolEncoder());
// 心跳处理器
pipeline.addLast("heartbeatHandler", new HeartbeatHandler());
// 业务处理器
pipeline.addLast("dispatchHandler", new MessageDispatchHandler(sessionManager, channelRegistry));
}
}
如果你真的想在某些无状态 Handler 上用 Spring 单例,那必须给 Handler 加上 @Sharable 注解,并且确认它真的不持有任何与具体 Channel 相关的数据。不过我的建议是别折腾,直接 new 就行了,Spring Bean 的生成成本在这里可以忽略。
线程模型上的坑
bossGroup 线程数固定 1 就够了,TCP accept 本身很轻量,多线程反而增加上下文切换。workerGroup 默认值其实够用。但注意,如果你的 Handler 里做了阻塞操作,比如查数据库、调远程接口,千万别直接写在 EventLoop 线程里。你阻塞的可能是你这个 Channel 对应的那个 EventLoop,但那个 EventLoop 还管着几百个其他连接呢,你一卡,其他连接全跟着遭殃。
解决办法有两种。第一种是不要在 Handler 里做耗时操作,把任务丢到单独的线程池处理,然后异步返回。第二种是在 Pipeline 上给指定 Handler 配置独立的 EventExecutorGroup:
java
// 早就被 Netty 官方推荐的做法
DefaultEventExecutorGroup bizGroup = new DefaultEventExecutorGroup(16);
pipeline.addLast(bizGroup, "businessHandler", new BusinessHandler());
我一般用第一种,因为第二种牵扯到线程顺序问题,出 bug 的时候挺难查。
协议设计:别小看这一个字节
长连接网关最让人头大的就是协议设计。为了节省带宽,肯定要用二进制协议。我定义了一个很简短的协议头:
+--------+--------+---------+
| Length | Type | Payload |
| 4字节 | 1字节 | N字节 |
+--------+--------+---------+
- Length:4 字节,表示后面 Type + Payload 的总长度(不包含 Length 自己)。
- Type:1 字节,比如 0x01 是心跳,0x02 是认证,0x03 是业务数据。
- Payload:就是具体的业务数据,用 JSON、Protobuf,或者纯字节看你心情。
这里有个核心问题:TCP 是流协议,你发的一条消息在网络里可能被拆成多个包,也可能多个小消息合并成一个包。所以必须做粘包/拆包处理。Netty 的 LengthFieldBasedFrameDecoder 就是干这个的,你只要告诉它长度字段在哪、占几个字节,它就能把一个个完整的帧挖出来。
java
new LengthFieldBasedFrameDecoder(
maxFrameLength, // 一个帧最大长度,防止恶意大包把内存打爆
0, // 长度字段从第 0 个字节开始
4, // 长度字段占 4 字节
0, // 不需要额外调整
4, // 解码后把 Length 字段本身剥离掉
true // 如果帧长度超过 maxFrameLength,立即失败
)
用这个之后,后面的 Handler 拿到的 ByteBuf 已经是一个完整帧,Type 和 Payload 都很干净,粘包问题算是解决了。
除了粘包,还得防一下伪包。万一某个设备发了长度字段为 0xFFFFFFF 的恶意数据,maxFrameLength 拦不住就得 OOM。所以我把帧长上限设成 4KB,并且在解码器里校验 Type 合法,非法直接关连接。另外我还会强制要求设备连接后先发认证消息,认证通过前不给它分配任何业务资源,超过 5 秒没认证就断开。
编解码器
解码器用 ByteToMessageDecoder,在 decode 方法里把 ByteBuf 转换成 Message:
java
public class ProtocolDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// 走到这里已经是一个完整帧,Length 被剥掉了
if (in.readableBytes() < 1) {
return;
}
byte type = in.readByte();
byte[] payload = new byte[in.readableBytes()];
in.readBytes(payload);
out.add(new Message(type, payload));
}
}
编码器反过来:
java
public class ProtocolEncoder extends MessageToByteEncoder<Message> {
@Override
protected void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) {
out.writeInt(msg.getPayload().length + 1); // Length = Type + Payload
out.writeByte(msg.getType());
out.writeBytes(msg.getPayload());
}
}
Message 就是一个纯 POJO:
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Message {
private byte type;
private byte[] payload;
}
心跳检测:别让死连接占着茅坑
长连接最烦的一点是客户端可能突然断电、断网、进程崩溃,服务端根本收不到 RST 包,只能等超时。如果不做心跳,这些死连接会一直占着文件描述符和内存,最后把服务器拖垮。所以必须定期检查连接是否还活着。Netty 的 IdleStateHandler 可以触发读空闲、写空闲、全部空闲事件。
我在 Pipeline 里已经加了这个 Handler,配置是读空闲 60 秒、写空闲 30 秒、全部空闲 90 秒。在心跳处理器里,收到 IdleStateEvent 就根据状态处理:
java
@Slf4j
public class HeartbeatHandler extends ChannelInboundHandlerAdapter {
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (event.state() == IdleState.ALL_IDLE) {
log.info("Channel {} idle too long, closing", ctx.channel().id());
ctx.close();
} else if (event.state() == IdleState.READER_IDLE) {
log.warn("Read idle, no data received from {}", ctx.channel().remoteAddress());
// 这里可以发 ping 包,连续多次没回应再关
ctx.close();
}
}
}
}
业务层还要维护一个"在线表"。我用了 Redis 存 deviceId 和 Channel 的对应关系,并设置过期时间。每次收到心跳就刷新过期时间。单机环境下可以再用一个本地 ConcurrentHashMap 做 deviceId 到 Channel 的映射,这样在本机推送时就不用查 Redis 了。
java
@Component
public class DeviceSessionManager {
private final ConcurrentHashMap<String, Channel> localChannels = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Channel, String> channelToDeviceId = new ConcurrentHashMap<>();
private final StringRedisTemplate redisTemplate;
private static final String ONLINE_KEY_PREFIX = "device:online:";
public void online(String deviceId, Channel channel) {
localChannels.put(deviceId, channel);
channelToDeviceId.put(channel, deviceId);
redisTemplate.opsForValue().set(ONLINE_KEY_PREFIX + deviceId,
channel.id().asLongText(), Duration.ofSeconds(120));
}
public void heartbeat(String deviceId) {
redisTemplate.expire(ONLINE_KEY_PREFIX + deviceId, Duration.ofSeconds(120));
}
public void offline(String deviceId) {
Channel ch = localChannels.remove(deviceId);
if (ch != null) channelToDeviceId.remove(ch);
redisTemplate.delete(ONLINE_KEY_PREFIX + deviceId);
}
public void removeByChannel(Channel channel) {
String deviceId = channelToDeviceId.remove(channel);
if (deviceId != null) {
localChannels.remove(deviceId);
redisTemplate.delete(ONLINE_KEY_PREFIX + deviceId);
}
}
public Channel getLocalChannel(String deviceId) {
return localChannels.get(deviceId);
}
}
心跳消息很简单,Type 0x01 表示请求,Payload 是 deviceId;服务端返回 Type 0x02 + "pong"。在 MessageDispatchHandler 里判断一下:
java
if (msg.getType() == 0x01) {
String deviceId = new String(msg.getPayload(), StandardCharsets.UTF_8);
sessionManager.heartbeat(deviceId);
ctx.writeAndFlush(new Message((byte) 0x02, "pong".getBytes()));
}
主动推送:单机很简单,集群就有点绕
长连接的价值在于服务端能主动推消息。我的做法是提供一个 PushService,让上层 HTTP 接口直接调用,不感知 Netty 细节。
单机推送
维护一个全局 ChannelGroup,每个连接建立后加进去,断开时移掉。然后利用 ChannelGroup.writeAndFlush 给所有连接广播。
java
@Component
public class ChannelRegistry {
public static final ChannelGroup ALL_CHANNELS =
new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
}
在 MessageDispatchHandler 里维护这个组:
java
@Override
public void channelActive(ChannelHandlerContext ctx) {
ChannelRegistry.ALL_CHANNELS.add(ctx.channel());
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
ChannelRegistry.ALL_CHANNELS.remove(ctx.channel());
sessionManager.removeByChannel(ctx.channel());
}
单机推送很简单:
java
public void pushToDevice(String deviceId, String payload) {
Channel channel = sessionManager.getLocalChannel(deviceId);
if (channel != null && channel.isActive()) {
channel.writeAndFlush(new Message((byte) 0x03, payload.getBytes()));
}
}
集群推送:Redis Pub/Sub 转发
生产环境不会只部署一台 Netty。假如有 A、B、C 三台服务器,设备X连在 A 上,某个请求到了 B,B 本地没有设备X的 Channel,怎么办?那就得让消息在网络里转一下。我图省事用了 Redis Pub/Sub,因为公司本来就有 Redis,不用额外引入 MQ。
每个节点启动的时候订阅同一个频道,比如 netty:push。当某个节点接到推送请求,先查本地有没有目标设备,有就本地推过去;没有就发一条 Redis 消息,所有节点都会收到,但只有持有那个设备的 Channel 的节点才会真正推送。
这里要小心的坑是:Redis Pub/Sub 是广播,发布者自己也是订阅者,会收到自己发出去的消息。但因为我们只在"本地没有目标设备"时才发这条消息,所以就算发布者自己收到,也不会重复推送------它本地还是没有那个 Channel,消费时直接忽略。全量广播的话,每个 Channel 只存在于某一个节点,所以每个节点各自用 ChannelGroup.writeAndFlush 推一遍,客户端只会收到一次,不会重复。
订阅的代码就不全贴了,关键是用 Spring Data Redis 的 RedisMessageListenerContainer 注册一个监听器,在 onMessage 里解析消息并调用 PushService 里的方法。消息体别用简单冒号拼接了,我一开始就是冒号拼的,结果 payload 里带冒号就炸了。老老实实改成 JSON:
json
{
"deviceId": "xxx",
"payload": "....."
}
如果对消息可靠性要求更高,比如不允许丢失推送,那建议换成 RabbitMQ 的广播模式或者 RocketMQ,原理一样,但消息可以持久化,消费者离线时消息不会丢。Redis Pub/Sub 丢消息这事我是踩过坑的,别把它用在关键业务上。
压测能告诉你真实水平
写完网关不压测等于没写,心里没底。压测工具不必自己写,我以前用自研的 Netty 客户端模拟多连接,其实最方便的还是多准备几台客户端机器,每台开个 1 万个连接,异步连接后定时发心跳,记录响应时间和吞吐。
压测时重点看几个指标:QPS、平均/最大延迟、CPU 占用、文件描述符数量和 GC 频率。我记得当时 8C16G 的机器,10 万个心跳连接,只跑 Netty 的 event loop,CPU 占用大约是 30% 上下,延迟在本地网络下基本小于 1ms。但如果你把数据库查询直接写在 Handler 里,CPU 立刻升到 90% 以上,延迟也开始抖动。
有几个优化经验直接列给你:
第一个,业务线程池隔离。EventLoop 线程只做 I/O,任何可能阻塞的操作都丢给业务线程池。我用的是线程池加异步回调。
第二个,减少不必要的对象分配。比如解码器里避免用 String 频繁拼接,用字节数组或者 ByteBuf 直接操作。压测的时候注意一下 GC,Full GC 频率一高就完蛋。
第三个,调整系统参数。somaxconn、tcp_max_syn_backlog、max_map_count 这些该调就调,不然连接一多就开始丢包。
第四个,Linux 上换 Epoll 传输。Netty 默认 NIO 是跨平台的,但也带来了 overhead。Linux 下直接用 EpollEventLoopGroup 和 EpollServerSocketChannel,性能有提升。
java
if (Epoll.isAvailable()) {
bossGroup = new EpollEventLoopGroup(1);
workerGroup = new EpollEventLoopGroup();
bootstarp.channel(EpollServerSocketChannel.class);
} else {
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();
bootstarp.channel(NioServerSocketChannel.class);
}
压测的坑也讲一下。如果客户端用单机开太多连接,可能先到文件描述符上限,最好是几台机器一起打。压测时不要只测心跳,还要测一下推送大量消息的场景,看看流量控制有没有生效。Netty 有 WRITE_BUFFER_WATER_MARK 可以设置高水位和低水位,否则有的客户端消费慢了,写缓冲区会被撑爆,内存一路涨上去。
最后
长连接网关这块,Netty 已经帮你把底层搞得很稳定了,你的精力应该花在协议设计和连接管理上。我这套方案里还有一些东西没来得及讲,比如 TLS/SSL、限流熔断、连接迁移、灰度发布,都是生产环境躲不开的。但骨架已经通了,后面往上面填功能就行。
文中的代码都是从实际项目里脱敏简化来的,可能有细节和你的场景不一样,比如协议格式、心跳间隔、线程池大小,都需要根据实际情况调。如果你打算照着这套方案做,我建议先把单机版跑通,再加集群,别一上来就直接上 Redis Pub/Sub,容易自己在坑里绕不出来。