Netty权威指南阅读

2016年03月29日

扫盲

TCP协议服务在传输层

OSI七层协议
  • 应用层
  • 表示层
  • 会话层
  • 传输层
  • 网络层
  • 数据链路层
  • 物理层
TCP/IP四层协议
  • 应用层 (对应OSI 应用层,表示层,会话层): 各种服务及应用程序通过该层利用网络,常用协议:HTTP,FTP,SMTP
  • 传输层 (对应OSI 传输层) : 确认数据传输及进行纠错处理,常用协议:TCP,UDP
  • 网际层 (对应OSI 网络层) : 负责数据传输,路由及地址选择,常用协议IP,ARP(地址解析协议)
  • 网络接口 (对应OSI 数据链路层,物理层) : 针对不同物理网络的连接形式协议,例如:Ethernet,ATM

Linux网络I/O模型

fd : file discriptor 文件描述符

  • 阻塞I/O模型 (缺省情况下,所有文件的读写炒作都会是阻塞的)
  • 非阻塞I/O模型 (反复轮询缓冲区内有没有数据,没有数据则直接返回 EWOULDBLOCK, 有数据则返回数据)
  • I/O复用模型 (select/poll, 将一个或多个fd传递给select或者poll系统调用,阻塞在select上,select/poll侦测多个fd是否处于就绪状态)
  • 信号驱动I/O模型 (系统调用信号处理函数,立即返回,非阻塞,但数据准备就绪,生成SIGIO信号,回调应用程序,处理数据)
  • 异步I/O (告知内核启动某个操作,并让内核在整个操作完成后通知我们, 与信号驱动模型的差别是,信号驱动I/O是由内核通知我们何时可以开始一个I/O操作,异步I/O由内核通知我们I/O操作何时已经完成)

详细Unix系统网络编程知识,可阅读 “UNIX网络编程”

I/O多路复用技术

通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同事处理多个客户端请求.

主要应用场景
  • 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字
  • 服务器需要同时处理多中网络协议的套接字
epoll 改进
  • 支持一个进程打开的socket描述符不受限制(仅受限与操作系统的最大文件句柄数)
  • I/O效率不会随着FD数目的增加而线性下降
  • 使用mmap加速内核与用户空间的消息传递(通过内核和用户空间mmap同一块内存来实现)
  • epoll的API更加简单
NIO相关概念
  • 缓冲区 Buffer
  • 通道 Channel
  • 多路复用器 Selector (Selector不断轮询注册骑上的Channel)

NIO2.0 对应于UNIX网络编程中的事件驱动I/O(AIO) JDK1.4提供的NIO严格来说只能被称为非阻塞I/O,不能称为异步非阻塞I/O

TCP粘包/拆包问题

粘包/拆包

tcp是”流”协议,即没有界限的一串数据(可以想象成河里的流水),tcp底层会根据tcp缓冲区的实际情况进行包的划分,一个业务上认为完整的包,可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送.这就是所谓的粘包/拆包问题.

粘包/拆包原因
  • 应用程序write写入的字节大小小于套接口发送缓冲区大小.
  • 进行MSS大小的TCP分段 (什么鬼?)
  • 以太网帧大 payload 大于MTU进行IP分片 (这特么又是什么鬼?)
解决策略
  • 消息定长,例如每个报文固定大小200字节,不够则空位补空格
  • 在包尾增加回车换行符进行分割,例如FTP协议
  • 将消息分为消息头和消息体,消息头中包含标示消息总长度(或者消息体总长度)的字段,通常设计私立为消息头的第一个字段使用int32来标示消息的总长度.
  • 更复杂的应用层协议
使用LineBasedFrameDecoder 和 StringDecoder 与顺序强相关

下面两个使用方式,是不同的效果

ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new TimeClientHandler());
---------------------
ch.pipeline().addLast(new StringDecoder());
ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
ch.pipeline().addLast(new TimeClientHandler());
DelimiterBasedFrameDecoder 和 FixedLengthFrameDecoder

编解码

java序列化缺点

编解码的性能瓶颈是什么?

  • 无法跨语言
  • 序列化后的码流太大
  • 序列化性能太低

ObjectInputStream 和 ObjectOutputStream

可直接将java对象作为可存储的字节数组写入文件(如何写入? instance Serializable)

相关编解码框架

