Netty 权威指南
2017-02-16基础篇 走进Java NIO
第1章 Java的I/O演进之路
- Linux网络I/O模型简介
- 阻塞I/O模型
- 非阻塞I/O模型
- I/O复用模型:select/poll
- 信号驱动I/O模型
- 异步I/O:与信号驱动模型区别是:信号驱动I/O由内核通知我们何时开始一个I/O操作,异步I/O模型由内核通知我们I/O何时已经完成。
- I/O多路复用技术:目前支持I/O多路复用的系统调用有select、pselect、poll、epoll由于select有一些缺点,所以epoll作了很大改进,总结如下:
- 支持一个进程打开的socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数)。
cat /proc/sys/fs/file-max
可以进行察看,这个值跟系统的内存关系比较大。 - I/O效率不会随着FD数目的增加而线性下降。
- 使用mmap加速内核与用户空间的消息传递。
- epoll的API更加简单。
- 支持一个进程打开的socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数)。
- Java的I/O演进。
- JDK1.4推出NIO之前都是BIO,BIO简单,性能和可靠性有巨大瓶颈。
- JDK1.7 NIO2.0
第2章 NIO入门
- 传统的BIO编程。
- 伪异步I/O编程
- 弊端分析:读写IO都是同步阻塞的,只是对BIO线程模型进行了简单的优化,无法从跟不上解决同步I/O导致的通信线程阻塞问题。
- NIO(Non-block I/O): 以下是NIO类库和相关概念
- 缓冲区Buffer:包含了一些要写入或者要写出的数据。本质就是字节数组,提供了对数据的结构化访问以及维护读写位置等信息。
ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer
,分别对应基本数据类型:byte, char, double, float, int, long, short
。当然NIO中还有MappedByteBuffer, HeapByteBuffer, DirectByteBuffer等 - 通道Channel:通过它来读取和写入数据,网络数据通过Channel读取和写入,通道与流的不同之处在于通道是双工的,流只有一个方向。通道可以用于读、写或者同时读写。分为两大类:用于网络读写的SelectableChannel和文档操作FileChannel。主要实现有
FileChannel,DatagramChannel,SocketChannel,ServerSocketChannel
分别可以对应文件IO、UDP和TCP(Server和Client)。 - 多路复用器Selector:简单来讲,Selector会不断地轮训注册在其上的Channel,如果某个Channel上面有新的TCP连接接入、读写事件,这个Channel就处于就绪状态,会被Selector轮训出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。一个多路复用器Selector可以同时轮训多个Channel,由于JDK采用了epoll()代替了传统的select实现,所以它并没有最大连接句柄1024/2048的限制,意味着只需要一个线程负责Selector的轮询就可以接入成千上万的客户端。
- 缓冲区Buffer:包含了一些要写入或者要写出的数据。本质就是字节数组,提供了对数据的结构化访问以及维护读写位置等信息。
- NIO服务端序列图
- 打开ServerSocketChannel
- 绑定监听地址InetSocketAddress
- 创建Selector启动线程
- 将ServerSocketChannel注册到Selector,监听
- Selector轮询就绪的Key
- handleAccept()处理新的客户端接入
- 设置新的客户端连接的Socket参数
- 向Selector注册监听读操作SelectionKey.OP_READ
- handleRead()异步请求消息到ByteBuffer
- decode请求消息
- 异步写ByteBuffer到SocketChannel
- NIO客户端序列图
- 打开SocketChannel
- 设置SocketChannel为非阻塞模式,同时设置TCP参数
- 异步连接服务端
- 判断连接结果,如果成功则调到步骤10,否则5
- 向Reactor线程的多路复用器注册OP_CONNECT事件
- 创建Selector启动线程
- Selector轮询就绪的Key
- handerConnect()
- 判断连接是否完成,完成步骤10
- 向多路复用器注册读事件OP_READ
- HandleRead()异步请求消息到ByteBuffer
- decode请求消息
- 异步写ByteBuffer到SocketChannel
- AIO编程
-
TODO
- 选择Netty而直接使用JDK的NIO类库开发的理由
- NIO的类库和API繁杂,使用麻烦,需要熟练掌握Selector,ServerSocketChannel,SocketChannel,ByteBuffer等
- 需要具备其他额外的技能做铺垫,例如熟悉Java多线程编程,这是因为NIO编程涉及到Reactor模式,你必须对多线程和网络编程非常熟悉,才能写出高质量的NIO程序。
- 可靠性能补齐,工作量和难度都非常大。如客户端面临断链重连,网络闪断,半包读写,失败缓存,网络拥塞和异常码流的处理等问题,NIO编程的特点是功能开发相对容易,但可靠性能力的工作量和难度都非常大。
- JDK NIO的BUG。例如epoll bug,导致Selector空轮询,最终导致CPU 100%
- Netty优点:
- API简单,开发门槛低
- 功能强大,预置了多种编解码功能,支持多种主流协议
- 定制能力强,可以通过ChannelHandler对通信框架进行灵活扩展。
- 性能高,成熟稳定,修复了以发现的所有JDK NIO bug,不需要再为NIO的bug烦恼
- 社区活跃,版本迭代周期短,发现的bug能即时修复,以及更多的新功能加入。
- 经历了大规模的商业应用考验,质量得到验证。
入门篇 Netty NIO开发指南
第3章 Netty入门应用
- DEOM
第4章 TCP粘包/拆包问题的解决之道
-
TCP粘包/拆包:TCP是个流协议,没有界限的一串数据,没有分界线,TCP底层并不了解上层业务数据的具体含义,它根据TCP缓冲区的实际情况进行包的划分,所以一个完成的包可能会被TCP拆分成多个包发送,也有可能把多个小包封装在一个大的数据包发送,这就是所谓的粘包和拆包问题。
- TCP粘包/拆包发生原因:
- 应用程序write写入的字节大小大于套接口发送缓冲区的大小。
- 进行MSS大小的TCP分段。
- 以太网帧payload大于MTU进行IP分片
- 粘包解决策略:
- 消息定长,例如每个报文大小固定长度200字节,如果不够空位补空格。
- 在包尾增加回车换行符进行分割,例如FTP协议。
- 将消息分为消息头和消息体,消息头中包含表示消息总长度的字段,通常设计思路为消息头的第一个字段使用int32来表示消息的总长度。
- 更复杂的应用层协议。
- Netty利用LineBasedFrameDecoder、StringDecoder解决TCP粘包问题。Netty提供了多种编解码器用于处理半包问题。
- LineBasedFrameDecoder的工作原理是它依次遍历ByteBuf中的可读字节,判断是否有’\n’或者’\r\n’,如果有,就以此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行,它是支持配置单行的最大长度。如果连续读取到最大长度后仍然没有发现换行符,就抛出异常,同时忽略之前读到的异常码流。
- StringDecoder的功能非常简单,就是将接收到的对象转换成字符串,然后继续调用后面的handler,LineBasedFrameDecoder+StringDecoder组合就是按行切换的文本解码器。
第5章 分隔符和定长解码器的应用
- DelimiterBasedFrameDecoder和FixedLengthFramerDecoder前者可以自动完成分隔符做结束标志的消息的解码,后者可以自动完成对定长消息的解码,它们都能解决TCP粘包/拆包导致的半读问题。
- 使用指南:只要将DelimiterBasedFrameDecoder或FixedLengthFramerDecoder添加到对应的ChannelPipeline的起始位置即可。
Bootstrap b = new Bootstrap();
b.group(group).channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
ByteBuf delimiter = Unpooled.copiedBuffer("$_".getBytes());
socketChannel.pipeline().addLast(
new DelimiterBasedFrameDecoder(1024, delimiter));
socketChannel.pipeline().addLast(new StringDecoder());
socketChannel.pipeline().addLast(new EchoClientHandler());
}
});
ChannelFuture f = b.connect(host, port).sync();
f.channel().closeFuture().sync();
中级篇 Netty编解码开发指南
第6章 编解码技术
- Java序列化,实现 java.io.Serializable并生成序列ID即可,缺点如下:
- 无法跨语言最致命的问题,不同的服务可能语言不同。Java序列化后的字节数组,别的语言无法进行反序列化,事实上目前几乎所有流行的RPC通信框架都没有使用Java序列化作为编解码框架,原因就是无法跨语言。
- 序列化后的码流太大。导致存储占空间更大成本就越高,传输更占带宽,导致系统吞吐量低。
- 序列化性能低。 4.
//java序列化 info对象 java.io.ObjectInput或java.io.ObjectOutput进行反序列化和序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream os = new ObjectOutputStream(bos);
os.writeObject(info);
os.flush();
os.close;
- 评判一个编解码框架主要考虑以下因素:
- 是否支持跨语言
- 编码后的码流大小
- 编解码的性能
- 类库是否小巧API使用是否方便
- 使用者需要手工开发的工作量和难度
- Protobuf(Google Protocol Buffers):特点如下:
- 结构化数据存储(XML, JSON等),二进制编码
- 高效编解码性能
- 语言无关、平台无关、扩展性好。
- 官方支持Java、C++、Python三种语言
- Protobuf优点如下:
- 文本化数据结构,语言和平台无关,适合做异构系统间的集成。
- 通过标识字段的顺序,可以实现协议的前向兼容
- 自动代码生成,不要手工编写同样数据结构的C++和Java版本
- 方便后续管理和维护
- Thrift/JBoss Marshalling
第7章 Java序列化
- 通过使用Netty的Java序列化编解码handler,可以完成POJO的序列化和反序列化。
第8章 Google Protobuf编解码
- 优点:
- Google内部长期使用,产品成熟度高;
- 跨语言,C++,Java,Python
- 编码后的消息更小,更加有利于存储和传输
- 编解码性能高
- 支持不同协议版本的前后兼容
- 支持定义可选和必选字段
第9章 JBoss Marshalling编解码
- 略。
高级篇 Netty多协议开发和应用
第10章 HTTP协议开发应用
- HTTP协议主要特点:
- 支持Client/Server模式
- 简单–客户向服务器请求服务时,只需指定服务URL,携带必要的请求参数或者消息体
- 灵活–HTTP允许传输任意类型的数据对象,传输的内容类型由HTTP消息头中的Content-Type加以标记
- 无状态–HTTP协议是无状态协议。
- P232 TODO
第11章 WebSocket协议开发
第12章 UDP协议开发
第13章 文件传输
第14章 私有协议栈开发
Netty高性能之道
- 传统RPC调用采用BIO即阻塞IO,线程模型问题:由于采用同步阻塞IO,会导致每个TCP连接都占用一个线程,由于线程资源是JVM虚拟机非常宝贵的资源,当IO读写阻塞导致无法及时释放时,会导致系统性能急剧下降,严重的甚至会导致虚拟机无法创建新的线程。
- 高性能RPC三个主题
- 传输:用什么样的通道将数据传输给对方,BIO、NIO、AIO,IO模型很大程度上决定了框架的性能。
- 协议:采用什么样的通信协议,HTTP或者内部私有自定义协议,协议的不同,性能也有不同。相比公有协议,内部私有协议的性能通常可以被设计的更优。
- 线程:数据报如何读取,读取后编解码在那个线程进行,编解码后的消息如何派发,Reactor线程模型的不同,对性能影响也非常大。
- 零拷贝。
- Netty的接收和发送ByteBuffer采用Direct Buffers,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(Heap Buffers)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
- Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大Buffer。
- Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。
- 高效的Reactor线程模型: 有三种。
- Reactor单线程模型
- Reactor多线程模型
- 主从Reactor多线程模型.
- Reactor单线程模型,指的是所有IO操作都在同一个NIO线程上面完成,NIO线程职责如下:
- 作为NIO服务端,接受客户端的TCP连接。
- 作为NIO客户端,向服务端发起TCP连接。
- 读取通信对端的请求或者应答。
- 向通信对端发送消息请求或者应答消息。 - 单线程模型缺点:
- 一个NIO线程同时处理成百上千的链路,性能上无法支撑,即便NIO线程的CPU负荷达到100%,也无法满足海量消息的编码、解码、读取和发送;
- 当NIO线程负载过重之后,处理速度变慢,导致大量客户端连接超时,超时后往往会进行重发,这更加重了NIO线程的负载,最终导致大量消息积压和处理超时,NIO线程会成为系统的性能瓶颈。
- 可靠性问题:一旦NIO线程意外跑飞,或者死循环,导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。 - Reactor多线程模型:
- 有专门一个NIO线程Acceptor线程用于监听服务端,接收客户端的TCP连接请求。
- 网络IO操作读、写等由一个NIO线程池负责,线程池可以采用标准的JDK线程池实现,它包含一个任务队列和N个可用线程,由这些NIO线程负责消息的读取、解码、编码和发送。
- 1个NIO线程可以同时处理N条链路,但是1个链路只对应1个NIO线程,防止并发操作问题。 - 主从Reactor多线程模型:解决了1个服务端监听线程无法有效处理所有客户端连接的性能不足问题。 - Netty的线程模型并非固定不变,通过在启动辅助类中创建不同的EventLoopGroup实例并通过适当的参数配置,就可以支持上述三种Reactor线程模型。正是因为Netty 对Reactor线程模型的支持提供了灵活的定制能力,所以可以满足不同业务场景的性能诉求。
- Netty无锁化的串行设计。
- 高效的并发编程:主要体现在如下几点。
- volatile的大量且正确使用。
- CAS和原子累的广泛使用。
- 线程安全容器的使用。
- 通过读写锁提升并发性能。
- 高性能的序列化框架:
- 序列化后的码流大小(网络带宽的占用)
- 序列化&反序列化的性能(CPU资源占用)
- 是否支持跨语言(异构系统的对接口和开发语言的切换)
- Netty默认提供了堆Google Protobuf的支持,通过扩展Netty的编解码接口,可以实现其他高性能序列化框架。由于Java原生的序列化性能太差才催生了各种高性能开源序列化技术和框架。以及跨语言、IDL定义等其他原因。
- Netty灵活的TCP参数配置能力,合理设置TCP参数在某些场景下对性能的提升可以起到显著地效果。