深入理解web开发中海量小文件带来的的危害及解决方案
深入理解web开发中海量小文件带来的的危害及解决方案
本文为原创内容,转载请注明出处 ,谢谢
引言
试想一个电商项目中,允许每一个商铺拥有自己独立的前端设计,那么在店铺装修过程中每个店铺都会生成大量的静态文件(图片、css样式表、js脚本)。并且每个店铺会不断更新自己的商品,每上架一个商品也会产生新的静态文件,但就图片来说,每个商品少则5到10张多则几十张。另外,通常为了方便项目的组织与规划,适用于不同用途的样式表与脚本会分离出来形成不同的css文件和js文件,这就导致了这两种文件可能会非常小,有的可能只有几百Byte。像淘宝这样的巨型电商项目,这种小文件恐怕早已达到千亿级别的数据量了,那么如此之多的小文件到底会带来怎样的危害呢?
谈谈危害
-
从文件系统说起
打开计算机,在任意位置新建一个文本文件,从键盘随意输入一个字符。这个文本文件的大小理应为一个字节即1Byte,但是它占用的空间却是4KB。这是因为文件是存储在磁盘上的,磁盘的最小存储单位是扇区,一个扇区可以存储512Byte数据,操作系统读取硬盘时,由于一次读一个扇区效率过低,于是会一次性连续读入多个扇区,多个扇区组成一个“块”,即文件系统存取的最小单位。最常见的就是8个扇区,4KB大小。由此可见,即使是小于4KB的文件,其所占用的空间依然是4KB。这使得每有一个这样的文件存在于文件系统中时,就会产生造成一定的存储空间浪费。当然了,即使是大文件,例如一个4095KB大小的文件,它的占用空间也必须是4KB的倍数,也即4MB。也就是说,大文件的存储,由于操作系统的设计原因,每个文件也会产生一个内部碎片,这个碎片小于4KB,为存储一个大文件而牺牲这么一点空间显然是值得的。而对于小文件,尤其是数量到达一定程度的小文件,若其平均大小只有3KB,那么百万数量级的这种小文件就会产生GB大小级别的内部碎片,十亿数量级的这种小文件就会产生TB级别的内部碎片。像淘宝这样的巨型电商项目若仍直接去存储这样的小文件,将会造成大量的空间浪费。

-
说说inode
文件存储在磁盘上时,文件系统同时会生成一个存储文件元信息的inode。这些元信息包括但不限于文件的大小,文件的创建者,文件的创建日期以及文件的读写权限等。那么理所当然的,存储这些inode也是会消耗磁盘的空间的。在Linux中,有专门的一个区域用于存放inode,因此,inode是有限的。试想,当有成千上万的小文件存在于服务器的文件系统当中时,最先消耗完的肯定不是磁盘的空间,而是inode,这就会导致大量的空闲空间无法使用。(深入了解inode可查看这篇大佬的博客)
-
浅谈web优化
web项目的优化中最重要的一项就是速度,而速度优化最重要的一项原则就是尽可能地减少HTTP的Request请求数,然而一个华丽的web页面少不了大量的图片、css样式以及js脚本文件。这些静态文件都是需要通过HTTP来GET的,如果项目中这些大量的静态文件都直接存放到数据库当中的话,检索一个小文件要耗费的时间相对来说会非常久。而如果可以将诸多小文件合并为一个大文件,只在数据库中存储这个大文件的索引信息,由数据库找到大文件,再由文件系统从大文件中分离出要获取的小文件,那么不仅速度上会快很多,而且前文提到的两个问题也能得以解决。
解决方案
上述问题其实在数据量没有当今如此爆炸的情形之下已经有具有前瞻性的学者们进行研究了,Apache基金会所开发的分布式系统基础架构Hadoop实现了一个分布式文件系统,针对海量小文件问题,Hadoop官方给出了几种解决方案,HAR, Sequence file和CombineFileInputFormat。不展开来说这几种Hadoop系统的既有方法,只谈其核心思想。小文件带来的问题归根结底是由于其小并且数量巨大,那么如果将一定数量的小文件合并为一个个的大文件,并且只存储合并后的大文件,那么存储系统中的文件数量会成数量级的下降。通过一定方式再从合并后的大文件分离出小文件,按需获取想要的数据,那么问题便迎刃而解了。这个核心思想听起来好像很容易,但实际上实现起来,怎么合并,怎么存储,怎么只通过小文件原本的索引信息就能找到它对应的大文件,又怎么从它对应的大文件中分离出它所对应的数据信息...每一步都是需要考虑实现的,并且实现的方法也千差万别,Hadoop的几种方法就已经各不相同了,一定程度上对查询速度和存储等方面上做了各自的取舍。(ps:记录一下感受哈哈,开始做的时候并没有想太多,只有一个大的方向,Hadoop是后来小有成果时查资料看文献时才了解到的,没有架构,上来就是瞎写,但结果走向居然还不错,当时很开心😄)下面来说说我的方案,先上图:

我的大概想法就是只通过小文件原文本的路径就能从合并的诸多大文件中找到小文件所对应的数据信息。比如说应用于web开发中时,有一个http请求get一个图片时,http链接告诉了原本该去哪里找到这个文件,拿到了这个文件的路径后,通过一系列索引的查找,便能快速地将小文件对应的数据从存储的大文件中剥离出来。
-
先说合并
合并的操作相对来说比较简单,文件的本质都是一大段二进制码,《深入理解计算机系统》中总结到信息就是位加上下文,文件不同的后缀信息表征了其编码方式,不同的编解码方式其实就对应着二进制位所处的不同上下文环境。但是要做的合并操作实质上只需要将多个小文件一段一段的二进制码信息整合成一大段二进制码,然后重新作为一个文件对象存储起来,也就是说,这种合并无关乎小文件的文件格式,合并后的大文件的格式也是无关紧要的,因为对大文件的要求只有两个,整齐地存放在存储介质上以及完整有序地保存好小文件的信息以便分离,所以也不必对其解码,分离时完整地读出它所保存的二进制信息即可。我是用java写的整个项目,处理这些二进制信息,java的io包中有着丰富多样的输入输出流以及文件类以供操作。总结起来,我的合并操作就是用合适的输入流将要合并的小文件信息读出来放到一个字节码数组中,再将这个字节码数组通过合适的输出流保存为一个任意格式的文件(开始我直接设置的是txt格式,打开后能够直接看到文件中的字节码,在debug时起到了一些作用)。
另外合并后的大文件在参考了一些成熟系统后,我将这个大文件的尺寸设为固定的64MB。这样做的原因一个是因为这个大小是前人经过计算获取的较为合理的值,另外设计成固定的大小虽然每个大文件有一个很小的内部碎片,但是却一定程度上很好地解决了外部碎片的问题。由于造价问题,目前大部分服务器的存储还是依赖于机械硬盘,外部碎片如果在一个磁盘中越来越多,由于磁盘寻道等操作细节,访问一个文件的速度一定会受到影响的,并且越到后面,会有很多空间无法利用起来。详细了解这部分知识戳这篇大佬的博客。贴两张我电脑磁盘磁盘碎盘整理前后的图片,整理前后可用空间上升了11个G,引起舒适~


-
再谈分离
相比合并,分离要考虑的东西就多了,这里自底向上地说
-
首先要解决的问题是只从一个大文件中找到一个具体的小文件。由于在合并时是保存在字节码数组中的,因此只需要将每个小文件的字节码信息在这一个大的字节码数组中所处的开始和结束位置的索引号记录起来,便能通过读取大文件中指定位置的信息后输出这个具体的小文件。我想尽可能在最少的时间复杂度内完成,因此想到了用哈希来做索引,因为在哈希表中查找一条数据的时间复杂度是O(1),恰好java中的HashMap满足了我的这一需求。HashMap中键值对的键我用一个String将小文件的路径信息存储起来,其值我同样用一个String将小文件在合并后数组的起始位置信息以及大文件的文件名以及大文件路径存放起来。从而,知道了小文件的路径就可以从其对应的大文件中找到自己相应的数据段。这一步的索引在我的系统中是二级索引,采用哈希索引实现。

