Netty
2024-08-01 18:38:34 20 举报
AI智能生成
Netty是一个高性能的Java异步事件驱动的网络应用程序框架和工具,用于简化网络编程。它提供了一套灵活、易于使用的API,支持多种传输协议,如TCP、UDP、HTTP等。Netty的模块化设计使得开发者能够根据需求组合和扩展各种功能,如SSL/TLS支持、HTTP处理、代码生成等。借助于其异步处理机制,Netty可以高效地处理大量并发连接,从而提供更高的性能和可伸缩性。此外,Netty还提供了可靠性、安全性和可配置性等多种特性,使其成为构建分布式系统、游戏服务器、物联网应用等网络密集型应用的理想选择。
作者其他创作
大纲/内容
Bootstrap
Bootstrap是Netty框架的启动类和主入口类,分为客户端类Bootstrap和服务器类ServerBootstrap两种。<br>网络编程里,"服务器"和"客户端"实际上表示了不同的网络行为:换句话说,是监听传入的连接还是建立到<br>一个或者多个进程的连接。<br>因此,有两种类型的引导:一种用于客户端(简单地称为Bootstrap),而另一种(ServerBootstrap)用于服务器。<br>无论应用程序使用哪种协议或者处理哪种类型地数据,唯一决定它使用哪种引导类的是它是作为一个客户端还是<br>作为一个服务器<br>
ServerBootstrap和Bootstrap<br>ServerBootstrap将绑定到一个端口,因为服务器必须要监听连接,而Bootstrap则是由<br>想要连接到远程节点的客户端应用程序所使用的.<br>第二个区别可能更加明显。引导一个客户端只需要一个EventLoopGroup,但是一个ServerBootstrap<br>则需要两个(也可以是同一个实例),因为服务器需要两组不同的Channel,第一组将只包含一个ServerChannel,<br>代表服务器自身的已绑定到某个本地端口的正在监听的套接字。而第二组将包含所有已创建的用来传入客户端<br>连接(对于每个服务器已经接受的连接都有一个)的Channel.与ServerChannel相关联的EventLoopGroup将<br>分配一个负责为传入连接请求创建Channel的EventLoop。一旦连接被接受,第二个EventLoopGroup就会<br>给它的Channel分配一个EventLoop<br>
内置通信传输模式
NIO:io.netty.channel.socket.nio 使用java.nio.channels包作为基础--基于选择器的方式
Epoll:io.netty.channel.epoll由JNI驱动的epoll()和非阻塞IO.这个传输支持只有在Linux上可用的多种特性,<br>如果SO_REUSEPORT,比NIO传输更快,而且是完全非阻塞的。将NioEventLoopGroup替换为<br>EpollEventLoopGroup,并且将NioServerSocketChannel.class替换为EpoolServerSocketChannel.class即可<br>
OIO:io.netty.channel.socket.oio使用java.net包作为基础--使用阻塞流
Local:io.netty.channel.local可以在VM内部通过管道进行通信的本地传输
Embedded:io.netty.channel.embedded Embedded传输,允许使用ChannelHandler而又<br>不需要一个真正的基于网络的传输,在测试ChannelHandler实现时非常有用
Channel
Channel是JavaNIO的一个基本构造,它代表一个到实体(如一个硬件设备、一个文件、一个网络套接字<br>或者一个能够执行一个或者多个不同的IO操作的程序组件)的开发连接,如读操作和写操作
目前,可以把Channel看作是传入(入站)或者传出(出站)数据的载体。因此,它可以被打开或者被关闭,<br>连接或者断开连接<br>
Channel、EventLoopGroup和ChannelFuture
Netty网络抽象的代表:<br>Channel--Socket<br>EventLoop--控制流、多线程处理、并发<br>ChannelFuture--异步通知<br>Channel和EventLoop关系如图:<br>我们可以看出Channel需要被注册到某个EventLoop上,在Channel整个声明周期内部都由这个EventLoop处理IO事件,也就是说一个Channel和一个EventLoop进行了绑定,但是一个EventLoop可以同时被多个Channel绑定。<br>
Channel接口。<br>基本的IO操作(bind()、connect()、read()和write())依赖于底层网络传输所提供的原语。<br>在基于Java的网络编程中,其基本的构造是类Socket.Netty的Channel接口所提供的API,<br>被用于所有的IO操作。大大地降低了直接使用Socket类地复杂性。此外,Channel也是<br>许多预定义、专门化实现地广泛类层次结构的根<br><br>由于Channel是独一无二的,所以为了保证顺序将Channel声明为java.lang.Comparable<br>的一个子接口。因此,如果两个不同的Channel示例都返回了相同的散列码,那么AbstractChannel<br>中的compareTo()方法的实现将会抛出一个Error<br>
Channel的生命周期状态<br>ChannelUnregistered:Channel已经被创建,但是还未注册到EventLoop<br>ChannelRegistered:Channel已经被注册到了EventLoop<br>ChannelActiveLChannel处于活动状态(已经连接到它的远程节点)。它现在可以接收和发送数据了<br>ChannelInactive:Channel没有连接到远程节点<br>当这些状态发生改变时,将会生成对应的事件。这些事件将会被转发给ChannelPipeline中的<br>ChannelHandler,其可以随后对它们做出响应。在日常编程中,关注ChannelActive和ChannelInactive<br>会更多一些<br>
重要的Channel的方法。<br>eventLoop:返回分配给Channel的EventLoop<br>pipeline:返回Channel的ChannelPipeline,也就是说每个Channel都有自己的ChannelPipeline.<br>isActive:如果Channel是活动的,则返回true。活动的意义可能依赖于底层的传输。<br>例如,一个Socket传输一旦连接到了远程节点便是活动的,而一个Datagram传输一旦被打开<br>便是活动的。<br>localAddress:返回本地的SocketAdress<br>remoteAddress:返回远程的SocketAddress<br>write:将数据写到远程节点,注意,这个写只是写往Netty内部的缓存,还没有真正写往Socket<br>flush:将之前写的数据冲刷到底层Socket进行传输<br>writeAndFlush:一个简便的方法,等同于调用write()并接着调用flush()<br>
EventLoop/EventLoopGroup
EventLoop可以看成一个线程,EventLoopGroup自然就可以看成线程组<br>在NIO中一个while循环select出事件,然后依次处理每种事件,我们可以把它称为事件循环,<br>这就是EventLoop,它定义了Netty的核心抽象,用于处理网络连接的声明周期中所发生的事件。<br>io.netty.util.concurrent包构建在JDK的java.util.concurrent包上。而io.netty.channel包中的类,<br>为了与Channel的事件进行交互,扩展了这些接口/类,一个EventLoop将由一个永远都不会改变的Thread驱动,<br>同时任务(Runnable/Callable)可以直接交给EventLoop实现,以立即执行或者调度执行<br>
EventLoop继承关系图
线程的分配.<br>服务于Channel的I/O和事件的EventLoop包含在EventLoopGroup中,异步传输实现只使用了少量的EventLoop<br>(以及和它们相关联的Thread),而且在当前的线程模型中,它们可能会被多个Channel所共享,这使得可以通过<br>尽可能少的Thread来支撑大量的Channel,而不是每个Channel分配一个Thread.EventLoopGroup负责为每个<br>新创建的Channel分配一个EventLoop.在当前实现中,使用顺序循环(round-robin)的方式进行分配以获取一个<br>均衡的分布,并且相同的EventLoop可能会被分配给多个Channel.一旦一个Channel被分配给一个EventLoop,<br>它将在它的整个声明周期中都使用这个EventLoop(以及相关联的Thread)<br><br>需要注意:EventLoop的分配方式对ThreadLocal的使用的影响。因为一个EventLoop通常会被用于支撑多个Channel,<br>所以读与所有相关联的Channel来说,ThreadLocal都将是一样的。这使得它对于实现状态追踪等功能来说是个糟糕<br>的选择。然而,在一些无状态的上下文中,它仍然可以被用于在多个Channel之间共享一些重度的或者代价昂贵的对象,<br>甚至是事件<br>
线程管理。<br>在内部,当提交任务到如果(当前)调用线程正是支撑EventLoop的线程,那么所提交的代码块将会被(直接)执行,<br>否则,EventLoop将调度该任务以便稍后执行,并将它放入到内部队列中。当EventLoop下次处理它的事件时,<br>它会执行队列中的那些任务/事件<br>
<font color="#ec7270">为什么要设计成一个Channel中的事件处理只能交给一个EventLoop?</font><br><font color="#000000">在以前的版本中所使用的线程模型只保证了入站(之前称为上游)事件会在所谓的IO线程(对应Netty4中的EventLoop)中执行。<br></font>所有的出站(下游)事件都由调用线程处理,其可能是I/O线程也可能是别的线程。开始看起来这似乎是个好主意,但是已经被<br>发现是有问题的,因为需要在ChannelHandler中对出站事件进行仔细的同步。简而言之,不可能保证多个线程在同一时刻<br>尝试访问出站事件。例如你通过在不同的线程中调用Channel.write()方法,针对同一个Channel同时触发出站的事件,就会<br>发生这种情况。当出站事件触发了入站事件时,将会导致另一个负面影响。当Channel.write()方法导致异常时,需要在调用<br>线程中执行代码,然后将事件移交给I/O线程去执行(在出站事件中,因为出站的数据是要发往对端的,所以当异常触发时要把<br>它当作入站,交由发送端来处理)然而这将带来额外的上下文切换。<br>Netty4中所采用的线程模型,通过在同一个线程中处理某个给定的EventLoop中所产生的所有事件,解决了这个问题,<br>这提供了一个更加简单的执行体系架构,并且消除了在多个ChannelHandler中进行同步的需要,也解决了线程安全和同步的问题<br>
事件
Netty使用不同的事件来通知我们状态的改变或者是操作的状态。这使得我们能够<br>基于已经发生的事件来触发适当的动作<br>
Netty事件是按照它们与入站或出站数据流的相关性进行分类的。可能由入站数据<br>或者相关的状态更改而触发的事件包括:<br>连接已被激活或者连接失活;数据读取;用户事件;错误事件;<br>
出站事件是未来将会触发的某个动作的操作结果,这些动作包括:打开或者关闭到<br>远程节点的连接;将数据写到或者冲刷到套接字<br>
ChannelHandler
每个事件都可以被分发给ChannelHandler类中的某个用户实现的方法,<br>既然事件分为入站和出站,用来处理事件的ChannelHandler也被分为<br>可以处理入站事件的Handler和出站事件的Handler,当然有些Handler,<br>既可以处理入站也可以处理出站<br>
提供提供了大量预定义的可以开箱即用的ChannelHandler实现,包括<br>用于各种协议(如HTTP和SSL/TLS)的ChannelHandler<br>
ChannelHandler的生命周期。<br>在ChannelHandler被添加到ChannelPipeline中移除时会调用下面这些方法。<br>这些方法中的每一个都接受一个ChannelHandlerContext参数。<br>handlerAdded:当把ChannelHandler添加到ChannelPipeline中被调用<br>handlerRemoved:当从ChannelPipeline中移除ChannelHandler时被调用<br>exceptionCaught:当处理过程中在ChannelPipeline中有错误产生时被调用<br>
ChannelPipeline中的ChannelHandler.<br>入站和出站ChannelHandler被安装到同一个ChannelPipeline中,ChannelPipeline以双向链表的形式进行维护管理。<br>如上图,在网络上传递的数据,要求加密,但是加密后密文比较大,需要压缩后再传输,而且按照业务要求,需要检查<br>报文中携带的用户信息是否合法,于是图中实现了,解压(入)Handler、压缩(出)Handler、解密(入)Handler、加密(出)Handler\<br>授权(入)Handler.<br>如果一个消息或者任何其他入站事件被读取,那么它会从ChannelPipeline的头部开始流动,但是只被处理入站事件的Handler处理,<br>也就是解压(入)Handler、解密(入)Handler、授权(入)Handler,最终,数据将会到达ChannelPipeline的尾端,届时,所有的处理就<br>都结束了<br>数据的出站运动(即正在被写的数据)在概念上也是一样的。在这种情况下,数据将从链的尾端开始流动,但是制备处理出站的Handler<br>处理,也就是加密(出)Handler、压缩(出)Handler,直到它到达链的头部为止。在这之后,出站数据将会到达网络传输层,也就是Socket<br>Netty能区分入站事件的Handler和出站的Handler,并确保数据只会在具有相同定向的两个ChannelHandler之间传递<br>在编写Netty应用程序时要注意,分属出站和入站不同的Handler,在业务没有特殊要求的情况下是无所谓顺序的,<br>比如压缩(出)Handler可以放在解压(入)Handler和解密(入)Handler中间,也可以放在解密(入)Handler和授权之间<br><br>而同属一个方向的Handler则是有顺序的,因为上一个Handler处理的结果往往是下一个Handler的要求的输入。比如入站处理,对于<br>收到的数据,只有先解压才能得到密文,才能解密,只有解密后才能拿到明文中的用户信息进行授权检查,所以解压-解密-授权这个<br>三个入站Handler的顺序就不能乱<br>
入站处理流程
出站处理流程
匹配入站/出站处理器
入站/出站事件掩码
Handler的顺序可以被打乱,但是相对顺序不能轻易改变
从应用程序开发人员的角度来看,Netty的主要组件是ChannelHandler,它充当了所有处理入站和<br>出站数据的应用程序逻辑的容器。ChannelHandler的方法是由网络事件触发的。事实上,ChannelHandler<br>可专门用于几乎任何类型的动作,例如将数据从一种格式转换为另一种格式,例如各种编解码,或者处理<br>转换过程中所抛出的异常.<br>
ChannelInboundHandler<br>该接口将会是经常实现的子接口。这种类型的ChannelHandler接收入站事件和数据,<br>这些数据随后将会被应用程序的业务逻辑所处理。当你要给连接的客户端发送响应时,<br>也可以从ChannelInboundHandler直接虫偶刚刷数据然后输出到对端。应用程序的业务<br>逻辑通常实现在一个或者多个ChannelInboundHandler中。这种类型的ChannelHandler<br>接收入站事件和数据,这些数据随后将会被应用程序的业务逻辑所处理<br>
ChannelInboundHandler的生命周期方法。这些方法将会在数据被接收时或者与其对应的<br>Channel状态发生改变时被调用。和Channel的生命周期密切相关<br><br>channelRegistered:当Channel已经注册到它的EventLoop并且能够处理I/O时被调用<br>channelUnRegistered:当Channel从它的EventLoop注销并且无法处理任务I/O时被调用<br>channelActive:当Channel处于活动状态时被调用;Channel已经连接/绑定并且已经就绪<br>channelInactive:当Channel离开活动状态并且不再连接它的远程节点被调用<br>chabnelReadComplete: 当Channel上的一个读操作完成时被调用<br>channelRead:当从Channel读取数据时被调用<br>channelWritabilityChanged:<br>当Channel的可写状态发生改变时被调用,可以通过调用Channel的isWritable()方法来<br>检测Channel的可写性。与可写性相关的阈值可以通过Channel.config().setWriteHighWaterMark()<br>和Channel.config().setWriteLowWaterMark()方法来设置,<br>userEventTriggered:当ChannelInboundHanadler.fireUserEventTriggered()方法被调用时被调用<br>
SimpleChannelInboundHandler
ChannelOutboundHandler<br>出站操作和数据将由ChannelOutboundHandler处理。它的方法将被Channel、ChannelPipeline以及<br>ChannelHandlerContext调用,所有由ChanneloutboundHandler本身所定义的方法如下<br>biind():当请求将Channel绑定到本地地址时调用<br>connect():当请求将Channel连接到远程节点时被调用<br>disconnect():当请求将Channel从远程节点断开时被调用<br>close():当请求关闭Channel时被调用<br>deregister():当请求将Channel从它的EventLoop注销时被调用<br>read()当请求从Channel读取更多的数据时被调用<br>flush():当请求通过Channel将入队数据冲刷到远程节点时被调用<br>write()当请求通过Channel将数据写到远程节点时被调用<br>
ChannelHandler的适配器。<br>有一些适配器类可以将编写自定义的ChannelHandler所需要的工作降到最低限度,<br>因为它们提供了定义在对应接口中的所有方法的默认实现。因为有时会忽略那些不感兴趣的<br>事件,所以Netty提供了抽象积累ChannelInboundHandlerAdapter(处理入站)和<br>ChannelOutboundHandlerAdapter(处理出站)的基本实现,通过扩展抽象类ChannelHandlerAdapter,<br>它们获得了它们共同的超接口ChannelHandler的方法.不过ChannelOutboundHandler有个非常让人<br>迷惑的read()方法,ChannelOutboundHandler不是处理出站事件的吗?怎么会有read()方法呢?<br>其实这个read方法不是表示读数据,而是表示业务发出了读(read)数据的要求,这个要求也会封装为<br>一个事件进行传播,这个事件因为时业务发出到网络的,自然就是个出站事件,而且这个事件触发的就是<br>ChannelOutboundHandler中read()方法。<br>如果Handler纪要处理入站又要处理出站怎么办呢?这个时候就可以使用类ChannelDuplexHandler,<br>当然也可以同时实现ChannelOutboundHandler,ChannelInboundHandler这两个接口<br>
Handler的共享和并发安全性。<br>ChannelHandlerAdapter还提供了实用方法isSharable().如果其对应的实现被标注为Sharable,那么这个方法<br>将返回true,表示它可以被添加到多个ChannelPipeline.这就牵涉到了我们实现的Handler的共享性和线程安全性。<br>在往pipeline安装Handler的时候,基本上都是new出Handler的实例,因为每个socketChannel有自己的pipeline,<br>而且每个socketChannel又是和线程绑定的,所以这些Handler的实例之间完全独立的,只要Handler的实例不是<br>共享了全局变量,Handler的实例是线程安全的<br>但是如果业务需要在多个SocketChannel之间共享一个Handler的实例怎么办呢?比如统计服务器接收到和发出的<br>业务报文总数,我们就需要用一个Handler的实例来横跨所有的socketChannel来统计socketChannel业务报文数。<br>为了实现这一点,我们可以实现一个MessageCountHandler,并且在MessageCountHandler上使用Netty的@Sharable,<br>然后在安装MessageCountHandler到MessageCountHandler的统计功能时,请务必注意线程安全,具体实现时就使用<br>Java并发编程里的Atomic类来保证这一点<br>
资源管理和SimpleChannelInboundHandler.<br>回想一下我们在NIO中时如何接收和发送网络数据的?都是首先创建了一个Buffer,应用程序中的业务部分和Channel之间<br>通过Buffer进行数据的交换:ByteBuffer.allocate(1024),如图所示<br>Netty在处理网路数据时,同样也需要Buffer,在Read网络数据时由Netty创建Buffer,Write网络数据时Buffer往往由业务方<br>创建的。不管是读和写,Buffer用完后都必须进行释放,否则可能会造成内存泄漏,在Write网络数据时,可以确保数据被<br>写往网络了,Netty会自动进行Buffer的释放,但是如果Write网络数据时,我们有outBoundHandler处理了write()操作并<br>丢弃了数据,没有继续往下写,要由我们负责释放这个Buffer,就必须调用ReferenceCountUtil.release()方法,否则就可能<br>造成内存泄漏。在Read网络数据时,如果我们可以确保每个Inboundhandler都把数据往后传递了,也就是调用了相关的<br>fireChannelRead()方法,Netty也会帮我们释放,同样的,如果我们有InboundHandler处理了数据,又不继续往后传递,<br>又不调用负责释放的ReferenceCountUtil.release()方法,就可能会造成内存泄漏。<br>但是由于消费入站数据是一项常规任务,所以Netty提供了一个特殊的被称为SimpleChannelInboundHandler的<br>ChannelInboundHandler实现,这个实现会在数据被channelRead0()方法消费之后自动释放数据,同时系统为我们提供的<br>各种预定义的Handler实现,都实现了数据的正确处理,所以我们自行在编写业务Handler时,也需要注意:要么继续传递,<br>要那么自行释放<br>
ChannelInitializer
负责初始化Channel.Netty提供了一个特殊的ChannelInboundHandlerAdapater子类:它定义了<br>下面的方法:<br>protected abstract void initChannel(C ch) throws Exception;<br>这个方法提供了一种将多个ChannelHandler添加到一个ChannelPipeline中的简便方法,你只需要<br>简单地向Bootstrap或者ServerBootstrap的实例提供你的ChannelInitializer实现即可,并且一旦<br>Channel被注册到了它的EventLoop之后,就会调用你的initChannel()版本。在该方法返回之后,<br>ChannelInitializer的实例将会从ChannelPipeline中移除它自己,所以,在我们自己的应用程序中,<br>如果存在着某个Handler只使用一次的情况,也可以伪造Channelinitializer,用完以后将自己从ChannelPipeline<br>中移除自己,比如授权Handler,某客户端第一次连接登录以后,进行授权检查,检查通过后,就可以把这个<br><br>
ChannelHandlerContext
ChannelHandlerContext代表了ChannelHandler和ChannelPipeline之间的关联,每当有ChannelHandler<br>添加到ChannelPipeline中时都会创建ChannelHandlerContext,为什么需要这个ChannelHandlerContext?<br>ChannelPipeline以双向链表的形式进行维护管理Handler,毫无疑问,Handler在放入ChannelPipeine的时候必须要有两个<br>指针pre和next来说明它的前一个元素和后一个元素,但是Handler本身维护这两个指针合适吗?<br>想想我们在使用JDK的LinkedList的时候,我们放入LinkedList的数据是不会带这两个指针的,LinkedList内部会用 <br>类Node对我们的数据进行包装,而类Node则带有两个指针pre和next,所以ChannelHandlerConntext的主要作用就和<br>LinkedList内部的Node类似,不过ChannelHandlerContext不仅仅只是个包装类,它还提供了很多的方法,比如让<br>事件从当前ChannelHandler传递给链中的下一个ChannelHandler,还可以被用于获取底层的Channel,还可以用于写出站数据<br><br>
Channel、ChannelPipeline和ChannelhandlerContext上的事件传播。<br>ChannelHandlerContext有很多的方法,其中一些方法也存在于Channel和ChannelPipeline本身上,<br>但是有一点重要的不同,如果调用Channel或者ChannelPipeline上的这些方法,它们将沿着整个<br>ChannelPipeline进行传播。而调用位于ChannelHandlerContext上的相同的方法,则将从当前所关联的<br>ChannelHandler开始,并且只会传播给位于该ChannelPipeline中的下一个(入站下一个,出站上一个)能<br>够处理该事件的ChannelHandler<br>
eg:比如服务器收到对端发过来的报文,解压后需要进行机密,结果解密失败,要给对端一个应答。<br>如果发现解密失败 原因时服务器和对端的加密算法不一致,应答报文只能以明文的压缩格式发送,<br>就可以在解密Handler中直接使用ctx.write给对端应答,这样应答报文就只会经过压缩Handler就<br>发往了对端<br>
ChannelHandlerContext的API<br>alloc返回和这个示例相关联的Channel所配置的ByteBufAllocator<br>bind绑定到给定的SocketAddress,并返回ChannelFuture<br>channel返回绑定到这个实例的Channel<br>close关闭Channel,并返回ChannelFuture<br>connect连接给定的SocketAddress,并返回ChannelFuture<br>deregister从之前分配的EventExecutor注销,并返回ChannelFuture<br>disconnect从远程节点断开,并返回ChannelFuture<br>executor返回调度时间的EventExecturo<br>fireChannelActive触发对下一个ChannelInboundHandler上的channelActive()方法(已连接)的调用<br>fireChannelInactive触发对下一个ChannelInboundHandler上的ChannelInactive()(已关闭)方法<br>fireChannelRead触发对下一个ChannelInboundHandler上的channelRead()方法(已接收的消息)调用<br>fireChannelReadComplete触发对下一个ChannelInboundHandler上的channelReadComplete()方法的调用<br>fireChannelRegistered触发对下一个ChannelinboundHandler上的fireChannelRegistered()方法的调用<br>fireChannelUnRegistered()触发对下一个ChannelInboundHandler上的fireChannelUnRegistered()方法的调用<br>fireChannelWritablityChanged触发对下一个ChannelInboundHandler上的fireChannelWritabilityChanged()方法的调用<br>fireChannelExceptionCaught()触发对下一个ChannelInboundHandler上的fireExceptionCaught()方法的调用<br>fireUserEventTriggered触发对下一个ChannelInboundHandler上的fireUserEventTriggered(Object evet)方法的调用<br>handler返回绑定到这个实例的ChannelHandler<br>isRemoved如果所关联的ChannelHandler已经被从ChannelPipeline中移除则返回true<br>name返回这个实例的唯一名称<br>pipeline返回这个实例所关联的ChannelPipeline<br>read将数据从Channel读取到第一个入站缓冲区,如果读取成功则触发一个channelRead事件,并(在最后一个消息被读取完成后)通知<br>ChannelInboundHandler的channelReadComplte(ctx)方法<br>write通过这个实例写入消息并经过ChannelPipeline<br>writeAndFlush通过这个实例写入并冲刷消息并经过ChannelPipeline<br>当使用ChannelHandlerContext的API的时候,有以下两点<br>1.ChannelHandlerContext和ChannelHandler之间的关联(绑定)是永远不会改变的,所以缓存对它的引用是安全的<br>2.相对于其他类的同名方法,ChannelHandlerContext的方法将产生更短的事件流,应该尽可能地利用这个特性来获得最大地性能<br>
ChannelPipeline
基于Netty的网路应用程序中根据业务需求会使用Netty已经提供的Channelhandler<br>或者自行开发ChannelHandler,这些ChannelHandler都放在ChannelPipeline中统一<br>管理,事件就会在ChannelPipeline中流动,并被其中一个或者多个ChannelHandler处理<br>
当Channel被创建时,它将会被自动地分配一个新的ChannelPipeline,每个Channel都有自己<br>的ChnanelPipeline.这项关联是永久性的。在Netty组件的生命周期中,这是一项固定的操作,<br>不需要开发人员的任何干预<br>
ChannelPipeline提供了ChannelHandler链的容器,并定义了在该链上传播入站(也就是从网络到业务处理)<br>和出站(也就是从业务处理到网络),各种事件流的API,代码中的ChannelHandler都是放在ChannelPipeline中的。<br><br>使得事件流经ChannelPipeline是ChannelHandler的工作,它们是在应用程序的初始化或者引导阶段被安装的。<br>这些ChannelHandler对象接收事件、执行它们所实现的处理逻辑,并将数据传递给链中的下一个ChannelHandler,<br>而且Channelhandler对象也完全可以拦截事件不让事件继续传递。它们的执行顺序是由它们被添加的顺序所决定的。<br>
ChannelPipeline上的方法。<br>既然ChannelPipeline以双向链表的形式进行维护管理Handler,自然也提供了对应的方法在ChannelPipeline中<br>增加或者删除、替换handler.addFist、addBefore、addAfter、addLast将一个ChannelHandler添加到ChannelPipeline中。<br>remove将一个ChannelHandler从ChannelPipeline中移除<br>replace将ChannelPipeline中的一个ChannelHandler替换为另一个ChannelHandler<br>get通过类型或者名称返回ChannelHandler<br>context返回和ChannelHandler绑定的ChannelHandlerContext<br>names返回ChannelPipeline中所有ChannelHandler的名称<br>ChannelPipeline的API公开了用于调用入站和出站操作的附加方法<br>
ChannelFuture
Netty中所有的IO操作都是异步的,我们知道"异步的意思就是不需要主动等待结果的返回,<br>而是通过其他手段比如,状态通知,回调函数等",也就是说至少我们需要一种获得异步执行<br>结果的手段<br>
JDK预置了java.util.concurrent.Future,Future提供了一种在操作完成时通知应用程序的方式,<br>这个对象可以看作是一个异步操作的结果的占位符;它将在未来的某个时刻完成,并提供对其<br>结果的访问。但是其所提供的实现,只允许手动检查对应的操作是否已经完成,或者一直阻塞<br>直到它完成。这是非常繁琐的,所以Netty提供了它自己的实现ChannelFture,用于在执行异步<br>操作的时候使用,一般来说,每个Netty的出站I/O操作都将返回一个ChannelFuture。<br>我们还可以添加ChannelFutureListener,针对某个异步操作添加通知回调<br>
JDK原生Future提供的方法
ChannelOption
ChannelOption.SOBACKLOG
ChannelOption.SO_BACKLOG对应的是tcp/ip协议listen函数中的backlog参数,服务端<br>处理客户端连接请求是顺序处理的,所以同一时间只能处理一个客户端连接,多个客户端<br>来的时候,服务端将不能处理的客户端连接请求放在队列中等待处理。所以操作系统里一般<br>有两个队列,一个是ACCEPT队列,保存着已经完成了TCP的三次握手的连接,一个SYN队列,<br>服务器正在等待TCP的三次握手完成的队列。BSD派生系统里backlog指的就是SYN队列的大小,<br>在Linux的实现里backlog相对来说就含糊不清了,有些内核版本指的是ACCEPT队列+SYN队列<br>合起来的大小,有的是指SYN队列的大小。<br>但是从Linux2.2开始,backlog的参数行为在Linux2.2中发生了变化,现在它指定等待接受的完全<br>建立的套接字的队列长度,而不是不完整的连接请求的数量,不完整套接字队列的最大长度<br>可以使用/proc/sys/net/ipv4/tcp_max_sync_backlog设置,默认128。<br>如果backlog参数大于/proc/sys/net/core/somaxconn 中的值,那么它会被静默截断为值128.<br>在2.4.25之前的内核中,此限制是硬编码值,后续内核版本也可以通过vim /etc/sysctl.conf来修改,<br>包括前面说的tcp_max_sync_backlog也可以在此处修改,然后通过命令sysctl -p生效<br>
TCP半连接队列和全连接队列<br>https://plantegg.github.io/2017/06/07/%E5%B0%B1%E6%98%AF%E8%A6%81%E4%BD%A0%E6%87%82TCP--%E5%8D%8A%E8%BF%9E%E6%8E%A5%E9%98%9F%E5%88%97%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5%E9%98%9F%E5%88%97/<br>
正常TCP建连接三次握手过程:<br>1.client发送SYN到server发起握手<br>2.server收到SYN后回复SYN+ACK给client<br>3.client收到SYN+ACK后,回复server一个ACK表示收到了server的<br>SYN+ACK
可能会造成的问题:<br>场景:Java的client和
ChannelOption.SO_REUSEADDR
ChannelOption.SO_REUSEADDR对应与套接字选项中的SO_REUSEADDR,这个参数表示<br>允许重复使用本地地址和端口,比如,多网卡(IP)绑定相同端口,比如某个进程非正常退出,<br>内核需要一定的时间才能够释放此端口<br>
ChannelOption.SO_KEEPALIVE
ChannelOption.SO_KEEPALIVE参数对应于套接字选项中的SO_KEEPALIVE,<br>该参数用于设置TCP连接,当设置该选项以后,连接会测试连接状态,这个选项用于<br>可能长时间没有数据交流的连接。当设置该选项以后,如果在两小时内没有数据的<br>通信,TCP会自动发送一个活动探测数据报文<br>
ChannelOption.SO_SNDBUF/ChannelOption.SO_RCVBUF
ChannelOption.SO_SNDBUF参数对应于套接字选项中的SO_SNDBUF,<br>ChannelOPtion.SO_RCVBUF参数对应于套接字选项中的SO_RCVBUF<br>这两个参数用于操作接受缓冲区和发送缓冲区的大小,接收缓冲区用于保存网络协议栈<br>内收到的数据,知道应用程序读取成功,发送缓冲区用于保存发送数据,直到发送成功<br>
ChannelOption.SO_LINGER
ChannelOption.SO_LINGER参数对应于套接字选项中的SO_LINGER,Linux内核默认的处理方式是<br>当用户调用close()方法的时候,函数返回,在可能的情况下,尽量发送数据,不一定保证会发剩余的<br>数据,造成了数据的不确定性,使用SO_LINGER可以阻塞close()的调用时间,直到数据完全发送<br>
ChannelOption.TCP_NODELAY
ChannelOption.TCP_NODELAY参数对应于套接字选项中的TCP_NODELAY,该参数的使用与Nagle<br>算法有关,Nagle算法是将小的数据包组装为更大的帧然后进行发送,而不是输入一次发送一次,<br>因此在数据包不足的时候会等待其他数据的到了,组装成大的数据包进行发送,虽然该方式有效提高<br>网络的有效负载,但是却造成了延时,而该参数的作用就是禁止使用Nagle算法,使用小数据即时传输,<br>与TCP_NODELAY相对应的是TCP_CORK,该选项是需要等到发送的数据量最大的时候,一次性发送数据,<br>适用于文件传输<br>
PooledByteBuf
PooledByteBuf是池化的ByteBuf,提高了内存分配与释放的速度,它本身是一个抽象泛型类,<br>有三个子类:PooledDirectByteBuf、PooledHeapByteBuf、PooledUnsafeDirectByteBuf.<br>
Arena是什么<br>在Netty中,Arena是一种内存分配器(Memory Allocator)的概念。Arena的主要目的是提高内存分配<br>和释放的效率,减少碎片化,从而提高系统性能。<br><br>具体而言,Netyy中的Arena主要用于分配和管理ByteBuf对象的内存,ByteBuf是Netty中用于处理<br>字节数据的缓冲区类.Arena在这里的作用是为ByteBuf提供内存块,以避免频繁地进行直接内存分配和释放。<br><br>Arena的实现通常采用了一些内存池技术,例如Chunked Memory Pool。它将内存分割成固定大小的块(Chunk)<br>当需要分配内存时,会从这些块中选择合适大小的块进行分配,而不是每次都直接向操作系统申请新的内存.<br>这样做可以减少内存碎片化,提高内存的利用率,也能够更有效地进行内存回收<br><br>Netty的Arena实现是为了优化网络应用程序中频繁的数据传输操作,特别时在高并发的情况下。<br>通过使用Arena,Netty可以更高效地管理和利用内存,从而提高系统的性能<br>
Jemalloc算法。<br>Netty的PooledByteBuf采用与jemalloc一致的内存分配算法。基本思路可用这样的情景类比,想象一下电商的<br>配送流程。当顾客采购小件商品(比如书籍)时,直接从同城仓库送出;当顾客采购大件商品(比如电视)时,从区域<br>仓库送出;当顾客采购超大件商品(比如汽车)时,则从全国仓库送出。Netty的分配算法与此相似。<br>Netty中,Tiny和Small类型的请求都首先从同城仓库(ThreadCache-tcache)送出;如果同城仓库没有,则会从区域<br>仓库(PoolArena)送出,Normal类型的请求则从区域仓库(PoolArena)送出,Huge类型的请求则从全国仓库(系统内存)送出。<br>Netty中规定:<br>1.内存分配的最小单位为16B<br>2.小于512B的请求为Tiny,512B<x<8KB(PageSize)的请求为Small,8KB<=X<=16MB(ChunkSize)的请求为Normal,<br>大于16MB(ChunkSize)的请求为Huge<br>3.Tiny、Small、Normal、Huge中还有细层级,小于Tiny的请求以16B为起点每次增加16B作为一个层级,也就是<br>Tiny中还有16B、32B、48B、.......480B、496B的层级<br>其他类型的则是翻倍:<br>Small中还有512B、1KB、2KB、4KB的层级<br>Normal中还有8KB、16KB、32KB......8MB、16MB的层级<br>Huge中还有32MB、64MB....的层级<br>4.不管请求的大小,都会将向上规范化,比如:请求分配511B、512B、513B,将依次规范化为512B、512B、1KB<br>为了提高内存分配效率并减少内部碎片,jemalloc算法将Arena切分为小块Chunk,根据每块的内存使用率又将小块组合为以下<br>集中状态QINIT,Q0,Q25,Q50,Q75,Q100。Chunk块可以在这几种状态间随着内存使用率的变化进行转移,内存使用率<br>和状态转移如图所示。<br>其中横轴表示内存使用率(百分比),纵轴表示状态,可以看到:<br>QINIT的内存使用率为[0,25),Q0为(0,50),Q100为[100,100]等等。<br>Chunk的初始状态为QINIT,当使用率达到25时转移到Q0状态,再次达到50时转移到Q25,依次类推直到Q100;当内存释放时<br>又从Q100转移到Q75,直到Q0状态且内存使用率为0时,该Chunk从Arena中删除。<br>像qinit、q000、q075因为本身要维护很多Chunk块,所以内部是以链表的形式来组织Chunk块,同时qinit、q000、q075<br>本身又组织为一个近似的双向链表,如图所示。<br><br>
虽然已将Arena切分成小块的Chunk,但实际上Chunk是相当大的内存块,在Netty中默认使用16MB。为了进一步提高内存利用率,<br>并减少内部碎片,需要继续将Chunk切分为小的快Page.一个典型的切换将Chunk切分为2048块,可知Page的大小为:<br>16MB/2048=8KB.一个好的内存分配算法,应使得已分配内存块尽可能保持连续,这将大大减少内部碎片,由此jemalloc使用伙伴<br>分配算法尽可能提高连续性。伙伴分配算法的基本思想是:<br>我们直到一个Chunk切分为2048块Page,将这些Page作为叶子节点,然后组织起一个满二叉树<br>然后按层分配满足要求的内存块。<br>以待分配序列8KB、16KB、8KB为例分析分配过程(每个Page大小8KB):<br>8KB--需要一个Page,第11层满足要求,故分配2048节点即Page0<br>16KB-需要两个Page,故需要在第10层进行分配,而1024节点的子节点2048已分配,从左到右找到满足要求的1025节点,故分配<br>节点1025即Page2和Page3<br>8KB--需要一个Page,第11层满足要求,2048已分配,从左到右找到2049节点即Page1进行分配。<br>分配结束后,已分配连续的Page0-Page3,这样的连续内存块,大大减少内部碎片并提高内存使用率<br>为了实现伙伴算法,Netty中使用了memoryMap和depthMap来表示两棵二叉树,其中MemoryMap存放分配信息,depthMap存放<br>节点的高度信息<br><br>在看完两张图之后,我们在前面说过,一个page是8KB,但是Netty又支持Tiny、Small这种小于8KB,最小可达16B的内存分配请求,<br>每次都分配一个page,很浪费。为了进一步切分Page成更小的SubPage,SubPage是jemalloc中内存分配的最小单位,不能<br>再进行切分.SubPage切分的单位并不固定,以第一次请求分配的大小为单位(最小切分单位为16B).<br>比如,第一次请求分配32B,则Page按照32B均等切分为256块;第一次请求请求16B,则Page按照16B均等切分512块。<br>为了便于内存分配和管理,根据SubPage的切分单位进行分组,对每个组而言,Arena会以双向链表的形式进行管理。<br>那么根据切分的单位的大小和Page的大小,SubPage分为两类:tinySubPagePools和smallSubPagePools,tinySubPage<br>中的subPage的大小,从16字节到496个字节,共有32个元素,smallSubPagePools则有512字节、1024、2048、4096共4个元素<br>如图所示。<br><br>在Arena数量上,为了减少各个线程进行内存分配时竞争,Netty中会有多个Arena,默认的数量与处理器的个数有关,<br>线程首次分配内存时,首先会为其分配一个固定的Arena<br>
左图表示每个节点的编号,注意从1开始,省略0是因为这样更容易计算父子关系:子节点加倍,<br>父节点减半,比如512的子节点为1024=512 *2.右图表示每个节点的深度,注意从0开始。在<br>代表二叉树的数组中,左图中节点上的数字作为数组索引即id,右图节点上的数字作为值。初始<br>状态时,memoryMap和depthMap相等,可知一个id为512节点的初始值为9<br>memoryMap[512]=depthMap[512]=9<br>depthMap的值初始化后不再改变,memoryMap的值随着节点分配而改变,当一个节点被分配以后,<br>该节点的值设置为12(最大高度+1)表示不可用,并且会更新祖先节点的值。<br>
分配过程如下:<br>4号节点被完全分配,将高度值设置为12表示不可用。<br>4号节点的父亲节点即2号节点,将高度值更新为两个子节点的较小值;其他祖先节点亦然,<br>直到高度值更新到根节点,可推知,memoryMap数组的值有如下三种情况:<br>memoryMap[id]=depthMap[id] -- 该节点没有被分配<br>memoryMap[id] > depthMap[id] -- 至少有一个子节点被分配,不能再分配该高度满足的<br>内存,但可以根据实际分配较小一些的内存,比如,上图中分配了4号子节点的2号节点,值<br>从1更新为2,表示该节点不能再分配8MB的只能最大分配4MB内存,因为分配了4号节点后<br>只剩下5号节点可用。<br>memoryMap[id] = 最大高度 +1 (本例中12) -- 该节点及其子节点已被完全分配,没有剩余空间。<br><br>
PoolThreadCache.<br>同时在Netty中为了提升性能,并不会一开始就从PoolArena中分配,因为Arena为几个线程共享,而是先从每个线程自己的<br>PoolThreadCache中去获取。当然开始的是偶,这些Cache里面都是没有值的,要先从PoolArena中获取,当释放Buf的时候,<br>才会把之前分配的内存大小放到该cache里面,当下次申请内存的时候,就会先从PoolThreadCache中找。<br>PoolThreadCache中则维护了6个这样的线程缓存区域,3个堆内存相关,3个直接内存相关,分别对应着三种<br>分配内存的大小<br>small类型的数组的大小为4,而tiny、normal数组的大小分别为32、3<br>smallSubPageHeapCache的数组长度为4,依次缓存[512K,1024K,2048K,4096K]大小的缓存,每个元素对应的缓存<br>queue中的元素个数不能超过256个,而tinySubPageHeapCaches数组缓存的是[16B,32B,...,496B]大小的内存块,每个元素<br>对应的缓存queue中元素个数不能超过512个,normalHeapCaches数组结构相同,但是只缓存[8K,16K,32K]大小的内存块,<br>每个元素对应的缓存queue中元素个数不超过64个。<br>每一个MemoryRegionCache中又包含一个队列。队列中的每个元素类型为Entry,Entry中又包含了一个PoolChunk,以方便<br>对内存的管理<br>
常见的为什么
为什么会有Netty?
假如你的系统要支撑高并发的用户场景,你可能会进行搜索如何搭建高性能的Java网络编程,你可能会看到<br>Netty是一款异步的事件驱动的网络应用程序框架,支持快速地开发可维护的高性能的面向协议的服务器和<br>客户端<br>
接着你的下一步多半是阅读一些博客,看一些例子,然后写点代码试试,如果掌握了网络通信编程的技术,<br>遇到的问题可能会少点,否则你可能看不懂在说些什么<br>
高性能系统不仅要求超一流的编程技巧,还需要几个复杂领域(网络编程、多线程处理和并发)的专业知识,<br>Netty优雅地处理了这些领域的知识,使得我们可以将精力放在业务处理上,而不是关注网络编程方便的<br>非业务逻辑<br>
Netty的架构方法和设计原则是:每个小点都和它的技术性内容一样重要,穷其精妙。<br>1.关注点分离——业务和网络逻辑解耦<br>2.模块化和可复用性<br>3.可测试性作为首要的要求<br>
早期的网络编程中,需要花费大量的事件取学习复杂的C语言套接字库,去兼容不同的操作系统。<br>尽管Java引入了足够多的Socket Facade(门面)来隐藏一些棘手的细节问题,但是创建一个复杂的<br>客户端/服务端协议仍然需要大量的样板代码(以及相当多的底层研究才能使它整个流畅地运行起来)<br><br>最早期地Java API(java.net)只支持本地系统套接字库提供地所谓地阻塞函数BIO<br>这段代码片段只能同时处理一个链接,要管理多个并发客户端,需要为每个新的客户端Socket创建一个<br>新的Thread,这种方案的影响<br>
1.在任何时候都可能有大量的线程处于休眠状态,只是等待输入或者输出数据就绪,这可能算是一种资源浪费。
2.需要为每个线程的调用栈都分配内存,其默认值大小区间为64kb-1mb
3.即使JVM可以在物理上支持非常大的数量,但是远在到达该极限之前,上下文切换所带来的<br>开销就会带来麻烦<br>
这种并发方案对于支撑中小数量的客户端来说还算可以接收,但是为了支撑100 0000或者更多的<br>并发连接所需要的资源会使得它很不理想<br>
为什么不用Netty5? Netty5已经停止开发了
为什么Netty使用NIIO而不是AIO?
Netty不看重Windows上的使用,在Linux系统上,AIO的底层实现仍使用EPOLL,没有很好的实现AIO,<br>因此性能上没有明显的优势,而且被JDK封装了一层,不容易深度优化<br>
AIO还有个缺点是接收数据需要预先分配缓存,而不是NIO那种需要接收时才需要分配缓存,所以对连接<br>数量非常大但流量小的情况,内存浪费很多<br>
而且Linux上AIO不够成熟,处理回调结果速度跟不上处理需求,作者原话:<br>Not faster than NIO(epoll) on unix systems(which is true)<br>There is no daragram support<br>Unnecessary threading model(too much abstraction without usage)<br>
为什么不用Mina?<br>简单来说,Mina机会不再更新了,Netty本来就是因为Mina不够好所以开发出来的<br>
优势
1.API使用简单,开发门槛低
2.功能强大,预置了多种编解码功能,支持多种主流协议
3.定制能力强,可以通过ChannelHandler对通信框架进行灵活地扩展
4.性能高,通过与其他业界主流地NIO框架对比,Netty的综合性能最优
5.成熟、稳定,Netty修复了已经发现的所有JDK NIO BUG,业务人员不需要再为NIO的bug而烦恼
6.社区活跃,版本迭代周期短,发现的bug可以被及时修复,同事,更多的新功能会加入
7.经历了大规模的商业应用考研,直到得到验证
通信框架功能设计
功能描述
功能描述。<br>通信框架承载了业务内部各模块之间的消息交互和服务调用,它的主要功能如下:<br>基于Netty的NIO通信框架,提供高性能的异步通信能力<br>提供消息的编解码框架,可以实现POJO的序列化和反序列化<br>消息内容的防篡改机制<br>提供基于IP地址的白名单接入认证机制<br>链路的有效性校验机制<br>链路的断连重连机制<br>
通信模型
通信模型<br>1.客户端发送应用握手请求,携带节点ID等有效身份认证信息<br>2.服务端对应应用握手请求消息进行合法性校验,包括节点ID有效性校验、节点重复登录校验和IP地址合法性校验,<br>校验通过后,返回登录成功的应用握手应答消息<br>3.链路建立成功之后,客户端发送业务消息<br>4.链路成功之后,服务端发送心跳消息<br>5.链路建立成功之后,客户端发送心跳消息<br>6.链路建立成功之后,服务端发送业务消息<br>7.服务端退出时,服务端关闭连接,客户端感知对方关闭连接后,被动关闭客户端连接<br><br>备注:需要指出的是,协议通信双方链路建立成功之后,双方可以进行全双工通信,无论是客户端还是服务端,都可以<br>主动发送请求消息给对方,通信方式可以是TWO WAY或者ONE WAY.双方之间的心跳采用Ping-Pong机制,当链路处于<br>空闲状态时,客户端主动发送Ping消息给服务端,服务端接收到Ping消息后发送应答消息Pong给客户端,如果客户端连续<br>发送N条Ping消息都没有接收到服务器返回的Pong消息,说明链路已经挂死或者对方处于异常状态,客户端主动关闭连接,<br>间隔周期T后发起重连操作,直到重连成功<br>
消息定义
消息定义包含两部分:消息头+消息体。<br>在消息的定义上,因为是同步处理模式,不考虑应答消息需要填入请求消息ID,所以消息头中只有一个消息的ID,如果要支持异步模式,<br>则请求消息头和应答消息头最好分开设计,应答消息头中除了包含本消息的ID外,好应该包括请求消息ID,以方便请求消息的发送方<br>根据请求消息ID做对应的业务处理<br>
Netty消息定义表<br>
消息头定义(Header)
链路的建立
客户端的说明如下:如果A节点需要调用B节点的服务,但是A和B之间还没有建立物理链路,则由调用方主动发起连接,<br>此时,调用方为客户端,被调用方为服务端。<br>考虑到安全,链路建立需要通过IP地址或者号段的黑白名单安全认证机制,比如,协议使用基于IP地址的安全认证,<br>如果有多个IP,通过逗号进行分割。在实际的商用项目中,安全认证机制会更加严格,例如通过密钥对用户名和密码进行安全认证<br>客户端 与服务端链路建立成功之后,由客户端发送业务握手请求的认证消息,服务端接收到客户端的握手请求消息之后,如果<br>IP校验通过,返回握手成功应答消息给客户端,应用层链路建立成功。握手应答消息中消息体为byte类型的结果:0:认证成功 -1:认证失败,服务端关闭连接<br>链路建立成功之后,客户端和服务端就可以互相发送业务消息了,在客户端和服务端的消息通信过程中,业务消息体的内容需要通过MD5进行摘要防篡改<br>
可靠性设计
心跳机制。<br>在凌晨等业务低谷时段,如果发生网络闪断、连接被Hang住等问题时,由于没有业务消息,应用程序很难发现。<br>到了白天业务高峰期时,会发生大量的网络通信失败,严重的会导致一段时间进程内无法处理消息。为了解决这个<br>问题,在网络空闲时采用心跳机制来检测链路的互通性,一旦发现网络故障,立即关闭链路,主动重连。<br>当读或者写心跳消息发生I/O异常的时候,说明已经中断,此时需要立即关闭连接,如果时客户端,则需要重新<br>发起连接,如果是服务端,需要清空缓存的半包信息,等到客户端重连<br>
空闲的连接和超时。<br>检测空闲连接以及超时对于及时释放资源来说是至关重要的。由于这是一项常见的任务,Netty特地为它提供了几个ChannelHandler实现。<br>IdleStateHandler当连接空闲时间太长时,将会触发一个IdleStateEvent时间。然后,可以通过在ChannelInboundHandler中重写<br>userEventTriggered()方法来处理该IdleStateEvent时间<br>ReadTimeoutHandler如果在指定的时间间隔内没有收到任何的入站出局,则抛出一个ReadTimeoutException并关闭对应的Channel.<br>可以通过重写ChannelHandler中的exceptionCaught()方法来检测该Read-TImeoutException<br>
重连机制。<br>如果链路中断,等到INTERVAL时间后,由客户端发起重连操作,如果重连失败,间隔周期INTERVAL后再次发起重连,直到重连成功。<br>为了保证服务端能够有重组的时间释放句柄资源,在首次断连时客户端需要等待INTERVAL时间之后再发起重连,而不是失败后立即重连。<br>为了保证句柄资源能够及时释放,无论什么场景下重连失败,客户端必须保证自身的资源被及时释放,包括但不限制SocketChannel、Socket等<br>重连失败后,可以打印异常堆栈信息,方便后续的问题定位<br>
重复登录保护。<br>当客户端握手成功之后,在链路处于正常情况下,不允许客户端重复登录,以防止客户端在异常情况下反复重连导致句柄资源被耗尽。<br>服务端接收到客户端的握手请求消息之后,对IP地址进行合法性校验,如果校验成功,在缓存的地址列表中查看客户端是否已经登录,<br>如果登录,则拒绝重复登录,同时关闭TCP链路,并在服务端的日志中打印握手失败的原因。客户端接收到握手失败的应答消息之后,<br>关闭客户端的TCP连接,等待INTERVAL时间之后,再次发起TCP连接,直到认证成功<br>
实现设计
模型
前期准备。<br>定义好消息有关的实体类,为了防篡改,消息体需要进行摘要,可以使用MD5、SHA-1和SHA-256.<br>还可以对MD5进行额外的加盐摘要,序列化框架可以使用Kryo<br>
服务端。<br>最先安装的当然是解决粘包半包问题的Handler,很自然,这里应该用LengthFieldBasedFrameDeocder进行编码,<br>为了实现方便,可以在消息报文中不附带消息的长度,由Netty帮我们在消息报文的最开始增加长度,所以编码器选择<br>LengthFieldPrepender.接下来自然就是序列化和反序列化,服务端需要进行登录检查、心跳应答、业务处理,对应<br>着三个Handler,于是分别安装LoginAuthRespHandler、HeartBeatRespHandler、ServerBusiHandler.<br>为了节约网络和服务器资源,如果客户端长久没有发送业务和心跳报文,我们认为客户端出现了问题,需要关闭这个连接,<br>我们引入Netty的ReadTimeoutHandler,当一定周期内(默认值50s,我们可以设定为15s)没有读取到对方任何消息时,<br>会触发一个ReadTimeoutException,这时我们检测到这个异常,需要主动关闭这个链路,并清楚客户端登录缓存信息,<br>等待客户端重连<br>
客户端。<br>最先安装的当然是解决粘包和半包问题的Handler,同样这里应该用LengthFieldBasedFrameDecoder进行解码,<br>编码器选择LengthFieldPrepender,接下来就是序列化和反序列化。客户端需要主动发出认证请求和心跳请求。<br>在TCP三次握手,链路建立后,客户端需要进行应用层的握手认证,才能使用服务,这个功能由LoginAuthReqHandler<br>负责,而这个Handler在认证通过之后其实就没用了,送一在认证通过后,可以将这个LoginAuthReqHandler移除<br>(其实服务端的认证应答LoginAuthRespHandler同样也可以移除)。对于发出心跳请求有两种实现方式,一是定时发出,<br>这种方式其实有浪费的情况,因为如果客户端和服务器正在正常通信,其实是没有必要发送心跳的;第二种方式就是,<br>当链路写空闲时,为了维持通道,避免服务器关闭链接,发出心跳请求,如果要实现这一点,需要在整个pipeline的最前<br>面安装一个CheckWriteIdleHandler进行写空闲检测,设置一个空闲时间,一般取服务器读空闲时间15s的一半,然后<br>再安装一个HeartBeatReqHandler,因为写空闲会触发一个FIRST_WRITER_IDLE_STATE_EVENT入站事件,我们在<br>HeartBeatReqHandler的userEventTriggered方法中捕捉这个事件,并发出心跳请求报文,也可以考虑双向心跳<br>(即使客户端向服务器发送心跳请求,是服务器也向客户端发送心跳请求),这里我们可以让客户端安装一个ReadTimeoutHandler,<br>来检测服务器是否存活,捕捉ReadTimeoutException后提示调用者,并关闭通信链路,触发重连机制<br>
测试。<br>1.正常情况<br>2.客户端宕机,服务器应能清楚客户端的缓存信息,允许客户端重新登录<br>3.服务器宕机,客户端应能发起重连<br>4.在LoginAuthRespHandler中进行注释,可以模拟当服务器不处理客户端的请求时,<br>客户端在超时后重新进行登录<br>
功能的增强。<br>作为一个通信框架,支持诊断也是很重要的,所以我们可以在服务端单独引入一个MetricsHandler,<br>可以提供,目前在线Channel数、发送队列积压消息数、读取速率、写出速率相关数据,以方便应用<br>方对自己的应用的性能和繁忙程度进行检查和调整。<br>当然对于一个通信框架还可以提供SSL安全访问、流控、I/O线程和业务线程分离、参数的可配置化<br>等等功能<br>
源码:netty-adv
常见的问题
Netty是如何解决JDK中的Selector BUG的?
<br>Selector BUG: JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%,<br>官方声称在JDK 1.6版本的update18修复了该问题,但是直到JDK1.7版本该问题仍旧存在,只不过该BUG发生<br>概率降低了一些而已,它并没有被根本解决,甚至JDK1.8的131版本中仍然存在<br>https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6403933<br>https://bugs.java.com/bugdatabase/view_bug.do?bug_id=2147719<br>https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6670302<br>https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6481709<br>简单来说,JDK认为Linux的epoll告诉我事件来了,但是JDK没有拿到任何事件(READ、WRITE、CONNECT、ACCEPT),<br>但此时select()方法不再选择阻塞了,而是选择返回了0,于是就会进入一种无限循环,导致CPU 100%.<br>这个问题的具体原因是:在部分Linux的2.6的Kernel中。poll和epoll对于突然中断的连接socket会对返回的eventSet<br>事件集合置为POLLHUP或POLLERR,eventSet事件集合发生了变化,这就可能导致Selector会被唤醒。但是这个时候<br>selector的select方法返回numKeys是0,所以下面本应该对key值进行遍历的事情处理根本执行不了,又回到最上面的<br>while(true)循环,循环往复,不断的轮询,直到Linux系统出现100%的CPU情况,最终导致程序崩溃<br>Netty解决办法:对Selector的select操作周期进行统计,每完成一次空的select操作进行一次技术,若在某个周期内连续<br>发生N次空轮询,则触发了epoll死循环bug.重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel<br>从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭,<br>NioEventLoop的select方法中<br>
如何让单机下Netty支持百万长连接?<br>如果要支持百万长连接,需要有很多的工作要做<br>
操作系统
首先就是要突破操作系统的限制。<br>在Linux平台上,无论编写客户端程序还是服务端程序,在进行高并发TCP连接处理时,最高的并发数量都要受到系统对<br>用户单一进程同时可打开文件数量的限制(这是因为系统为每个TCP连接都要创建一个Socket句柄,每个socket句柄也是<br>一个文件句柄)。可使用ulimit命令查看系统允许当前用户进程打开的句柄数限制:<br>ulimit -n<br>1024<br>这表示当前用户的每个进程最多允许同时打开1024个句柄,这1024个句柄中还得出去每个进程必然打开的标准输入,<br>标准输出,标准错误,服务器监听socket,进程间通讯的unix域socket等文件,那么剩下的可用于客户端socket连接的文件数<br>只有大概1024-10=1014个左右。也就是说缺省情况下,基于Linux的通讯程序最多允许同时1014个TCP并发连接<br>对于想支持更高数量的TCP并发连接的通讯处理程序,就必须修改Linux对当前用户进程同时打开的文件数量<br>修改单个进程打开最大文件数限制的最简单的办法就是使用ulimit命令:<br>ulimit -n 1000000<br>如果系统回显类似于"Operation not permitted"之类的话,说明上述限制修改失败,实际上是因为指定的数值超过了Linux<br>系统对该用户打开文件数的软限制或硬限制,因此,就需要修改Linux系统对用户的关于打开文件数的软限制和硬限制<br>软限制(soft limit):是指Linux在当前系统能够承受的范围内进一步限制一个进程同时打开的文件数;<br>硬限制(hard limit):是根据系统硬件资源状况(主要是系统内存)计算出来的系统最多可同时打开的文件数量.<br>第一步:修改/etc/security/limits.conf文件,在文件中添加如下行<br>* soft nofile 1000000<br>* hard nofile 1000000<br>'*'号表示修改所有用户的限制;<br>soft 和hard为两种限制方式,其中soft表示警告的限制,hard表示真正限制,nofile表示打开的最大文件数。1000000则指定了<br>想要修改的新的限制值,即最大打开文件数(请注意软限制值要小于或等于硬限制),修改完后保存文件.<br>第二步:修改/etc/pam.d/login文件,在文件中添加如下行:<br>session required /lib/security/pan_limits.so<br>这是告诉Linux在用户完成系统登录后,应该调用pam_limits.so模块来设置系统对该用户可使用的各种资源数量的最大限制<br>(包括用户可打开的最大文件数限制),而pam_limits.so模块就会从/etc/security/limits.conf文件中读取配置来设置这些限制值,<br>修改完后保存此文件<br>第三步,查看Linux系统级的最大打开文件数限制,使用如下命令:<br>cat /proc/sys/fs/file-max<br>12158<br>这表明这台Linux系统最多允许同时打开(即包含所有用户打开文件数总和)12158个文件,是Linux系统级硬限制,所有用户级<br>打开文件数限制都不应超过这个数值。如果没有特殊需要,不应该修改此限制,除非想为用户级打开文件数限制设置超过此<br>限制的值。如果想要修改系统最大文件描述符的限制,需要修改sysctl.conf<br>vim /etc/sysctl.conf<br>在末尾添加,fs.file_max=1000000<br>立即生效 sysctl -p<br>
Netty调优
<font color="#e74f4c">设置合理的线程数。</font><br>对于线程池的调优,主要集中在用于接收海量设备TCP连接、TLS握手的Acceptor线程池(Netty通常叫boss NioEventLoopGroup)上,<br>以及用于处理网络数据读写、心跳发送的IO工作线程池(Netty通常叫work NioEventLoopGroup)上。<br>对于Netty服务端,通常只需要启动一个监听端口用于端侧设备接入即可,但是如果服务端集群实例比较少,甚至是单机(或者双机冷备)<br>部署,在端侧设备在短时间内大量接入时,需要对服务端的监听方式和线程模型做优化,以满足短时间内(例如30s)百万级的端侧设备<br>接入的需要。<br>服务端可以监听多个端口,利用主从Reactor线程模型做接入优化,前端通过SLB做4层门7层负载均衡。<br>主从Reactor线程模型特点如下:服务端用于接收客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO线程池,Acceptor接收到<br>客户端TCP连接请求并处理后(可能包含接入认证等),将新创建的SocketChannel注册到I/O线程池(subReactor线程池)的某个IO线程<br>由它负责SocketChannel的读写和编解码工作;Acceptor线程池仅用于客户端的登录、握手和安全认证等,一旦链路建立成功,就将<br>链路注册到后端subReactor线程池的IO线程,由IO线程负责后续的IO操作。<br>对于IO工作线程池的优化,可以先采用系统默认值(即CPU内核数x2)进行性能测试,在性能测试过程中采集IO线程的CPU占用大小,<br>看是否存在瓶颈,具体可以观察线程堆栈,如果连续采集几次进行对比,发现线程堆栈都停留在SelectorIml.lockAndDoSelect,<br>则说明IO线程比较控线,无需对工作线程数做调整。<br>如果发现IO线程的热点停留在读或者写操作,或者停留在ChannelHandler的执行处,则可以通过适当调大NioEventLoop线程的个数<br>来提升网络的读写性能<br>
<font color="#e74f4c">心跳优化。</font><br>针对海量设备接入的服务端,心跳优化策略如下:<br>1.要能够即使检测失效的连接,并将其剔除,防止无效的连接句柄积压,导致OOM等问题<br>2.设置核里的心跳周期,防止心跳定时任务积压,造成频繁的老年代GC(新生代和老年代都有STW的GC,不过耗时差异较大),<br>导致应用暂停<br>3.使用Netty提供的链路空闲检测机制,不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题。<br><br>当设备突然断电、连接被防火墙挡住、长时间GC或者通信线程发生非预期异常时,会导致链路不可用且不易被即时发现。<br>特别是如果异常发生在凌晨业务低谷期间,当早晨业务高峰期来时,由于链路不可用会导致瞬间大批量业务失败或者超时,<br>这将对系统的可靠性产生重大的威胁。从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测,<br>目前最流行和通用地做法就是心跳检测。心跳检测机制分为三个层面:<br>1.TCP层的心跳检测,即TCP得Keep-Alive机制,它得作用域是整个TCP协议栈<br>2.协议层的心跳检测,主要存在长连接协议中,例如MQTT<br>3.应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。<br>心跳检测的目的就是确认当前链路是否可用,对方是否或者并且能够正常接收和发送消息,作为高可靠的NIO框架,NIO也<br>提供了心跳检测机制.<br>一般的心跳检测策略如下:<br>1.连续N次心跳检测都没有受到对方的Pong应答消息或者Ping请求消息,则认为链路已经发生逻辑失效,这被称为心跳超时。<br>2.在读取和发送心跳消息的时候如果直接发生了IO异常,说明链路已经失效,这被称为心跳失败,无论发生心跳超时还是心跳失败,<br>都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常<br><br>Netty提供了三种链路空闲检测机制,利用该机制可以轻松地实现心跳机制<br>1.读空闲,链路持续时间T没有读取到任何消息<br>2.写空闲,链路持续时间T没有发送任何消息<br>3.读写空闲,链路持续时间T没有接收或者发送任何消息<br>对于百万级的服务器,一般不建议很长的心跳周期和超时时长<br>
<font color="#e74f4c">接收和发送缓冲区调优</font><br><font color="#000000">在一些场景下,端侧设备会周期性地上报数据和发送心跳,单个链路的消息收发量并不大,针对此类场景,可以通过调小<br></font>TCP的接收和发送缓冲区来降低单个TCP连接的资源占用率。<br>当然对于不同的应用场景,收发缓冲区的最优值可能不同,用户需要根据实际场景,结合性能测试数据进行针对性的调优<br>
<font color="#e74f4c">合理使用内存池</font><br>随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是一个非常轻量级的工作。但是对于缓冲区Buffer,情况却稍<br>有不同,特别是对外直接内存的分配和回收,是一个耗时的操作。为了尽量重用缓冲区,Netty提供了基于内存池的缓冲区<br>重用机制。<br>在百万级的情况下,需要为每个接入的端侧设备至少分配一个接收和发送ByteBuf缓冲区对象,采用传统的非池模式,每次<br>消息读写都需要创建和释放ByteBuf对象,如果有100万个连接,每秒上报一次数据或者心跳,就会有100万次/秒的ByteBuf<br>对象申请和释放,即便服务端的内存可以满足要求,GC的压力也会非常大。<br>以上问题最有效的解决办法就是使用内存池,每个NioEventLoop线程处理N个链路,在线程内部,链路的处理是串行的,<br>假如A链路首先被处理,它会创建接收缓冲区等对象,待解码完成,构造的POJO对象被封装成任务后投递到后台的线程池<br>中执行,然后接收缓冲区会被释放,每条消息的接收和处理都会重复接收缓冲区的创建和释放。如果使用内存池,则当A<br>链路接收到新的数据报时,从NioEventLoop的内存池中申请空闲的ByteBuf,解码后调用release将ByteBuf释放到内存池中,<br>供后续的B链路使用<br>Netty内存池从实现上可以分为两类:堆外直接内存和堆内存。由于ByteBuf主要用于网络IO读写,因此采用堆外直接内存会<br>减少一次从用户堆内存到内核态字节数组拷贝,所以性能更高。由于DirectByteBuf的创建成本比较高,因此如果使用DirectByteBuf,<br>则需要配合内存池的使用,否则性价比可能还不如HeapByte<br>Netty默认的IO读写操作采用的都是内存池的堆外直接内存模式,如果用户需要额外使用ByteBuf,建议也采用内存池方式;如果<br>不涉及网络IO操作(只是纯粹的内存操作),可以使用堆内存池,这样内存的创建效率会更高一些<br>
<font color="#e74f4c">IO线程和业务线程分离</font><br>如果服务端不做复杂的业务逻辑操作,仅仅时简单的内存操作和消息转发,则可以通过调大NioEventLoop工作线程池的方式,<br>直接在IO线程中执行业务ChannelHandler,这样便减少了一次线程上下文切换,性能反而更高。<br>如果有复杂的业务逻辑操作,则将以IO线程和业务线程分离,对于IO线程,由于互相之间不存在锁竞争,可以创建一个大的<br>NioEventLoopGroup线程组,所有Channel都共享同一个线程池。<br>对于后端的业务线程池,则加你创建多个小的业务线程池,线程池可以与IO线程绑定,这样既减少了锁竞争,又提升了后端的<br>处理性能<br>
<font color="#e74f4c">针对端侧并发连接数的流控</font><br>无论服务端的性能优化到多少,都需要考虑流控功能。当资源称为瓶颈,或者遇到端侧设备的大量接入,需要通过流控对系统<br>做保护。流控的策略有很多种,比如针对端侧连接数的流控;<br>在Netty中,可以非常方便地实现流控功能:新增一个FlowCOntrolChannelHandler,然后添加到ChannelPipeline靠前的位置,<br>覆盖channelActive()方法,创建TCP链路后,执行流控逻辑,如果达到流控阈值,则拒绝该连接,调用ChannelHandlerContext<br>的close()方法关闭连接<br>
<font color="#e74f4c">JVM层面相关性能优化。</font><br><font color="#000000">当客户端的并发连接数达到数十万或者数百万时,系统一个较小的抖动就会导致很严重的后果,例如服务端的GC,导致应用<br></font>暂停(STW)的GC持续几秒,就会导致海量的客户端设备掉线或者消息积压,一旦系统恢复,就会有海量的设备接入或者海量<br>的数据发送很可能瞬间就把服务端冲垮。<br>JVM层面的调优主要涉及GC参数优化,GC参数设置不当会导致频繁GC,甚至OOM异常,对服务端的稳定运行产生重大影响<br><br>1.确定GC优化目标<br>GC(垃圾收集)有三个主要指标<br>1.1 吞吐量:是评价GC能力的重要指标,在不考虑GC引起的停顿时间或内存消耗时,吞吐量是GC能支撑应用程序达到的最高性能指标。<br>1.2 延迟:GC能力的最重要指标之一,是由于GC引起的停顿时间,优化目标是缩短延迟时间或完全消除停顿(STW),避免应用程序在运行<br>过程中发生抖动<br>1.3 内存占用:GC正常时占用的内存量<br><br>JVM调优的三个基本原则如下:<br>1.Minor gc回收原则:每次新生代GC回收尽可能多的内存,减少应用程序发生Full gc的频率<br>2.GC内存最大化原则:垃圾收集器能够使用的内存越大,垃圾收集器效率越高,应用程序运行也越流畅,但是过大的内存一次Fullgc<br>耗时可能较长,如果能够有效避免FullGC,就需要做精细化调优<br>3. 3选2原则:吞吐量、延迟和内存占用不能兼得,无法同时做到吞吐量和暂停时间都最优,需要根据业务场景做选择。对于大多数<br>应用,吞吐量有限,其次是延迟。当然对于时延敏感型的业务,需要调整次序<br><br>2.确定服务端内存占用<br>在优化GC之前,需要确定应用程序的内存占用大小,以便为应用程序设置合适的内存,提升GC效率。内存占用与活跃数据有关,<br>活跃数据指的是应用程序稳定运行时长时间存活的Java对象。活跃数据的计算方式:通过GC日志采集GC数据,获取应用程序稳定<br>时老年代占用的Java堆大小,以及永久代(元数据区)占用的Java堆大小,两者之和就是活跃数据的内存占用大小<br><br>3.GC优化过程<br>3.1 GC数据的采集和研读<br>3.2 设置何实的JVM堆大小<br>3.3 选择合适的垃圾回收期和回收策略<br><br>GC调优会是一个需要多次调整的过程,期间不仅有参数的变化,更重要的是需要调整业务代码<br>
什么是水平触发(LT)和边缘触发(ET)?
LT(level_triggered,水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。<br>如果这次没有把数据一次性全部读写完,那么下次调用epoll_wait()时,它还会通知你在上一个没读写完的文件描述符<br>上继续读写,当然如果你一直不去读写,它会一直通知你<br>
ET(Edge_triggered,边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。<br>如果这次没有把数据全部读写完,那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该<br>文件描述符上出现第二次可读写事件才会通知你,这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪<br>文件描述符.<br><br>select(),poll()模型都是水平触发模式,信号驱动IO是边缘触发模式,epoll()模型支持水平触发,也支持边缘触发,<br>默认是水平触发,JDK中的select()实现是水平触发,而Netty提供的epoll的是实现中是边缘触发<br>
0 条评论
下一页