架构解密:从分布式到微服务(第2版)
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

1.2 NIO,一本难念的经

我们知道,分布式系统的基础是网络。因此,网络编程是分布式软件工程师和架构师的必备技能之一,而且随着当前大数据和实时计算技术的兴起,高性能RPC架构与网络编程技术再次成为焦点。不管是RPC领域的ZeroC Ice、Thrift,还是经典分布式框架Actor模型中的Akka,或者实时流领域的Storm、Spark、Flink,又或者开源分布式数据库中的Mycat、VoltDB,这些高大上产品的底层通信技术都采用了NIO(非阻塞通信)通信技术。而Java领域里大名鼎鼎的NIO框架——Netty,则被众多的开源项目或商业软件所采用。

相对于它的老前辈BIO(阻塞通信)来说,NIO模型非常复杂,以至于我们难以精通它,难以编写出一个没有缺陷、高效且适应各种意外情况的稳定的NIO通信模块。之所以会出现这样的问题,是因为NIO编程不是单纯的一个技术点,而是涵盖了一系列相关技术、专业知识、编程经验和编程技巧的复杂工程。

1.2.1 难懂的ByteBuffer

Java NIO抛弃了我们所熟悉的Stream、byte[]等数据结构,设计了一个全新的数据结构——ByteBuffer,ByteBuffer的主要使用场景是保存从Socket中读取的输入字节流并循环利用,以减少GC的压力。Java NIO功能强大,但难以掌握。以经典的Echo服务器为例,其核心是读入客户端发来的数据,并且回写给客户端,这段代码用ByteBuffer来实现,大致就是下面的逻辑:

如果我们能马上发现在上述代码中存在一个严重缺陷且无法正常工作,那么说明我们的确精通了ByteBuffer的用法。这段代码的缺陷是在第6行之前少了一个byteBuffer.flip()调用。之所以ByteBuffer会设计这样一个名称奇怪的Method,是因为它与我们所熟悉的InputStream&OutStream分别操作输入输出流的传统I/O设计方式不同,是“二合一”的设计方式。我们可以把ByteBuffer设想成内部拥有一个固定长度的Byte数组的对象,属性capacity为数组的长度(不可变),position变量保存当前读(或写)的位置,limit变量为当前可读或可写的位置上限。当Byte被写入ByteBuffer中时,position++,而0到position之间的字符就是已经写入的字符。如果后面要读取之前写入的这些字符,则需要将position重置为0,limit则被设置为之前position的值,这个操作恰好就是flip要做的事情,这样一来,position到limit之间的字符刚好是要读的全部数据。

ByteBuffer有三种实现方式:第一种是堆内存储数据的HeapByteBuffer;第二种是堆外存储数据的DirectByteBuffer;第三种是文件映射(数据存储到文件中)的MappedByteBuffer。HeapByteBuffer将数据保存在JVM堆内存中,我们知道64位JVM的堆内存在最大为32GB时内存利用率最高,一旦堆超过了32GB,就进入64位的世界里了,应用程序的可用堆空间就会减小。另外,过大的JVM堆内存也容易导致复杂的GC问题,因此最好的办法是采用堆外内存,堆外内存的管理由程序员自己控制,类似于C语言的直接内存管理。DirectByteBuffer是采用堆外内存来存放数据的,因此在访问性能提升的同时带来了复杂的动态内存管理问题。而动态内存管理是一项高端编程技术,涵盖了内存分配性能、内存回收、内存碎片化、内存利用率等一系列复杂问题。

在内存分配性能方面,我们通常会在Java里采用ThreadLocal对象来实现多线程本地化分配的思路,即每个线程都拥有一个ThreadLocal类型的ByteBufferPool,然后每个线程都管理各自的内存分配和回收问题,避免共享资源导致的竞争问题。Grizzy NIO框架中的ByteBufferThreadLocalPool,就采用了ThreadLocal结合ByteBuffer视图的动态内存管理技术:

上面的代码很简单也很经典,可以分配任意大小的内存块,但存在一个问题:它只能从Pool的当前位置持续往下分配空间,而中间被回收的内存块是无法立即被分配的,因此内存利用率不高。另外,当后面分配的内存没有被及时释放时,会发生内存溢出,即使前面分配的内存早已释放大半。其实上述问题可以通过一个环状结构(Ring)来解决,即分配到头以后,回头重新继续分配,但代码会稍微复杂点。

Netty则采用了另外一种思路。首先,Netty的作者认为JDK的ByteBuffer设计得并不好,其中ByteBuffer不能继承,以及API难用、容易出错是最大的两个问题,于是他重新设计了一个接口ByteBuf来代替官方的ByteBuffer。如下所示是ByteBuf的设计示意图,它通过分离读写的位置变量(reader index及writer index),简单、有效地解决了ByteBuffer难懂的flip操作问题,这样一来ByteBuf也可以实现同时读与写的功能了。

由于ByteBuf是一个接口,所以可以继承与扩展,为了实现分配任意长度的Buffer,Netty设计了一个CompositeByteBuf实现类,它通过组合多个ByteBuf实例的方式巧妙实现了动态扩容能力,这种组合扩容的方式存在一个读写效率问题,即判断当前的读写位置是否要移到下一个ByteBuf实例上。

Netty的ByteBuf实例还有一个很重要的特征,即记录了被引用的次数,所有实例都继承自AbstractReferenceCountedByteBuf。这点非常重要,因为我们在实现ByteBufPool时,需要确保ByteBuf被正确释放和回收,由于官方的ByteBuffer缺乏这一特征,因此很容易因为使用不当导致内存泄漏或者内存访问错误等严重Bug。

由于使用ByteBuffer时用得最多的是堆外DirectByteBuffer,因此一个功能齐全、高效的Buffer Pool对于NIO来说相当重要。官方JDK并没有提供这样的工具包,于是Netty的作者基于ByteBuf实现了一套可以在Netty之外单独使用的Buffer Pool框架,如下图所示。

MappedByteBuffer说得通俗一点就是Map把一个磁盘文件(整体或部分内容)映射到计算机虚拟内存的一块区域,这样就可以直接操作内存中的数据,无须每次都通过I/O从物理硬盘上读取文件,所以在效率上有很大提升。要想真正理解MappedByteBuffer的原理和价值,就需要掌握操作系统内存、文件系统、内存页与内存交换的基本知识。

如下图所示,每个进程都有一个虚拟地址空间,也被称为逻辑内存地址,其大小由该系统上的地址大小规定,比如32位Windows的单进程可寻址空间是4GB,虚拟地址空间也使用分页机制,即我们所说的内存页面。当一个程序尝试使用虚拟地址访问内存时,操作系统连同硬件会将该分页的虚拟地址映射到某个具体的物理位置,这个位置可以是物理内存、页面文件(Page File是Windows的说法,对应Linux下的swap)或文件系统中的一个普通文件。尽管每个进程都有自己的地址空间,但程序通常无法使用所有这些空间,因为地址空间被划分为内核空间和用户空间。大部分操作系统都将每个进程地址空间的一部分映射到一个通用的内核内存区域。被映射来供内核使用的地址空间部分被称为内核空间,其余部分被称为用户空间,可供用户的应用程序使用。

MappedByteBuffer使用mmap系统调用来实现文件的内存映射,如下图中的过程1所示。此外,内存映射的过程只是在逻辑上被放入内存中,具体到代码,就是建立并初始化了相关的数据结构(struct address_space),并没有实际的数据复制,文件没有被载入内存,所以建立内存映射的效率很高。仅在此文件的内容要被访问时,才会触发操作系统加载内存页,这个过程可能涉及物理内存不足时内存交换的问题,即过程4。

通过上面的原理分析,我们就不难理解JDK中关于MappedByteBuffer的一些方法的作用了。

● fore():当缓冲区是READ_WRITE模式时,此方法对缓冲区内容的修改强行写入文件。

● load():将缓冲区的内容载入内存,并返回该缓冲区的引用。

● isLoaded():如果缓冲区的内容在物理内存中,则返回真,否则返回假。

MappedByteBuffer的主要使用场景有如下两个。

