简介:这份源码资源面向具备一定Java与网络编程基础、希望深入高并发服务架构的开发者,围绕Netty与Disruptor两大框架的整合展开,解决百万级长连接场景下的高性能消息分发与低延迟处理问题。资源包共23个文件,以14个Java源码为核心,辅以3个xml构建配置、3个zbak备份文件及md说明文档等,整体约24KB,按client、common、server等模块组织,便于对照理解工程结构。已有44人学习下载。通过阅读源码,读者可掌握Netty的ChannelPipeline处理器链设计、Disruptor环形缓冲区无锁事件队列的集成方式,以及I/O事件如何发布到RingBuffer并分发至业务处理单元,从而获得一套可参考的百万长连接服务骨架与并发优化思路。
1. Netty 与 Disruptor 整合:百万级长连接服务的真实门槛在哪里
单机 80 万长连接、消息 P99 延迟压到 10ms 以内,这个量级下真正先崩的往往不是 Netty 的 I/O 线程,而是业务线程池的队列和锁竞争。Netty 负责把海量连接上的字节流高效收进来,Disruptor 负责把这些事件以无锁环形队列的方式分发给业务消费者,两者拼在一起,才构成一套能扛住百万级长连接的骨架。这套架构适合做高并发 IM、推送网关、物联网设备接入、行情推送等场景,前提是你已经能跑通 Netty 的基本收发,并且愿意为内存布局和线程模型花时间调参。源码解析的价值不在于背下类图,而在于搞清楚每个 RingBuffer 槽位、每个 EventLoop 线程到底在干什么,出问题时才知道去哪里看。
2. 为什么是 Netty 加 Disruptor:线程模型与队列选型的硬账
2.1 Netty 主从 Reactor 的瓶颈到底在哪
Netty 的默认线程模型是主从 Reactor:bossGroup 负责 accept,workerGroup 负责 read/write,每个 EventLoop 绑定一个线程,串行处理注册在它上面的所有 Channel。这个设计在连接数暴涨时非常稳,因为一个连接的所有事件永远在同一个线程里执行,天然没有并发问题。但问题出在业务处理上:如果你在 channelRead 里直接做数据库查询、RPC 调用或者复杂计算,EventLoop 线程就被阻塞了,它上面挂着的成千上万个连接全部卡住。
常见的补救办法是往业务线程池里丢任务,但 ThreadPoolExecutor 在百万级 QPS 下会暴露两个硬伤。第一是锁:核心线程池的 BlockingQueue 用 ReentrantLock 保护,入队出队都要抢锁,线程一多锁竞争就变成瓶颈。第二是伪共享和缓存失效:任务对象在堆上散落分布,CPU 缓存命中率低,上下文切换开销大。这两个问题在连接数上到几十万、消息吞吐上到百万级时会被放大到不可忽视。
Disruptor 的设计目标就是解决这两个问题。它用 RingBuffer 这个预分配的环形数组替代队列,生产者通过 CAS 抢占序号,消费者通过序号屏障协调进度,全程无锁。数组内存连续,CPU 预取友好;序号用 long 填充缓存行,避免伪共享。这就是为什么高并发长连接服务里,Netty 收包、Disruptor 分发的组合会成为常见做法。
2.2 RingBuffer 的序号机制与等待策略
RingBuffer 的核心是 sequence。生产者要发布事件时,先 CAS 申请一个可用序号,然后往对应槽位写数据,最后 publish 这个序号。消费者维护自己的 sequence,通过 SequenceBarrier 等待可消费的序号。这里的关键参数是 RingBuffer 大小,必须是 2 的幂,常见取值 1024、4096、8192、16384。太小会导致生产者覆盖未消费数据,太大会增加缓存 miss 概率。
等待策略直接决定延迟和 CPU 占用。BlockingWaitStrategy 用锁和条件变量,CPU 占用低但延迟高;BusySpinWaitStrategy 自旋等待,延迟最低但吃满一个核;YieldingWaitStrategy 自旋加 Thread.yield(),是延迟和 CPU 的折中,也是大多数长连接服务的默认选择。我一般会在压测时把三种策略都跑一遍,看 P99 和 CPU 曲线再定。
// Disruptor 初始化:RingBuffer 大小、线程工厂、等待策略 int ringBufferSize = 8192; // 必须是 2 的幂 Disruptor<NettyEvent> disruptor = new Disruptor<>( NettyEvent::new, // EventFactory,预分配事件对象 ringBufferSize, DaemonThreadFactory.INSTANCE, // 线程工厂,设为守护线程 ProducerType.MULTI, // 多生产者,Netty 多个 EventLoop 同时写入 new YieldingWaitStrategy() // 等待策略,压测后按需替换 );这段代码里 ProducerType.MULTI 很关键。Netty 的多个 worker 线程都会往 RingBuffer 里写,必须用 MULTI 模式,底层用 CAS 而不是单生产者的无竞争写入。EventFactory 负责预分配事件对象,RingBuffer 启动时就会创建 ringBufferSize 个实例,运行期不再 new,这是避免 GC 抖动的核心。线程工厂建议设成守护线程,否则 JVM 退出时会被 Disruptor 线程卡住。
2.3 事件对象的内存布局与伪共享规避
Disruptor 的事件对象是复用的,消费者处理完必须重置字段,否则下一轮会读到脏数据。更隐蔽的坑是伪共享:如果多个消费者线程频繁读写相邻的字段,这些字段落在同一个缓存行里,一个线程的写会导致另一个线程的缓存行失效,性能断崖式下跌。Disruptor 内部用继承链加 padding 的方式给 Sequence 填充了 7 个 long,确保每个 Sequence 独占缓存行。你自己定义的事件对象如果要在多线程间共享,也要注意把热点字段隔开。
// 自定义事件对象:字段按读写频率分组,避免伪共享 public class NettyEvent { // 高频读写字段放一起 public long channelId; public byte[] payload; public int length; // 低频字段隔开 public long timestamp; public int eventType; public void clear() { channelId = 0; payload = null; // 帮助 GC,但注意别频繁置 null 造成额外开销 length = 0; timestamp = 0; eventType = 0; } }clear() 方法在消费者处理完后调用,把事件对象恢复到初始状态。payload 置 null 是为了让大数组能被回收,但如果你的消息体很小且复用同一个 byte[],就不要置 null,直接覆盖内容即可。这里没有银弹,得根据消息大小分布来定。我见过有人把 payload 设成固定 64KB 数组复用,结果小消息场景下内存直接爆掉,这就是没算清楚账。
3. 从 Netty 收包到 Disruptor 分发:最小可跑通的整合链路
3.1 在 channelRead 里发布事件到 RingBuffer
整合的入口在 Netty 的 ChannelInboundHandler。收到数据后,不要做任何业务逻辑,直接把 ByteBuf 内容拷贝到 Disruptor 事件里发布。这里有个取舍:拷贝有开销,但不拷贝的话 ByteBuf 的引用计数管理会跨线程,极易泄漏。常见做法是拷贝到堆内 byte[],然后 release ByteBuf。
public class NettyServerHandler extends ChannelInboundHandlerAdapter { private final RingBuffer<NettyEvent> ringBuffer; public NettyServerHandler(RingBuffer<NettyEvent> ringBuffer) { this.ringBuffer = ringBuffer; } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { ByteBuf buf = (ByteBuf) msg; try { int len = buf.readableBytes(); // 申请下一个可用序号 long seq = ringBuffer.next(); try { NettyEvent event = ringBuffer.get(seq); event.channelId = ctx.channel().id().asLongText().hashCode(); event.length = len; // 拷贝到堆内数组,避免跨线程持有 ByteBuf if (event.payload == null || event.payload.length < len) { event.payload = new byte[len]; } buf.readBytes(event.payload, 0, len); } finally { // 必须在 finally 里 publish,否则序号泄漏 ringBuffer.publish(seq); } } finally { buf.release(); } } }ringBuffer.next() 和 publish(seq) 必须成对出现,中间任何异常都要保证 publish 被执行,否则这个序号永远不被释放,消费者会卡死。这是血泪经验,线上出过一次因为业务代码抛异常导致整个 RingBuffer 停摆。payload 数组按需扩容,避免每条消息都 new 一个数组,但也要注意别让单个大消息把数组撑得太大。
3.2 消费者批量拉取与背压处理
消费者端用 WorkHandler 或 EventHandler 都可以,区别是 WorkHandler 多个消费者分摊同一批序号,EventHandler 每个消费者都收到全量。长连接服务通常用 WorkHandler 做并行处理。批量拉取能显著提升吞吐,Disruptor 提供 BatchEventProcessor,一次可以拉取多个可用序号。
// 消费者:批量处理,每批最多 64 条 public class NettyEventConsumer implements WorkHandler<NettyEvent> { private static final int BATCH_SIZE = 64; @Override public void onEvent(NettyEvent event) throws Exception { // 单条处理逻辑 process(event); } private void process(NettyEvent event) { // 业务处理:解码、路由、写回 // 注意不要在这里做阻塞操作 } } // 启动消费者 disruptor.handleEventsWithWorkerPool( new NettyEventConsumer(), new NettyEventConsumer(), new NettyEventConsumer(), new NettyEventConsumer() ); disruptor.start();handleEventsWithWorkerPool 里的消费者数量建议和 CPU 核数挂钩,一般是核数的 1 到 2 倍。消费者内部不要做阻塞调用,如果必须做 RPC 或 DB 操作,再往下一级线程池丢,但那样就失去了 Disruptor 的意义。背压方面,Disruptor 本身没有内置背压,生产者会一直覆盖旧数据。如果消费者跟不上,要么扩容消费者,要么在生产者侧做限流,比如用 Netty 的 WriteBufferWaterMark 或者自定义的令牌桶。
3.3 连接生命周期事件与心跳的接入方式
长连接服务除了消息收发,还有连接建立、断开、心跳超时这些事件。这些事件频率远低于消息,但也不能忽略。常见做法是单独开一个小 RingBuffer 或者直接用 Netty 的 EventLoop 处理,不要和消息走同一个 RingBuffer,否则低频事件会被高频消息淹没,延迟不可控。
// 连接断开时清理关联资源 @Override public void channelInactive(ChannelHandlerContext ctx) { long channelId = ctx.channel().id().asLongText().hashCode(); // 从连接映射表移除 connectionMap.remove(channelId); // 通知业务层做清理,走独立通道 cleanupExecutor.execute(() -> cleanup(channelId)); }心跳检测用 IdleStateHandler 就够了,读空闲触发后主动关闭连接。注意心跳超时时间要结合客户端行为设置,太短会误杀弱网连接,太长会积累僵尸连接。我一般设读空闲 90 秒、写空闲 30 秒,具体看业务场景。
4. 百万连接压测下的参数调优与避坑清单
4.1 文件描述符、内存与 GC 的联动调参
百万连接首先撞的是文件描述符上限。Linux 默认单进程 1024,必须改。同时每个连接在内核里占用的读写缓冲区、Netty 的 ByteBuf、Disruptor 的事件对象,加起来内存开销要提前算。一个连接如果占 10KB,百万连接就是 10GB,加上 JVM 堆和元空间,机器内存至少 32GB 起步。
# 系统层调参 ulimit -n 1000000 sysctl -w net.core.somaxconn=65535 sysctl -w net.ipv4.tcp_max_syn_backlog=65535 sysctl -w net.ipv4.ip_local_port_range="1024 65535" sysctl -w net.ipv4.tcp_tw_reuse=1JVM 层建议用 G1 或 ZGC,堆大小根据连接数算。每个连接在 Netty 里至少有一个 Channel 对象、一个 Pipeline、若干 Handler,粗算 2KB 到 5KB。Disruptor 的 RingBuffer 预分配事件对象,8192 个槽位如果每个 payload 平均 1KB,就是 8MB,可以接受。GC 日志一定要开,观察 Full GC 频率,超过一天一次就要查内存泄漏。
4.2 避坑清单:五个真实翻车现场
现象一:连接数上到 30 万后新连接被拒绝。原因:somaxconn 和 tcp_max_syn_backlog 没调,accept 队列满了。解决:按上面命令调大内核参数,同时检查 Netty 的 SO_BACKLOG 是否设置合理,建议 1024 以上。
现象二:消息延迟周期性飙高,P99 从 5ms 跳到 500ms。原因:Disruptor 消费者里做了阻塞操作,比如同步写日志或调用外部服务,导致 RingBuffer 积压。解决:消费者内只做内存操作,阻塞逻辑异步化,同时监控 RingBuffer 剩余容量。
现象三:运行几小时后 OOM,堆 dump 显示大量 ByteBuf。原因:channelRead 里没有 release,或者异常路径漏了 release。解决:用 try-finally 保证 release,或者用 SimpleChannelInboundHandler 自动释放。开启 Netty 的泄漏检测级别 PARANOID 排查。
现象四:CPU 利用率只有 30% 但吞吐上不去。原因:等待策略用了 BlockingWaitStrategy,线程频繁休眠唤醒。解决:换成 YieldingWaitStrategy 或 BusySpinWaitStrategy,压测对比。注意 BusySpin 会吃满核,要留够 CPU 给 Netty 的 I/O 线程。
现象五:Disruptor 消费者处理速度忽快忽慢。原因:事件对象没有正确 clear,消费者读到了上一轮的残留数据,业务逻辑走了异常分支。解决:在 onEvent 开头或结尾强制 clear,或者用 EventHandler 的 onEvent 里先重置再处理。
4.3 监控指标:哪些数字必须盯住
长连接服务的监控不能只看 QPS。必须盯的指标包括:当前连接数、每秒新建连接数、每秒断开连接数、RingBuffer 剩余容量、消费者处理延迟 P50/P99/P999、EventLoop 的任务队列积压、GC 停顿时间、文件描述符使用量。RingBuffer 剩余容量低于 20% 就要告警,说明消费者跟不上了。EventLoop 队列积压说明有阻塞操作混进了 I/O 线程。
// 定期上报 RingBuffer 剩余容量 long remaining = ringBuffer.remainingCapacity(); if (remaining < ringBufferSize * 0.2) { log.warn("RingBuffer 剩余容量告警: {}", remaining); }这些指标用 Micrometer 或 Prometheus 客户端暴露出去,配合 Grafana 看板。没有监控的百万连接服务就是黑匣子,出了问题只能重启,那是运维的噩梦。
5. 源码级排查技巧:用 JFR 和火焰图定位 Disruptor 消费延迟
线上出问题时,日志往往不够用。我习惯用 JFR(Java Flight Recorder)抓一段时间的运行记录,重点看 Disruptor 消费者线程的状态和 RingBuffer 的序号推进情况。JFR 开销低,生产环境也能开。
# 开启 JFR,持续 60 秒,输出到文件 jcmd <pid> JFR.start duration=60s filename=recording.jfr # 分析时重点看 ExecutionSample 和 JavaMonitorEnter 事件 jfr print --events ExecutionSample recording.jfr | grep -A 5 "Disruptor"火焰图用 async-profiler 抓,命令是./profiler.sh -d 30 -f flamegraph.html <pid>。重点看 Disruptor 消费者线程的栈,如果大量时间花在 YieldingWaitStrategy 的 yield 上,说明消费者数量不够或者单条处理太慢;如果花在业务方法上,就去优化那个方法。Netty 的 I/O 线程如果出现在火焰图顶部且占比高,检查是否有业务逻辑泄漏到了 channelRead 里。
还有一个技巧是给 RingBuffer 的序号推进打点。在消费者处理前后记录 System.nanoTime(),算出差值,按分钟聚合上报。这样能精确知道是消费慢还是生产快。我一般会在压测阶段就加上这个打点,上线后保留低采样率版本,出问题时能直接看历史曲线。
最后说个习惯:每次调整 RingBuffer 大小、等待策略、消费者数量这三个参数,都要重新跑一遍全链路压测,记录 P99 和 CPU 曲线。不要凭感觉调,也不要抄别人的配置。机器规格、消息大小、业务逻辑复杂度不同,最优参数就不同。希望帮到你。
本文还有配套的精品资源,点击获取