Netty、Redis、Zookeeper高并发实战

尼恩

前言

  • 分布式Java框架、Redis缓存、分布式搜索ElasticSearch、分布式协调ZooKeeper、消息队列Kafka、高性能通信框架Netty。

1.1 Netty为何这么火

  • Netty是JBOSS提供的一个Java开源框架,是基于NIO的客户端/服务器编程框架,它既能快速开发高并发、高可用、高可靠性的网络服务器程序,也能开发高可用、高可靠的客户端程序。
  • 火爆的Kafka、RocketMQ等消息中间件、火热的ElasticSearch开源搜索引擎、大数据处理Hadoop的RPC框架Avro、主流的分布式通信框架Dubbo,它们都使用了Netty。
  • Netty之所以受青睐,是因为Netty提供异步的、事件驱动的网络应用程序框架和工具。作为一个异步框架,Netty的所有IO操作都是异步非阻塞的,通过Future-Listener机制,用户可以方便地主动获取或者通过通知机制获得IO操作结果。
  • 定制能力强,可以通过ChannelHandler对通信框架进行灵活扩展。
  • Netty是互联网中间件领域使用最广泛、最核心的网络通信框架之一

1.2 高并发利器Redis

  • Redis缓存目前已经成为缓存的事实标准。
  • Redis是Remote Dictionary Server(远程字典服务器)
  • Redis的主要应用场景:缓存(数据查询、短连接、新闻内容、商品内容等)、分布式会话(Session)、聊天室的在线好友列表、任务队列(秒杀、抢购、12306等)、应用排行榜、访问统计、数据过期处理(可以精确到毫秒)。
  • 单线程,避免了线程切换和锁机制的性能消耗。

1.3 分布式利器ZooKeeper

  • ZooKeeper就是目前极为重要的分布式协调工具
  • 通用的无单点问题的分布式协调框架
  • ZooKeeper的核心优势是,实现了分布式环境的数据一致性,简单地说:每时每刻我们访问ZooKeeper的树结构时,不同的节点返回的数据都是一致的。也就是说,对ZooKeeper进行数据访问时,无论是什么时间,都不会引起脏读、重复读。注:脏读是指在数据库存取中无效数据的读出。

1.4 高并发IM的综合实践

  • 企业级Web, QPS(Query Per Second,每秒查询率)峰值可能在1000以内,甚至在100以内,没有多少技术挑战性和含金量,属于重复性的CRUD的体力活
  • ,作为一个顶级的架构师,就应该具备全栈式的架构能力,对不同用户规模的、差异化的应用场景,提供和架构出与对应的应用场景相匹配的高并发IM系统

2.1 IO读写的基础原理

  • 用户程序进行IO的读写,依赖于底层的IO读写,基本上会用到底层的read&write两大系统调用
  • 调用操作系统的read,是把数据从内核缓冲区复制到进程缓冲区;而write系统调用,是把数据从进程缓冲区复制到内核缓冲区。
  • 缓冲区的目的,是为了减少频繁地与设备之间的物理交换
  • 客户端请求:Linux通过网卡读取客户端的请求数据,将数据读取到内核缓冲区。· 获取请求数据:Java服务器通过read系统调用,从Linux内核缓冲区读取数据,再送入Java进程缓冲区。· 服务器端业务处理:Java服务器在自己的用户空间中处理客户端的请求。· 服务器端返回数据:Java服务器完成处理后,构建好的响应数据,将这些数据从用户缓冲区写入内核缓冲区。这里用到的是write系统调用。· 发送给客户端:Linux内核通过网络IO,将内核缓冲区中的数据写入网卡,网卡通过底层的通信协议,会将数据发送给目标客户端。

2.2 四种主要的IO模型

  • 同步IO,是一种用户空间与内核空间的IO发起方式。同步IO是指用户空间的线程是主动发起IO请求的一方,内核空间是被动接受方。异步IO则反过来,是指系统内核是主动发起IO请求的一方,用户空间的线程是被动接受方。
  • 同步非阻塞IO的特点:应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好,就继续轮询,直到完成IO系统调用为止。
  • 在IO多路复用模型中,引入了一种新的系统调用,查询IO的就绪状态。在Linux系统中,对应的系统调用为select/epoll系统调用。通过该系统调用,一个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核能够将就绪的状态返回给应用程序。随后,应用程序根据就绪的状态,进行相应的IO系统调用。
  • 异步IO模型(Asynchronous IO,简称为AIO)。AIO的基本流程是:用户线程通过系统调用,向内核注册某个IO操作。内核在整个IO操作(包括数据准备、数据复制)完成后,通知用户程序,用户执行后续的业务操作。

