0x00. 前言
简单介绍图的发展史, 解释以下几个名词:
- 什么是图, 什么是知识图谱
- 图的定义:图是由一组顶点和一组能够将两个顶点相连的边组成的:无向图、有向图、加权图、加权有向图。对于有向图而言,我们分别定义入度和出度,顶点的入度表示有多少条边指向这个节点,顶点的出度表示有多少条边以这个节点为起点指向其他节点。
- 知识图谱是由一些相互连接的实体和他们的属性构成的。知识图谱是由一条条知识组成,每条知识表示为一个SPO三元组(Subject-Predicate-Object)。
- 图的表示方式 (两种)
-
学术上常见的RDF表示 (源自1999年W3C提出)
- 资源描述框架RDF(Resource Description Frame)是全球资讯网协会W3C提出的。其主要目的在于描述关于资源的元数据,即关于数据的数据,从而能够是数据本身的信息得以存储,并能够被机器理解和处理。RDF的基础是三元组(triple)
-
工业常见的属性图表示 (源自? 大概2010年提出)
- 属性图:顶点和边都具有属性。
- 属性图:顶点和边都具有属性。
-
- 图的存储方式, 它和图数据库的关系
- DB中(表)
- 对于非原生图数据库而言,底层存储使用非图模型进行存储,在存储之上封装图的语义,其优点是易于开发,适合产品众多的大型 公司,形成相互配合的产品栈,例如 Titan、JanusGraph底层采用KV存储非图模型。这意味着它们将图数据存储在磁盘上的关系模型,RDF三元组或键值对中。
- 磁盘上
- 对于非原生图而言,实现将图数据存储到后端,支持的后端包括:Memory、Cassandra、ScyllaDB、RocksDB、HBase及MySQL,遵从存储后端存储规则,比如B+Tree(关系型) 或 LSM Tree (NoSQL)
- DB中(表)
0x01. 图的数据结构
A. 整体分类
不管存储层是否为原生图, 在内存中我们都需要重点关注的是图的数据结构, 基本基于传统DS书中的设计分为以下两类:
- 数组
- 邻接矩阵 (二维数组)
- 三元组 (一维数组 or 表)
- 邻接表 (数组+链表 / 数组+数组)
- 逆邻接表 (同上)
- 边集数组 (一维数组 * 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中的"表"的结构是类似的, 看一下如下场景:
- 如果我要判断v1和v2之间是否直连, 把v1,v2设为联合主键, 那么只需要判断
"v1 + v2"是否存在即可. - 查找v1的所有邻居(出边), 通过前缀匹配v1之后, 查找它所有的后缀即可.
2. 属性图
属性图本质也是在三元组的基础上再进行了优化/压缩, 并让它更符合"图"的原生数据结构定义, 不用再使用拗口的"主-谓-宾"的概念, 就是单纯的G = (V, E)
3. 邻接表
- 邻接表(数组+链表)
-
优点:空间效率高;容易寻找顶点的出或者入边。
-
缺点:
- 从结构上上:可表示出边或入边的一种,无法全部体现入和出,一般需要维护另外一张逆邻接表来体现反向关系,但是这样又增加了存储空间。
- 从存储上:在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;
- 邻接表(数组)
- 优点:能够很好的表示入边和出边,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中定义了四种基本的元数据以及本表中可选值:
PropertyKey:属性的类型表Property_keys:name|age|city|weight|lang|date|price.VertexLabel:顶点的类型表Vertex_labels: person|softwareEdgeLabel:边的类型表 Edge_labels: knows|createdIndexLabel:索引的类型 personByAge|personByCity|personByAgeandCity|..
1.3 数据存储
A. Studio:
g.addV('person').property('name','marko').next()
B. HTTP-Rest
-
添加顶点
向空图中添加两个顶点: 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 } } -
创建好两点一条边后,在studio中查看创建的图:
 -
mysql存储:
-
顶点表:当添加点入表graph_vertices(当插入相同节点或者边,之前数据将被覆盖):
-
入边表,出边表:添加一条边用以连接两个点时,入度点-出度点,出度点-入度点都存储了一遍。每加一条边,两个表数据相当于是反向对称存储的,利用存储的冗余来加快点的入度出度的查询速度,是典型的以空间换时间。且主键索引方便加快查找节点所有的入边或出边,hugegraph存储中使用的邻接表顺序表模式和此相呼应:
- 索引表:xgraph加额外的数据表来存储索引数据,区别于数据库索引,实质是一张数据表,也叫二级索引。并根据不同的顺序来进行优先索引。以下两张表一二列是置换的,
filed_values-index_label_id-element_ids, 主键是三者的联合
-
元数据表比如属性表类型表,主键是自增id,元数据相对来说不会经常变动,且数据量小,但对于存放边信息以及索引的表来说,新增边操作非常频繁,对于分布式图来说,很难保证id是自增的,因此使用联合主键来保证边的唯一。
-
一级索引的本质是 --> <id, row> 二级索引的本质是 --> <property, id>
-