图数据库学习

1,845 阅读9分钟

0x00. 前言

简单介绍图的发展史, 解释以下几个名词:

  1. 什么是图, 什么是知识图谱
  • 图的定义:图是由一组顶点和一组能够将两个顶点相连的边组成的:无向图、有向图、加权图、加权有向图。对于有向图而言,我们分别定义入度和出度,顶点的入度表示有多少条边指向这个节点,顶点的出度表示有多少条边以这个节点为起点指向其他节点。
  • 知识图谱是由一些相互连接的实体和他们的属性构成的。知识图谱是由一条条知识组成,每条知识表示为一个SPO三元组(Subject-Predicate-Object)。
  1. 图的表示方式 (两种)
    • 学术上常见的RDF表示 (源自1999年W3C提出)

      • 资源描述框架RDF(Resource Description Frame)是全球资讯网协会W3C提出的。其主要目的在于描述关于资源的元数据,即关于数据的数据,从而能够是数据本身的信息得以存储,并能够被机器理解和处理。RDF的基础是三元组(triple)
    • 工业常见的属性图表示 (源自? 大概2010年提出)

      • 属性图:顶点和边都具有属性。
  2. 图的存储方式, 它和图数据库的关系
    • DB中(表)
      • 对于非原生图数据库而言,底层存储使用非图模型进行存储,在存储之上封装图的语义,其优点是易于开发,适合产品众多的大型 公司,形成相互配合的产品栈,例如 Titan、JanusGraph底层采用KV存储非图模型。这意味着它们将图数据存储在磁盘上的关系模型,RDF三元组或键值对中。
    • 磁盘上
      • 对于非原生图而言,实现将图数据存储到后端,支持的后端包括:Memory、Cassandra、ScyllaDB、RocksDB、HBase及MySQL,遵从存储后端存储规则,比如B+Tree(关系型) 或 LSM Tree (NoSQL)

0x01. 图的数据结构

A. 整体分类

不管存储层是否为原生图, 在内存中我们都需要重点关注的是图的数据结构, 基本基于传统DS书中的设计分为以下两类:

  • 数组
    1. 邻接矩阵 (二维数组)
    2. 三元组 (一维数组 or 表)
    3. 邻接表 (数组+链表 / 数组+数组)
    4. 逆邻接表 (同上)
    5. 边集数组 (一维数组 * 2)
  • 链表
    1. 十字链表
    2. 邻接多重表 (数组 + 十字链表,仅适用无向图)

关键是弄清楚优缺点, 和适用场景, 生产环境只关心有向图 (仅适用于无向图的方式不细究)

B. 具体分析

1. RDF图 (邻接矩阵 or 三元组)

传统的图表示采用RDF的方式, 并且实际存储也是采用了三元组的形式, G = (V, E, P) (点-边-属性) 三元组其实并不是凭空诞生, 它的前生其实就是邻接矩阵, 或者说是改良/压缩版的邻接矩阵.

回想一下, 邻接矩阵最大的缺点就是存了很多不存在的0(边), 以至空间占用高达S(N^2^) 且大量浪费, 原因在于它把任意两点的可达性都存了一遍, 那我们换个思路, 只存储存在的边, 不存储不可达的边,是不是就能大幅节省空间呢? 如下图对比:

最简单的三元组就是, 只存储{v1, v2, edgeValue}这样的结构体, 然后N行就代表N个关系:

// 然后把Triple放入数组, 就构成了一个图
type Triple struct {
  fromVid int
  toVid int
  edgeValue Edge
}

这样和存储在传统Mysql中的"表"的结构是类似的, 看一下如下场景:

  1. 如果我要判断v1和v2之间是否直连, 把v1,v2设为联合主键, 那么只需要判断"v1 + v2"是否存在即可.
  2. 查找v1的所有邻居(出边), 通过前缀匹配v1之后, 查找它所有的后缀即可.

2. 属性图

属性图本质也是在三元组的基础上再进行了优化/压缩, 并让它更符合"图"的原生数据结构定义, 不用再使用拗口的"主-谓-宾"的概念, 就是单纯的G = (V, E)

3. 邻接表

  1. 邻接表(数组+链表)
  • 优点:空间效率高;容易寻找顶点的出或者入边。

  • 缺点:

    • 从结构上上:可表示出边或入边的一种,无法全部体现入和出,一般需要维护另外一张逆邻接表来体现反向关系,但是这样又增加了存储空间。
    • 从存储上:在DB中,为了实现这种结构,需要将所有的点边和属性存储在一张表中,类似 v1 {v2:0.2,v3:0.9,v5:0.7} ,v1连接了顶点v2,v3,v5.,每次查询顶点的边时需要将其全部从内存取出,进行字符处理才得到需要的边。实际上,Janusgraph就是采用这种方式进行存储。

    typedef struct tableHead {
        char data;//顶点的数据域
        struct tableBody *firstarc;//指向邻接点的指针
    } tableHead, *tableHeadArr;//存储各链表头结点的数组
    /**图-邻接表定义*/
    typedef struct {
        tableHead vertices[20];//图中顶点及各邻接点数组
        int vexnum, arcnum;//记录图中顶点数和边或弧数
    } LJBGraph;
    
  1. 邻接表(数组)
  • 优点:能够很好的表示入边和出边,hugegraph使用的正是这种结构。
  • 缺点:其实是通过数据冗余,来实现快速查询入边出边。比如对于节点v1,需要存储每条出边,v1-v2,v1-v3,v1-v5;还要存储入边v1-v4,所以每条边存储了两次。使用空间换时间。