xml的可读性和可扩展性非常好,也非常适合描述数据结构,但是xml解析的时间开销和xml为了可读性而牺牲的空间开销都非常大,因此不适合做高性能的通信协议.

  • Protobuf (Google Protocal Buffers, 使用二进制编码,在空间和性能上具有更大的优势)
  • Thrift (Facebook 的 Thrift)
  • JBoss Marshalling

MessagePack 编解码

特点

  • 编解码高效, 性能高
  • 序列化之后的码流小
  • 支持跨语言

Google Protobuf 编解码

优点

  • google内部长期使用,成熟度高
  • 跨语言,支持多种语言
  • 编码后的消息更小,更加有利于存储和传输
  • 编解码的性能非常高
  • 支持不同协议版本的前向兼容
  • 支持定义可选和必选字段

HTTP协议开发应用

HTTP 协议特点

  • 支持Client/Server 模型
  • 简单 - 客户端向服务器请求时,只需制定服务url,携带必要的请求者消息体
  • 灵活 - HTTP允许传输任意类型的数据对象,传输的内容类型由HTTP消息头中的Content-type加以标记
  • 无状态 - HTTP协议是无状态协议,无状态是指协议对于事务处理没有记忆能力,缺少状态意味着如果后续处理需要之前的信息,则它必须重传,这样可能导致每次连接传送的数据量增大,另一方面,在服务器不需要先前信息时它的应答就较快,负载较轻

HTTP请求消息

  • HTTP请求行
  • HTTP请求头
  • HTTP请求正文

HTTP请求方法(get,post,head,connect,option,delete……)

get vs post
  • 根据HTTP规范,get用于信息获取,而且应该是安全的和幂等的;post则表示可能改变服务器上的资源的请求
  • get请求,请求数据会附在url之后,而post提交会把提交的数据放置在HTTP消息的包体中,数据不会在地址栏中显示出来.
  • 传输数据的大小不同,特定浏览器和服务器对url长度有限制(IE长度限制是2083字节,即2KB+35B),get参数长度会收到浏览器的限制,而post理论上数据长度不会受限.
  • 安全性,post比get安全性更高(get提交数据,1.登录页面可能被浏览器缓存,2.其他人查看浏览器记录可拿到帐号密码,3.可能存在Cross-site request forgery攻击),post不存在上述问题

HTTP响应消息

  • 状态行
  • 消息报文
  • 响应正文

WebSocket 协议开发

HTTP协议主要弊端

  • HTTP协议为半双工协议(指数据可以在客户端和服务器两个方向上传输,但是不能同时传输,意味着同一时刻,只有一个方向上的数据传送).
  • HTTP协议冗长而繁琐(HTTP消息包含消息头,消息体,换行符等,通常情况下采用文本方式传输,相比于其他的二进制通信协议,冗长而繁琐).
  • 针对服务器推送的黑客攻击.例如长时间轮询.

WebSocket

WebSocket是HTML5开始提供的一种浏览器与服务器间进行全双工通信的网络技术,基于TCP双向全工进行消息传递,同一时刻,即可发送消息,也可接收消息,相比HTTP的半双工协议,性能得到很大提.WebSocket设计的目的就是为了取代轮询和comet技术

netty协议栈

功能

  • 基于netty的NIO通信框架,提供高性能的异步通讯能力
  • 提供消息的编解码框架,可以实现POJO的序列化和反序列化
  • 提供基于IP地址的白名单接入认证机制
  • 链路的有效性校验机制(Ping-Pong机制)
  • 链路的断连重连机制

协议消息定义

|名称|类型|长度|说明| |———-| |header|Header|变长|消息头定义| |body|Object|变长|消息体定义|

消息头

|名称|类型|长度|说明| |—————–| |crcCode|int|32|三部分,1.固定值,标识是netty协议栈消息(0xABEF),2.主版本号(1B),3.次版本号(1B)| |length|int|32|消息长度,整个消息,包括消息头和消息体| |sessionID|long|64|全局唯一的会话ID| |type|byte|8|消息类型(0:业务请求,1:业务响应,…..,6:心跳应答)| |priority|byte|8|消息优先级(0-255)| |attachment|Map<String,Object>|变长|可选字段,扩展消息头|

可靠性设计

  • 心跳机制
  • 重连机制
  • 重复登录保护
  • 消息缓存重发