2.3 通过合理配置来支持百万级并发连接

  • Linux操作系统中文件句柄数的限制。
  • Linux的系统默认值为1024,也就是说,一个进程最多可以接受1024个socket连接
  • 文件句柄,也叫文件描述符。在Linux系统中,文件可分为:普通文件、目录文件、链接文件和设备文件。文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,它是一个非负整数(通常是小整数),用于指代被打开的文件。所有的IO系统调用,包括socket的读写调用,都是通过文件描述符完成的。
  • 终极解除Linux系统的最大文件打开数量的限制,可以通过编辑Linux的极限配置文件/etc/security/limits.conf来解决

3.1 Java NIO简介

  • New IO类库的目标,就是要让Java支持非阻塞IO,基于这个原因,更多的人喜欢称Java NIO为非阻塞IO(Non-Block IO),称“老的”阻塞式Java IO为OIO(Old IO)。总体上说,NIO弥补了原来面向流的OIO同步阻塞的不足
  • Channel(通道)· Buffer(缓冲区)· Selector(选择器)
  • Java NIO,属于第三种模型—— IO多路复用模型
  • NIO中引入了Channel(通道)和Buffer(缓冲区)的概念。读取和写入,只需要从通道中读取数据到缓冲区中,或将数据从缓冲区中写入到通道中。NIO不像OIO那样是顺序操作,可以随意地读取Buffer中任意位置的数据。
  • 在NIO中,同一个网络连接使用一个通道表示,所有的NIO的IO操作都是从通道开始的。一个通道类似于OIO中的两个流的结合体,既可以从通道读取,也可以向通道写入。
  • 什么是IO多路复用?指的是一个进程/线程可以同时监视多个文件描述符(一个网络连接,操作系统底层使用一个文件描述符来表示),一旦其中的一个或者多个文件描述符可读或者可写,系统内核就通知该进程/线程。
  • 通道的读取,就是将数据从通道读取到缓冲区中;通道的写入,就是将数据从缓冲区中写入到通道中。

3.2 详解NIO Buffer类及其属性

  • Buffer类是一个非线程安全类。
  • MappedByteBuffer是专门用于内存映射的一种ByteBuffer类型
  • 有三个重要的成员属性:capacity(容量)、position(读写位置)、limit(读写的限制)。
  • capacity容量不是指内存块byte[]数组的字节的数量。capacity容量指的是写入的数据对象的数量。
  • position属性与缓冲区的读写模式有关
  • 可以使用(即调用)flip翻转方法,将缓冲区变成读取模式。
  • 表3-1 Buffer四个重要属性的取值说明

3.3 详解NIO Buffer类的重要方法

  • allocate()创建缓冲区
  • 调用子类的allocate()方法。
  • put()写入到缓冲区
  • flip()翻转方法是Buffer类提供的一个模式转变的重要方法,它的作用就是将写入模式翻转成读取模式
  • 最后,清除之前的mark标记,因为mark保存的是写模式下的临时位置。在读模式下,如果继续使用旧的mark标记,会造成位置混乱。
  • 可以调用Buffer.clear()清空或者Buffer.compact()压缩方法,它们可以将缓冲区转换为写模式。
  • Buffer.mark()方法的作用是将当前position的值保存起来,放在mark属性中,让mark属性记住这个临时位置;之后,可以调用Buffer.reset()方法将mark的值恢复到position中。
  • 在读取模式下,调用clear()方法将缓冲区切换为写入模式。此方法会将position清零,limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。
  • 总体来说,使用Java NIO Buffer类的基本步骤如下:(1)使用创建子类实例对象的allocate()方法,创建一个Buffer类的实例对象。(2)调用put方法,将数据写入到缓冲区中。(3)写入完成后,在开始读取数据前,调用Buffer.flip()方法,将缓冲区转换为读模式。(4)调用get方法,从缓冲区中读取数据。(5)读取完成后,调用Buffer.clear() 或Buffer.compact()方法,将缓冲区转换为写入模式。

