熵简技术谈 | 面向一体化大数据平台的文件系统架构设计

662 阅读16分钟

作者信息:熵简科技 Airworks 团队,团队致力于打造高性能、低代码的一体化大数据分析平台,为机构组织的数据团队及各业务部门人员提供“数据智能全链路”平台及解决方案,涵盖数据清洗、数据融合、数据建模、数据可视化、数据服务等多个维度,全面助力客户实现业务数智化。

导读:在熵简科技的资管数据中台解决方案中,文件系统作为整套架构的存储基础,提供了对于数据库、数据表等资源的操作和管理,保证了平台对于各个资源灵活、高效的调用,为数据治理、资源安全、数仓搭建、数据模型管理等上层应用提供了底层支撑。

1、文件系统需求描述

在长期的实践中,依托于 Airworks 平台,熵简科技已经探索起一套可靠、高性能的文件系统。本文从文件系统的需求、技术方案对比以及文件系统的架构设计三个方面入手,详细介绍文件系统在熵简数据智能平台中的方案和探索。数据中台是资管机构数据智能解决方案中核心的一环。资管机构内部的数据源和数据量往往是异构且巨大的,数据中台的引入可以打通企业内的各部门,各系统的数据资源,建立起数据标准体系和数据指标系统;整合数据类服务,统一对外数据服务接口,提供企业级数据资产管理,能通过元数据的管理实现数据全链路追踪;提供可视化、拖拽式自助开发与分析平台,统一数据开发流程与项目周期管理。

文件系统作为资管数据中台的基础,完成对各数据表、数据库、数据查询脚本、ETL工作流、数据模型、接口服务等各类资源的管理;实现各资源的读取、写入、移动、复制、删除操作;保留各账户的个人空间同时支持企业目录的公共协同空间,保证整个平台对资源使用的高度灵活,各功能模块对资源的调用清晰合理;结合权限系统为系统内资源针对不同账户设置不同的权限。因此,文件系统是包括数据治理,资源安全,数据仓库的搭建,项目、工作流、数据模型等应用的底层基础。文件系统的需求要点包括如下:

要点一:文件系统是一个树型结构,其中文件夹内可以包含子文件,另外不排除非文件夹也包含树型结构的可能;

要点二:需要支持多种文件类型,不同的文件类型可以支持不同的操作;但是都需要支持类似创建、删除之类的共性操作;

要点三:针对不同的文件类型,还可能存在不同的筛选项;在文件列表中,根据不同的文件类型还会有不同的展示字段;

要点四:权限系统与文件系统的写作;

1.1 文件系统构架的整体设计

针对文件系统的需求,主要由几部分工作组成:

第一:文件系统本身的树型结构的数据库设计和增删改查;

第二:文件系统的框架设计,在操作不同文件类型的时候支持每种类型定义不同的行为;

第三:获取文件列表的接口需要在文件系统中写好,并且对不同文件类型支持不同的展示项和筛选项;因此,我们从各种不同的文件类型中将树型结构和通用方法抽象出来,并且在请求上也将请求分为通用外部请求和针对某种特定文件类型的个性化请求,如下图:

2、文件系统-数据库方案

在大多数sql数据库中,没有直接对树型数据结构类型的支持,因此需要一些额外的设计,在《SQL反模式》这本书中的第三章,介绍了常用的几种树形结构的数据库设计方案,其中两种最具代表性:

(1)邻接表方案:给每个树节点添加 parent_id 的外键,指向其父亲;

(2)闭包表方案:每一个祖先-孩子关系保存一张映射表。

另外,书中给出的另一种嵌套集方案难以维护而且效率不高,已经很少有人使用了。对于邻接表方案,还有递归查询和枚举路径两种优化可以选择,因此在邻接表不能满足性能需求的时候,还可以进行持续改进。

2.1邻接表方案

邻接表的存储形式非常的简单直接,即对于每个孩子保存一条指向其父亲的关联,正向可以直接找到它的父亲,逆向可以通过父亲找到所有的孩子,在反模式一书中的示例如下图:

  • 效率陷阱