● 基于文件共享的高性能进程间通信(IPC)。

● 大文件高性能读写访问。

正因为上述两个独特的使用场景,MappedByteBuffer有很多高端应用,比如Kafka采用MappedByteBuffer来处理消息日志文件。分布式文件系统Tachyon也采用了MappedByteBuffer加速文件读写。高性能IPC通信技术在当前的大数据和实时计算方面越来越重要,原因很简单:当前服务器的核心数越来越多,而且都支持NUMA技术,在这种情况下,单机上的多进程架构能最大地提升系统的整体吞吐量。于是,有人基于MappedByteBuffer实现了一个DEMO性质的高性能IPC通信实例,该实例采用内存映射文件来实现Java多进程间的数据通信,其原理图如下所示。

其中,一个进程负责写入数据到内存映射文件中,其他进程(不限于Java)则从此映射文件中读取数据。经笔者测试,其性能极高,在笔者的笔记本计算机上可以达到每秒4000万的传输速度,消息延时仅仅25ns。受此项目的启发,笔者也发起了一个更为完善的Mycat-IPC开源框架,此项目的关键点在于用一个MappedByteBuffer模拟了N组环形队列的数据结构,用来表示一个进程发送或者读取的消息队列。

如下所示是MappedByteBuffer的内存结构图,内存起始位置记录了当前定义的几个RingQueue,随后记录了每个RingQueue的长度以确定其开始内存地址与结束内存地址,RingQueue类似于ByteBuffer的设计,有记录读写内存位置的变量,而被放入队列中的每个“消息”都有两个字节的长度、消息体本身,以及下个消息的开始位置Flag(继续当前位置还是已经掉头、从头开始)。笔者计划未来将Mycat拆成多进程的架构,一个进程负责接收客户端的Socket请求,然后把数据通过IPC框架分发给后面几个独立的进程去处理,处理完的响应再通过IPC回传给Socket监听进程,最终写入客户端。

MappedByteBuffer还有另外一个奇妙的特性,“零复制”传输数据,它的transferTo方法能节省一次缓冲区的复制过程,将其直接写入另外一个Channel通道,如下图所示。

Netty传输文件的逻辑就用到了transferTo这一特性,下面的代码片段给出了真相:

1.2.2 晦涩的“非阻塞”

NIO里“非阻塞”(None Blocking)这个否定式的新名称对于大多数程序员来说的确很难理解。在解释“非阻塞”这个概念之前,让我们先来恶补一下TCP/IP通信的基础知识。

首先,对于TCP通信来说,每个TCP Socket在内核中都有一个发送缓冲区和一个接收缓冲区,TCP的全双工工作模式及TCP的滑动窗口便依赖于这两个独立的Buffer及此Buffer的填充状态。接收缓冲区把数据缓存入内核中,若应用进程一直没有调用Socket的read方法进行读取,则此数据会一直被缓存在接收缓冲区中。不管进程是否读取Socket,对端发来的数据都会经由内核接收并且缓存到Socket的内核接收缓冲区中。read方法所做的工作,就是把内核接收缓冲区中的数据复制到应用层用户的Buffer中。进程在调用Socket的send方法发送数据时,最简单的情况(也是一般情况)是将数据从应用层用户的Buffer中复制到Socket的内核发送缓冲区中,send方法便会在上层返回。换句话说,在send方法返回时,数据不一定会被发送到对端(与write方法写文件有点类似),send方法仅仅是把应用层Buffer的数据复制到Socket的内核发送Buffer中。而对于UDP通信来说,每个UDP Socket都有一个接收缓冲区,没有发送缓冲区,从概念上来说只要有数据就发送,不管对方是否可以正确接收,所以不缓冲,也不需要发送缓冲区。