3.4 详解NIO Channel(通道)类

  • 一个通道可以表示一个底层的文件描述符,例如硬件设备、文件、网络连接等
  • FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。
  • FileChannel为阻塞模式,不能设置为非阻塞模式。
  • 虽然对于通道来说是读取数据,但是对于ByteBuffer缓冲区来说是写入数据,这时,ByteBuffer缓冲区处于写入模式。
  • 此时的ByteBuffer缓冲区要求是可读的,处于读模式下。
  • 在将缓冲区写入通道时,出于性能原因,操作系统不可能每次都实时将数据写入磁盘。如果需要保证写入通道的缓冲数据,最终都真正地写入磁盘,可以调用FileChannel的force()方法。
  • 更高效的文件复制,可以调用文件通道的transferFrom方法
  • 在NIO中,涉及网络连接的通道有两个,一个是SocketChannel负责连接传输,另一个是ServerSocketChannel负责连接的监听。
  • 在非阻塞模式下,通道的操作是异步、高效率的,这也是相对于传统的OIO的优势所在
  • 非阻塞情况下,与服务器的连接可能还没有真正建立,socketChannel.connect方法就返回了,因此需要不断地自旋,检查当前是否是连接到了主机:
  • 在非阻塞模式下,如何知道通道何时是可读的呢?这就需要用到NIO的新组件——Selector通道选择器
  • 在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用一次shutdownOutput()终止输出方法,向对方发送一个输出的结束标志(-1)。然后调用socketChannel.close()方法,关闭套接字连接。
  • 和Socket套接字的TCP传输协议不同,UDP协议不是面向连接的协议。使用UDP协议时,只要知道服务器的IP和端口,就可以直接向对方发送数据。
  • 由于UDP是面向非连接的协议,因此,在调用send方法发送数据的时候,需要指定接收方的地址(IP和端口)。

3.5 详解NIO Selector选择器

  • 数据总是从通道读到缓冲区内,或者从缓冲区写入到通道中
  • 选择器的使命是完成IO的多路复用。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系,是监控和被监控的关系。
  • 这里的IO事件不是对通道的IO操作,而是通道的某个IO操作的一种就绪状态,表示通道具备完成某个IO操作的条件。
  • 判断一个通道能否被选择器监控或选择,有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道)。如果继承了SelectableChannel,则可以被选择,否则不能。
  • SelectionKey选择键就是那些被选择器选中的IO事件
  • (1)获取选择器实例;(2)将通道注册到选择器中;(3)轮询感兴趣的IO就绪事件(选择键集合)。
  • 注册到选择器的通道,必须处于非阻塞模式下,否则将抛出IllegalBlockingModeException异常。
  • 通过Selector选择器的select()方法,选出已经注册的、已经就绪的IO事件,保存到SelectionKey选择键集合中
  • select()方法返回的数量,指的是通道数,而不是IO事件数,准确地说,是指发生了选择器感兴趣的IO事件的通道数。

第4章 鼎鼎大名的Reactor反应器模式

  • Reactor反应器模式是高性能网络编程在设计和架构层面的基础模式

4.1 Reactor反应器模式为何如此重要

  • 高性能网络编程都绕不开反应器模式。很多著名的服务器软件或者中间件都是基于反应器模式实现的。
  • 越是高水平的Java代码,抽象的层次越高,到处都是高度抽象和面向接口的调用,大量用到继承、多态的设计模式。
  • 反应器模式由Reactor反应器线程、Handlers处理器两大角色组成:(1)Reactor反应器线程的职责:负责响应IO事件,并且分发到Handlers处理器。(2)Handlers处理器的职责:非阻塞的执行业务处理逻辑。
  • Connection Per Thread(一个线程处理一个连接)模式
  • Connection Per Thread模式的缺点是:对应于大量的连接,需要耗费大量的线程资源,对线程资源要求太高。

4.2 单线程Reactor反应器模式

  • Reactor反应器:负责查询IO事件,当检测到一个IO事件,将其发送给相应的Handler处理器去处理。这里的IO事件,就是NIO中选择器监控的通道IO事件。
  • 什么是单线程版本的Reactor反应器模式呢?简单地说,Reactor反应器和Handers处理器处于一个线程中执行。
  • 在选择键注册完成之后,调用attach方法,将Handler处理器绑定到选择键;当事件发生时,调用attachment方法,可以从选择键取出Handler处理器,将事件分发到Handler处理器中,完成业务处理。

4.3 多线程的Reactor反应器模式

  • 将负责输入输出处理的IOHandler处理器的执行,放入独立的线程池中。这样,业务处理线程与负责服务监听和IO事件查询的反应器线程相隔离,避免服务器的连接监听受到阻塞。