在这样的设计方式下,增删改都会比较容易,毕竟每个节点只和它的父亲有关联,每次改动只需要修改parent这个字段即可。唯一可能会遇上问题的是查询操作,如果只查询直接孩子也是相对容易的,比较复杂的是查询某个节点对应的所有的子孙,这段代码需要用递归实现,可能会写成下面这样:

def get_sons(self, root):
    return {'name': root.name, 'update_time': root.update_time.timestamp(),
            'sons': [self.get_sons(x) for x in Node.objects.filter(parent=root)]}

这段代码乍一看是没什么问题,也是通过递归的方式拉取所有的文件,但是等文件数量上升之后就会发现耗时大大增加,在我们的系统中,当单个用户的文件数量达到四五百的数量级时,上面这段代码的运行耗时就已经达到500ms。耗时激增主要原因是:这里使用了深度优先搜索编写查询代码,导致请求数据库的次数正比于树形数据结构的节点数目,当数据增长之后,请求数据库的次数激增,可以通过单元测试来复现这个现象。

class TreeTest(TestCase):

    def setUp(self) -> None:
        super(TreeTest, self).setUp()
        self.root_node = Node.objects.create(name='root')
        for i in range(3):
            parent = self.root_node
            for j in range(3):
                parent = Node.objects.create(name='sons_{}_{}'.format(i, j), parent=parent)

    @override_settings(DEBUG=True)
    def test_scan(self):
        self.get_sons(self.root_node)
        for item in connection.queries:
            print(item)

例如这样一个测试用例,我们首先创建了一个根节点,其下面有3层结构,每层均有3个节点,然后使用get_sons函数获取整棵树的,最后通过框架自带的组件打印出所有的数据库查询语句,运行之后我们可以看到一共有10次select查询(包含根节点),这正好等于节点数量,假设需要查询的文件树有四五百个节点的话,就会请求四五百次sql数据库,这个耗时就非常严重了。

  • 改进方案

上述问题的改进方案就是减少对数据库的查询次数,也就是把深度优先搜索改成宽度优先搜索,这会将查询sql的次数从正比于文件节点数量改造成正比于树的深度,由于文件都是用户创建的,能有个10-20层的目录深度已经接近极限了,这样就可以符合性能要求了。

采用上述同样的测试用例可以发现,这一次测试用例只请求了4次数据库,和树的深度一致,大大减少了数据库请求的时间浪费。

2.2 闭包表

闭包表在数据结构上需要新增一张关联表,每一对拥有祖先-子孙关系的节点都需要在表中有一条记录,另外最好可以顺带记录两节点的距离(自己和自己默认为0)方便查询,在sql反模式书中的解释图如下:

从图中可以看到:闭包表其实就是在所有有祖先-子孙关系的节点中,加入了关联关系,没有共同祖先的节点,就没有关联关系。在邻接表中,我们如果查询以某个节点为根的整颗树,都需要使用递归来查找,但是在闭包表中,这个操作就变得非常简单——只需要找到与该节点有关联的节点即可,可以用一次数据库请求实现,相应的,插入、删除和移动的复杂度就变高了。在闭包表中进行增删改查的时候,就不能像原来的邻接表那么简单,需要增加一些额外的步骤:

(1) 增加一个节点:需要批量添加它和它祖先的关联关系;

(2) 删除一个节点:需要删除它和它所有的子孙节点,在Django框架中,关联关系可以通过外键限制自动删除;

(3) 移动一个节点:需要小心地处理它的关联关系,将它和它所有的子孙视为一个集合,删除所有集合和外部的关联,并重建它们和新祖先的所有关联

需要注意的是在删除节点的时候不能仅删除关联关系,必须删除节点本身,否则节点就会成为游离节点,永远无法通过关联关系找到,就必须写额外的清理代码来处理它们了。

  • 代码设计

在写闭包表代码的时候,可以使用Django建议的设计风格,将这部分复杂的操作封装到model层中去,这样外部操作的时候就可以忽略掉NodeRelation的影响,另外外部的代码都不直接操作NodeRelation,这样可以保障数据只在一处代码修改,便于扩展和查错。话不多说,直接上代码吧,其中的创建、删除相对比较简单,如下:

class NodeManager(models.Manager):

    def create_with_relation(self, parent_id=None, **kwargs):
        node = self.create(**kwargs)
        relations = [] if parent_id is None else [NodeRelation(
            ancestor_id=x.ancestor_id, descendant=node, level=x.level + 1
        ) for x in NodeRelation.objects.filter(descendant=parent_id)]
        relations.append(NodeRelation(ancestor=node, descendant=node, level=0))
        NodeRelation.objects.bulk_create(relations)
        return node

    def delete_with_relation(self, node_id):
        node_ids = [x['descendant_id'] for x in NodeRelation.objects.filter(
            ancestor_id=node_id).values('descendant_id')]
        self.filter(id__in=node_ids).delete()

获取某个根节点的整棵树需要由两次请求实现,先获取所有的子孙节点,再获取它们之间所有距离为1的关联,根据关联信息构建树形结构信息:

class NodeManager(models.Manager):

    def get_tree(self, node_id):
        node_dict = defaultdict(lambda: {'name': '', 'sons': []})
        for node in Node.objects.filter(descendant__ancestor_id=node_id):
            node_dict[node.id]['name'] = node.name
        for relation in NodeRelation.objects.filter(level=1, descendant_id__in=node_dict.keys()):
            node_dict[relation.ancestor_id]['sons'].append(node_dict[relation.descendant_id])
        return node_dict[node_id]

最复杂的部分是移动操作,移动的时候需要先删除原有的关联,再加入新关联。这里创建新关联的时候选择将数据载入到内存里进行操作,另一个选择是直接使用sql语句操作,只是这样的sql语句会非常复杂,复杂度很高,可读性很差,我们不建议这么做。

  • 效率陷阱

闭包表看似是针对树型结构存储更好的解决方案,它在获取任意节点为根的整棵树、获取节点的所有祖先都有着更好的性能。

但是,闭包表也有两个比较明显的问题:

在空间复杂度上,需要的空间其实是 n^2 复杂度的,但是这仅针对极限情况,在日常使用下规模会小很多,但是使用的额外空间也比邻接表大多了。

另外一个比较致命的问题是在移动节点的时候,这里会有一个笛卡尔积的操作,如果移动一个深度为 d 且节点数量为 n 的子树的话,会导致一个 d*n 复杂度的操作。如果移动一个有几万个子文件的目录,将它移动到一个5层深度的目录下,就会涉及几十万条关联数据的删除和新增,如果这种操作频繁进行,那么数据库肯定不堪重负。

2.3 邻接表优化

如果项目里面已经使用了邻接表,或者说不能接受闭包表的代价,那么有什么更好的办法呢?答案是肯定的,之前介绍过的两种方案就可以作为邻接表的改进和优化。

  • 递归查询

这里的递归查询不是指代码内部的递归查询,而是特指在某些数据库中支持的递归查询操作。对于Oracle数据库是有相关的操作支持的,不过连淘系都放弃Oracle了,可见Oracle也不是万能药。在Mysql这边,从Mysql8开始支持with子句用于递归查询。

有了递归查询之后,原本的查询就变得简单起来了,只要将代码中的递归改写成sql语句就可以了,可以参考:

[Stackoverflow:mysql-recursive-query](stackoverflow.com/questions/2…)。

但是如果mysql版本是5.6或者5.7该怎么办呢?其实也 可以有解决方案的,一种是对文件系统进行深度限制,这样的话可以直接通过有限的join操作实现查询,不过这样实在是太不优雅了,并且join那么多,效率也不行;另外一种方案在上面Stackoverflow中也提到了,直接使用sql的CTE公式去做,但是这种骚操作的效果也不好,节点数量一多效率就急剧下降,主要原因是其中是否在集合内这个判断效率非常低下。