其次,我们来说说TCP/IP的滑动窗口和流量控制机制。前面提到,Socket的接收缓冲区被TCP和UDP用来缓存在网络上收到的数据,保存到应用进程读取为止。对于TCP来说,如果应用进程一直没有读取,则在Buffer满了之后发生的动作是:通知对端TCP中的窗口关闭,保证TCP套接口接收缓冲区不会溢出,保证TCP是可靠传输的,这便是滑动窗口的实现。因为对方不允许发出超过通告窗口大小的数据,所以如果对方无视窗口的大小发出了超过窗口大小的数据,则接收方TCP将丢弃它,这就是TCP的流量控制原理。对于UDP来说,当接收方的Socket接收缓冲区满时,新来的数据报无法进入接收缓冲区,此数据报会被丢弃。UDP是没有流量控制的,快的发送者可以很容易地淹没慢的接收者,导致接收方的UDP丢弃数据报。

明白了Socket读写数据的底层原理,我们就容易理解传统的“阻塞模式”了:对于读取Socket数据的过程而言,如果接收缓冲区为空,则调用Socket的read方法的线程会阻塞,直到有数据进入接收缓冲区;对于写数据到Socket中的线程而言,如果待发送的数据长度大于发送缓冲区的空余长度,则会被阻塞在write方法上,等待发送缓冲区的报文被发送到网络上,然后继续发送下一段数据,循环上述过程直到数据都被写入发送缓冲区为止。

从上述过程来看,传统的Socket阻塞模式直接导致每个Socket都必须绑定一个线程来操作数据,参与通信的任意一方如果处理数据的速度较慢,则都会直接拖累另一方,导致另一方的线程不得不浪费大量的时间在I/O等待上,所以,每个Socket都要绑定一个单独的线程正是传统Socket阻塞模式的根本“缺陷”。之所以这里加了“缺陷”两个字,是因为这种模式在一些特定场合下效果是最好的,比如只有少量的TCP连接通信,双方都非常快速地传输数据,此时这种模式的性能最高。

现在我们可以开始分析“非阻塞”模式了,它就是要解决I/O线程与Socket解耦的问题,因此,它引入了事件机制来达到解耦的目的。我们可以认为在NIO底层中存在一个I/O调度线程,它不断扫描每个Socket的缓冲区,当发现写入缓冲区为空(或者不满)时,它会产生一个Socket可写事件,此时程序就可以把数据写入Socket中,如果一次写不完,则等待下次可写事件的通知;当发现在读取缓冲区中有数据时,会产生一个Socket可读事件,程序在收到这个通知事件时,就可以从Socket读取数据了。

上述原理听起来很简单,但实际上有很多“坑”,如下所述。

● 收到可写事件时,想要一次性地写入全部数据,而不是将剩余数据放入Session中,等待下次可写事件的到来。

● 在写完数据并且没有可写数据时,若应答数据报文已经被全部发送给客户端,则需要取消对可写事件的“订阅”,否则NIO调度线程总是报告Socket可写事件,导致CPU使用率狂飙。因此,如果没有数据可写,就不要订阅可写事件。

● 如果来不及处理发送的数据,就需要暂时“取消订阅”可读事件,否则数据从Socket里读取以后,下次还会很快发送过来,而来不及处理的数据被积压到内存队列中,导致内存溢出。

此外,在NIO中还有一个容易被忽略的高级问题,即业务数据处理逻辑是使用NIO调度线程来执行还是使用其他线程池里的线程来执行?关于这个问题,没有绝对的答案,我们在Mycat的研发过程中经过大量测试和研究得出以下结论:

如果数据报文的处理逻辑比较简单,不存在耗时和阻塞的情况,则可以直接用NIO调度线程来执行这段逻辑,避免线程上下文切换带来的损耗;如果数据报文的处理逻辑比较复杂,耗时比较多,而且可能存在阻塞和执行时间不确定的情况,则建议将其放入线程池里去异步执行,防止I/O调度线程被阻塞。

如下所示是Mycat里相关设计的示意图。

1.2.3 复杂的Reactor模型

Java NIO框架比较原始,目前主流的Java网络程序都在其上设计实现了Reactor模型,隐藏了NIO底层的复杂细节,大大简化了NIO编程,其原理和架构如下图所示。Acceptor负责接收客户端Socket发起的新建连接请求,并把该Socket绑定到一个Reactor线程上,于是这个Socket随后的读写事件都交给此Reactor线程来处理。Reactor线程在读取数据后,交给用户程序中的具体Handler实现类来完成特定的业务逻辑处理。为了不影响Reactor线程,我们通常使用一个单独的线程池来异步执行Handler的接口方法。