4.4 Reactor反应器模式小结

  • 反应器模式是基于查询的,没有专门的队列去缓冲存储IO事件,查询到IO事件之后,反应器会根据不同IO选择键(事件)将其分发给对应的Handler处理器来处理。

5.2 join异步阻塞

  • 阻塞当前的线程,直到准备合并的目标线程的执行完成
  • 假设线程A调用了线程B的B.join方法,合并B线程。那么,线程A进入阻塞状态,直到B线程执行完成。
  • join有一个问题:被合并的线程没有返回值。

5.3 FutureTask异步回调之重武器

  • 为了解决Runnable接口的问题,Java定义了一个新的和Runnable类似的接口—— Callable接口。并且将其中的代表业务处理的方法命名为call, call方法有返回值。
  • Callable接口是一个泛型接口,也声明为了“函数式接口”。
  • Callable接口的实例不能作为Thread线程实例的target来使用;而Runnable接口实例可以作为Thread线程实例的target构造参数,开启一个Thread线程。
  • FutureTask类间接地继承了Runnable接口,从而可以作为Thread实例的target执行目标。
  • FutureTask类就像一座搭在Callable实例与Thread线程实例之间的桥。FutureTask类的内部封装一个Callable实例,然后自身又作为Thread线程的target。
  • 总体来说,FutureTask类首先是一个搭桥类的角色,FutureTask类能当作Thread线程去执行目标target,被异步执行;其次,如果要获取异步执行的结果,需要通过FutureTask类的方法去获取,在FutureTask类的内部,会将Callable的call方法的真正结果保存起来,以供外部获取。
  • Future接口不复杂,主要是对并发任务的执行及获取其结果的一些操作。主要提供了3大功能:(1)判断并发任务是否执行完成。(2)获取并发的任务完成后的结果。(3)取消并发执行中的任务。
  • V get():获取并发任务执行的结果。注意,这个方法是阻塞性的。如果并发任务没有执行完成,调用此方法的线程会一直阻塞,直到并发任务执行完成。
  • 再次,FutureTask内部有另一个重要的成员——outcome属性,用于保存结果:
  • 因为通过FutureTask类的get方法,获取异步结果时,主线程也会被阻塞的。这一点,FutureTask和join也是一样的,它们俩都是异步阻塞模式。

5.4 Guava的异步回调

  • (1)引入了一个新的接口ListenableFuture,继承了Java的Future接口,使得Java的Future异步任务,在Guava中能被监控和获得非阻塞异步执行的结果。(2)引入了一个新的接口FutureCallback,这是一个独立的新接口。该接口的目的,是在异步任务执行完成后,根据异步结果,完成不同的回调处理,并且可以处理异步结果。
  • Guava引入了一个新接口ListenableFuture,它继承了Java的Future接口,增强了监控的能力
  • ListenableFuture仅仅增加了一个方法——addListener方法。它的作用就是将前一小节的FutureCallback善后回调工作,封装成一个内部的Runnable异步回调任务,在Callable异步任务完成后,回调FutureCallback进行善后处理。
  • 首先创建Java线程池,然后以它作为Guava线程池的参数,再构造一个Guava线程池。有了Guava的线程池之后,就可以通过submit方法来提交任务了;任务提交之后的返回结果,就是我们所要的ListenableFuture异步任务实例了。
  • 总结一下,Guava异步回调的流程如下:
  • Guava是非阻塞的异步回调,调用线程是不阻塞的,可以继续执行自己的业务逻辑。· FutureTask是阻塞的异步回调,调用线程是阻塞的,在获取异步结果的过程中,一直阻塞,等待异步线程返回结果。

5.5 Netty的异步回调模式

  • Netty继承和扩展了JDK Future系列异步回调的API,定义了自身的Future系列接口和类,实现了异步任务的监控、异步执行结果的获取。
  • Netty使用了监听器的模式,异步任务的执行完成后的回调逻辑抽象成了Listener监听器接口。
  • ChannelFuture子接口表示通道IO操作的异步任务;如果在通道的异步IO操作完成后,需要执行回调操作,就需要使用到ChannelFuture接口。
  • Netty的出站和入站操作都是异步的
  • 这就是网络通信中的粘包/半包问题

5.6 本章小结

  • Guava和Netty的异步回调是非阻塞的,而Java的join、FutureTask都是阻塞的。

第6章 Netty原理与基础

  • Netty是为了快速开发可维护的高性能、高可扩展、网络服务器和客户端程序而提供的异步事件驱动基础框架和工具
  • Netty的目标之二,是要做到高性能、高可扩展性

