根据对Netty社区部分用户的调查,结合Netty在其它开源项目中的使用情况,我们可以看出目前Netty商用的主流版本集中在3.X和4.X上,其中以Netty 3.X系列版本使用最为广泛。 Netty社区非常活跃,3.X系列版本从2011年2月7日发布的netty-3.2.4 Final版本到2014年12月17日发布的netty-3.10.0 Final版本,版本跨度达3年多,期间共推出了61个Final版本。 相比于其它开源项目,Netty用户的版本升级之路更加艰辛,最根本的原因就是Netty 4对Netty 3没有做到很好的前向兼容。 由于版本不兼容,大多数老版本使用者的想法就是既然升级这么麻烦,我暂时又不需要使用到Netty 4的新特性,当前版本还挺稳定,就暂时先不升级,以后看看再说。 坚守老版本还有很多其它的理由,例如考虑到线上系统的稳定性、对新版本的熟悉程度等。无论如何升级Netty都是一件大事,特别是对Netty有直接强依赖的产品。 从上面的分析可以看出,坚守老版本似乎是个不错的选择;但是,“理想是美好的,现实却是残酷的”,坚守老版本并非总是那么容易,下面我们就看下被迫升级的案例。 除了为了使用新特性而主动进行的版本升级,大多数升级都是“被迫的”。下面我们对这些升级原因进行分析。 表面上看,类库包路径的修改、API的重构等似乎是升级的重头戏,大家往往把注意力放到这些“明枪”上,但真正隐藏和致命的却是“暗箭”。如果对Netty底层的事件调度机制和线程模型不熟悉,往往就会“中枪”。 本文以几个比较典型的真实案例为例,通过问题描述、问题定位和问题总结,让这些隐藏的“暗箭”不再伤人。 由于Netty 4线程模型改变导致的升级事故还有很多,限于篇幅,本文不一一枚举,这些问题万变不离其宗,只要抓住线程模型这个关键点,所谓的疑难杂症都将迎刃而解。 随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty4.X提供了基于内存池的缓冲区重用机制。性能测试表明,采用内存池的ByteBuf相比于朝生夕灭的ByteBuf,性能高23倍左右(性能数据与使用场景强相关)。 业务应用的特点是高并发、短流程,大多数对象都是朝生夕灭的短生命周期对象。为了减少内存的拷贝,用户期望在序列化的时候直接将对象编码到PooledByteBuf里,这样就不需要为每个业务消息都重新申请和释放内存。 业务的相关代码示例如下: 业务代码升级Netty版本并重构之后,运行一段时间,Java进程就会宕机,查看系统运行日志发现系统发生了内存泄露(示例堆栈):
图2-1 OOM内存溢出堆栈 对内存进行监控(切换使用堆内存池,方便对内存进行监控),发现堆内存一直飙升,如下所示(示例堆内存监控):
图2-2 堆内存监控 使用jmap -dump:format=b,file=netty.bin PID 将堆内存dump出来,通过IBM的HeapAnalyzer工具进行分析,发现ByteBuf发生了泄露。 因为使用了内存池,所以首先怀疑是不是申请的ByteBuf没有被释放导致?查看代码,发现消息发送完成之后,Netty底层已经调用ReferenceCountUtil.release(message)对内存进行了释放。这是怎么回事呢?难道Netty 4.X的内存池有Bug,调用release操作释放内存失败? 考虑到Netty 内存池自身Bug的可能性不大,首先从业务的使用方式入手分析: 初次排查并没有发现导致内存泄露的根因,一筹莫展之际开始查看Netty的内存池分配器PooledByteBufAllocator的Doc和源码实现,发现内存池实际是基于线程上下文实现的,相关代码如下: 也就是说内存的申请和释放必须在同一线程上下文中,不能跨线程。跨线程之后实际操作的就不是同一块内存区域,这会导致很多严重的问题,内存泄露便是其中之一。内存在A线程申请,切换到B线程释放,实际是无法正确回收的。 通过对Netty内存池的源码分析,问题基本锁定。保险起见进行简单验证,通过对单条业务消息进行Debug,发现执行释放的果然不是业务线程,而是Netty的NioEventLoop线程:当某个消息被完全发送成功之后,会通过ReferenceCountUtil.release(message)方法释放已经发送成功的ByteBuf。 问题定位出来之后,继续溯源,发现Netty 4修改了Netty 3的线程模型:在Netty 3的时候,upstream是在I/O线程里执行的,而downstream是在业务线程里执行。当Netty从网络读取一个数据报投递给业务handler的时候,handler是在I/O线程里执行;而当我们在业务线程中调用write和writeAndFlush向网络发送消息的时候,handler是在业务线程里执行,直到最后一个Header handler将消息写入到发送队列中,业务线程才返回。 Netty4修改了这一模型,在Netty 4里inbound(对应Netty 3的upstream)和outbound(对应Netty 3的downstream)都是在NioEventLoop(I/O线程)中执行。当我们在业务线程里通过ChannelHandlerContext.write发送消息的时候,Netty 4在将消息发送事件调度到ChannelPipeline的时候,首先将待发送的消息封装成一个Task,然后放到NioEventLoop的任务队列中,由NioEventLoop线程异步执行。后续所有handler的调度和执行,包括消息的发送、I/O事件的通知,都由NioEventLoop线程负责处理。 下面我们分别通过对比Netty 3和Netty 4的消息接收和发送流程,来理解两个版本线程模型的差异: Netty 3的I/O事件处理流程:
图2-3 Netty 3 I/O事件处理线程模型 Netty 4的I/O消息处理流程:
图2-4 Netty 4 I/O事件处理线程模型 Netty 4.X版本新增的内存池确实非常高效,但是如果使用不当则会导致各种严重的问题。诸如内存泄露这类问题,功能测试并没有异常,如果相关接口没有进行压测或者稳定性测试而直接上线,则会导致严重的线上问题。 内存池PooledByteBuf的使用建议: 某业务产品,Netty3.X升级到4.X之后,系统运行过程中,偶现服务端发送给客户端的应答数据被莫名“篡改”。 业务服务端的处理流程如下: 业务相关代码示例如下: 首先对应答消息被非法“篡改”的原因进行分析,经过定位发现当发生问题时,被“篡改”的内容是调用writeAndFlush接口之后,由后续业务分支代码修改应答消息导致的。由于修改操作发生在writeAndFlush操作之后,按照Netty 3.X的线程模型不应该出现该问题。 在Netty3中,downstream是在业务线程里执行的,也就是说对SubInfoResp的编码操作是在业务线程中执行的,当编码后的ByteBuf对象被投递到消息发送队列之后,业务线程才会返回并继续执行后续的业务逻辑,此时修改应答消息是不会改变已完成编码的ByteBuf对象的,所以肯定不会出现应答消息被篡改的问题。 初步分析应该是由于线程模型发生变更导致的问题,随后查验了Netty 4的线程模型,果然发生了变化:当调用outbound向外发送消息的时候,Netty会将发送事件封装成Task,投递到NioEventLoop的任务队列中异步执行,相关代码如下: 通过上述代码可以看出,Netty首先对当前的操作的线程进行判断,如果操作本身就是由NioEventLoop线程执行,则调用写操作;否则,执行线程安全的写操作,即将写事件封装成Task,放入到任务队列中由Netty的I/O线程执行,业务调用返回,流程继续执行。 通过源码分析,问题根源已经很清楚:系统升级到Netty 4之后,线程模型发生变化,响应消息的编码由NioEventLoop线程异步执行,业务线程返回。这时存在两种可能: 由于线程的执行先后顺序无法预测,因此该问题隐藏的相当深。如果对Netty 4和Netty3的线程模型不了解,就会掉入陷阱。 Netty 3版本业务逻辑没有问题,流程如下:
图3-1 升级之前的业务流程线程模型 升级到Netty 4版本之后,业务流程由于Netty线程模型的变更而发生改变,导致业务逻辑发生问题:
图3-2 升级之后的业务处理流程发生改变 很多读者在进行Netty 版本升级的时候,只关注到了包路径、类和API的变更,并没有注意到隐藏在背后的“暗箭”- 线程模型变更。 升级到Netty 4的用户需要根据新的线程模型对已有的系统进行评估,重点需要关注outbound的ChannelHandler,如果它的正确性依赖于Netty 3的线程模型,则很可能在新的线程模型中出问题,可能是功能问题或者其它问题。 相信很多Netty用户都看过如下相关报告: 在Twitter,Netty 4 GC开销降为五分之一:Netty 3使用Java对象表示I/O事件,这样简单,但会产生大量的垃圾,尤其是在我们这样的规模下。Netty 4在新版本中对此做出了更改,取代生存周期短的事件对象,而以定义在生存周期长的通道对象上的方法处理I/O事件。它还有一个使用池的专用缓冲区分配器。 每当收到新信息或者用户发送信息到远程端,Netty 3均会创建一个新的堆缓冲区。这意味着,对应每一个新的缓冲区,都会有一个‘new byte[capacity]’。这些缓冲区会导致GC压力,并消耗内存带宽:为了安全起见,新的字节数组分配时会用零填充,这会消耗内存带宽。然而,用零填充的数组很可能会再次用实际的数据填充,这又会消耗同样的内存带宽。如果Java虚拟机(JVM)提供了创建新字节数组而又无需用零填充的方式,那么我们本来就可以将内存带宽消耗减少50%,但是目前没有那样一种方式。 在Netty 4中,代码定义了粒度更细的API,用来处理不同的事件类型,而不是创建事件对象。它还实现了一个新缓冲池,那是一个纯Java版本的 jemalloc (Facebook也在用)。现在,Netty不会再因为用零填充缓冲区而浪费内存带宽了。 我们比较了两个分别建立在Netty 3和4基础上echo协议服务器。(Echo非常简单,这样,任何垃圾的产生都是Netty的原因,而不是协议的原因)。我使它们服务于相同的分布式echo协议客户端,来自这些客户端的16384个并发连接重复发送256字节的随机负载,几乎使千兆以太网饱和。 根据测试结果,Netty 4: 正是看到了相关的Netty 4性能提升报告,很多用户选择了升级。事后一些用户反馈Netty 4并没有跟产品带来预期的性能提升,有些甚至还发生了非常严重的性能下降,下面我们就以某业务产品的失败升级经历为案例,详细分析下导致性能下降的原因。 首先通过JMC等性能分析工具对性能热点进行分析,示例如下(信息安全等原因,只给出分析过程示例截图):
图4-1 JMC性能监控分析 通过对热点方法的分析,发现在消息发送过程中,有两处热点: 对使用Netty 3版本的业务产品进行性能对比测试,发现上述两个Handler也是热点方法。既然都是热点,为啥切换到Netty4之后性能下降这么厉害呢? 通过方法的调用树分析发现了两个版本的差异:在Netty 3中,上述两个热点方法都是由业务线程负责执行;而在Netty 4中,则是由NioEventLoop(I/O)线程执行。对于某个链路,业务是拥有多个线程的线程池,而NioEventLoop只有一个,所以执行效率更低,返回给客户端的应答时延就大。时延增大之后,自然导致系统并发量降低,性能下降。 找出问题根因之后,针对Netty 4的线程模型对业务进行专项优化,性能达到预期,远超过了Netty 3老版本的性能。 Netty 3的业务线程调度模型图如下所示:充分利用了业务多线程并行编码和Handler处理的优势,周期T内可以处理N条业务消息。
图4-2 Netty 3业务调度性能模型 切换到Netty 4之后,业务耗时Handler被I/O线程串行执行,因此性能发生比较大的下降:
图4-3 Netty 4业务调度性能模型 该问题的根因还是由于Netty 4的线程模型变更引起,线程模型变更之后,不仅影响业务的功能,甚至对性能也会造成很大的影响。 对Netty的升级需要从功能、兼容性和性能等多个角度进行综合考虑,切不可只盯着API变更这个芝麻,而丢掉了性能这个西瓜。API的变更会导致编译错误,但是性能下降却隐藏于无形之中,稍不留意就会中招。 对于讲究快速交付、敏捷开发和灰度发布的互联网应用,升级的时候更应该要当心。 为了提升业务的二次定制能力,降低对接口的侵入性,业务使用线程变量进行消息上下文的传递。例如消息发送源地址信息、消息Id、会话Id等。 业务同时使用到了一些第三方开源容器,也提供了线程级变量上下文的能力。业务通过容器上下文获取第三方容器的系统变量信息。 升级到Netty 4之后,业务继承自Netty的ChannelHandler发生了空指针异常,无论是业务自定义的线程上下文、还是第三方容器的线程上下文,都获取不到传递的变量值。 首先检查代码,看业务是否传递了相关变量,确认业务传递之后怀疑跟Netty 版本升级相关,调试发现,业务ChannelHandler获取的线程上下文对象和之前业务传递的上下文不是同一个。这就说明执行ChannelHandler的线程跟处理业务的线程不是同一个线程! 查看Netty 4线程模型的相关Doc发现,Netty修改了outbound的线程模型,正好影响了业务消息发送时的线程上下文传递,最终导致线程变量丢失。 通常业务的线程模型有如下几种: 在实践中我们发现很多业务使用了第三方框架,但是只熟悉API和功能,对线程模型并不清楚。某个类库由哪个线程调用,糊里糊涂。为了方便变量传递,又随意的使用线程变量,实际对背后第三方类库的线程模型产生了强依赖。当容器或者第三方类库升级之后,如果线程模型发生了变更,则原有功能就会发生问题。 鉴于此,在实际工作中,尽量不要强依赖第三方类库的线程模型,如果确实无法避免,则必须对它的线程模型有深入和清晰的了解。当第三方类库升级之后,需要检查线程模型是否发生变更,如果发生变化,相关的代码也需要考虑同步升级。 通过对三个具有典型性的升级失败案例进行分析和总结,我们发现有个共性:都是线程模型改变惹的祸! 下面小节我们就详细得对Netty3和Netty4版本的I/O线程模型进行对比,以方便大家掌握两者的差异,在升级和使用中尽量少踩雷。 Netty 3.X的I/O操作线程模型比较复杂,它的处理模型包括两部分: 我们首先分析下Inbound操作的线程模型:
图6-1 Netty 3 Inbound操作线程模型 从上图可以看出,Inbound操作的主要处理流程如下: 通过对Netty 3的Inbound操作进行分析我们可以看出,Inbound的Handler都是由Netty的I/O Work线程负责执行。 下面我们继续分析Outbound操作的线程模型:
图6-2 Netty 3 Outbound操作线程模型 从上图可以看出,Outbound操作的主要处理流程如下: 业务线程发起Channel Write操作,发送消息; 相比于Netty 3.X系列版本,Netty 4.X的I/O操作线程模型比较简答,它的原理图如下所示:
图6-3 Netty 4 Inbound和Outbound操作线程模型 从上图可以看出,Outbound操作的主要处理流程如下: 通过流程分析,我们发现Netty 4修改了线程模型,无论是Inbound还是Outbound操作,统一由I/O线程NioEventLoop调度执行。 在进行新老版本线程模型PK之前,首先还是要熟悉下串行化设计的理念: 我们知道当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。 为了解决上述问题,Netty 4采用了串行化设计理念,从消息的读取、编码以及后续Handler的执行,始终都由I/O线程NioEventLoop负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险,对于用户而言,甚至不需要了解Netty的线程细节,这确实是个非常好的设计理念,它的工作原理图如下:
图6-4 Netty 4的串行化设计理念 一个NioEventLoop聚合了一个多路复用器Selector,因此可以处理成百上千的客户端连接,Netty的处理策略是每当有一个新的客户端接入,则从NioEventLoop线程组中顺序获取一个可用的NioEventLoop,当到达数组上限之后,重新返回到0,通过这种方式,可以基本保证各个NioEventLoop的负载均衡。一个客户端连接只注册到一个NioEventLoop上,这样就避免了多个I/O线程去并发操作它。 Netty通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来的额外性能损耗。 了解完了Netty 4的串行化设计理念之后,我们继续看Netty 3线程模型存在的问题,总结起来,它的主要问题如下: 讲了这么多,似乎Netty 4 完胜 Netty 3的线程模型,其实并不尽然。在特定的场景下,Netty 3的性能可能更高,就如本文第4章节所讲,如果编码和其它Outbound操作非常耗时,由多个业务线程并发执行,性能肯定高于单个NioEventLoop线程。 但是,这种性能优势不是不可逆转的,如果我们修改业务代码,将耗时的Handler操作前置,Outbound操作不做复杂业务逻辑处理,性能同样不输于Netty 3,但是考虑内存池优化、不会反复创建Event、不需要对Handler加锁等Netty 4的优化,整体性能Netty 4版本肯定会更高。 总而言之,如果用户真正熟悉并掌握了Netty 4的线程模型和功能类库,相信不仅仅开发会更加简单,性能也会更优! 就Netty 而言,掌握线程模型的重要性不亚于熟悉它的API和功能。很多时候我遇到的功能、性能等问题,都是由于缺乏对它线程模型和原理的理解导致的,结果我们就以讹传讹,认为Netty 4版本不如3好用等。 不能说所有开源软件的版本升级一定都胜过老版本,就Netty而言,我认为Netty 4版本相比于老的Netty 3,确实是历史的一大进步。 李林锋,2007年毕业于东北大学,2008年进入华为公司从事高性能通信软件的设计和开发工作,有7年NIO设计和开发经验,精通Netty、Mina等NIO框架和平台中间件,现任华为软件平台架构部架构师,《Netty权威指南》作者。 联系方式:新浪微博 Nettying 微信:Nettying 微信公众号:Netty之家1. 背景
1.1. Netty 3.X系列版本现状
1.2. 升级还是坚守老版本
1.3. “被迫”升级到Netty 4.X
1.4. 升级不当付出的代价
2. Netty升级之后遭遇内存泄露
2.1. 问题描述
//在业务线程中初始化内存池分配器,分配非堆内存
ByteBufAllocator allocator = new PooledByteBufAllocator(true);
ByteBuf buffer = allocator.ioBuffer(1024);
//构造订购请求消息并赋值,业务逻辑省略
SubInfoReq infoReq = new SubInfoReq ();
infoReq.setXXX(......);
//将对象编码到ByteBuf中
codec.encode(buffer, info);
//调用ChannelHandlerContext进行消息发送
ctx.writeAndFlush(buffer);
2.2. 问题定位
final ThreadLocal<PoolThreadCache> threadCache = new ThreadLocal<PoolThreadCache>() {
private final AtomicInteger index = new AtomicInteger();
@Override
protected PoolThreadCache initialValue() {
final int idx = index.getAndIncrement();
final PoolArena<byte[]> heapArena;
final PoolArena<ByteBuffer> directArena;
if (heapArenas != null) {
heapArena = heapArenas[Math.abs(idx % heapArenas.length)];
} else {
heapArena = null;
}
if (directArenas != null) {
directArena = directArenas[Math.abs(idx % directArenas.length)];
} else {
directArena = null;
}
return new PoolThreadCache(heapArena, directArena);
}
2.3. 问题总结
3. Netty升级之后遭遇数据被篡改
3.1. 问题描述
//构造订购应答消息
SubInfoResp infoResp = new SubInfoResp();
//根据业务逻辑,对应答消息赋值
infoResp.setResultCode(0);
infoResp.setXXX();
后续赋值操作省略......
//调用ChannelHandlerContext进行消息发送
ctx.writeAndFlush(infoResp);
//消息发送完成之后,后续根据业务流程进行分支处理,修改infoResp对象
infoResp.setXXX();
后续代码省略......
3.2. 问题定位
@Override
public void invokeWrite(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
if (msg == null) {
throw new NullPointerException("msg");
}
validatePromise(ctx, promise, true);
if (executor.inEventLoop()) {
invokeWriteNow(ctx, msg, promise);
} else {
AbstractChannel channel = (AbstractChannel) ctx.channel();
int size = channel.estimatorHandle().size(msg);
if (size > 0) {
ChannelOutboundBuffer buffer = channel.unsafe().outboundBuffer();
// Check for null as it may be set to null if the channel is closed already
if (buffer != null) {
buffer.incrementPendingOutboundBytes(size);
}
}
safeExecuteOutbound(WriteTask.newInstance(ctx, msg, size, promise), promise, msg);
}
}
3.3. 问题总结
4. Netty升级之后性能严重下降
4.1. 问题描述
4.2. 问题定位
4.3. 问题总结
5. Netty升级之后上下文丢失
5.1. 问题描述
5.2. 问题定位
5.3. 问题总结
6. Netty3.X VS Netty4.X 之线程模型
6.1 Netty 3.X 版本线程模型
6.2 Netty 4.X 版本线程模型
6.3. 线程模型对比
6.4. 思考
7. 作者简介