这里提一下自己的小设计😏:start,end,blockName,dir四个传参分别是上面提到的小文件在合并数组中的开始结束位置,大文件文件名以及大文件路径。如何在一个String中准确地获取这四项信息呢?我想了还挺久的,最终得出了现在的方案。将前三个数据都设为定长的字符串,其中32位int类型的start与end是将其转成8个十六进制的字符串,高位不够的用0补齐。blockName则采用大小写字母以及数字随机生成一个固定长度的字符串来作为大文件文件名,目前也用了8个字符,因为这种方式随机生成8个字符已经有(26+26+10)的8次方个不同的结果了,我测试时完全够用了,后面要增加容量的话,更改项目中的一个常量即可。其次是大文件的路径dir,这个本身是变长的,想过通过哈希将其也变为定长但想了想没有必要,只要将它放在键对应String的最后一段位置就可以了,前三个定长数据索引的结束位置就是它的开始位置,键String的结尾就是它的结尾。
-
其次要解决的就是如何从一大堆合并后的大文件中找到小文件所对应的大文件。由于我二级索引采用HashMap实现,在二级索引中存储了小文件所对应的大文件信息,因此理论上只要能通过一级索引找到二级索引HashMap中存放的键值对就能从众多合并后的大文件找到与小文件对应的那一个。查了一些资料后最终决定同数据库底层一样采用B+树来作为一级索引的实现(有考虑过使用全文检索技术,但被前辈提点说用这个太重了,还是B+树更轻量一些)。这棵B+树的作用就是仅通过小文件的路径信息就可以找到与之对应的二级哈希索引,所以它的内部节点所存储的信息就是小文件原本的路径字符串,由于java的String类覆写了Comparable接口的compareTo方法,因此实现B+树查找时各个节点的比较方法可以直接使用这个方法,不用特意地去求字符串的哈希值再存储到结点以作比较。然后理论上通过B+树的索引,查找到叶子节点时,通过一次磁盘io即可拿到这个小文件对应的哈希索引(哈希索引我是将它序列化后存到了本地,每一个大文件对应一个HashMap序列化后的文件,通过一级索引找到这个序列化后的文件再反序列化即可拿到小文件具体的索引信息。但是现在我B+树实现的有问题,一个是不够完善,一个是它可能更像一颗B树而不是B+树)。
一级索引可能还有更合理的实现方式,那就是直接将序列化后的HashMap存储到数据库中,由数据库来做这第一级的索引。但由于当时对数据库的知识了解很少,又想着照着别人的样子把B+树用java实现一下,让整个项目都跑在自己的程序里,就没有尝试这种方式。
-
上述内容就是我处理小文件合并以及分离的大概想法,也是项目中已经实现可以跑起来的部分。下面看一下实际的运行效果,从合并到分离一个具体的小文件过程。
| 测试对象 | 合并后大文件大小 | 生成二级索引文件大小 | 生成一级索引文件大小 | 速度 |
|---|---|---|---|---|
| 2000+张小图片,来源于豆瓣的电影封面,爬虫抓取,平均大小20kB左右 | 原本系统打算采用64MB作为一个合并后大文件的大小,但测试时考虑到数据量太少,改成了1MB(项目中更改一个常量值即可) | 每个大文件对应一个二级索引文件,这里测试1MB的大文件生成的索引文件大小不到4kB | 原本打算在系统运行时将这颗B+树直接放到内存里,测试时为了明确当前数据量一颗B+树大概的占用空间,也将其序列化到了本地,大小为182kB | 整个从合并到分离的过程用时应该在微秒级别,没有具体计算,由于测试数据量太少,并且CPU,存储介质的不同都会对这一项有所影响,所以打算完善项目后再具体测试,目前测试的不具参考性,但目前速度应该达到了我的预期 |
目前进展
日期 2021.2.25
-
已实现的功能
- 存:可将本地某一目录下的所有[^ 小文件]合并为多个固定尺寸的[^ 大文件]并存储至某一指定路径
- 查:根据小文件的原路径,可从已经合并的大文件中提取原本的小文件数据并输出到本地某一路径查看
-
未实现功能
- 改 :经过合并后的小文件若是出现更改,需要将大文件对应对的小文件部分数据该问更改后的数据
- 删:删除一个小文件后,需要将合并后大文件中对应的小文件信息进行删除
- 增:有新的小文件生成时需要将其及时进行处理合并到大文件中
note:改操作直接进行会影响到全局的索引问题,因此打算以增操作作为底层实现,每次该操作都会新增小文件到系统,原本已经合并的文件不动,添加相关信息作为老版本标识,然后定期进行一次大规模的删除操作,将最新版本之前的小文件删除,整理全局的索引
-
已解决的问题
-
iNode不够用:大量小文件合并为一个大文件可以将原本需要使用大量iNode的情况变为只使用一个iNode即可
-
磁盘碎片:大量小文件会产生很多小的内部碎片,合并为固定大小的大文件后既可以尽可能的避免外部碎片并且每个大文件只产生一个很小的内部碎片
-
查操作的速度:二级索引采用哈希索引可以最快获取小文件在大文件中的位置等相关信息,最大程度使查操作速度变快
-
-
现存的问题及预想方案
问题描述 预想方案 一级索引B+树实现的有问题 在查找一项数据时,只将必要的结点读出,而不是将整棵树放到内存中,进行尽可能少次数的磁盘IO 合并操作的实现方式问题 将现在的将某一目录下的全部文件合并,改为每有一个新的小文件生成就将其进行合并,动态地处理小文件的合并操作,而不是一次合并写死 小文件数量巨大时,存放索引栈溢出问题 暂无 -
代码阅读逻辑
从src/test/java中的StaticFileTest.java往深层debug即可了解编写逻辑
note:AtomFileGenerator.java可用于生成大量小文件用于测试
后记
能力有限,还得不断努力啊,学习了解的知识越多,才发现以前的一些想法和实现多么稚嫩。没有用多么巧妙的设计模式,也没有用多么高明的算法,但就是做的这么一点点东西,也会让自己有些成就感,还是希望能把这个小项目完善下去吧,目标在github拿10颗星哈哈。😃

浙公网安备 33010602011771号