6.1 第一个Netty的实践案例DiscardServer

  • Netty是基于反应器模式实现的
  • 反应器的作用是进行一个IO事件的select查询和dispatch分发
  • Netty的服务启动类ServerBootstrap,它的职责是一个组装和集成器,将不同的Netty组件组装在一起
  • 入站指的是输入,出站指的是输出
  • Netty的Handler处理器需要处理多种IO事件(如可读、可写),对应于不同的IO事件,Netty提供了一些基础的方法。这些方法都已经提前封装好,后面直接继承或者实现即可。比如说,对于处理入站的IO事件的方法,对应的接口为ChannelInboundHandler入站处理接口,而ChannelInboundHandlerAdapter则是Netty提供的入站处理的默认实现。

6.2 解密Netty中的Reactor反应器模式

  • 第1步:通道注册。IO源于通道(Channel)。IO是和通道(对应于底层连接而言)强相关的。一个IO事件,一定属于某个通道。但是,如果要查询通道的事件,首先要将通道注册到选择器。只需通道提前注册到Selector选择器即可,IO事件会被选择器查询到。 第2步:查询选择。在反应器模式中,一个反应器(或者SubReactor子反应器)会负责一个线程;不断地轮询,查询选择器中的IO事件(选择键)。 第3步:事件分发。如果查询到IO事件,则分发给与IO事件有绑定关系的Handler业务处理器。 第4步:完成真正的IO操作和业务处理,这一步由Handler业务处理器负责。
  • 反应器模式和通道紧密相关,反应器的查询和分发的IO事件都来自于Channel通道组件。
  • Netty中的每一种协议的通道,都有NIO(异步IO)和OIO(阻塞式IO)两个版本
  • 一个NioEventLoop拥有一个Thread线程,负责一个Java NIO Selector选择器的IO事件轮询。
  • Netty的Handler处理器分为两大类:第一类是ChannelInboundHandler通道入站处理器;第二类是ChannelOutboundHandler通道出站处理器。
  • 重点申明:一个Netty通道拥有一条Handler处理器流水线,成员的名称叫作pipeline。
  • Netty是这样规定的:入站处理器Handler的执行次序,是从前到后;出站处理器Handler的执行次序,是从后到前

6.3 详解Bootstrap启动器类

  • Bootstrap类是Netty提供的一个便利的工厂类,可以通过它来完成Netty的客户端或服务器端的Netty组件的组装,以及Netty程序的初始化。
  • 在Netty中,将有接收关系的NioServerSocketChannel和NioSocketChannel,叫作父子通道。其中,NioServerSocketChannel负责服务器连接监听和接收,也叫父通道(Parent Channel)。对应于每一个接收到的NioSocketChannel传输类通道,也叫子通道(ChildChannel)。
  • 默认的EventLoopGroup内部线程数为最大可用的CPU处理器数量的2倍。
  • 为了及时接受(Accept)到新连接,在服务器端,一般有两个独立的反应器,一个反应器负责新连接的监听和接受,另一个反应器负责IO事件处理。对应到Netty服务器程序中,则是设置两个EventLoopGroup线程组,一个EventLoopGroup负责新连接的监听和接受,一个EventLoopGroup负责IO事件处理。
  • 在服务器端,建议设置成两个线程组的工作模式。
  • 为什么仅装配子通道的流水线,而不需要装配父通道的流水线呢?原因是:父通道也就是NioServerSocketChannel连接接受通道,它的内部业务处理是固定的:接受新连接后,创建子通道,然后初始化子通道,所以不需要特别的配置。如果需要完成特殊的业务处理,可以使用ServerBootstrap的handler(ChannelHandler handler)方法,为父通道设置ChannelInitializer初始化器。
  • 关闭Reactor反应器线程组,同时会关闭内部的subReactor子反应器线程,也会关闭内部的Selector选择器、内部的轮询线程以及负责查询的所有的子通道。在子通道关闭后,会释放掉底层的资源,如TCP Socket文件描述符等。
  • TCP的全双工的工作模式以及TCP的滑动窗口便是依赖于这两个独立的缓冲区及其填充的状态。
  • 此为TCP参数。表示立即发送数据,默认值为True(Netty默认为True,而操作系统默认为False)。该值用于设置Nagle算法的启用,该算法将小的碎片数据连接成更大的报文(或数据包)来最小化所发送报文的数量,如果需要发送一些较小的报文,则需要禁用该算法。Netty默认禁用该算法,从而最小化报文传输的延时。