安全性设计

  • ip白名单
  • 密匙
  • AES加密的用户名+密码认证机制

可扩展性设计

服务端创建

netty服务端创建的关键步骤

  1. 创建ServerBootstrap实例(启动辅助类,底层通过门面模式进行抽象与封装,避免用户与底层API打交道,降低开发难度).
  2. 设置并绑定Reactor线程池(事件处理线程池,不仅处理网络I/O事件,用户自定义的Task和定时任务Task也统一处理).
  3. 设置并绑定服务端Channel.
  4. 链路建立的时候创建并初始化ChannelPipeline(本质就是一个负责处理网络事件的责任链,管理和执行ChannelHandler).
  5. 初始化ChannelPipeline后,添加并设置ChannelHandler.
  6. 绑定并监听端口.
  7. Selector轮询.由Reator线程NioEventLoop负责调度和执行Selector轮询操作,选择准备就绪的Channel集合.
  8. 当轮询到准备就绪的Channel之后,就由Reactor线程NioEventLoop执行ChannelPipeline的相应方法,最终调度并执行ChannelHandler
  9. 执行Netty系统ChannelHandler和用户添加定制的ChannelHandler.

客户端创建

Netty客户端创建流程分析

  1. 用户线程创建Bootstrap实例,设置相关参数,异步发起客户端连接
  2. 创建处理客户端连接,I/O读写的Ractor线程组NioEventLoopGroup.可通过构造函数制定I/O线程个数,默认为CPU的2倍
  3. 通过Bootstrap的ChannelFactory和用户制定的Channel类型创建用于客户端连接的NioSocketChannel,功能类似与JDK NIO类库的SocketChannel
  4. 创建默认的Channel Handler Pipline,用户调度和执行网络事件
  5. 异步发起TCP连接,判断连接是否成功,直接将NioSocketChannel注册到多路复用器上,监听读操作位,用于数据报读取和消息的发送.如果没有立即连接成功,则 注册连接监听位到多路复用器,等待连接结果
  6. 注册对应的网络监听状态位到多路复用器
  7. 由多路复用器在I/O现场轮询各个Channel,处理连接结果
  8. 链接成功,设置Future结果.发送连接成功事件,触发ChannelPipline执行
  9. 由ChannelPipline调度执行系统和用户的ChannelHandler.执行业务逻辑

byteBuf

java.nio.ByteBufer缺点

  • 长度固定,一旦分配完成,不能动态扩展和收缩,待编码POJO对象过大,会发生索引越界异常
  • 只有一个标识位置的指针position,读写需手工调用flip()和rewind(),很容易造成程序处理失败
  • 功能有限,一些高级和实用的特性不支持,需调用者自己实现

ByteBuf优点

  • 使用readerIndex和writerIndex,简化缓冲区读写操作,避免由于遗漏或者不熟悉flip()操作导致的功能异常

ByteBuf内存分配

  1. 堆内存字节缓冲区, 特点是内存的分配和回收速度快,可以被JVM自动回收; 确定是如果进行Socket的I/O读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核Channel中,性能会有一定程度的下降.
  2. 直接内存字节缓冲区, 非堆内存,在堆外进行内存分配,相比于堆内存,分配和回收速度会慢一点,但是将它写入或者从SSocketChannel中读取时,由于少了一次内存复制,速度比堆内快.

*所以从以上两点看,Socket Channel中的信息,是放在直接内存中的咯?*

作者的经验表明,ByteBuf的最佳实践是在I/O通信线程的读写缓冲区使用DirectByteBuf,后端业务消息的编解码模块使用HeapByteBuf,这用的组合可以达到性能最优

PooledByteaBuf内存池

内存分为PoolArena,PoolChunk,Page

一个PoolArena,包含多个PoolChunk,一个PoolChunk包含多个Page

每个Pageu会被切分成大小相等的多个存储块,存储块的大小由第一次申请的内存块大小决定,例如,一个Page是8个字节,第一次申请的块大小是4个字节,那么这个Page就包含2个存储块,如果第一次申请的是8个字节,那么这个Page就被分成1个存储块

Chunk通过二叉树上对节点进行标识实现内存回收,Page通过维护块的使用状态标识来实现内存回收

