先通过回顾内核态的通用块层来详细介绍SPDK通用块层,包括通用块层的架构、核心数据结构、数据流方面的考量等。最后描述基于通用块层之上的两个特性:一是逻辑卷的支持,基于通用块设备的Blobstore和各种逻辑卷的特性,精简配置(Thin-Provisioned)、快照和克隆等;二是对流量控制的支持,结合SPDK通用块层的优化特性来支持多应用对同一通用块设备的共享。
内核通用块层
Linux操作系统的设计总体上是需要满足应用程序的普遍需求的,因此在设计模块的时候,考虑更多的是模块的通用性。针对内核块设备驱动而言,每个不同的块设备都会有自己特定的驱动,与其让上层模块和每一个设备驱动来直接交互,不如引入一个通用的块层。这样设计的好处在于:一是容易引入新的硬件,只需要新硬件对应的设备驱动能接入通用的块层即可;二是上层应用只需要设计怎么和通用块层来交互,而不需要知道具体硬件的特性。
当然,如果要特别地发挥某个硬件的特性,上层应用直接和设备驱动交互是值得推荐的方式。通用块层的引入除了可以提供上面两个优点,还可以支持更多丰富的功能,如下所示。
· 软件I/O请求队列:更多的I/O请求可以在通用块层暂时保存,尤其是某些硬件本身不支持很高的I/O请求并发量。
· 逻辑卷管理:包括对一个硬件设备的分区化,多个硬件的整体化逻辑设备,比如支持不同的磁盘阵列级别和纠删码的逻辑卷。又如快照、克隆等更高级的功能。
· 硬件设备的插拔:包括在系统运行过程中的热插拔。
· I/O请求的优化:比如小I/O的合并,不同的I/O调度策略。
· 缓存机制:读缓存,不同的写缓存策略。
· 更多的软件功能:基于物理设备和逻辑设备。
由此可见,通用块层的重要性,除了对上层应用和底层硬件起承上启下的作用,更多的是提供软件上的丰富功能来支撑上层应用的不同场景。
SPDK用户态通用块层
上层应用是通过SPDK提供的API来直接操作NVMe SSD硬件设备的。这是一个典型的让上层应用加速使用NVMe SSD的场景。但是除了这个场景,上层应用还有更多丰富的场景,如后端管理多种不同的硬件设备,除了NVMe SSD,还可以是慢速的机械磁盘、SATA SSD、SAS SSD,甚至远端挂载的设备。又如需要支持设备的热插拔、通过逻辑卷共享一个高速设备等存储服务。复杂的存储应用需要结合不同的后端设备,以及支持不同的存储软件服务。值得一提的是,有些上层应用程序还需要文件系统的支持,在内核态的情况下,文件系统也是建立在通用块层之上的。类似的文件系统的需求在SPDK用户态驱动中也需要提供相应的支持。
由此可见,在结合SPDK用户态驱动时,也需要SPDK提供类似的用户态通用块层来支持复杂和高性能的存储解决方案。另外,在考虑设计用户态通用块层的时候,也要考虑它的可扩展性,比如是否能很容易地扩展来支持新的硬件设备,这个通用块层的设计是不是高性能的,是否用户态通用块层的时候,也要考虑它的可扩展性,比如是否能很容易地扩展来支持新的硬件设备,这个通用块层的设计是不是高性能的,是否可以最小限度地带来软件上的开销,以充分发挥后端设备的高性能。
SPDK从2013年实现用户态的NVMe驱动到现在经历了多年的技术演进和持续开发,已经形成了相对完整的用户态存储解决方案。这里主要分为3层,如下图所示。
SPDK架构解析如下。
· 最下层为驱动层,管理物理和虚拟设备,还管理本地和远端设备。
· 中间层为通用块层,实现对不同后端设备的支持,提供对上层的统一接口,包括逻辑卷的支持、流量控制的支持等存储服务。这一层也提供了对Blob(Binary Larger Object)及简单用户态文件系统BlobFS的支持。
· 最上层为协议层,包括NVMe协议、SCSI协议等,可以更好地和上层应用相结合。
SPDK应用框架采用的优化思想,在SPDK通用块层也是类似的实现。包括从内存资源分配上、I/O资源池、大小Buffer资源池等,既要考虑全局总的分配数量,也要考虑每个CPU核独享的资源。这样在单线程、单核的情况下,可以实现资源的快速存取,也要考虑给单核分配过多的资源而造成资源浪费。
每个核上,SPDK实现了单线程的高性能使用。线程的数量和核的数量对应关系是1∶1的匹配,所有单核上的操作由一个线程来完成,这样可以很好地实现单核上的无锁化。同时采用运行直到完成的I/O处理方式,保证了一个I/O的资源分配和核心操作在同一个核上完成,避免了额外的核间同步的问题。
为了达到这个目的,在通用块层引入了逻辑上的I/O Channel概念来屏蔽下层的具体实现。目前来说,I/O Channel和Thread的对应关系也是1∶1的匹配。这样总的匹配如下:
I/O Channel是上层模块访问通用块层的I/O通道,因此当我们把I/O Channel和块设备暴露给上层模块后,可以很容易地对通用块层进行读/写等各种操作。基于I/O Channel,为了方便操作通用块设备,给每个I/O Channel分配了相应的Bdev Channel来保存块设备的一些上下文,比如I/O操作的相关信息。Bdev Channel和I/O Channel的对应关系也是1∶1匹配,如图所示:
SPDK Bdev设计主要考虑以下几个维度:一是,抽象出来的通用设备需要数据结构来维护;二是,操作通用设备的函数指针表,提供给后端具体硬件来实现;三是,I/O数据结构用来打通上层协议模块和下层驱动模块。下面我们具体来看一下这些核心的数据结构。
· 通用块设备的数据结构:需要包括标识符如名字、UUID、对应的后端硬件名字等;块设备的属性如大小、最小单块大小、对齐要求等;操作该设备的方法如注册和销毁等;该设备的状态,如重置、记录相关功能的数据结构等。具体可以参考SPDK源码中的struct spdk_bdev结构体。
· 操作通用设备的函数指针表:定义通用的操作设备的方法。包括如何拿到后端具体设备相应的I/O Channel、后端设备如何处理I/O(Read、Write、Unmap等)、支持的I/O类型、销毁后端具体块设备等操作。每一类具体的后端设备都需要实现这张函数指针表,使得通用块设备可以屏蔽这些实现的细节,只需要调用对应的操作方法就可以了。具体可以参考SPDK源码中的struct spdk_bdev_fn_table结构体。
· 块设备I/O数据结构:类似于内核驱动中的bio数据结构,同样需要一个I/O块数据结构来具体操作块设备和后端对应的具体设备。具体的I/O读和写的信息都会在这个数据结构中被保存,以及涉及的Buffer、Bdev Channel等相关资源,后期需要结合高级的存储特性像逻辑卷、流量控制等都需要在I/O数据结构这里有相关的标识符和额外的属性。具体可以参考SPDK源码中的struct spdk_bdev_io结构体。
这些核心的数据结构,提供了最基本的功能上的特性来支持不同的后端设备,比如通过SPDK用户态NVMe驱动来操作NVMe SSD;通过Linux AIO来操作除NVMe SSD外的其他慢速存储设备比如HDD、SATA SSD、SAS SSD等;通过PMDK(Persistent Memory Development Kit)来操作英特尔的Persistent Memory设备;通过Ceph RBD(Reliable Block Device)来操作远端Ceph OSD设备;通过GPT(GUID Partition Table)在同一设备上创建逻辑分区;等等。
同时这些数据结构定义了通用的使用方法。例如,函数指针表来支持新后端设备的引入,可以是某种本地新的硬件设备,也可以是某种远端分布式存储暴露出来的虚拟设备。在可扩展性上除了一定需要支持的高速NVMe SSD设备,还提供了对传统设备及新设备的支持。
在设计通用块层的数据流的时候,需要考虑后端不同设备的特性,比如某些设备可以支持很高的并发量,某些设备无法支持单个I/O的终止操作(Abort),对数据流上的考虑大致包括以下内容。
· 引入I/O队列来缓存从上层模块接收到的I/O请求,而不是直接传递给下层。这样不同的后端设备都可以按照不同的速率来完成这些I/O请求。同时基于这个I/O队列还能起到一些额外的作用,比如限速流控、不同优先级处理、I/O分发等。当后端设备遇到一些异常情况时,比如当Buffer资源不够时,这个I/O队列也可以重新把这些发下去的I/O请求再次进入队列做第二次读/写尝试。
· 引入通用的异常恢复机制。比如某个I/O请求可能在下层具体设备停留过久导致的超时问题;比如设备遇到严重问题导致无法响应而需要设备重置;比如设备的热插拔导致的I/O请求的出错处理。与其让每一个下层具体设备都来实现这些异常恢复机制,不如在通用块层就来进行处理。
能够让通用块层起到承接上层应用的读/写请求,高性能地利用下层设备的读/写性能,在实现高性能、可扩展性的同时,还需要考虑各种异常情况、各种存储特性的需求。这些都是在实现数据流时需要解决的问题。
通用块层的管理
管理通用块层涉及两方面问题,一方面是,对上层模块、对具体应用是如何配置的,怎么样才能让应用实施到某个通用块设备。这里有两种方法,一种是通过配置文件,另一种是通过远程过程调用(RPC)的方法在运行过程中动态地创建和删除新的块设备。
当我们引入更多的存储特性在通用块层的时候,我们可以把块设备分为两种:支持直接操作后端硬件的块设备,可以称之为基础块设备(Base Bdev);构建在基础块设备之上的设备,比如逻辑卷、加密、压缩块设备,称之为虚拟块设备(Virtual Bdev)。
有层次地来管理这些基础块设备和虚拟块设备是管理通用块层另一方面需要考虑的问题。需要注意的是,虚拟块设备和基础块设备从本质上来说都是块设备,具有相同的特性和功能。区别在于基础块设备可以通过指针指向虚拟块设备,然后虚拟块设备也可以通过指针指向基础块设备。这些对应的指针存放在struct spdk_bdev数据结构上。
这里还需要考虑的一个问题是当一个块设备动态创建后,需要做些什么,怎么和已经存在的块设备进行交互,比如提到的基础块设备和虚拟块设备之间的相互关系。这里主要是由struct spdk_bdev_module数据结构来支持的,该数据结构定义了下面几个重要的函数指针,需要具体的设备模块来实现。
· module_init(),当SPDK应用启动的时候,初始化某个具体块设备模块,如NVMe。
· module_fini(),当SPDK应用退出的时候,销毁某个具体块设备模块,如分配的各种资源。
· examine(),当某个块设备,如基础块设备创建出来后对应的其他设备,尤其是虚拟块设备可以被通知做出相应的操作,比如创建出对应的虚拟块设备和基础块设备。
逻辑卷
类似于内核的逻辑卷管理,SPDK在用户态也实现了基于通用块设备的逻辑卷管理。
1)内核LVM
大多数内核LVM都有着相同的基本设计,如下图所示。它们由物理卷入手,可以是硬盘、硬盘分区,或者是外部存储设备的LUN。LVM将每一个物理卷都视作是由一系列称为物理区段(Physical Extent,PE)的块组成的。
通常,物理卷只是简单地一对一映射到逻辑区域(Logical Extent,LE)中。通过镜像,多个物理区段映射到单个逻辑区域。物理区段从物理卷组(Physical Volume Group,PVG)中抽取,这是一组相同大小的物理卷,其作用类似于RAID1阵列中的硬盘。系统将逻辑区域集中到一个卷组中。合并后的逻辑区域可以被连接到称为逻辑卷(简称为LV)的虚拟磁盘分区中。系统可以使用LV作为原始块设备,就像磁盘分区一样,在其上创建可安装的文件系统,或者使用它们作为块存储空间。
2)Blobstore
Blobstore是支撑SPDK逻辑卷的核心技术。Blobstore本质上是一个Block的分配管理。如果后端的具体设备具有数据持久性的话,如NVMe SSD,那么Block分配的这些信息,或者元数据可以在断电的情况下被保留下来,等下次系统正常启动时,对应的Block的分配管理依旧有效。
这个Block的分配管理可为上层模块提供更高层次的存储服务,比如这里提到的逻辑卷管理,以及下面将要介绍的文件系统。这些基于Blobstore的更高层次的存储服务,可以为本地的数据库,或者Key/Value仓库(RocksDB)提供底层的支持。
基于Blobstore的逻辑卷也好,文件系统也好,更多是从用户态的角度来支持最基础的要求的,还要提供高性能、可扩展性的支持。因此这里对用户态的考虑并不是最终设计成和传统通用文件系统一样的模式。另外,目前的考虑是不去支持复杂的可移植操作系统接口语义。
因此,为了避免和传统通用文件系统相混淆,这里我们使用Blob(Binary Large Object)术语,而不是用文件或对象这些在通用文件系统的常用术语。Blobstore的设计初衷和核心思想是要自上而下地实现相同的优化思想------异步与并行,对多个Blob采用的是无锁的、异步并行的读/写操作。需要指出的是,目前的设计里面没有支持缓存,读/写的操作都会直接和后端的具体设备交互。在后续的持续优化中,也有可行的读/写操作。需要指出的是,目前的设计里面没有支持缓存,读/写的操作都会直接和后端的具体设备交互。在后续的持续优化中,也有可能引入缓存来提供更好的读/写性能。
通常来说Blob设计的大小是可以配置的,远比块设备的最小单元(扇区大小)大得多,可以从几百KB到几MB。在兼顾管理这些Blob开销的同时,越小的Blob需要越多的元数据来维护,也要考虑性能问题。同时,特别针对NAND NVMe SSD硬件设备,Blob的大小可以是NAND NVMe SSD最小擦除单位(块大小)的整数倍。这样可以支持快速的随机读/写性能,同时避免了进行后端NAND管理的垃圾回收工作。
类似于大部分的内核逻辑卷管理,需要不同的管理粒度和逻辑结构在块设备上有效地创建出可以动态划分的空间。SPDK Blobstore也定义了类似的层次结构,需要注意的是这些都是逻辑上的概念,所以如果需要考虑Blobstore在断电的情况下恢复的问题,这些相关的配置信息要么是在本身设计的时候固定的,要么是通过配置在非易失后端设备的特定位置上固定下来的。
· 逻辑块(Logical Block):一般就是指后端具体设备本身的扇区大小,比如常见的512B或4KiB大小,整体空间可以相应地划分成逻辑块0~N。
· 页:一个页的大小定义成逻辑块的整数倍,在创建Blobstore时固定下来,后续无法再进行修改。为了管理方便,比如快速映射到某个具体的逻辑块,往往一个页是由物理上连续的逻辑块组成的。同样地,页也会有相应的索引,从0~N来指定。
如果考虑单个页的原子操作的话,一个简单的方法是按照后端的具体硬件支持的原子大小来设定页的大小。比如说大部分NVMe SSD支持4KiB大小的原子操作,那么这个页可以是4KiB,这样,如果逻辑块是512B的话,那么页的大小就是8个连续逻辑块。当然,如果要在Blobstore这个层面上实现其他大小的原子操作,那么从Blobstore设计上来说需要更多的软件方法来实现。目前,为了考虑易用、易维护性,原子的操作主要是依赖于后端设备能支持的粒度。
· Cluster:类似于页的实现,一个Cluster的大小是多个固定的页的大小,也是在Blobstore创建的时候确定下来的。组成单个Cluster的多个页是连续的,页就是物理上连续的逻辑块。这些操作都是为了能够通过算数的方法来找到对应的逻辑块的位置,最终实现对后端具体块设备的读/写操作,完全是从性能角度考虑的。类似于页,Cluster也是从0开始的索引。Cluster不考虑原子性,因此Cluster可以定义的相对来说比较大,如1MiB的大小。如果页是4KiB的话,对应256个连续的页。
· Blob:一个Blob是一个有序的队列,存放了Cluster的相关信息。Blob物理上是不连续的,无法通过索引来读/写某个Cluster,而是需要队列的查找来操作某个特定的Cluster。这样的设计在性能和管理上带来了一定的复杂性,比如这些信息需要固定下来,在系统遇到故障时,还能重新恢复和原来一样的信息。但是从提供更多高级的存储服务的角度看,这样的设计可以很容易地实现快照、克隆等功能。
在SPDK Blobstore的设计中,Blob是对上层模块可见、可操作的对象,隐藏了Cluster、页、逻辑块的具体实现。每个Blob都有唯一的标识符提供给上层模块进行操作。通过具体的起始地址、偏移量和长度,可以如前面所说的,很容易地算出具体的哪个页、哪个逻辑块来读/写具体的后端设备。应用程序也可以把Blob的相关信息、元数据通过成对的键/值(Key/Value)来保存下来。
· Blobstore:如果SPDK通用设备的空间被初始化成通过Blob接口来访问,而不是通过固有的块接口来操作,那么这个通用块设备就被称为一个Blobstore(Blob的存储池)。Blobstore本身除了那些可以给到上层应用访问的Blob,还有相应的私有的元数据空间来固化这些信息,因此Blobstore会管理整个通用块设备。Blobstore allocator示例如图所示:
3)SPDK用户态逻辑卷
SPDK用户态逻辑卷基于Blobstore和Blob。每个逻辑卷是多个Blob的组合,它有自己唯一的UUID和其他属性,如别名。
对上层模块而言,这里我们引入一个类似的概念,逻辑卷块设备。对逻辑卷块设备的操作会转换成对SPDK Blob的操作,最终还是依照之前Blob的层次结构,转换成对Cluster、页和设备逻辑块的操作。这里Cluster的大小,如之前所说的,在不考虑原子操作的情况下,可以动态地配置它的大小。
基于逻辑卷的功能,可以通过对Blob的不同处理来实现更高级别的存储服务,比如如果这个Blob中的Cluster是在上层模块写入时才分配的,那么这种特性就是常见的精简配置,可以达到空间的高效使用。相反地,逻辑卷在创建的时候就把Blob的物理空间分配出来的特性,可以称之为密集配置。这样的操作可以保证写性能的稳定性,没有额外分配Cluster而引入的性能开销。
下图说明了密集配置和精简配置的不同用法。密集配置(传统配置),总是把空间先分配出来,即便这些空间后续永远不会被用到。而精简配置会看到可用的空间,但是只在真实写入的时候才分配空间。在虚拟化的场景下,更多地会使用到精简配置来提供更多的用户可见的空间。
前面提到Blob包含了Cluster的有序队列,这些Cluster可以在精简配置的情况下被动态分配。基于类似的方法来动态地管理Cluster,我们可以引入快照和克隆功能。快照,是指在某一时刻的数据集,就如按下快门后,定格在那个瞬间的景象。后续对Blob上的读操作,总是可以拿回来那份数据,但是对Blob上的写操作,不会覆盖原先的数据,而是会分配新的Cluster保存下来。这就要求逻辑卷需要额外的属性来存储这些快照信息。
使用快照可以很快地生成某个时刻的数据集,尤其是对上层应用是读密集型的场景,可以按需要生成多个快照给应用,同时能保证稳定的读性能。但是快照需要考虑一个潜在的数据可靠性的限制,因为多个快照都是指向同一个Cluster的,如果这个Cluster出现数据问题的话,则会导致所有引用这个Cluster的快照都出现问题。
因此我们引入克隆这个特性,顾名思义把某个时间点的数据集复制一份,而且是分配同等大小空间的数据集。这个过程需要花费时间,并且依赖于需要做克隆的数据集的大小,但是一旦克隆完成,将会提供更高的性能,比如新的读操作可以直接在克隆上完成。同时可以提供更好的可靠性,即使原先的数据出现问题,克隆的数据还是有效的。从实现来说,在复制某一时刻的数据集时,是通过先在那个时刻做一个快照,将那个时刻的数据集固定下来,后续的读和写都可以基于相同时刻的快照。
这两种不同的特性,对空间、性能、可靠性的需求不同,上层应用可以按需要来启用。从实现角度来看,有些Cluster上是没有任何写数据的,在启动克隆这个特性的时候,对这类特性的Cluster,我们可以进行标识,比如空Cluster可以是一个全零数据的特殊Cluster,在需要读取这个Cluster的时候,直接返回全零的数据。Blobstore中对克隆的读/写操作如下图所示。大概可以看出,全零Cluster与快照对克隆读/写起到底层支撑的作用。
从图中可以看出,对克隆的支持在没有分配的Cluster的时候,可以直接对应全零的Cluster(全零设备的一部分);对已经复制过来的Cluster,可以直接读克隆对应的Cluster,即便某个Cluster已经发生了改变;对尚未复制过来但是含有数据的Cluster,直接读取对应同一时刻的快照的Cluster。克隆的写操作相对来说更复杂,也是需要依赖全零Cluster和快照上的Cluster的。
需要强调的是,对克隆和快照可以引入更复杂的写操作和同步逻辑,比如是否克隆、快照可写,写完的数据可以覆盖还是引入新的空间分配。快照、克隆和当前的数据集之间是否可以相互转换,比如把某个快照或克隆设置成当前有效的数据。这些特性和需求的支持,也是SPDK用户态逻辑卷可以支持的方向。
基于通用块的流量控制
流量控制是高级存储特性中一种常用的实现,在内核通用块层也有类似的实现,流量控制的需求主要来自以下两方面。
· 多个应用需要共享一个设备,不希望出现的场景是某个应用长时间通过高I/O压力占用该设备,而影响其他应用对设备的使用。
· 给某些应用指定预留某些带宽,这类应用往往有高于其他应用的优先级。
SPDK的流量控制,是基于通用块层来实现的,这样设计的好处如下所示。
· 可以是任何一个通用块设备,前面我们介绍了基础块设备、虚拟块设备、逻辑卷等,只要是块设备,流量控制都可以在上面启用。这样的设计可以很好地结合SPDK通用块层的特性。
· 和上层各种协议无关,无论是本地的传输协议,还是跨网络的传输协议,都可以很好地支持。这些上层模块需要关心的是,各种协议的设备和通用块设备是如何对应的。比如在iSCSI的场景中,暴露出来的LUN是怎么和SPDK通用块设备对应的。这样,当LUN的用户需要启用流设备和通用块设备是如何对应的。比如在iSCSI的场景中,暴露出来的LUN是怎么和SPDK通用块设备对应的。这样,当LUN的用户需要启用流量控制的时候,对相应的通用块设备设置流量控制就可以了。
· 和后端具体设备无关,无论是本地的高速NVMe SSD、低速的硬盘驱动器,还是某个远端的块设备,这些具体的硬件已经由SPDK通用块层隐藏起来了。但是需要注意的是,流量控制本身是不会提高硬件的自身能力的上限的,需要给出合理的流量控制的目标。
前面已经介绍了SPDK通用设备层的各种特性:无锁化、异步、并发等。基于SPDK通用块层实现的流量控制,也是很自然地结合了这些特性来实现可扩展和准确控制的目标。下图所示是一个简单的架构图,概括了SPDK流量控制的主要操作。
前面我们详细描述了通用块层的一些主要结构,包括通用块设备结构、I/O请求结构、I/O Channel结构、每个Channel的上下文块设备通道结构等,也具体描述了通用块层的线程模型。这里涉及流量控制的主要操作流程,就是结合这些重要数据结构和线程模型来实现资源上的管理和I/O请求上的管理的。
上图简单描述了流量控制需要的额外资源上的管理。简单描述如下所示。
· 当流量控制功能启用后,静态配置文件或RPC动态配置文件,需要分配特定的资源和指定特定的线程。这个线程只是逻辑上的概念,本质上是SPDK应用框架分配的单核上唯一的那个线程。
· 在执行流量控制的线程上,启动一个周期性操作的Poller,或者一个任务来周期性地做些工作。在SPDK的实现中,为了简化这个控制的操作,将1s要达到的流量目标,比如IOps和带宽,对应到更小粒度的目标,比如1ms,或者500μs,所以这个周期性的任务就是每个这样的小周期来处理允许的I/O流量。
· 有了流量控制的线程和相应的周期性的任务,其他接受上层模块I/O请求的I/O Channel可以通过事件的形式来异步无锁化地通知到流量控制的线程。
· 当对上层分配的I/O Channel需要关闭时,需要将该Channel上所有尚未处理的I/O请求处理掉,释放掉相应的I/O请求资源。
· 当上层应用关闭掉所有I/O Channel,或者RPC动态停用流量控制后,所有未处理的I/O请求会被及时处理,完成后释放相应的流量控制分配的资源。
相对于资源管理,I/O请求的管理就相对简单很多,如上图所示。如前所述,I/O在不同的线程间的传递是通过消息、事件来驱动的,这样避免了线程间的同步问题。另外需要提到的是,SPDK的I/O的操作是单核运行直到完成的模式,I/O从哪个I/O Channel或者哪个I/O线程接收到,最终还是需要从同一个线程回调上层模块的。对上层模块而言,具体流量控制的操作对它来说是透明的,只需要关心I/O从哪里回调回来即可。
SPDK基于通用块层的流量控制提供了很好的扩展性。在算法层面上的实现简单明确,如果需要引入更高级的流量控制算法,可以很容易地替换默认的算法,也可以支持其他更多种类的流量控制,比如读/写分开的IOps,带宽限速;比如读/写不同优先级的区别控制;等等。SPDK通用块层和SPDK应用框架为这一存储服务提供了很好的技术保障和可扩展性。