刚经历了一次让人印象深刻的线上事故:某推送网关在业务高峰期整体带宽吃到接近上限,单个连接的数据还能正常处理,但新连接进来之后要么握手阶段超时,要么连进来以后迟迟拿不到响应。更诡异的是,已经建立的长连接里,一部分心跳正常,一部分数据链路却像被冻住了一样,等了大半天都不动。刚开始以为是连接数太多导致文件描述符耗尽,结果一看连接数远没到上限;以为是内存泄漏,结果内存曲线虽然涨得快,GC之后又能降下去。折腾了一天才意识到,这根本不是连接数的问题,也不是单纯的内存问题,而是带宽饱和场景下,连接处理链路里多个环节共同失效导致的。
这个题目我想认真拆一拆:Netty在带宽饱和场景下,连接处理到底该怎么设计、怎么调、怎么排查。整篇文章不会只贴几个参数就完事,我会把水线机制、autoRead、连接接入限流、业务线程隔离、环境监控这些串起来,讲清楚它们各自解决什么问题,以及我认为最稳的落地方案。无论你是做网关、做长连接推送、做文件传输还是做实时消息系统,只要你的服务有大量TCP长连接,这篇文章应该能帮你少踩几个坑。
1. 先聊清楚:带宽饱和的时候,连接处理到底难在哪里
1.1 带宽饱和和连接处理是什么关系
先说一个很多人容易忽略的事实:带宽饱和并不是“所有连接都变慢”这么简单。带宽是共享资源,当链路利用率接近上限时,最先出现的是丢包率升高、TCP重传增加、RTT变大。这些现象会传导到连接层,表现成各种让人挠头的症状。
举一个更具体的例子。假设你的服务器出网带宽是1Gbps,某个业务高峰期已经跑到950Mbps,这时候有一条新连接发起三次握手。三次握手的SYN包和SYN-ACK包虽然很小,但同样要走这条已经接近满负荷的链路。如果链路拥塞,SYN-ACK在传输过程中丢了,客户端就会一直等,等到超时后才重试。从业务方的视角看,就是“服务端连不上了”。
这个问题和数据量大小没有必然关系。就算业务面平均每个包只有几十字节,只要带宽饱和,这些控制面小包一样会被拥塞拖死。所以你会看到一种矛盾现象:明明服务器CPU、内存都很空闲,连接数也没到上限,但客户端就是感觉“服务端没有响应”。
还有一类典型表现:带宽饱和后,TCP的接收窗口和发送窗口会动态缩小。接收方如果来不及消费数据,会通告更小的窗口,发送方就只能停等。这种窗口收缩如果持续下去,连接就会进入一种“半死不活”的状态——TCP层还连着,应用层却没有数据流动。心跳包如果走同一条链路,可能也被影响;心跳包如果走另一条独立链路,就可能出现“心跳正常、业务数据不动”的诡异组合。
1.2 你以为的连接数问题,很多是背压问题
我在处理这类问题时发现,大家第一反应往往是“限制连接数”。这个方向不能说错,但它不是最核心的。带宽饱和场景下,真正需要解决的是背压——也就是说,生产者生产数据的速度超过了消费者消费数据的速度,而你又没有一套机制把这种压力正确地传导回去。
TCP本身是有背压机制的:接收窗口满了就不收,发送方自然就会停。但这个机制在网络层生效,到了Netty这一层,如果你不配合它,它就很难发挥作用。Netty替你维护了一个出站缓冲区,也就是outboundBuffer。业务代码往里写数据,底层的socketChannel负责把数据发出去。如果业务代码写得太快、网卡发不出去,这些数据就会在outboundBuffer里堆积。
这个堆积是有限度的吗?严格来说,Netty给它设置了上限——就是我们常说的写缓冲水位。但问题在于,很多人上线时根本没有调过这个水位,用的还是默认值。默认的低水位是32KB,高水位是64KB。这个值对于某些小报文场景是够用的,但对于大消息、高并发、高带宽利用率的场景,往往会导致两种问题:
- 高水位太低,isWritable频繁变成false,业务层误以为系统过载;
- 高水位设得太高,或者干脆没有监控,导致缓冲区积压了几十MB甚至上百MB的内存。
这些不是连接数问题,是背压没有正确设计的问题。
1.3 解题框架:接入层、传输层、业务层分层治
我后来总结了一个比较实用的处理框架,就是遇到带宽饱和,不要试图用一个参数解决所有问题,而是把问题拆到三个层面,逐层治理:
- 接入层:控制新连接的进入速度和数量,避免连接洪峰把accept队列打满;
- 传输层:利用Netty的水线、autoRead、空闲检测这些机制,保证I/O线程不会因为背压而堆积内存;
- 业务层:业务处理和I/O线程隔离,可以限流、可以排队、可以丢弃,但不能阻塞EventLoop。
这个框架的核心思想是:把保护I/O线程放在第一位。只要I/O线程不被打死,连接就能被正常处理;只要连接能被正常处理,带宽饱和带来的问题就只是性能问题,而不是可用性问题。
2. Netty里几个容易被忽略的背压和水线机制
2.1 写缓冲高水位与低水位
Netty的写缓冲水位机制,源码里对应的是WriteBufferWaterMark。它有两个值:low和high。当你通过Channel写数据时,Netty会维护一个pendingWriteBytes的计数,也就是当前还没发出去的字节数。
- 当pendingWriteBytes超过high值时,channel.isWritable()会变成false;
- 当pendingWriteBytes降到low值以下时,channel.isWritable()会恢复true。
这个机制本身很简单,但怎么用好它,很多文档并没有讲透。
关键点在于:isWritable()不是用来“告知你已经出问题”的,而是用来“在出问题前调整业务行为”的。正确用法是,在业务代码准备写数据之前,先判断一下channel.isWritable(),如果已经是false,就不要继续往这个连接上写数据,要么排队,要么丢弃,要么做一些降级处理。
我在一个文件传输系统里见过这样的问题:业务线程从数据库里拉了一批文件路径,然后逐个往channel里写文件内容。文件不大,每个几百KB,但数量很多。因为完全没有判断isWritable,高并发下outboundBuffer里的pendingWriteBytes一路涨到了几百MB。最后是怎么发现的?不是通过监控,是因为某台机器内存告警。
这个问题的解法比较简单:写之前判断isWritable,如果不可写,就先把文件对象放到一个带容量上限的队列里,等channel恢复可写后再继续。也可以用ChannelFutureListener,在write操作完成后再写下一个,但这个做法更接近流控,吞吐会受影响。
水位值到底怎么设置
我见过不少人问:水位值设成多少合适?我的答案是:不要拍脑袋定,先算一个基准值,再根据压测结果调整。
可以把链路容量套用到公式里,也就是带宽延迟积。举个例子,假设你的内网RTT大约是5ms,带宽是1Gbps,那么链路里能同时容纳的数据量大约是 1Gbps × 5ms = 1,000,000,000bit × 0.005s = 5,000,000bit ≈ 625KB。这意味着,如果你的高水位远大于625KB,那么在带宽打满的情况下,onBuffer里可能攒下大量超出链路容量的数据,这些数据只能堆在内存里。我还见过有人把高水位设置到10MB,单条连接最多就可能囤10MB,如果有1万条连接同时打满,那结果的数学谁都清楚。
我的建议是:对于大消息、高带宽业务,先按带宽延迟积估算一个高水位,然后压测验证。常见取值是几百KB到几MB,这个区间相对安全。小报文业务建议把高水位适当降低,可以减少内存浪费和延迟波动。
另外,要留意的是,Netty的isWritable变化是“跨过高水位才是false,降到低水位才是true”,中间有一段滞回区间。不要在高水位边缘反复横跳时做太多复杂逻辑,否则可能引发抖动。
2.2 autoRead关闭与恢复
Netty的ChannelConfig里有一个autoRead选项,默认是true。很多初学者对这个选项的理解是“自动读数据”,实际上它更准确的理解是:当autoRead为true时,通道一旦可读,Netty就会主动触发读操作;当autoRead为false时,Netty只会接收已经到达的事件,但不会主动向内核申请更多数据。
这个机制在做背压时非常有用。如果你的下游处理能力有限,但又不想靠TCP窗口来传导压力,可以通过关闭autoRead来暂停从socket里读取数据。数据还留在内核的socket接收缓冲区里,不进入Netty的用户态内存,压力被挡在更早的阶段。
我记得有一个消息转发服务,它的上游是海量消息的生产方,下游是消费能力有限的后端。消息进入Netty后要经过解码、鉴权、业务处理、转发几个环节,一旦后端响应变慢,消息就会在pipeline里堆积,内存很快就满了。后来做了这样一个改造:在业务处理完成之前,先调用channel.config().setAutoRead(false),等处理完再恢复true。这样一旦处理速度跟不上,Netty就会停止读取新数据,消息堆积在接收缓冲区里,内存压力大幅下降。
关闭autoRead的注意事项
这个方案有个很阴险的副作用:如果你在处理流程里设置了autoRead=false,但某个异常分支忘了恢复true,那么这个连接就会静默卡死,表现为“连接还在,但不再收到任何数据”。排查起来很费劲,因为没有任何报错,心跳也可能不受影响——心跳走的是另一个周期任务。
我建议的做法是:
- 用finally块统一恢复autoRead;
- 如果处理流程较长,用一个专门的flag标记当前是否可读,避免重复调用setAutoRead;
- 加一个定时任务,检查“最近一次读取时间”和“autoRead标志”,如果发现长时间没有读且标志为false,就强制恢复并打日志。
“连接卡死”这个坑,我在实际项目中至少踩过两次,每次都要靠人肉排查很久,后来查出来都是autoRead没恢复。
2.3 ChannelOption参数选型与建议值
Netty的ChannelOption很多,但带宽饱和场景下,最值得关注的几个我整理成了一张表:
| 参数 | 作用 | 我的建议 |
|---|---|---|
| SO_BACKLOG | 全连接队列大小 | 结合系统somaxconn设置,一般1024左右 |
| TCP_NODELAY | 关闭Nagle算法 | true,减少小报文延迟 |
| SO_KEEPALIVE | TCP层心跳 | 可以开,但别依赖它做业务健康检查 |
| ALLOCATOR | 缓冲分配器 | 使用PooledByteBufAllocator.DEFAULT |
| WRITE_BUFFER_WATER_MARK | 写缓冲水位 | 按带宽延迟积计算,不要拍脑袋 |
| AUTO_READ | 自动读取 | 默认true即可,必要时在代码里动态调整 |
这里要单独说一下SO_BACKLOG。它控制的是TCP全连接队列的大小,不是半连接队列。如果accept速度跟不上连接建立速度,全连接队列会堆满,此时新的TCP连接会直接被内核拒绝或者丢弃。很多人在应用层限制连接数,但忽略了SO_BACKLOG太小,导致高并发下连接建立本身就失败。
合理做法是:用十几个连接压测一下,观察全连接队列溢出情况,然后相应调SO_BACKLOG。同时注意系统参数net.core.somaxconn的限制,如果内核只允许128,你设置1024也没用,需要同步调整系统配置。
2.4 动态背压的落地做法
把水线和autoRead结合起来的做法,我管它叫“动态背压”。核心思路是:不要等到内存爆了才反应,而是通过isWritable作为信号,实时调整业务处理速度。
我在网关项目里落地过一个比较实用的方案,逻辑大概是这样的:
- 每个连接维护一个“可写状态”标志,isWritable变化时通过channelWritabilityChanged事件更新;
- 业务处理前先检查这个标志,如果不可写,就停止往这个连接写数据,把消息放入一个带容量上限的队列;
- 如果队列也满了,就直接丢弃消息并记录监控指标,或者返回一个明确的错误给客户端;
- 当isWritable恢复true之后,从队列里继续写积压的消息。
这个方案的收益是:内存占用基本稳定,不会随着带宽饱和而无限上涨;代价是:消息可能延迟或丢弃,需要业务方接受这种降级策略。
如果你不想做这么复杂的自定义逻辑,可以考虑Netty自带的ChannelTrafficShapingHandler,它支持对读写速率做限制。它在内部帮你看流量,但它在带宽饱和场景下更多是“人肉限速”,不如基于isWritable的动态策略更贴合实际瓶颈。
3. 连接接入层的实操:限流、拒连与优雅关闭
3.1 限制连接数的几种姿势
带宽饱和时,限制连接数是有必要的,但要搞清楚限的是哪个环节。
第一种方式是在设置ServerBootstrap时限制accept队列。这种方式管的是“内核层能容纳多少pending连接”,它的作用是防止连接洪峰瞬间把内核队列打满,但它管不了已经accept进来、正在被业务处理的连接。所以它只能算第一道防线。
第二种方式是在pipeline的channelActive事件里做限制。当连接建立成功,Netty回调channelActive时,通过一个计数器判断当前连接数是否达到上限。如果达到,就直接调用ctx.close()关闭,然后记录日志。这种方式控制的是“应用层可见的连接数”,能精确限制,但有一个小问题:从TCP三次握手完成到channelActive回调之间,连接已经在内核和Netty那边花了一些资源,如果限流发生在channelActive,这些资源已经白白消耗了。
要想更早限制,可以在ServerBootstrap的childHandler之前使用ChannelInitializer的initChannel里判断。不过说实话,相差并不大,多数场景在channelActive里做已经够了。
还有一种是按IP限流。在高带宽场景下,某个客户端可能因为自身问题发起大量重连,不做IP限流的话,会把整个服务拖垮。按IP限流可以参考这样的实现:用一个ConcurrentHashMap<InetAddress, AtomicInteger>统计每个IP的连接数,在channelActive时自增、channelInactive时自减。
实现限流器时,我提醒一点:连接数计数器必须用原子操作,防止并发场景下计数不准确。我的习惯是用AtomicInteger配合ConcurrentHashMap,而不是用get和set手工组合,那种写法在并发下很容易出现计数漂移。
3.2 拒绝连接还分为“直接不让你进”和“进来了马上送走”
连接数达到上限后,策略不是只有“关闭连接”这一种。
比较强硬的做法是直接关闭新的连接。客户端发起连接时,服务端accept成功之后,马上返回一个关闭。这种做法速度快、代码简单,但客户端可能会反复重试,形成“连接风暴”,反而消耗了更多的带宽和资源。
更柔和的做法是“先收下连接,然后返回一个业务层的繁忙提示,再关闭连接”。这种策略在TCP层看起来连接是成功建立的,但应用层立刻会收到一个明确定义的错误,比如“SERVER_BUSY”。客户端可以根据这个错误码做退避,而不是盲目重试。
实现上,需要在新连接Handler中先向客户端写入一个预定义好的错误报文,然后flush,再关闭连接。比如:
@Override public void channelActive(ChannelHandlerContext ctx) throws Exception { if (connectionLimiter.tryAcquire()) { ctx.fireChannelActive(); } else { ByteBuf buf = Unpooled.wrappedBuffer("SERVER_BUSY".getBytes(StandardCharsets.UTF_8)); ctx.writeAndFlush(buf).addListener(ChannelFutureListener.CLOSE); } }这里有个细节:写出去之后再关,和直接关,效果差别很大。直接关可能让客户端读到EOF,误以为是网络问题;先写错误报文再关,客户端能明确知道是服务端主动拒绝。这个语义在业务上很重要。
3.3 连接超时、空闲检测与假连接清理
带宽饱和时期,最容易出现的就是大量“假连接”——连接已经建立,但双方都没有数据传输,处于半死不活状态。如果不主动清理,这些连接会占住内存和文件描述符。
Netty里最常用的就是IdleStateHandler。它的作用是在指定时间内没有读事件、写事件或读写事件时,触发对应的IdleStateEvent。用法很简单,在pipeline里加上:
pipeline.addLast("idleStateHandler", new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));这个60就代表60秒内如果没有任何读事件,就会触发IdleStateEvent。然后在后面的自定义Handler里拦截这个事件:
@Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event = (IdleStateEvent) evt; if (event.state() == IdleState.READER_IDLE) { ctx.close(); } } else { super.userEventTriggered(ctx, evt); } }阈值怎么定,要看业务心跳周期。如果客户端每30秒发一个心跳,服务端60秒没读到任何东西基本可以判定连接已经断了或者对端卡死了。但注意不要设得太小,否则网络抖动会导致大量正常连接被误杀。我一般建议取心跳周期的1.5到2倍。
3.4 优雅关闭连接的具体细节
带宽饱和时,服务端有时需要主动降载,比如摘掉一部分连接,把流量导到别的节点。这时候“优雅关闭”就很关键。
所谓优雅,就是不让正在传输的数据突然断掉。理想流程是:
- 先停止从该连接读取新数据,也就是设置autoRead=false;
- 让已进入pipeline的数据继续处理完;
- 等待outboundBuffer中积压的数据全部flush出去;
- 再写一个明确的关闭信号给客户端;
- 最后关闭连接。
代码层面可以这样组织:
public void gracefulClose(ChannelHandlerContext ctx) { ctx.channel().config().setAutoRead(false); ctx.writeAndFlush(CLOSE_NOTIFY).addListener(future -> { if (future.isSuccess()) { ctx.close(); } else { LogUtil.warn("close notify write failed, force close"); ctx.close(); } }); }这里有一个常见误区:close操作本身是异步的,不要在EventLoop线程中调用sync()去等待关闭完成,那样会阻塞I/O线程。正确的做法是添加ChannelFutureListener,在关闭完成回调里做后续处理。
我见过有些人在优雅关闭时用Thread.sleep等待,这是非常糟糕的写法,会阻塞整个EventLoop,连带着其他所有挂在同一个EventLoop上的连接一起遭殃。不管你对关闭流程多不放心,都不要用sleep。
4. 业务处理与IO线程的隔离调优
4.1 线程模型的边界和常见误区
Netty的经典线程模型是:Boss线程负责accept,Worker线程(EventLoop)负责socket读写和pipeline处理。这个模型对于高并发I/O是很高效的,但前提是,EventLoop线程不能做耗时操作。
很多故障的根源并不是连接数太多,而是某个Handler里做了过于耗时的操作,把EventLoop线程卡住了。一旦一个EventLoop卡住,挂在它上面的所有连接都会被拖慢——因为pipeline里的读写都是串行执行的。
我见过一个典型场景:某个Handler在收到消息后,内部调用了一个外部服务的同步接口,响应时间最坏能达到几十秒。这个Handler跑在EventLoop上,结果就是该EventLoop上的所有连接全部超时。
所以无论带宽是否饱和,我都建议遵循一个铁律:EventLoop线程只做CPU密集型的轻量操作和编解码,所有可能阻塞的操作必须扔到独立的业务线程池中。
4.2 解码器和业务处理必须分开
很多人会忽略解码器的性能问题。带宽饱和时,数据包到达速度快,解码器如果过于复杂,I/O线程一样会被拖死。
常见的解码器耗时点包括:
- 大消息的json反序列化;
- 使用十分耗性能的加密解密流程;
- 在解码过程中查数据库或者远程配置中心。
这些逻辑都不应该放在解码器里。解码器只负责把字节流转换成业务对象,至于这个业务对象要不要查库、要不要调远程接口,那是业务线程池的事。
在实际项目中,我会在pipeline里这样设计:
pipeline.addLast("frameDecoder", new LengthFieldBasedFrameDecoder(...)); pipeline.addLast("msgDecoder", new JsonDecoder()); pipeline.addLast("businessExecutor", new BusinessHandler());BusinessHandler内部再通过业务线程池异步处理,而不是直接在EventLoop上执行业务逻辑。
4.3 业务线程池饱和策略怎么选
把业务逻辑丢到线程池后,你还需要决定线程池满载时怎么办。Java线程池提供了几种拒绝策略,但在Netty环境下,有一个常见陷阱需要特别注意。
ThreadPoolExecutor.CallerRunsPolicy的意思是“由调用线程执行任务”。如果这个策略用在Netty的EventLoop线程上,当线程池满了,EventLoop会直接执行任务——这就等于把阻塞操作带回EventLoop。其结果和你写一个耗时Handler没有区别,整个EventLoop都会被卡住。
所以在Netty环境里,我极度不推荐CallerRunsPolicy。那应该用什么?
我见过比较稳妥的方案是自定义一个RejectedExecutionHandler,在任务被拒绝时:
- 如果是心跳等关键控制消息,单独走一个紧急队列,保证不丢;
- 如果是普通业务消息,记录丢弃指标,并返回一个明确的繁忙响应给客户端;
- 绝不阻塞I/O线程。
如果想简单一点,也可以使用有界队列加AbortPolicy,配合拒绝指标监控。总之,宁可丢弃业务消息,也不要让EventLoop被阻塞。
4.4 合并消息、批量下发降低带宽损耗
带宽饱和时,小数据包过多会带来额外的协议开销,本身也是一种带宽浪费。如果你发现业务里有大量几十字节的小消息,就应该考虑合并发送。
最常见的做法是消息聚合:把一小段时间内的多条消息打包成一个大的ByteBuf,一次flush出去。这样TCP层可以利用Nagle算法和批量发送来提高有效载荷率,减少头部开销。
Netty里可以用PendingWriteQueue来积压消息,然后设定一个时间窗,定时flush。举个例子:每100毫秒批量flush一次,这样在高吞吐时能明显减少发送次数。
但这个方案要兼顾延迟敏感场景。如果是行情推送、实时对话这类对延迟要求很高的业务,批量处理会带来额外的延迟,需要权衡。我在设计时习惯把消息分为两类:延迟敏感类(比如心跳ACK、实时命令)优先发送;延迟不敏感类(比如批量统计、日志上报)走合并队列。这样既能减少带宽损耗,又不牺牲核心链路的延迟。
5. 实战排障:常见问题与排查技巧实录
5.1 先盯这几个监控指标
排查带宽饱和场景下的连接问题,Classic的JVM监控基本不够,你需要把视角放到Netty层和TCP层。
我在生产环境会盯着这几个指标:
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 当前连接数 | 已建立的连接总数 | 持续增长且不回落 |
| pendingWriteBytes | 所有连接待发送字节总数 | 持续上涨 |
| isWritable为false的连接数 | 出站缓冲超标的连接数量 | 数量超过总连接数的10% |
| 入站消息队列积压量 | pipeline内待处理的ByteBuf | 增长说明消费速度不及读取速度 |
| 业务线程池活跃度 | 正在执行任务的线程数 | 稳定等于最大线程数时可能过载 |
| TCP重传率 | 内核层重传比例 | 高是带宽饱和的典型表现 |
其中pendingWriteBytes这个指标,可以用下面的方式从Channel获取:
long pendingBytes = 0; for (Channel ch : channelGroup) { try { pendingBytes += ch.unsafe().outboundBuffer().totalPendingWriteBytes(); } catch (Exception ignored) {} }需要注意,outboundBuffer()这个接口不是对外公开的API,某些Netty版本里方法名可能不同,升级版本时要回归验证。如果你觉得依赖内部API风险大,退一步的方案是:统计isWritable为false的连接数,以及在业务代码中主动上报pending队列大小。
5.2 连接假死与连接泄漏排查
连接假死的典型特征是:TCP连接还维持着,但客户端和服务端之间已经没有有效数据了。排查时我喜欢两条线并行:
先看TCP层。用ss或者netstat看连接状态:
- CLOSE_WAIT状态过多,说明对端关闭了连接,但本端没有主动关闭,很可能是有连接泄漏;
- ESTABLISHED状态没有数据传输,可能是应用层卡死或带宽导致,也可能是对方悄悄断了,只是TCP还没感知。
再看应用层。用jstack找到对应的Netty线程,看它的状态。如果EventLoop线程长时间处于RUNNABLE但某个Handler里没有退出,说明业务Handler写了一个死循环或长耗时操作。如果EventLoop线程处于WAITING状态很久,则需要确认是不是autoRead被关闭后没有恢复。
假连接的处理,我更倾向于用“主动确认机制”而不是被动等待。除了IdleStateHandler之外,我会定期向客户端发送一次ping,要求客户端回pong。连续两次ping没有pong就判定连接失效,主动关闭。这种方案比单纯读空闲检测更可靠,能覆盖“对端悄悄卡死但TCP层面没有触发异常”的场景。
5.3 内存只涨不降的排查思路
带宽饱和时,最让人头疼的往往是内存“明明没泄漏,但就是不停涨”。这种场景通常不是Java堆泄漏,而是Netty的缓冲区积压。
排查步骤可以这样:
- 观察内存曲线,如果GC后能回到低位,说明不是泄漏;
- 检查pendingWriteBytes指标,看是不是有大量待发送数据堆积;
- 检查业务线程池队列深度,看是不是任务全部排队,没人消费;
- 用jmap -histo:live查看是否有大量byte[]或者PooledByteBuf对象堆积;
- 检查是否有关闭autoRead失败,导致数据不断从内核读入用户态。
有一次排查了很久,最后发现问题是高水位被设置成8MB,但业务里写文件时,一个文件块就是4MB。由于每个连接都一次性写入大量数据,8MB高水位实际上允许每个连接攒下十几个文件块,一个下载服务单连接就囤了几百MB。把批量写拆小,并且在水位达标时暂停写入,内存马上恢复正常。
所以在带宽饱和场景下,我通常会同时限制“单次写入大小”和“在线连接数量”这两个维度,不要只调水位。
5.4 常见问题速查表
| 问题现象 | 大概率原因 | 优先排查动作 |
|---|---|---|
| 新连接握手超时 | 全连接队列溢出 / 带宽饱和导致SYN-ACK丢失 | 查accept队列大小,查TCP重传率 |
| 连接建立成功但收不到响应 | EventLoop被阻塞 / 编解码器耗时 / autoRead被关闭未恢复 | jstack看线程状态,检查Handler耗时 |
| 内存只涨不降 | 出站缓冲积压 / 业务任务排队堆积 | 查pendingWriteBytes和线程池队列深度 |
| 大量CLOSE_WAIT | 业务没有正确关闭连接 | 检查channelInactive后有没有释放资源 |
| isWritable为false连接数过高 | 下游消费能力不足 / 大批量写入 | 调低高水位,判断是否暂停写入 |
| 部分连接长时间无数据 | 网络拥塞导致窗口收缩 / 应用层卡死 | 看TCP窗口,检查该连接所在EventLoop状态 |
这张表没有办法覆盖所有场景,但基本方向是对的。你排查问题时可以先对应一个典型原因,再逐步排除,不要一上来就怀疑Netty本身。
6. 最后想说的几点
那次“带宽饱和连接故障”之后,我最大的体会是:Netty并不复杂,复杂的是它和你所在的网络环境、业务模型之间的相互作用。同样是带宽饱和,文件传输服务和实时消息服务的解法完全不同;同样是连接数过多,有的要限流,有的要扩容,有的其实只要调一下SO_BACKLOG就能解决。
我个人现在做连接处理相关设计时,会先画一张图,把“数据从哪个环节进来、在哪个环节积压、压力怎么传导回上游”完整走一遍。只要这条链路是清晰的,遇到故障时就不会眉毛胡子一把抓。
最后分享一个小技巧:在压测带宽饱和场景时,不要只看“每秒能处理多少请求”,还要看“当带宽达到90%时,各连接的处理延迟分布”。你会发现,真正的问题往往不是处理不过来,而是某一条连接或某几个连接把链路资源吃光了,其他连接跟着遭殃。把这个问题想清楚了,连接处理的设计思路会完全不一样。