6.4 详解Channel通道

  • AbstractChannel内部有一个parent属性,表示通道的父通道。对于连接监听通道(如NioServerSocketChannel实例)来说,其父亲通道为null;而对于每一条传输通道(如NioSocketChannel实例),其parent属性的值为接收到该连接的服务器连接监听通道。
  • EmbeddedChannel仅仅是模拟入站与出站的操作,底层不进行实际的传输,不需要启动Netty服务器和客户端。

6.5 详解Handler业务处理器

  • 整个的IO处理操作环节包括:从通道读数据包、数据包解码、业务处理、目标数据编码、把数据包写到通道,然后由通道发送到对端

6.6 详解Pipeline流水线

  • Netty的业务处理器流水线ChannelPipeline是基于责任链设计模式(Chain of Responsibility)来设计的,内部是一个双向链表结构,能够支持动态地添加和删除Handler业务处理器。
  • Channel、Handler、ChannelHandlerContext三者的关系为:Channel通道拥有一条ChannelPipeline通道流水线,每一个流水线节点为一个ChannelHandlerContext通道处理器上下文对象,每一个上下文中包裹了一个ChannelHandler通道处理器。在ChannelHandler通道处理器的入站/出站处理方法中,Netty都会传递一个Context上下文实例作为实际参数。通过Context实例的实参,在业务处理中,可以获取ChannelPipeline通道流水线的实例或者Channel通道的实例。
  • 说明通过没用调用父类的super.channelRead方法,处理流水线被成功地截断了
  • ChannelInitializer在完成了通道的初始化之后,为什么要将自己从流水线中删除呢?原因很简单,就是一条通道只需要做一次初始化的工作。

6.7 详解ByteBuf缓冲区

  • 与Java NIO的ByteBuffer相比,ByteBuf的优势如下:· Pooling (池化,这点减少了内存复制和GC,提升了效率)· 复合缓冲区类型,支持零复制· 不需要调用flip()方法去切换读/写模式· 扩展性好,例如StringBuffer· 可以自定义缓冲区类型· 读取和写入索引分开· 方法的链式调用· 可以进行引用计数,方便重复使用
  • ByteBuf是一个字节容器,内部是一个字节数组。从逻辑上来分,字节容器内部可以分为四个部分
  • 为了确保引用计数不会混乱,在Netty的业务处理器开发过程中,应该坚持一个原则:retain和release方法应该结对使用。简单地说,在一个方法中,调用了retain,就应该调用一次release。
  • 当引用计数已经为0, Netty会进行ByteBuf的回收。分为两种情况:(1)Pooled池化的ByteBuf内存,回收方法是:放入可以重新分配的ByteBuf池子,等待下一次分配。(2)Unpooled未池化的ByteBuf缓冲区,回收分为两种情况:如果是堆(Heap)结构缓冲,会被JVM的垃圾回收机制回收;如果是Direct类型,调用本地方法释放外部内存(unsafe.freeMemory)。
  • PoolByteBufAllocator(池化ByteBuf分配器)将ByteBuf实例放入池中,提高了性能,将内存碎片减少到最小;这个池化分配器采用了jemalloc高效内存分配的策略,该策略被好几种现代操作系统所采用。
  • 在Netty中,默认的分配器为ByteBufAllocator.DEFAULT,可以通过Java系统参数(System Property)的选项io.netty.allocator.type进行配置,配置时使用字符串值:"unpooled", "pooled"。
  • 根据内存的管理方不同,分为堆缓存区和直接缓存区,也就是Heap ByteBuf和Direct ByteBuf。
  • Direct ByteBuf要读取缓冲数据进行业务处理,相对比较麻烦,需要通过getBytes/readBytes等方法先将数据复制到Java的堆内存,然后进行其他的计算。
  • 如果hasArray()返回false,不一定代表缓冲区一定就是Direct ByteBuf直接缓冲区,也有可能是CompositeByteBuf缓冲区。
  • 调用nioBuffer()方法可以将CompositeByteBuf实例合并成一个新的Java NIOByteBuffer缓冲区(注意:不是ByteBuf)
  • Netty默认会在ChannelPipline通道流水线的最后添加一个TailHandler末尾处理器,它实现了默认的处理方法,在这些方法中会帮助完成ByteBuf内存释放的工作。
  • 在Netty开发中,必须密切关注Bytebuf缓冲区的释放,如果释放不及时,会造成Netty的内存泄露(Memory Leak),最终导致内存耗尽。

