资讯详情

SpringBoot+Netty打造聚合WebSocket:单机10万长连接实战

📅 2026/10/15 9:28:44 | 华诺云谱 👁 阅读
SpringBoot+Netty打造聚合WebSocket:单机10万长连接实战
做物联网、IM、实时消息推送这一类项目最绕不开的就是WebSocket长连接规模问题。很多人用SpringBoot默认的spring-boot-starter-websocket把业务写完本地测几百连接都很正常设备量一上来连接数到两三千就开始频繁掉线CPU和内存也跟着失控。要是把目标定到“单机10万长连接”你很快会发现问题根本不在SpringBoot的API好不好用而在底层连接模型、内存模型和整套架构是不是按规模化设计的。这篇文章要讲的就是如何用SpringBoot做底座用Netty去做一个聚合WebSocket接入层把单机连接数推到10万。这里说的“聚合WebSocket”是指用一个独立服务统一收敛所有终端的WebSocket连接负责连接管理、心跳维护和消息路由再与后端的业务服务解耦。这个思路适合正在做设备接入、IM服务、实时推送和在线状态服务的开发者哪怕你现在的量级只有几千下面的调优方法也能帮上大忙。1. 先搞清楚瓶颈在哪里1.1 默认SpringBoot WebSocket为什么扛不住SpringBoot默认的WebSocket是构建在Servlet容器上的开发确实省事引入依赖加一个ServerEndpoint然后onOpen、onMessage、onClose回调一套业务就通了。但代价是底层对连接的管理、线程模型和内存分配都不是为你这个“高连接数”场景准备的。Servlet容器里一个WebSocket连接从建立到升级走的是HTTP请求线程池握手之后交给容器内部的WebSocket处理器。Tomcat底层确实有NIO但它对每个连接依然会维护Session、SessionListener、原生buffer等对象这些对象在连接数上来以后很容易成为GC压力。更重要的是默认线程池配置是按短连接场景设计的连接一旦多了锁竞争、内部队列、Session扫描这些隐藏开销都会被放大。真实项目中最常见的问题是连接数到几千之后CPU开始飙高然后大量线程阻塞。因为容器内置的WebSocket实现把每次消息回调都放在一个比较大的业务线程池里执行如果业务代码里再碰一下数据库、调一下Redis一个连接的消息处理延迟会拖慢整个容器的线程池。等你把目标提到10万这种模型几乎不可能稳定。所以第一步不是优化SpringBoot配置而是换掉底层连接引擎。1.2 10万长连接到底意味着什么先算一笔账10万条TCP长连接首先意味着服务端至少需要10万个文件描述符如果把监听socket、epoll实例、线程内部描述符都算上进程的fd上限至少要开到20万以上。然后是内存一条连接在应用层至少要维护Channel对象、ChannelHandlerContext、业务绑定的设备信息如果再加上读写缓冲区一条连接占用几KB是非常正常的。按2KB算就是200MB按5KB算就是500MB这还只是应用层对象。如果Linux内核TCP缓冲区没有控制好一条连接缓冲区几十KB10万条就是几个GB。再然后是线程NIO模型下IO线程可以很少但业务处理一旦在IO线程里做了耗时操作10万连接的消息洪峰就能让CPU全部打满。所以“10万”不是某单个配置调大就行的而是从内核、JVM、Netty到业务代码层层优化的结果。我见过不少团队一上来就加机器认为一台机器扛不住是正常的。但单机10万连接在一台8核16G的云主机上其实是完全可行的。关键是让每一条连接占用的资源足够小连接不在网络层空转、消息在业务线程池异步流转、定时任务数量不随连接数线性增长。这几点做到位单机10万才有的谈。1.3 聚合层是规模化连接的第一步我之所以把这个服务叫“聚合WebSocket”是因为它不是一个嵌入在业务服务里的WebSocket端点而是一个独立的连接聚合层。所有客户端只连这个聚合服务聚合服务统一维护连接状态、设备ID和Channel的映射、心跳检测、消息去重和路由。业务服务不直接接触长连接它只知道“设备在线”这件事发送消息时把目标设备和内容交给聚合层由聚合层完成最终的推送。这种拆法解决了几个实际问题连接层可以独立扩容不需要因为业务服务的部署而跟着变业务服务的线程模型和连接模型不互相影响当需要广播推送时聚合层拥有完整的ChannelGroup一条广播消息可以瞬时触达全部设备。聚合层里的业务逻辑越少越稳。它不需要处理登录鉴权以外的重量级逻辑不需要写库不需要跑复杂算法。所有必须做的事情都异步转发给后端。这样的单一职责让调优目标变得非常清晰把连接维护到极致把消息通路做到无损。2. 技术选型SpringBoot Netty2.1 为什么选择Netty而不是Undertow或Jetty在SpringBoot生态里做WebSocket还有别的方案把内嵌容器换成Undertow或者用Jetty。这些容器性能都不差但要做到单机10万连接Netty的优势更明显。Netty是专门为高并发网络应用设计的NIO框架内存池、零拷贝、异步事件驱动这些能力都是从底层构建的。WebSocket协议在Netty里有专门的WebSocketServerProtocolHandler握手、帧编解码、Ping/Pong控制帧都处理得比较完善。还有一点很关键Netty允许你精细控制每个Channel的读写水位、缓冲区分配和空闲检测这些在Servlet容器里几乎不可控。出问题的时候Netty的链路可以用ChannelPipeline逐步排查而容器内部的黑盒往往只能重启。不是说Undertow、Jetty不行而是它们的天花板来得比Netty早。有人用Undertow做WebSocket压测单机几万连接是可以的但调优手段有限想再往上拼的时候能动的参数很少。Netty从内核fd、内存池到EventLoop线程数每一步都能定位和调整。所以我选择SpringBoot作为应用容器Netty作为网络接入层两者通过Spring管理生命周期。2.2 整体架构和职责划分聚合层启动后监听一个独立端口比如8080。客户端通过WebSocket协议升级上来连接建立后在握手完成事件里解析URL参数中的设备ID把Channel注册到ConnectionManager。之后所有客户端消息进入WebSocketFrameHandlerhandler不处理具体业务只把消息反序列化后交给一个业务线程池或者写入内部消息队列由独立的消费者转发给后端。后端需要主动推送时调用聚合层的HTTP接口或发布到Redis Channel/消息队列聚合层消费后根据设备ID或分组找到ConnectionManager里的Channel再writeAndFlush。这样聚合层的职责非常纯粹管连接不管业务。也正因为纯粹单机才能扛得住高连接数。2.3 关键代码骨架先加依赖。这里不需要spring-boot-starter-websocket只需要Netty。dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.100.Final/version /dependency启动Netty服务Component public class WebSocketServerRunner implements CommandLineRunner { private final WebSocketChildChannelInitializer channelInitializer; public WebSocketServerRunner(WebSocketChildChannelInitializer channelInitializer) { this.channelInitializer channelInitializer; } Override public void run(String... args) throws Exception { EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(Runtime.getRuntime().availableProcessors() * 2); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.SO_REUSEADDR, true) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(256 * 1024, 1024 * 1024)) .childHandler(channelInitializer); ChannelFuture future bootstrap.bind(8080).sync(); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }pipeline配置public class WebSocketChildChannelInitializer extends ChannelInitializerSocketChannel { private final WebSocketFrameHandler frameHandler; public WebSocketChildChannelInitializer(WebSocketFrameHandler frameHandler) { this.frameHandler frameHandler; } Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); pipeline.addLast(new HttpServerCodec()); pipeline.addLast(new HttpObjectAggregator(8192)); pipeline.addLast(new WebSocketServerProtocolHandler(/ws)); pipeline.addLast(frameHandler); } }HttpObjectAggregator这个限制大小不必设置很大WebSocket握手请求本身很小。如果业务消息不走HTTP这个值用8192足够太大了等于给攻击者留了一个内存消耗入口。业务handlerChannelHandler.Sharable public class WebSocketFrameHandler extends SimpleChannelInboundHandlerTextWebSocketFrame { public static final AttributeKeyString DEVICE_ID AttributeKey.valueOf(deviceId); private final ConnectionManager connectionManager; private final ExecutorService bizExecutor; public WebSocketFrameHandler(ConnectionManager connectionManager, ExecutorService bizExecutor) { this.connectionManager connectionManager; this.bizExecutor bizExecutor; } Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) { WebSocketServerProtocolHandler.HandshakeComplete handshake (WebSocketServerProtocolHandler.HandshakeComplete) evt; QueryStringDecoder decoder new QueryStringDecoder(handshake.requestUri()); ListString ids decoder.parameters().get(deviceId); if (ids null || ids.isEmpty()) { ctx.close(); return; } String deviceId ids.get(0); ctx.channel().attr(DEVICE_ID).set(deviceId); connectionManager.add(deviceId, ctx.channel()); } super.userEventTriggered(ctx, evt); } Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) { String text msg.text(); bizExecutor.submit(() - { // 这里只做消息解析和转发不要把数据库操作等重逻辑放进来 processMessage(ctx.channel(), text); }); } Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { String deviceId ctx.channel().attr(DEVICE_ID).getAndSet(null); if (deviceId ! null) { connectionManager.remove(deviceId, ctx.channel()); } super.channelInactive(ctx); } Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { ctx.close(); } }ChannelHandler.Sharable表示这个handler可以在多条Channel之间共享。因为WebSocketFrameHandler内部没有维护连接相关状态它是无状态的所以可以安全共享。如果handler里放了跟具体连接相关的成员变量就不加这个注解改成每次initChannel时new一个实例。ConnectionManagerComponent public class ConnectionManager { private final MapString, Channel deviceChannels new ConcurrentHashMap(); private final ChannelGroup allChannels new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); public void add(String deviceId, Channel newChannel) { Channel old deviceChannels.put(deviceId, newChannel); if (old ! null old ! newChannel old.isActive()) { old.close(); } allChannels.add(newChannel); } public Channel get(String deviceId) { return deviceId null ? null : deviceChannels.get(deviceId); } public void remove(String deviceId, Channel channel) { if (deviceChannels.get(deviceId) channel) { deviceChannels.remove(deviceId, channel); } allChannels.remove(channel); } public void broadcast(String message) { allChannels.writeAndFlush(new TextWebSocketFrame(message)); } }这里做了一个“单设备只保留一条连接”的策略。如果同一个设备ID重新连接就把旧连接直接关闭避免一个设备占用多个连接导致推送重复。remove方法里用deviceChannels.remove(deviceId, channel)而不是remove(deviceId)是为了防止旧连接断开时误删新连接的映射。这个细节在重连场景下非常关键丢过连接数的人会懂。业务线程池用Spring管理避免IO线程阻塞Bean(bizExecutor) public ExecutorService bizExecutor() { return new ThreadPoolExecutor(200, 200, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue(20000), r - { Thread t new Thread(r); t.setName(biz-thread- t.getId()); return t; }, new ThreadPoolExecutor.CallerRunsPolicy()); }业务线程池的线程数要根据消息量来调。消息体小、频率高的场景可以200个线程。消息体大、频率低的场景可能50个就够了。关键是不要让Netty的worker线程被业务阻塞。worker线程负责的是网络读写和协议处理一旦阻塞所有分配在这个EventLoop上的连接都会卡住。3. 从操作系统到JVM的深度调优3.1 文件描述符与内核参数10万连接第一道门槛不是代码而是操作系统默认的ulimit。很多服务器默认ulimit -n只有1024或者65535连接数一到这个数服务端就开始拒绝新的socket。修改/etc/security/limits.conf* soft nofile 1048576 * hard nofile 1048576 root soft nofile 1048576 root hard nofile 1048576如果你用systemd管理服务还要在service文件里加[Service] LimitNOFILE1048576配好之后重新登录或者重启服务用ulimit -n确认一下。如果这里没改后面所有压测都会撞墙。内核参数方面重点调整这些net.core.somaxconn 1024 net.ipv4.tcp_max_syn_backlog 2048 net.ipv4.tcp_fin_timeout 30 net.ipv4.tcp_tw_reuse 1 net.ipv4.tcp_tw_recycle 0 net.ipv4.ip_local_port_range 1024 65535 net.ipv4.tcp_keepalive_time 600 net.ipv4.tcp_keepalive_intvl 30 net.ipv4.tcp_keepalive_probes 3 net.core.rmem_max 16777216 net.core.wmem_max 16777216 net.ipv4.tcp_rmem 4096 8192 16777216 net.ipv4.tcp_wmem 4096 8192 16777216注意net.ipv4.tcp_tw_recycle在NAT环境下问题很大已经被Linux弃用绝对不要开。tcp_tw_reuse可以开但只是辅助手段真正要解决的是“不要让服务端频繁主动断开连接”。net.ipv4.tcp_rmem和net.ipv4.tcp_wmem是很多人忽略的重点。默认第三个值可能是87380甚至更大如果连接数达到10万内核按中值给每条连接预留缓冲内存会非常惊人。把小消息场景的默认接收缓冲中值调低到8192同时保留最大16MB给突发大流量可以在吞吐和内存之间取得平衡。如果你的业务是大文件传输这个值就不能盲目调低。3.2 每条连接的内存账本调优之前先算清楚每一条连接的资源占用。Netty里默认AdaptiveRecvByteBufAllocator会根据实际流量自动调整缓冲区大小。连接建立后如果没有数据缓冲区会保持在一个很小的初始值大概1KB左右。这比一上来就分配64KB buffer要省得多。所以不要随便给每个连接设置大的SO_RCVBUF这会让空闲连接也占着大块内存。粗估一条连接的资源消耗如下项目单连接估算10万连接合计fd与内核协议栈内存约1~4KB100~400MBNetty Channel与Handler对象1~2KB100~200MB读缓冲区1~4KB100~400MB写缓冲区0~1KB0~100MBConnectionManager中的索引对象约100B10MB这些是应用层加内核层的粗估实际还要看内存池分配、ChannelGroup的迭代开销。按这个量级一台16GB内存的机器堆外内存控制好完全可以支撑10万连接。但如果每个连接都分配一个固定64KB的buffer那内存直接多出6.4GB结果就完全不一样。所以连接建立时不要提前分配大块资源等到真有消息再走内存池分配。3.3 JVM参数与GC调优长连接对象生命周期很长连接建立了基本不会销毁所以对象会慢慢晋升到老年代。GC调优的核心不是压缩停顿时间而是“少触发Full GC”。JVM参数示例java -Xms2g -Xmx2g -Xmn1g -XX:MaxMetaspaceSize256m -XX:MaxDirectMemorySize512m -XX:UseG1GC -XX:MaxGCPauseMillis50 -XX:DisableExplicitGC -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs -jar app.jar不要盲目把-Xmx调到8G。连接对象长期存活堆越大Full GC时扫描时间越长。2G堆、1G新生代在10万连接的小消息场景下通常够用。如果有广播推送大量消息可以到4G但要看压测后的GC曲线再定。-XX:MaxDirectMemorySize512m限制Netty堆外内存防止操作系统被吃满。如果业务中有大批量二进制消息上传这个值需要调大。GC观察指标主要看这几个Minor GC频率、老年代增长曲线、Full GC停顿时间。如果Minor GC每秒超过一次说明消息处理过程中临时对象太多优先去业务线程池里找代码问题而不是加大堆。如果老年代持续增长优先怀疑连接没释放、Channel被Map引用或者某些业务对象被长期持有。3.4 Netty缓冲池、写水位与心跳策略Netty默认的内存分配器在现代JDK上大多能自动检测但显式指定PooledByteBufAllocator.DEFAULT更保险。内存池的好处是buffer复用避免每次收发消息都创建新字节数组。代价是连接多时内存池本身会占用不少Native内存所以MaxDirectMemorySize要配合着设。写水位的设置直接影响内存安全。低水位256KB、高水位1MB是相对保守的配置。平时心跳消息很小除了业务广播推送写缓冲不会占用太高。当channel.isWritable()变为false时应该停止继续写否则10万连接同时广播大消息内存瞬间爆掉。代码里判断一下if (ctx.channel().isWritable()) { ctx.channel().writeAndFlush(new TextWebSocketFrame(msg)); } else { // 对方消费不过来先缓存或者标记待重发绝不能无脑追加 }心跳是长连接另一个大坑。Netty的IdleStateHandler可以对每个Channel单独做空闲检测但10万连接会挂10万个ScheduledFuture即使EventLoop能分摊也是一笔不小开销。更稳的做法是全局心跳扫描在每个Channel上记录LastReadTime用一个全局ScheduledExecutorService每30秒遍历一次超过90秒没有收到任何帧就先发Ping再过30秒还没Pong就把连接断掉。这样无论连接多少额外定时任务始终只有一个。4. 压测与验证4.1 搭建多节点压测环境10万连接不能靠一台机器模拟。受ip_local_port_range限制单客户端最多约6万个可用源端口实际还会被TIME_WAIT占用所以建议准备4台压测机每台模拟2.5万连接。压测机也需要调nofile和本地端口范围不然客户端会先挂掉。压测客户端代码大致这样Bootstrap bootstrap new Bootstrap(); bootstrap.group(new NioEventLoopGroup(4)) .channel(NioSocketChannel.class) .option(ChannelOption.TCP_NODELAY, true) .handler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new HttpClientCodec()); ch.pipeline().addLast(new HttpObjectAggregator(8192)); ch.pipeline().addLast(new WebSocketClientProtocolHandler(URI.create(ws://192.168.1.100:8080/ws?deviceIddev- startId))); ch.pipeline().addLast(new SimpleChannelInboundHandlerTextWebSocketFrame() { Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) { // 业务响应计数 } }); } }); for (int i 0; i 25000; i) { int finalI i; bootstrap.connect(192.168.1.100, 8080).addListener((ChannelFutureListener) f - { if (f.isSuccess()) { connected.incrementAndGet(); } }); if (i % 500 0) { Thread.sleep(500); } }连接建立速率一定要控制住每秒500个比较稳妥。如果瞬间发起2.5万个connect服务端SYN队列会溢出表现就是大量connect timeout或者connect refused。压测时多台压测机要错开启动不要同时打满。4.2 关键指标怎么看连接建立完成后服务端验证命令ss -tan | grep -c ESTAB查看当前TCP连接数。ss -lnt查看监听队列Recv-Q如果比较大说明backlog满了。jstat -gcutil pid 1000看GC占比和各个区变化。jcmd pid VM.native_memory查看堆外内存占用压测环境需要开启-XX:NativeMemoryTrackingsummary。top -H -p pid看线程CPU如果某个Netty线程一直高CPU要确认是否有人在IO线程里做重逻辑。稳定性判断不是“连上10万就够了”而是让连接保持至少3小时以上。观察掉线率、老年代曲线、Full GC次数。如果掉线率持续大于千分之一多半有心跳超时设置不合理或者TCP keepalive参数不对。GC方面老年代曲线应该是缓慢上升后走平而不是阶梯式无限上涨。4.3 消息洪峰下的背压验证光建立连接不算完整压测。还要测消息推送每秒钟向全部10万连接推送一条小消息观察内存和CPU。如果内存上涨很快说明写缓冲水位设置有问题或者广播写入没有做分片。Netty的writeAndFlush是异步调用真实写入Socket是在IO线程里执行的。一次性广播10万条消息会让所有IO线程瞬间打满每个连接还要分配一个DefaultChannelPromise和TextWebSocketFrame。实际操作中我会把ChannelGroup按连接数分成多组每组之间间隔几十毫秒发送避免瞬时洪峰。压测时一定把广播场景测进来不测的话线上一次运营推送就能把服务打崩。5. 常见问题与排查技巧实录5.1 连接数卡在3万上不去如果压测到3万左右突然连接失败大概率不是代码问题而是系统资源限制。先看dmesg有没有Too many open files确认ulimit -n是否真的生效。如果是systemd部署单独在limits.conf里修改不够必须给service配置LimitNOFILE。如果fd没问题连接建立还是很慢再看ss -lnt的Recv-Q。Recv-Q持续超过0说明有新连接因为backlog队列满而没被accept。调大net.core.somaxconn的同时也要在ServerBootstrap里设置.option(ChannelOption.SO_BACKLOG, 2048)。压测时连接建立速率不要太猛每秒500个是安全值。5.2 TIME_WAIT堆积导致连接反复失败如果压测过程频繁建立、断开连接服务端会积累大量TIME_WAIT。用ss -tan state time-wait | wc -l查看。TIME_WAIT本身不是大问题但数量到数万时会占用内存并耗尽端口。解决思路是长连接尽量复用不要频繁断开重连。net.ipv4.tcp_tw_reuse可以开tcp_tw_recycle千万不能开。更关键的是找一找为什么连接会被频繁关闭。是不是心跳超时设置太短是不是服务端负载过高Ping还没来得及处理就判定超时把客户端心跳间隔和服务端超时时间拉开比如客户端每30秒发Ping服务端90秒没收到数据再判定超时。这样能有效减少误杀。5.3 内存呈阶梯式上涨10万连接稳定之后内存还是每隔一段时间上涨一点多半是连接对象没有真正释放。先检查ConnectionManager里的ConcurrentHashMap。如果channelInactive回调没有正确执行或者旧连接关闭时把新连接的映射删掉都会导致Map里堆积无用Channel。另一个常见场景是客户端异常断电TCP断开可能要很久才被发现。如果服务端没有读超时这条Channel会一直占着内存。解决方法就是全局心跳扫描超时主动踢掉。排查时用jmap -histo:live pid | head -20看看有没有某个对象数量接近当前连接数并且在持续增长。如果有说明这个对象被错误地长久持有。5.4 客户端在线但消息发不出去这是长连接服务最典型的“假死”客户端TCP还在但服务端已经写不进数据。原因通常是TCP窗口满了客户端处理太慢或者网络拥塞。压测时如果客户端只连接不读数据服务端写缓冲会迅速堆积。解决思路是水位背压isWritable()为false时停止继续写或者直接断开连接让客户端重连。我现在做的聚合层会记录每个Channel的“待发消息数”和“最后成功写时间”。如果连续一段时间无法写成功就主动断开。与其保留一个半死不活的连接不如让客户端重新建立连接来得干净。这个策略在弱网环境的设备接入场景里特别管用。最后分享一个我自己的习惯每轮压测结束不会只看连接数而是把压测脚本挂在后台让10万连接空跑一晚上。第二天看掉线曲线和GC日志。很多连接问题是跑几分钟发现不了的比如某个定时任务导致内存慢慢上涨或者某个客户端版本周期性发异常帧。另外生产环境的10万连接和压测还不一样线上有弱网、机型差异和客户端bug所以聚合层一定要有连接上限保护比如单IP连接数限制、总连接数达到阈值后拒绝新连接。宁可让新设备连不上也不能把存量连接全部搞崩。希望这套思路能帮你少踩几个坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