本文第一部分介绍经典文件系统ext3的块存储,第二部分介绍一个NoSQL分布式存储系统的块存储。
ext系列文件系统是linux的土著文件系统,历经4个版本,最新是ext4,在linux 2.6.28内核正式引入,目前比较新的linux发行版都已经把ext4做为默认文件系统。下面先看看ext3的数据块存储结构,而ext4是对ext3的继承与优化,核心结构基本类似,同时也对ext3的提供向下兼容,后面再讨论它们的区别。
首先看看ext3的文件系统的结构图:
ext3整体结构还是清晰明了:
1)引导块,主要是给系统引导的时候用的,包括512字节的MBR和64字节的分区表,余下的是保留区间。
2)块组部分,则是由ext3文件系统管理的区域,块组编号从0开始。每个块组都包含data block bitmap、inode bitmap、inode table以及data block table(少数块组会有super block和gdt(gdt包含了文件系统所有块组的块描述符),但是文件系统一般只使用块组0的super block和gdt,其他块组的super block和gdt只是在一些时间点上进行备份,以防块组0崩溃了可以恢复到一个比较一致性的状态)。
块大小、每个块组包含的块数目、块组内inode table和data block table都是在创建文件系统的时候就设定好了。所以在文件系统的使用过程中根据给定的inode number(inode number从0开始编号,一直单调递增1),可以快速定位到所属的块组和在该块组的inode table里面的偏移量。同理给定block number(block number也是从0开始编号,一直单调递增1),可以快速定位到所属的块组和在该块组中data block table的偏移量。可以看到这种线性的组织方式是静态的,从文件系统建立那一刻就分配好了(不过由于ext3有一些保留块,这些保留块也为文件系统的resize提供了一定支持,主要是为了保存更多的gd)。
下面再看看ext3的数据块索引:
ext3的inode中有一个重要的数据成员i_block(其实上面这个图如果横着展开的话,其实是一颗树型结构),是一个包含15个32位整数的数组,其中前12个元素直接保存的存储文件数据的block的block number;第13个元素保存的block number指向的block里面保存的是block number数组,而非文件数据,简称一次间接索引。同理,第14个元素保存的二次间接索引,第15个元素保存的是三次间接索引,此机制保证了ext3最大支持的单个文件大小为4TB多一点(假设block size是4K,那么最大的文件大小=4K*12 + 4K/4*4K + 4K/4*4K/4*4K+4K/4*4K/4*4K/4*4K ~= 4TB,但是实际上由于ext3实现的限制,为了使得每个文件包含的磁盘扇区数不超过2^32个,所以在block size为4K的情况下,ext3的最大文件大小实际上只有2TB)。如果文件系统的block size是4K,那么只要不大于48K,都只使用前12个元素,否则就会用到后面的间接索引。试想如果要访问一个2G文件的最后4KB,那么将要经历二次索引和一次索引,如果包含索引的数据块不在pagecache中,那么将发生多次IO读,效率有点低。
在ext4中,ext4的inode依然保留了i_block这种结构,而且提供向前兼容,同时也提供了更具弹性的extend分配方式(最多有4个extend,但是extend也可以是多级树形索引组织,一个extend由逻辑块号、块数和物理块号标识),只是extend的存储是重用这个i_block的存储空间的,也就是ext4兼容两种分配方式。对比ext3基于三级间接索引的分配机制和ext4的extend分配机制,首先在元数据规模上就有很大区别。试想如果一个4G的大文件,且文件系统block size是4K,则文件需要100w个block存储,也就是需要保存100w个block number,每个block number是4个字节,那么就至少需要4M的元数据了,这个还不包括一次和二次间接索引需要的数据块。其实问题很简单,在这种分配方式下的索引数据的熵值永远>=4/4K,即千分之一。如果采用extend分配方式,元数据规模将大大降低,因为一个extend可以标识很多块连续的数据块。
以上两个图的结构基本上是ext3的on disk结构(也就是物理磁盘上实实在在存储的信息),至于in memory的结构基本上跟这个on disk的结构一一映射,只是做了少许封装。个人认为ext3/4的精髓在于分组管理和bitmap应用(这里暂时不讨论ext3/4的journal部分,因为jbd严格上来说不属于具体的文件系统,而是一个通用的文件系统日志管理模块,jbd本身的复杂度一点不比ext3简单),一般文件的数据都优先存储在同一个块组里面,这样就带来了IO的聚集特征,对性能有好处。其次简单的bitmap既节省了元数据的规模计算也比较简单。由于简单的物理结构,所以必定带来不怎么高的性能,幸好inode cache和dentry cache和pagecache的充分利用使得ext3在一般情况下性能还可以。
使用ext3文件系统往往有这样一种纠结:该文件系统对小文件的物理存储本身是高效率的,从i_block的构造看出来,但是小文件带来了大量索引开销(inode存储和查找)却起到了相反的作用;物理存储本身对大文件却是低效率的,因为要经过多次索引,且数据块不连续的概率很大,但是使用大文件索引开销却比较低。从实际的应用角度上看,大量小文件的应用是真正的性能杀手,特别是32位系统以前经常会由于大量小文件而消耗了大量low memory,导致内核由于低内存不足而产生oom-killer现象,所以一般都对小文件进行封装成大文件,然后动态映射大文件访问,只要内存足够大,间接索引块的pagecache命中率还是很高的。
ext4是对ext3的继承和优化改进,主要打破了ext3在磁盘容量、文件大小、子目录数等等的硬限制。但是为了向前兼容,核心的ondisk数据结构基本没什么大改,主要是在结构尾部增加了一些细化计量和加速字段,ext4在功能算法和in memory结构组织方面有比较大的改进。
像ext3/4这种支持POSIX标准接口的文件系统,基本都实现在内核态,即位于内核中VFS的下一层(除非采用FUSE那样的机制,能在用户态实现)。这种经典的文件系统往往由于考虑太多功能需求和接口标准,无法应对互联网海量文件存储在性能和伸缩上的要求,因此也就涌现了像google的GFS和hadoop的HDFS这样的基于用户态的分布式文件存储系统,其实这类文件系统应该称为No-POSIX文件系统,因为No-SQL系统其实是相对于SQL-based的DBMS而言的(后续都称No-Posix)。下面介绍一种应用于互联网的No-Posix分布式文件系统的块存储实现(由于本主题只分析块存储实现,所以对于其他层次的东西暂时忽略)。
首先看看该文件系统是如何对硬盘进行管理的,其on disk结构图如下:
看这个图,跟一般的文件系统(比如ext3)对硬盘的管理有点相似,但它是直接在块设备文件(比如/dev/sdb)上构建的,在用户态通过libc的IO函数读写块设备文件来存储文件数据(并没有在块设备上建立任何内核中已经注册的文件系统)。除了前面4K的disk head是为了兼容系统对硬盘引导块的需求以外(基本无用),剩下的空间被被划分为多个等同大小的CHUNK块。每个CHUNK块里面主要由binlog section和data section构成。binlog section其实是存放了大量的inode变更记录,每次inode的增加、删除、修改都会相应在binlog中写入一条记录(因此binlog section的大小也限制了inode的个数)。但是这里的inode其实并不是像ext3文件系统中的文件inode,这里的inode只是描述CHUNK里面一片文件数据的元数据所形成的inode(实际这样一个inode的空间只有二三十个字节,比ext3的128字节的inode相差甚远)。而data section则是存储真正文件数据片的区域。这里可以看到,CHUNK里面是没有专门的像ext3那样的inode table区域的,因为inode只有in memory形态(机器故障恢复或者进程重启,都需要从binlog中恢复内存中的inode表)。
CHUNK是整个文件系统的基本存储空间分配单元,大量位于不同机器上不同硬盘的CHUNK构成了整个文件系统可用的存储空间,而CHUNK之间可以互相配对,形成应用态的Raid1存储。(一个用户角度的文件实际上被划分为多个数据片,一般按照某个定长来划分,这些数据片可以存储在不同的CHUNK上,用户读取文件的时候,通过一个文件索引层找出这些数据片所在的CHUNK,然后再访问这些CHUNK来读取数据)
虽然一个CHUNK的on disk结构很简单,但是其in memory结构相比稍微复杂一点点,也只有这样才能在性能上有提升,否则什么都跟硬盘映射起来,两者的数据一致性同步的代价是挺高的。下面看一个CHUNK的in memory结构:
一个CHUNK由一个chunk inode来描述(实际上chunk inode也只有in memory形态)。chunk inode主要使用inode hash table来管理属于本CHUNK的inode。简单的inode构造和hash table实现也注定了它不是传统意义上的文件系统,平坦而无序,更接近key-value式存储。每个chunk inode有一个offset变量表明其data section的已使用区间位置(由于每个CHUNK大小一样,所以offset大小也决定了CHUNK剩余空间的大小,从而可以利用这个指标来进行负载均衡,使得每个CHUNK的剩余空间都差不多)。而数据片的inode主要是保存了一个offset和length,分别表示该数据片在CHUNK里的偏移量和数据片长度,理论上说这样的访问效率会比较高。每个CHUNK都是以append的方式在不断增长,即使数据片删除了,也不会立即回收空间,只是对inode做一个标志。
如果很少删除,那么这种方式其实是挺好的,但是如果删除很频繁,那么每个CHUNK已使用区间会有很多空洞而不能重复利用,这是一个主要弊端。解决方法其实有很多,一个是修改算法使得inode和空洞能重用,另一个方法就是定期对整个CHUNK进行pack操作(此时需要暂时屏蔽对该CHUNK的读写访问),使得空洞全部转移到尾部的未使用区间,而且重新整理binlog section。当然,前面说到这里的CHUNK里面的数据片inode并不是代表真正的文件,所以该分布式文件系统还需要有一层分布式的索引层(索引层是否分布式并不重要,像GFS和HDFS则是集中式的,由master或name node来管理)来表示真正的用户角度的文件,这个索引层本身采用的是典型的key-value存储。相对来说,数据量至少降低2-3个数量级。
对比传统的文件系统(比如ext3),这类No-Posix的分布式文件系统,其实是大大简化了底层的存储块分配与读写机制,同时在元数据上也尽量节省,只要够用就行(更多的扩展性放到了更高的层次去解决)。其次,在数据一致性和数据恢复方面,在存储层这一级别也是比较轻量级的,起码没有像jbd这样重的机制。再次,这类No-Posix的分布式文件系统通过在应用层次的N份镜像存储,来规避单机可靠性较低,以得到较高的整体可用性。传统文件系统更多的是考虑到磁盘空间的利用率,比如如何重复利用磁盘空间(比如文件的覆盖写),如何对文件的并发读写进行同步互斥等等,这些在No-Posix的分布式文件系统里面基本都弱化了,往往采用的是append的方式来提升写速度,通过多CHUNK的并发读来提升读速度,通过上层限制并发写来维护数据一致性等等。最后,对于这种No-Posix的分布式文件系统而言,其实索引的组织并不是最关键的,往往简单的哈希分布已经足够,通过在多个机器间分摊计算量来达到较好的性能,而传统的单机文件系统往往需要考虑较复杂的索引组织结构来提升性能(比如遍历目录文件),因为单机容易出现性能瓶颈。
当然,对于No-Posix的分布式文件系统来说,其实也是可以在上层实现有限的Posix接口,只是这种实现往往牺牲了效率。对于传统单机文件系统,比如btrfs,zfs等已经达到了一个非常高的水平。而像ceph这类比较激进的分布式文件系统,既能实现Posix接口,又能高效利用btrfs等作为存储节点的文件系统,也许是一个比较好的结合点。