本节摘要:性能问题先分型(延迟型还是吞吐型),再按层排查(应用、线程、内存、内核),一次只动一个参数并压测对照。本节给出决策清单 SVG、四个维度的具体参数与典型病案,覆盖内存分析、线程竞争与 CPU 利用率的联动判断。
两种病的表征相反,用药也相反。延迟型:P99 抖动、个别请求超时,平均却很好;吞吐型:整体 QPS 上不去、CPU 可能没满。混淆分型是调优最大的弯路——给延迟病开吞吐药(加线程),常常越治越糟。
| 症状 | 常见根因 | 首查指标 |
|---|---|---|
| P99 尖刺、偶发超时 | GC 停顿、慢调用占用 EventLoop、锁竞争 | GC 日志、EventLoop 任务队列延迟 |
| 吞吐封顶、CPU 未满 | 锁争用、串行化瓶颈、批量不足、内核参数 | 线程状态采样、上下文切换次数 |
| 吞吐封顶、CPU 已满 | 解码效率低、拷贝过多、算法问题 | 火焰图热点 |
| 内存缓慢上涨 | ByteBuf 泄漏、队列无界积压 | 4.3 泄漏检测、堆外曲线 |

应用层:解码后的业务对象复用(对象池见 4.3 的先量后改原则);日志异步化——同步日志在 20 万 QPS 下就是最大串行段;攒批发送(writeN 次 + flush 一次)对吞吐提升立竿见影。
线程层:worker 数按 6.1 的计算密度原则;业务线程池与 IO 线程隔离(6.2);上下文切换次数(pidstat -w)异常高时,优先怀疑锁竞争而不是线程数不够。
内存层:池化保持默认;PooledByteBufAllocator metric() 观察小格命中率;GC 选型上,大堆低延迟场景 G1/ZGC 的停顿优势远大于任何 Netty 参数微调。
内核层(Linux 常用项,改动需压测验证):
net.core.somaxconn=65535 与 SO_BACKLOG 取小者生效,两处要同步调 net.ipv4.tcp_max_syn_backlog=65535 半连接队列,抗连接风暴 net.core.netdev_max_backlog=65535 网卡到内核的收包队列 net.ipv4.tcp_tw_reuse=1 短连接场景复用 TIME_WAIT 端口 ulimit -n 1000000 文件描述符上限,第八章资源耗尽的主角
病案一:P99 每隔几分钟跳一次。 GC 日志显示老年代回收停顿 300ms,与尖刺时间吻合;进一步看是解码后的大对象频繁晋升老年代。处方:消息对象瘦身(字段裁剪)+ 晋升阈值调整,尖刺消失。教训:延迟问题先看 GC,再看 Netty。
病案二:QPS 压到八万就上不去,CPU 才六成。 jstack 采样发现 worker 线程大量时间在 BLOCKED,等一把业务全局锁。处方:锁粒度从"全局 Map"缩到"分片 Map",QPS 上到二十万。教训:CPU 不满的吞吐瓶颈,八成在等待而非计算。
对自定义协议来说,现成压测工具(面向 HTTP 的居多)经常对不上口径,自写一个 Netty 负载客户端反而最快——顺带把前六章的知识全部用上:
public class LoadClient { public static void main(String[] args) throws Exception { int conns = 200, perConn = 5; // 200 连接 × 每连接 5 并发 EventLoopGroup group = new NioEventLoopGroup(); Bootstrap b = new Bootstrap(); b.group(group).channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast( new LengthFieldBasedFrameDecoder(1 << 20, 0, 4, 0, 4), new LoadHandler()); // 记延迟的工位 } }); for (int i = 0; i < conns; i++) b.connect("127.0.0.1", 8080).sync(); Thread.sleep(60_000); // 压 60 秒 group.shutdownGracefully(); } } class LoadHandler extends ChannelInboundHandlerAdapter { static final Histogram LAT = new Histogram(3600_000_000L, 3); // 纳秒直方图 @Override public void channelActive(ChannelHandlerContext ctx) { send(ctx); } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { LAT.recordValue(System.nanoTime() - start); // 记录一来一回的耗时 ReferenceCountUtil.release(msg); send(ctx); // 立刻发下一发,管道压满 } void send(ChannelHandlerContext ctx) { ByteBuf out = ctx.alloc().buffer(8); out.writeInt(4).writeInt(1); ctx.writeAndFlush(out); start = System.nanoTime(); } long start; }
跑完后取分位数:LAT.getValueAtPercentile(99) / 1_000_000 即 P99 毫秒数。三个使用要点:负载端与服务端必须在不同机器(否则压的是自己);负载端自身也要观察(客户端 EventLoop 打满时测得的是负载端上限);每连接"收到即发下一发"的管道式模型,比固定速率模型更能暴露队列积压问题——速率恒定时积压被掩盖,管道压满时积压无处可藏。
⚠️ 常见坑:在开发机上压测找"最优线程数"。开发机的 CPU、网卡、内核参数与生产完全不同,得出的数值没有迁移性;容量结论必须在同规格环境得出。
💡 关键直觉:调优清单的价值不在"照着做",而在"不漏项、不跳步"。症状分型 → 分层定位 → 单变量改动 → 压测对照,这个循环本身就是方法论,参数只是循环里的耗材。