背景
Tracon是Square公司的反向代理软件,最初它主要用于协调后段架构从传统单体架构向微服务架构的转换。作为反向代理前端,Tracon需要有非常优秀的性能,同时能够支撑微服务架构下的各种功能定制,例如:服务发现、配置和生命周期管理等。因此Tracon网络层基于Netty构建,以提供高效代理服务。
Tracon已经上线运行3年,其代码行数也增加到30000行。基于Netty 3的代理模块在如此庞大复杂的应用中运转正常,并抽离成独立模块应用到Square内部认证代理服务中。
Netty 4?
Netty 4已经发布3年了,相比于Netty 3,Netty 4在内存模型和线程模型上都进行了修改。现在Netty 4已经非常成熟,并且对于Square公司来说,Netty 4还有一个重大特性:对HTTP/2协议的原生支持。Square期望其移动设备都使用HTTP/2协议,并且正在将后台RPC框架切换到gRPC:一个基于HTTP/2协议的RPC框架。因此,Tracon作为代理服务,必须支持HTTP/2协议。
Tracon已经完成了到Netty 4的升级,整个升级过程也不是一帆风顺的,以下着重介绍一些在升级过程中容易遇到的问题。
单线程channel
和Netty 3不同,Netty 4的inbound(数据输入)事件和outbound(数据输出)事件的所有处理器(handler)都在同一个线程中。这是得在编写处理器的时候,可以移除线程安全相关的代码。但是,这个变化也使得在升级过程中遇到条件竞争导致的问题。
在Netty 3中,针对pipeline的操作都是线程安全的,但是在Netty 4中,所有操作都会以事件的形式放入事件循环中异步执行。作为代理服务的Tracon,会有一个独立的inbound channel和上游服务器进行交互,一个独立的outbound channel和下游服务器进行交互。为了提高性能,和下游服务器的连接会被缓存起来,因此当事件循环中的事件触发了写操作时,这些写操作可能会并发进行。这对于Netty 3来说没有问题,每个写操作都会完成后再返回;但是对于Netty 4,这些操作都进入了事件循环,可能会导致消息的乱序。
因此,在分块测试中,偶尔会遇到发出去的数据不是按照顺序到达,导致测试失败。
当从Netty 3升级到Netty 4时,如果有事件在事件循环外触发时,必须特别注意这些事件会被异步的调度。
连接何时真正建立?
Netty 3中,连接建立之后会发出channelConnected
事件;而在Netty 4中,这个事件变成了channelActive
。对于一般应用程序来说,这个改动变化不大,修改一下对应的事件处理方法即可。但是Tracon使用了双向TLS认证以确认对方身份。
对于两个版本的SslHandler
,TLS握手完成消息处理方式完全不同。在Netty 3中,SslHandler
在channelConnected
事件处理方法中阻塞,并完成整个TLS握手。因此后续的处理器在channelConnected
事件处理方法中就可以获得完成握手的SSLSession
。Netty 4则不同,由于其事件机制,SslHandler
完成TLS握手也是异步进行的,因此直接在channelConnected
事件中,是无法获取到SSLSession
的,此时TLS握手还没有完成。对应的SslHandler
会在TLS握手完成之后,发出自定义的SslHandshakeCompletionEvent
事件。
对于Netty 4,TLS握手完成后的逻辑应该改成:
@Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws if (evt.equals(SslHandshakeCompletionEvent.SUCCESS)) { Principal peerPrincipal = engine.getSession().getPeerPrincipal(); // 身份验证 // ... } super.userEventTriggered(ctx, evt); }
NIO内存泄漏
由于NIO使用direct内存,对于Netty这类网络库,监控direct内存是很有必要的,这可以通过使用JMX beanjava.nio:type=BufferPool,name=direct
来进行。
Netty 4引入了基于线程局部变量的回收器(thread-local Recyler)来回收对象池。默认情况下,一个回收器可以最多持有262k个对象,对于ByteBuf来说,小于64k的对象都默认共用缓存。也就是说,每个回收器最多可以持有17G的direct内存。
通常情况下,NIO缓存足够应付瞬间的数据量。但是如果有一个读取速度很慢的后端,会大大增加内存使用。另外,当缓存中的NIO内存在被其他线程读写时,分配该内存的线程会无法回收这些内存。
对于回收器无法回收导致内存耗尽的问题,Netty项目也做了一些修正,以解决限制对象增长的问题:
- 允许设置每个线程的WeakOrderQueue实例的最大数量;
- 回收器中引入内存分配/共用比例;
- 从Netty 3.10移植SendBufferPooled,当使用非共用ByteBuf分配器(ByteBufAllocator)时使用;
从升级Netty 4的经验来看,建议所有开发者基于可用内存和线程数来配置回收器。回收器最大持有对象数可以通过-Dio.netty.recycler.maxCapacity
参数设置,共用内存最大限制可以通过-Dio.netty.threadLocalDirectBufferSize
参数设置。如果要完全关闭回收器,可以将-Dio.netty.recycler.maxCapacity
设置为0,从Tracon的使用过程来看,使用回收器并没有对性能又多大的提升。
Tracon在内存泄漏上还做了一个小的改动:当JVM抛出错误时,通过一个全局的异常处理类(UncaughtExceptionHandler
)直接退出应用。因为通常情况下,当应用程序遇到了OutOfMemoryError
错误时,已经无法自我恢复。
class LoggingExceptionHandler implements Thread.UncaughtExceptionHandler { private static final Logger logger = Logger.getLogger(LoggingExceptionHandler.class); /** 注册成默认处理器 */ static void registerAsDefault() { Thread.setDefaultUncaughtExceptionHandler(new LoggingExceptionHandler()); } @Override public void uncaughtException(Thread t, Throwable e) { if (e instanceof Exception) { logger.error("Uncaught exception killed thread named '" + t.getName() + "'.", e); } else { logger.fatal("Uncaught error killed thread named '" + t.getName() + "'." + " Exiting now.", e); System.exit(1); } } }
限制回收器使用解决了泄漏问题,但是一个读取速度很慢的后端还是会消耗大量缓存。Tracon中通过使用channelWritabilityChanged
事件来缓解写入缓存压力。通过增加如下处理器,可以关联两个channel的读写:
/** * 监听当前inbound管道是否可写,设置关联的channel是否自动读取。 * 这可以让代理通知另外一端当前channel有一个读取很慢的消费者, * 仅当消费者准备完成后再进行数据读取。 */ public class WritabilityHandler extends ChannelInboundHandlerAdapter { private final Channel otherChannel; public WritabilityHandler(Channel otherChannel) { this.otherChannel = otherChannel; } @Override public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception { boolean writable = ctx.channel().isWritable(); otherChannel.config().setOption(ChannelOption.AUTO_READ, writable); super.channelWritabilityChanged(ctx); } }
当发送缓存到达高水位线时,将被标记为不可写,当发送缓存降低到低水位线时,重新被标记为可写。默认情况下,高水位线为64kb,低水位线为32kb。这些参数可以根据实际情况进行修改。
避免写异常丢失
当发生写操作失败时,如果没有对promise设置监听器,写操作失败会被忽略,这对于系统稳定性的分析会有很大影响。为了避免这种情况的发生,针对promise的监听器非常重要,但是如果每次创建promise时都需要设置一个日志记录的监听器,成本比较高,也容易遗忘。针对这种情况,Tracon中针对outbound事件设置了专门的处理器,统一为写操作的promise设置日志记录监听器:
@Singleton @Sharable public class PromiseFailureHandler extends ChannelOutboundHandlerAdapter { private final Logger logger = Logger.getLogger(PromiseFailureHandler.class); @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { promise.addListener(future -> { if (!future.isSuccess()) { logger.info("Write on channel %s failed", promise.cause(), ctx.channel()); } }); super.write(ctx, msg, promise); } }
这样,只需要在pipeline中添加该处理器即可记录所有的写异常日志。
HTTP解码器重构
Netty 4对HTTP解码器做了重构,特别完善了对分块数据的支持。HTTP消息体被拆分成HttpContent
对象,如果HTTP数据通过分块的方式传输,会有多个HttpContent
顺序到达,当数据块传输结束时,会有一个LastHttpContent
对象达到。这里需要特别注意的是,LastHttpContent
继承自HttpContent
,千万不能用以下方式来处理:
if (msg instanceof HttpContent) { ... } if (msg instanceof LastHttpContent) { … // 最后一个分块会重复处理,前面的if已经包含了LastHttpContent }
对于LastHttpContent
还有一个需要注意的是,接收到这个对象时,HTTP消息体可能已经传输完了,此时LastHttpContent
只是作为HTTP传输的结束符(类似EOF)。
灰度发布
这次升级Netty 4,涉及到100多个文件共8000多行代码。并且,由于线程模型和内存模型的修改,Tracon的替换必须非常小心。
在完成了发布前的单元测试、集成测试之后,首先需要部署到生产环境,并关闭流量。这样,代理服务能够和后端服务交互,同时避免用户真实流量导入。此时,需要正对这些服务做最终的确认,确保和线上后端服务交互没有任何问题。
完成验证之后,才能够开始逐步引入用户流量,最终完成Netty 4版本的Tracon升级。经过实际验证,使用UnpooledByteBufAllocator
分配内存和之前Netty 3版本性能基本相同,期待以后使用PooledByteBufAllocator
会有更好的性能。
总结
从Netty 3升级升级到Netty 4,在带来了性能提升和新特性的同时,对原有代码的修改需要特别注意Netty 4线程模型和内存模型的改变。以上这些遇到的问题,希望能够作为参考,避免在Netty 4应用开发过程中再遇到类似问题。
Netty权威指南 PDF完整版带目录书签+源码 http://www.linuxidc.com/Linux/2016-07/133575.htm
运用Spring注解实现Netty服务器端UDP应用程序 http://www.linuxidc.com/Linux/2013-09/89780.htm
Netty源码学习笔记 http://www.linuxidc.com/Linux/2013-09/89778.htm
Netty使用实例 http://www.linuxidc.com/Linux/2013-09/89779.htm
Java NIO框架–Netty4的简单示例 http://www.linuxidc.com/Linux/2015-01/111335.htm
Netty 的详细介绍:请点这里
Netty 的下载地址:请点这里
本文永久更新链接地址:http://www.linuxidc.com/Linux/2016-10/136288.htm