如果仅仅到此为止,则NIO里的Reactor模型还不算很复杂,但实际上,我们的服务器是多核心的,而且需要高速并发处理大量的客户端连接,单线程的Reactor模型就满足不了需求了,因此我们需要多线程的Reactor。一般原则是Reactor(线程)的数量与CPU核心数(逻辑CPU)保持一致,即每个CPU都执行一个Reactor线程,客户端的Socket连接则被随机均分到这些Reactor线程上去处理,如果有8000个连接,而CPU核心数为8,则每个CPU核心平均承担1000个连接。

多线程Reactor模型可能带来另外一个问题,即负载不均衡。虽然每个Reactor线程服务的Socket数量都是均衡的,但每个Socket的I/O事件可能是不均衡的,某些Socket的I/O事件可能大大多于其他Socket,从而导致某些Reactor线程负载更高,此时是否需要重新分配Socket到不同的Reactor线程呢?这的确是一个问题。因为如果要切换Socket到另外的Reactor线程,则意味着Socket相关的Connection对象、Session对象等必须是线程安全的,这本身就带来一定的性能损耗。另外,我们需要对I/O事件做统计分析,启动额外的定时线程在合适的时机完成Socket重新分配,这本身就是很复杂的事情。

由于Netty的代码过于复杂,所以下面以Mycat NIO Framework为例,来说说应该怎样设计一个基于多线程Reactor模式的高性能NIO框架。

如下图所示,我们先要有一个基础类NetSystem,它负责NIO框架中基础参数与基础组件的创建,其中常用的基础参数如下。

● Socket缓存区的大小。

● TCP_NODELAY标记。

● Reactor的个数。

● ByteBuffer Pool的参数。

● 业务线程池的大小。

基础组件如下。

● NameableExecutor:业务线程池。

● NIOAcceptor:负责接收客户端的新建连接请求。

● NIOConnector:负责发起客户端连接(NIO模式)。

考虑到不同的应用都需要创建自己的Connection实例来实现应用特定的网络协议,而且在一个程序里可能会有几种网络协议,因此人们在框架里设计了Connection抽象类,采用的是工厂模式,即由不同的ConnectionFactory来创建不同的Connection实现类。不管是作为NIO Server还是作为NIO Client,应用程序都可以采用这套机制来实现自己的Connection。当收到Socket报文(及相关事件)时,框架会调用绑定在此Connection上的NIO Handler来处理报文,而Connection要发送的数据被放入一个WriteQueue队列里,框架实现具体的无阻塞发送逻辑。

为了更好地使用有限的内存,Mycat NIO设计了一个“双层”的ByteBuffer Pool模型,全局的ByteBuffer Pool被所有Connection共享,每个Reactor线程则都在本地保留了一份局部占用的ByteBuffer Pool——ThreadLocalBufferPool,我们可以设定80%的ByteBuffer被N个Reactor线程从全局Pool里取出并放到本地的ThreadLocalBufferPool里,这样一来就可以避免过多的全局Pool的锁抢占操作,提升NIO性能。

NIOAcceptor在收到客户端发起的新连接事件后,会新建一个Connection对象,然后随机找到一个NIOReactor,并把此Connection对象放入该NIOReactor的Register队列中等待处理,NIOReactor会在下一次的Selector循环事件处理之前,先处理所有新的连接请求。下面两段来自NIOReactor的代码表明了这一逻辑过程:

NIOConnector属于NIO客户端框架的一部分,与NIOAcceptor类似,在需要发起一个NIO连接时,程序调用下面的方法将连接放入“待连接队列”中并唤醒Selector:

随后,NIOConnector的线程会先处理“待连接队列”,发起真正的NIO连接并异步等待响应:

最后,在NIOConnector的线程Run方法里,对收到连接完成事件的Connection,回调应用的通知接口,应用在得知连接已经建立时,可以在接口里主动发数据或者请求读数据: