图数据库的内部结构 (NEO4j)
What “Graph First” Means for Native Graph Technology
Neo4j是一个具有原生处理(native processing)功能和原生图存储(native graph storage)的图数据库
1.原生图处理
原生图处理:存在免索引邻接属性,因此她提供快速高效的图遍历
解读:
使用免索引邻接的数据库引擎中的每个节点都会维护其对相邻节点的引用。因此每个节点都表现为其附近节点的微索引,这比使用全局索引代价小很多。这意味着查询时间与图的整体规模无关,它仅和所搜索图的数量成正比。
相反,一个非原生图数据库引擎使用(全局)索引连接各个节点。这些索引对每个遍历都添加一个间接层,因此会导致更大的计算成本。原生图处理的拥护者认为免索引邻接至关重要,因为它提供快速、高效的图遍历。
索引查找在小型网络中可以工作,但对于大图的查询代价太高。具有原生图处理能力的图数据库在查询是不是使用索引查找来扮演联系的角色,而是使用免索引邻接来确保高性能遍历的。
非原生图处理引擎使用索引进行节点间遍历
上图中要寻找Alice的朋友,我们必须要首先执行索引查找,成本为O(log n ) ,这对于偶尔或者浅层的查找来说是可以接受的,但当我们改变遍历的方向时,他的代价就变得非常昂贵起来,如果相对于寻找alice的朋友,就必须要执行多个索引来完成查找,每个节点所代表的人都有可能把Alice当作他的朋友,这使得成本很高,找到Alice的朋友代价是O(log n ) ,而找到和Alice交朋友的人的代价则是O(mlogn)。
索引查找在小型网络中还可以,但是在大图中的查询代价太高,具有原生图处理能力的图数据库在查询时不是使用索引查找的,而是使用免索引零连接来确保高性能的遍历的,下图为Neo4j使用关系而非索引实现快速遍历
在通用图数据库中,可以以极小的代价双向(从尾部到头部或者从头部到尾部)遍历关系,上图中寻找ALICE的朋友,直接向外寻找friend就可以。其遍历的成本为O(1),要寻和Alice交朋友的人,我们只需要所有指向ALICE的friend关系联系在一起即可,这样的成本是O(1).
###2.原生图存储
native graph storage
免索引邻接(index-free adjacency) 是图数据库相比于传统的 mysql 的优势的核心 key,那么图数据库用什么结构去存储 index-free adjacency 是关键设计点。
架构上生层是对外访问的 api,右边是事务管理,左边有 cache 等,下面我们看下 disk 上存储的结构:
neo4j 在磁盘上会分不同的 store file 存储
neostore.nodestore.db:存储 node
neostore.propertystore.db:存储属性
neostore.relationshipstore.db:存储关系
一个重要的设计点是 store 中存储的 record 都是固定大小的,固定大小带来的好处是:因为每个 record 的大小固定,因此给定 id
就能快速进行定位。
具体结构是:
节点于关系的存储文件的物理结构图
上图第一个是 node record 的结构:
1byte:in-use flag,表明该 node 是否在使用
4byte:第一个 relation id
4byte:第一个 property id
5byte:label 信息(可能直接 inline 存储)
1byte:reversed
图中的节点和联系的存储文件都是固定大小的,每个记录长度为9字节,因此可以可以在O(1)的时间复杂度下计算位置.
解释1:
节点(指向联系和属性的单向链表,neostore.nodestore.db):第一个字节,表示是否被使用的标志位,后面4个字节,代表关联到这个节点的第一个关系的ID,再接着的4个字符,代表第一个属性ID,后面紧接着的5个字符是代表当前节点的标签,指向该节点的标签存储,最后一个字符作为保留位.
联系(双向链表,neostore.relationshipstore.db):第一个字节,表示是否被使用的标志位,后面4个字节,代表起始节点的ID,再接着的4个字符,代表结束个节点的ID,然后是关系类型占用5个字节,然后依次接着是起始节点的上下联系和结束节点的上下节点,以及一个指示当前记录是否位于联系链的最前面.
同时还有属性存储(neostore.propertystore.db)也是固定大小,每个属性记录包括4个属性块(一个属性记录最多容纳4个属性)和指向属性链中下一个属性的ID.
属性记录包括属性类型和指向属性索引文件的指针(neostore.propertysotre.db.index).
同时属性记录中可以内联和动态存储,在属性值存储占用小时,会直接存储在属性记录中,对于大属性值,可以分别存储在动态字符存储(neostore.propertysotre.db.strings)和动态数组存储(neostore.propertysotre.db.arrays)中,由于动态记录同样由记录大小固定的记录链表组成,因此大字符串和大数组会占据多个动态记录.
解释2:
节点存储文件用来存储节点的记录。每个用户级的图中创建的节点最终会终结于节点存储,其物理文件是"neostore.nodestore.db"。像大多数Neo4j存储文件一样,节点存储区是固定大小的记录存储,每个记录长度为9字节。通过大小固定的记录可以快速查询存储文件中的节点。
一个节点记录的第一个字节是“是否在使用”标志位。它告诉数据库该记录目前是被用于存储节点,还是可回收用于表示一个新的节点。接下来的4字节表示关联到该节点的第一个联系,随后4字节表示该节点的第一个属性的ID。标签的5字节指向该节点的标签存储(如果标签很少的话也可以内联到节点中)。最后的字节extra是标志保留位。这样一个标志是用来标识紧密连接节点的,而省下的空间为将来预留。节点记录是相当轻量级的:它真的只是几个指向联系和属性列表的指针。
相应的,联系被存储于联系存储文件中,物理文件是neostore.relationshipstore.db。像节点存储一样,联系存储区的记录的大小也是固定的。每个联系记录包含联系的起始点ID和结束节点ID、联系类型的指针(存储在联系类型存储区),起始节点和结束节点的上一个联系和下一个联系,以及一个指示当前记录是否位于联系链最前面。
下面是 relation record 的结构:
刚开始是开始和结束节点的 node id,接着是 relation type pointer,然后开始和结束节点的前驱和后继 relation id
更形象一点的图
一个可能的搜索过程是:对于给定的一个 node record,可以通过 id 进行简单的偏移计算得到 node,然后通过 relation_id 定位到 relation record,然后得到 end node id,通过偏移计算得到 node
两个节点记录都包含一个指向该节点的第一个属性的指针和联系链中第一个联系的指针。要读取节点的属性,我们从指向第一个属性的指针开始遍历单向链表结构。要找到一个节点的联系,我们从指向第一个联系(在示例中为LIKES联系)的节点联系指针开始,顺着特定节点的联系的双向链表寻找(即起始节点的双向链表或结束节点的双向链表),直到找到感兴趣的联系。一旦找到了我们想要的联系记录,我们可以使用和寻找节点属性一样的单向链表结构读取这种联系的属性(如果有的话),也可以使用联系关联的起始节点ID和结束节点ID检查它们的节点记录。用这些ID乘以节点记录的大小,就可以立即算出每个节点在节点存储文件中的偏移量。
联系存储文件中的双向链表:
双向存储
还有一个问题:图中节点的关系是有方向的,怎么记录这种方向呢?如果方向是双向的,我们难道要存储两个 relation 吗?
看例子:
这种 partner 的关系天然就是双向的,但是我们存储的时候,难道要存储两个关系吗,如下图:
那肯定是不需要的,这种存储就是一种浪费,那到底 neo4j 中是怎么存储 partner 这种双向关系的呢?
答案是:以任意一个节点为开端,另一个为尾端,即存储成为单向的关系
在 neo4j 中任意的关系都有一个 start node 和一个 end node,而且 start node 和 end node 都会有个关联的双向链表,这个双向链表中就记录了从该节点出去和进入的所有关系,一个实例是:
图片来源:neo4j 底层存储结构分析
上图中 B 节点的 prev 和 next 我们就能看到在这个链表中,B 有时候是 start node 有时候是 end node。