总的来说,想要使用递归查询的话,数据库的支持是一个必要条件,否则可能还不如代码实现的宽度优先搜索效率高。也因此在实际的项目中,考虑到数据库支持的问题,最早采用的就是宽度优先搜索来处理文件查找问题的。

  • 路径枚举

这种优化的方式其实很简单也很好理解,就是在每个节点增加一个字段,记录当前节点对应的路径信息,类似于文件路径。这样在搜索以某个节点为根的树的时候,可以利用sql字符串索引的特性,直接使用Like子句搜索前缀就可以,比如当前节点的路径是:1/2/3,那么找到文件路径以1/2/3开头的即可拉出所有节点。

这种操作的方式其实和闭包表方案有一些类似,即在移动的时候需要修改所有相关节点的路径信息,这里会有一个与节点数量成正比的复杂度的更新操作,但是在插入和删除的时候,都不需要更新路径信息。

作为邻接表的升级,枚举路径其实是用移动操作的时间换取了拉取树型节点的时间,建议作为递归查询方案无法实施之后的备选。另外闭包表其实也可以和邻接表共存,因此枚举路径也需要和闭包表进行对比,最后根据项目的实际情况进行选择。

3、文件系统构架设计

这里的框架设计主要目的是将文件系统相关的操作和每种文件类型自定义的操作分开,并且可以方便地增加文件类型而不用修改文件系统的相关代码,即增加文件系统的易用性和可扩展性。

3.1 使用回调切分模块

根据前面的需求分析,文件系统相关的操作都有可能影响具体文件的内容,整体流程大概会是下面这个顺序(以创建文件为例):

在上图的流程中,请求首先进入文件系统,文件系统会创建文件,并且维护文件的关联关系,之后将创建好的文件model以及请求的所有参数传递给特定文件类型的模块做处理,具体模块根据这些信息处理完毕之后,将需要展示到文件列表中的数据再返回给文件系统,由文件系统更新到数据库内,最后返回给客户端,形成一个闭环。

3.2 自动注册回调函数

为了方便扩展文件类型,创建一个回调函数的虚基类,由每个类型模块自己定义,并且注册到文件系统中,这样文件系统就可以根据客户端操作的文件类型,调用对应的回调函数。

为了让回调函数可以自动注册,定义的回调函数基类使用了python元类的特性,从而当有类型继承该基类时,系统可以自动将其注册到回调函数仓库中,下面是元类代码:

class CallbackMeta(ABCMeta):

    def __new__(mcs, what, bases=None, attrs=None):

        new_class = super().__new__(mcs, what, bases, attrs)
        if what != "FileCallback":
            file_type = new_class.file_type
            for callback_type in ("create", "update", "delete", "dump", "load"):
                CallbackRepository.register(
                    file_type,
                    callback_type,
                    getattr(new_class, callback_type),
                    getattr(new_class, "batch_{}".format(callback_type)),
                )
 
        return new_class

最后在调用回调函数的时候,就可以直接使用回调仓库进行调用。

4、结语

在设计类似于文件、分类、角色等系统的时候,都可能会遇到树型结构的设计问题,之前的工作中遇到过采用一段九层嵌套if语句的代码。至于使用怎样的设计,还是需要考量实际的需求和使用场景,我们可以分几种情况:

(1)如果说没有拉取树型结构的需求,每次都只需要拉取当前父亲的第一层孩子(许多文件系统的前端是这样的),那么其实邻接表就是最优的,任何操作速度都足够快;

(2)如果涉及了频繁的拉取整棵树的操作,但是又很少出现移动,或者说移动基本都由系统或者后台进行(比如带层级的分类体系),那么闭包表很可能是最好的选择;有的时候可能是原有的邻接表不符合性能要求,需要改造,那么可以根据数据库的情况,选择使用递归查询或者枚举路径,又或者直接改造成闭包表结构。

最后,不管选用什么样的方案,架构都是要跟随需求演进的,如果在早期就可以遇见之后的需求,那么在选择方案的时候就可以留下升级的余地,从而更优雅地进行代码重构。