做物联网、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缓冲区没有控制好,一条连接缓冲区几十KB,10万条就是几个GB。再然后是线程,NIO模型下IO线程可以很少,但业务处理一旦在IO线程里做了耗时操作,10万连接的消息洪峰就能让CPU全部打满。所以“10万+”不是某单个配置调大就行的,而是从内核、JVM、Netty到业务代码层层优化的结果。
我见过不少团队一上来就加机器,认为一台机器扛不住是正常的。但单机10万连接,在一台8核16G的云主机上其实是完全可行的。关键是让每一条连接占用的资源足够小:连接不在网络层空转、消息在业务线程池异步流转、定时任务数量不随连接数线性增长。这几点做到位,单机10万才有的谈。
1.3 聚合层是规模化连接的第一步
我之所以把这个服务叫“聚合WebSocket”,是因为它不是一个嵌入在业务服务里的WebSocket端点,而是一个独立的连接聚合层。所有客户端只连这个聚合服务,聚合服务统一维护连接状态、设备ID和Channel的映射、心跳检测、消息去重和路由。业务服务不直接接触长连接,它只知道“设备在线”这件事,发送消息时把目标设备和内容交给聚合层,由聚合层完成最终的推送。这种拆法解决了几个实际问题:连接层可以独立扩容,不需要因为业务服务的部署而跟着变;业务服务的线程模型和连接模型不互相影响;当需要广播推送时,聚合层拥有完整的ChannelGroup,一条广播消息可以瞬时触达全部设备。
聚合层里的业务逻辑越少越稳。它不需要处理登录鉴权以外的重量级逻辑,不需要写库,不需要跑复杂算法。所有必须做的事情都异步转发给后端。这样的单一职责,让调优目标变得非常清晰:把连接维护到极致,把消息通路做到无损。
2. 技术选型:SpringBoot + Netty
2.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。之后所有客户端消息进入WebSocketFrameHandler,handler不处理具体业务,只把消息反序列化后交给一个业务线程池,或者写入内部消息队列,由独立的消费者转发给后端。后端需要主动推送时,调用聚合层的HTTP接口或发布到Redis Channel/消息队列,聚合层消费后根据设备ID或分组找到ConnectionManager里的Channel,再writeAndFlush。这样聚合层的职责非常纯粹:管连接,不管业务。也正因为纯粹,单机才能扛得住高连接数。
2.3 关键代码骨架
先加依赖。这里不需要spring-boot-starter-websocket,只需要Netty。
<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.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 ChannelInitializer<SocketChannel> { 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足够,太大了等于给攻击者留了一个内存消耗入口。
业务handler:
@ChannelHandler.Sharable public class WebSocketFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { public static final AttributeKey<String> 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()); List<String> 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一个实例。
ConnectionManager:
@Component public class ConnectionManager { private final Map<String, 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] LimitNOFILE=1048576配好之后重新登录或者重启服务,用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~4KB | 100~400MB |
| Netty Channel与Handler对象 | 1~2KB | 100~200MB |
| 读缓冲区 | 1~4KB | 100~400MB |
| 写缓冲区 | 0~1KB | 0~100MB |
| ConnectionManager中的索引对象 | 约100B | 10MB |
这些是应用层加内核层的粗估,实际还要看内存池分配、ChannelGroup的迭代开销。按这个量级,一台16GB内存的机器,堆外内存控制好,完全可以支撑10万连接。但如果每个连接都分配一个固定64KB的buffer,那内存直接多出6.4GB,结果就完全不一样。所以连接建立时不要提前分配大块资源,等到真有消息再走内存池分配。
3.3 JVM参数与GC调优
长连接对象生命周期很长,连接建立了基本不会销毁,所以对象会慢慢晋升到老年代。GC调优的核心不是压缩停顿时间,而是“少触发Full GC”。JVM参数示例:
java -Xms2g -Xmx2g -Xmn1g -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=512m -XX:+UseG1GC -XX:MaxGCPauseMillis=50 -XX:+DisableExplicitGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs -jar app.jar不要盲目把-Xmx调到8G。连接对象长期存活,堆越大,Full GC时扫描时间越长。2G堆、1G新生代在10万连接的小消息场景下通常够用。如果有广播推送大量消息,可以到4G,但要看压测后的GC曲线再定。-XX:MaxDirectMemorySize=512m限制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 ChannelInitializer<SocketChannel>() { @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?deviceId=dev-" + startId))); ch.pipeline().addLast(new SimpleChannelInboundHandler<TextWebSocketFrame>() { @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:NativeMemoryTracking=summary。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连接数限制、总连接数达到阈值后拒绝新连接。宁可让新设备连不上,也不能把存量连接全部搞崩。希望这套思路能帮你少踩几个坑。