6.8 ByteBuf浅层复制的高级使用方式

  • ByteBuf的浅层复制分为两种,有切片(slice)浅层复制和整体(duplicate)浅层复制。
  • ByteBuf的slice方法可以获取到一个ByteBuf的一个切片。一个ByteBuf可以进行多次的切片浅层复制;多次切片后的ByteBuf对象可以共享一个存储区域。
  • 从根本上说,slice()无参数方法所生成的切片就是源ByteBuf可读部分的浅层复制。
  • duplicate() 和slice() 方法都是浅层复制。不同的是,slice()方法是切取一段的浅层复制,而duplicate( )是整体的浅层复制。
  • 因此,在调用浅层复制实例时,可以通过调用一次retain() 方法来增加引用,表示它们对应的底层内存多了一次引用,引用计数为2。在浅层复制实例用完后,需要调用两次release()方法,将引用计数减一,这样就不影响源ByteBuf的内存释放。

6.9 EchoServer回显服务器的实践案例

  • 从Netty 4.1开始,ByteBuf的默认类型是Direct ByteBuf直接内存。
  • 反过来,如果没有加@ChannelHandler.Sharable注解,试图将同一个Handler实例添加到多个ChannelPipeline通道流水线时,Netty将会抛出异常。

7.1 Decoder原理与实践

  • ByteToMessageDecoder继承自ChannelInboundHandlerAdapter适配器,是一个入站处理器,实现了从ByteBuf到Java POJO对象的解码功能。
  • ReplayingDecoder类是ByteToMessageDecoder的子类。其作用是: · 在读取ByteBuf缓冲区的数据之前,需要检查缓冲区是否有足够的字节。 · 若ByteBuf中有足够的字节,则会正常读取;反之,如果没有足够的字节,则会停止解码。
  • ReplayingDecoder的作用,远远不止于进行长度判断,它更重要的作用是用于分包传输的应用场景。

7.3 Encoder原理与实践

  • 编码器与解码器相呼应,Netty中的编码器负责将“出站”的某种Java POJO对象编码成二进制ByteBuf,或者编码成另一种Java POJO对象。

8.1 详解粘包和拆包

  • Netty发送和读取数据的“场所”是ByteBuf缓冲区。
  • (1)粘包,指接收端(Receiver)收到一个ByteBuf,包含了多个发送端(Sender)的ByteBuf,多个ByteBuf“粘”在了一起。(2)半包,就是接收端将一个发送端的ByteBuf“拆”开了,收到多个破碎的包。换句话说,一个接收端收到的ByteBuf是发送端的一个ByteBuf的一部分。
  • 如何解决呢?基本思路是,在接收端,Netty程序需要根据自定义协议,将读取到的进程缓冲区ByteBuf,在应用层进行二次拼装,重新组装我们应用层的数据包。接收端的这个过程通常也称为分包,或者叫作拆包。

8.2 JSON协议通信

  • 在POJO序列化成JSON字符串的应用场景,使用Google的Gson库;在JSON字符串反序列化成POJO的应用场景,使用阿里的FastJson库。

8.3 Protobuf协议通信

  • Protobuf的编码过程为:使用预先定义的Message数据结构将实际的传输数据进行打包,然后编码成二进制的码流进行传输或者存储。Protobuf的解码过程则刚好与编码过程相反:将二进制码流解码成Protobuf自己定义的Message结构的POJO实例。
  • 微信的消息传输就采用了Protobuf协议。

8.4 Protobuf编解码的实践案例

  • Netty默认支持Protobuf的编码与解码,内置了一套基础的Protobuf编码和解码器。

8.5 详解Protobuf协议语法

  • 一个“.proto”文件有两大组成部分:头部声明、消息结构体的定义。

9.7 详解心跳检测

  • 什么是连接假死呢?如果底层的TCP连接已经断开,但是服务器端并没有正常地关闭套接字,服务器端认为这条TCP连接仍然是存在的。
  • 解决假死的有效手段是:客户端定时进行心跳检测,服务器端定时进行空闲检测。
  • 客户端的心跳间隔,要比服务器端的空闲检测时间间隔要短,一般来说,要比它的一半要短一些,可以直接定义为空闲检测时间间隔的1/3。

第10章 ZooKeeper分布式协调

  • ZooKeeper(本书也简称ZK)是Hadoop的正式子项目,它是一个针对大型分布式系统的可靠协调系统,提供的功能包括:配置维护、名字服务、分布式同步、组服务等。