4. 边集数组

  • 优点:适合对边进行依次处理,类似三元组的结构。

  • 缺点:每条记录只有一条边的起点终点,在边集数组中要查找一个顶点的度需要扫描整个边数组,效率并不高,适合对边处理的操作,不适合对顶点操作。

    typedef struct
    {
        int head,tail;
        edgetype data;
    }data;
    edge edgearray[max];
    

5. 十字链表

  • 十字链表:十字链表可更好的支持有向图,可看成邻阶表和逆邻阶表的结合,同时记录入度和出度。第一列是节点指向入边出边,同行都是从节点出发的边指针,比如,对于节点v1,它的出边两条v1-v0,v1-v2。下标存储为tailvex,headvex,代表一条边。v0的入边(firstin)v1-v0,v2-v0,是由终点相同的headlink指针记录,图中线路1和2记录此过程,边1-0是点v0的入边,且边v1-v0记录了下一个v0的入边v2-v0,指针保存在headlink中,同理,出边都保存在taillink中。

  • 优点:邻接表对于某一点遍历它能够到达的所有点的边比较方便与快;但是遍历某一些到他的边就不太方便,所以就有了十字链表,可同时查询入边出边。

  • 缺点:但是其结构复杂度高

    typedef struct arcBox{
      int tailVex, headVex;
      struct arcBox *hLink, *tLink;
      infoType *info;
    }arcBox;//弧结点
    
    typedef struct vexNode{
      vertexType data;
      arcBox *firstIn, *firstOut;
    }vexNode;//顶点节点
    
    typedef struct{
      vexNode xList[MAX_VERTEX_NUM];
      int vexNum, arcNum;
    }OLGraph;//十字链
    
    

C. 时空复杂度对比

这里需要有个表格, 对比几种常用的图数据结构的空间代价, 以及新增/查询点和边的速度. 时间复杂度:(n是顶点数, m为边) --> 换为V,E

存储模型时间复杂度空间复杂度查找/增加 点/边时间(有向)出入度查询
边集数组O(n+m)S(m)O(n)
邻接表(逆)O(m+n)S(m+n)O(1)出或入一种
邻接表(顺序表)O(m+n)S(2m)O(1)
十字链表O(m+n)S(m+n)O(1)
邻接矩阵O(n^2^)S(n^2^)O(1)
三元组O(?)S(?)O(?)

0x02. 图的实际存储

这里侧重说的是存储在K-V型DB后端中, 类似Neo4j的分布式存储目前没看到开源实现, 暂时略过,以hugegraph为例:

1.1 基本概念

1. 图(Graph):指关系图。比如:同学及朋友关系图、银行转账图等。
2. 顶点(Vertex):一般指实体。比如:人、账户等。
3. 边(Edge):一般指顶点之间的关系。比如:朋友关系、转账动作等。
4. 属性(Property):顶点或边可以包含属性,比如:人的姓名、人的年龄、转账的时间等。

1.2 元数据

hugegraph中定义了四种基本的元数据以及本表中可选值:

  1. PropertyKey:属性的类型表Property_keys:name|age|city|weight|lang|date|price.
  2. VertexLabel:顶点的类型表Vertex_labels: person|software
  3. EdgeLabel:边的类型表 Edge_labels: knows|created
  4. IndexLabel:索引的类型 personByAge|personByCity|personByAgeandCity|..

1.3 数据存储

A. Studio:

g.addV('person').property('name','marko').next()

B. HTTP-Rest

  1. 添加顶点

    向空图中添加两个顶点:
    POST Content-Type:application/json http://localhost:8080/graphs/xgraph/graph/vertices
     {
        "label": "person",
        "properties": {
            "name": "josh",
            "age": 29
        }
    }
    POST Content-Type:application/json http://localhost:8080/graphs/xgraph/graph/vertices
     {
        "label": "person",
        "properties": {
            "name": "marko",
            "age": 29
        }
    }
    向图中加入一条边:
    POST Content-Type:application/json http://localhost:8080/graphs/xgraph/graph/edges
    {
        "label": "knows",
        "outV": "1:marko",
        "inV": "1:josh",
        "outVLabel": "person",
        "inVLabel": "person",
        "properties": {
            "date": "2017-5-18",
            "weight": 0.2
        }
    }
    
  2. 创建好两点一条边后,在studio中查看创建的图:

    ![image-20200713180918470.png](/image-20200713180918470.png)
    
  3. mysql存储:

    1. 顶点表:当添加点入表graph_vertices(当插入相同节点或者边,之前数据将被覆盖):

    2. 入边表,出边表:添加一条边用以连接两个点时,入度点-出度点,出度点-入度点都存储了一遍。每加一条边,两个表数据相当于是反向对称存储的,利用存储的冗余来加快点的入度出度的查询速度,是典型的以空间换时间。且主键索引方便加快查找节点所有的入边或出边,hugegraph存储中使用的邻接表顺序表模式和此相呼应:

    1. 索引表:xgraph加额外的数据表来存储索引数据,区别于数据库索引,实质是一张数据表,也叫二级索引。并根据不同的顺序来进行优先索引。以下两张表一二列是置换的, filed_values - index_label_id - element_ids , 主键是三者的联合
    • 元数据表比如属性表类型表,主键是自增id,元数据相对来说不会经常变动,且数据量小,但对于存放边信息以及索引的表来说,新增边操作非常频繁,对于分布式图来说,很难保证id是自增的,因此使用联合主键来保证边的唯一。

    • 一级索引的本质是 --> <id, row> 二级索引的本质是 --> <property, id>