Spring Boot 集成 Netty 构建高性能 TCP 长连接网关:协议解析、心跳检测与集群广播实战

做物联网设备接入、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,容易自己在坑里绕不出来。

相关推荐
Json____2 小时前
基于 Spring Boot + Vue3 的在线考试管理系统实战
java·spring boot·后端·it学习·wwwoop.com
Sam_Deep_Thinking2 小时前
如何理解java的信号量
java·后端·面试·程序员
东风破_2 小时前
NestJS 快速入门:先别背装饰器,把一条请求跑明白
后端
❀͜͡傀儡师2 小时前
Spring Boot 实现图片盲水印:基于 DCT 频域算法的隐形数字水印
spring boot·算法
晴空蓝天2 小时前
MDC traceId 全链路日志追踪:Spring Boot 3.5 里把日志串成一条线
java·spring boot·后端·python
明月_清风2 小时前
干了 6 年前端,我是怎么一步步转型到 AI 的?
前端·后端·ai编程
顽疲2 小时前
Java vue 养老系统源码详解:楼宇床位 care_space 与房态图 Spring Boot 实践
后端
Lyy2 小时前
DevOps平台 — 第十三篇:规划的设计与实现
后端·devops
今天不在线3 小时前
旧 Java 项目接入 AI 实战系列(一):四层递进,从基础对话到流式输出
后端