10.1 ZooKeeper伪集群安装和配置

  • ZooKeeper集群节点数必须是奇数。
  • 每一个节点需要有一个记录节点id的文本文件,文件名为myid
  • 首先,myid文件中id的值只能是一个数字,即一个节点的编号ID;其次,id的范围是1~255,表示集群最多的节点个数为255个。
  • 在每一行“server.id=host:port:port”中,需要配置两个端口。前一个端口(如示例中的2888)用于节点之间的通信,后一个端口(如示例中的3888)用于选举主节点。

10.2 使用ZooKeeper进行分布式存储

  • ZooKeeper的存储模型是一棵以 "/" 为根节点的树。ZooKeeper的存储模型中的每一个节点,叫作ZNode(ZooKeeper Node)节点。所有的ZNode节点通过树形的目录结构,按照层次关系组织在一起,构成一棵ZNode树。
  • ZooKeeper官方的要求是,每个节点存放的有效负载数据(Payload)的上限仅为1MB。
  • 事务id记录着节点的状态,ZooKeeper状态的每一次改变都对应着一个递增的事务id(Transaction id),该id称为Zxid,它是全局有序的,每次ZooKeeper的更新操作都会产生一个新的Zxid。Zxid不仅仅是一个唯一的事务id,它还具有递增性。

10.3 ZooKeeper应用开发的实践

  • ZooKeeper的Watcher监测是一次性的,每次触发之后都需要重新进行注册。
  • Curator是Netflix公司开源的一套ZooKeeper客户端框架
  • ZooKeeper节点有4种类型
  • 另外,在临时节点下面不能创建子节点。

10.4 分布式命名服务的实践

  • Dubbo分布式框架就是应用了ZooKeeper的分布式的JNDI功能
  • 由于UUID没有排序,无法保证趋势递增,因此用于数据库索引字段的效率就很低,添加记录存储入库时性能差

10.5 分布式事件监听的重点

  • Cache机制提供了反复注册的能力,而观察模式的Watcher监听器只能监听一次。
  • WatchedEvent包含了三个基本属性:· 通知状态(keeperState)· 事件类型(EventType)· 节点路径(path)
  • Curator监听的原理:无论是PathChildrenCache,还是TreeCache,所谓的监听都是在进行Curator本地缓存视图和ZooKeeper服务器远程的数据节点的对比,并且在进行数据同步时会触发相应的事件。

10.6 分布式锁的原理与实践

  • 跨机器的进程之间的数据同步问题。这种跨机器的锁就是分布式锁
  • 在重入锁的模型中,一把独占锁可以被多次锁定,这就叫作可重入锁。
  • ZooKeeper内部优越的机制,能保证由于网络异常或者其他原因造成集群中占用锁的客户端失联时,锁能够被有效释放。一旦占用锁的ZNode客户端与ZooKeeper集群服务器失去联系,这个临时ZNode也将自动删除。排在它后面的那个节点也能收到删除事件,从而获得锁。所以,在创建取号节点的时候,尽量创建临时ZNode节点
  • 目前分布式锁,比较成熟、主流的方案有两种:(1)基于Redis的分布式锁。适用于并发量很大、性能要求很高而可靠性问题可以通过其他方案去弥补的场景。(2)基于ZooKeeper的分布式锁。适用于高可靠(高可用),而并发量不是太高的场景。

11.1 Redis入门

  • Key的命名规范使用冒号分割,大致的优势如下:(1)方便分层展示。Redis的很多客户端可视化管理工具,如Redis Desktop Manager,是以冒号作为分类展示的,方便快速查到要查阅的Redis Key对应的Value值。(2)方便删除与维护。可以对于某一层次下面的Key,使用通配符进行批量查询和批量删除。

11.2 Redis数据类型

  • Redis的List类型是基于双向链表实现的,可以支持正向、反向查找和遍历。
  • Set集合也是一个列表,不过它的特殊之处在于它是可以自动去掉重复元素的。
  • Zset有序集合和Set集合的使用场景类似,区别是有序集合会根据提供的score参数来进行自动排序。

12.1 如何支撑亿级流量的高并发IM架构的理论基础

  • 什么是长连接呢?客户端向服务器发起连接,服务器接受客户端的连接,双方建立连接。客户端与服务器完成一次读写之后,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。
  • 短连接服务器也叫Web服务器,主要功能是实现用户的登录鉴权和拉取好友、群组、数据档案等相对低频的请求操作。