ByteBuf相关辅助类

  • ByteByfHolder ByteBuf容器,对ByteBuf进行包装和抽象(由于不同的消息体可以包含不同的协议字段和功能,使用Holder进行统一包装)
  • ByteBufAllocator 字节缓冲区分配器(分为 基于内存池和普通的 字节缓冲区分配器)
  • CompositeByteBuf 允许将多个ByteBuf实例组装到一起
  • ByteBufUtil 提供一系列静态方法用于操作ByteBuf对象

Channel and Unsafe

Channel

在Netty中每个Channel对应一个物理连接,每个连接都有自己的TCP参数配置

服务端Channel的parent为空,客户端Channel的parent为创建它的ServerSocketChannel

Netty不使用JDK的Channel原因

  • JDK SocketChannel 和 ServerSocketChannel 没有提供统一的操作视图,使用起来不方便
  • JDK SocketChannel 和 ServerSocketChannel 主要职责是网络I/O,是SPI类接口,直接实现两个接口的难度,和开发一个新的Channel是差不多的
  • NettyChannel能够和Netty整体架构融合,例如I/O模型,基于ChannelPipeline的定制模型,以及一些TCP参数配置等,JDK的Channel都没有提供
  • 自定义Channel功能实现更加灵活

Netty Channel 设计理念

  • 在Channel接口层采用Facade模型统一封装,将网络I/O操作,网络相关的其他操作封装起来,统一对外提供
  • Channel接口定义尽量大而全,通过SocketChannel和ServerSocketChannel的统一视图
  • 具体实现采用聚合而非包含的方式,将相关的功能聚合在Channel里,由Channel统一负责分配和调度.

Unsafe

Unsafe接口实际上是Channel接口的辅助接口,它不应该被用户代码直接调用.实际的I/O操作都是又Unsafe接口负责完成的

ChannelPipeline 和 ChannelHandler

ChannelPipeline和ChannelHandler的机制类似于Servlet和Filter过滤器

ChannlePipeline

ChannelPipeline是ChannelHandler的容器,负责CHannelHandler的管理和事件拦截与调度.

ChannlePipeline的inbound和outbound事件

  • inbound:例如链路建立,链路关闭,读取操作完成等
  • outbound:由用户线程或者代码发起的I/O操作称为outbound事件

ChannelHandler

  • @Sharable : 多个ChannelPileline共用同一个ChannelHandler
  • @Skip: 被Skip注解的方法不会被调用,直接被忽略

相关ChannelHandler

  • ByteToMessageDecoder
  • MessageToMessageDecoder
  • LengthFieldBasedFrameDecoder
  • MessageToByteEncoder
  • MessageToMessageEncoder
  • LenthFieldPrepender : 添加待发送消息的二进制字节长度到ByteBuf的缓冲区头中

EventLoop 和 EventLoopGroup

Netty线程模型

  • Reactor单线程模型: 所有I/O操作都在同一个I/O线程上面完成
  • Reactor多线程模型: 有一个专门的NIO线程(Acceptor)用于监听服务端,接收客户端连接请求.网络I/O操作由一个线程池负责.(一个NIO线程可以同时处理N条链路,但一个链路只对应NIO线程)
  • 主从Reactor多线程模型(推荐使用): Acceptor 和 网络I/O操作 都分别由线程池处理

Netty最佳实践

netty蛮多地方进行了无锁化的设计,例如I/O线程内部进行串行操作(ChannelHandler链式处理),避免多线程竞争导致的性能问题.这种局部无锁化的串行线程设计相比一个队列-多个工作线程的模型性能更优.

  • 使用两个NioEventLoopGroup,逻辑隔离NIO Acceptor 和 NIO I/O线程
  • 尽量不要在ChannelHandler中启动用户线程(解码后用户将pojo消息发到后端业务线程的除外)
  • 解码要放在NIO线程调用的解码Handler中进行,不要切换到用户线程中完成消息的解码
  • 如果业务逻辑比较简单,可以直接在NIO线程上完成业务逻辑操作,无需切换至用户线程
  • 如果业务逻辑复杂,简历将解码后的pojo消息封装成Task,派发到业务线程池中由业务线程执行,以保证NIO线程尽快被释放,处理其他I/O操作

NioEventLoop

处理任务:

  1. I/O读写
  2. 系统Task
  3. 定时任务

Future 和 Promise

Netty Future 对 JDK Future进行扩展, Promise对 Netty Future进行扩展(用于设置I/O操作结果)