图数据结构的进化

你发现了吗?从交通路网、电话交换网到社交网络、电网、金融网络……在我们的生活和工作场景中,用“图”来诠释,远比其他形式更为直观、易用,表达更为立体、充分。

图片

图:路网交通(来源网络)

图片

电话交换网络(来源网络)

图片

社交网络(来源网络)

 

你知道吗?如今许多赫赫有名的国际巨头公司,其“起家”的秘密即源于图技术,比如,facebook、Google 、推特、领英、贝宝……

·facebook
很多人听过大名鼎鼎的“六度分隔理论”——任意两个人之间,只要通过5或6个人就可以建立联系。脸书的技术框架核心就是它的Social Graph ,即朋友关联朋友再关联……值得一提的是,脸书在发展进程中开源了很多技术,唯独这个核心的图计算引擎与架构从未开源过。
·Google
早期谷歌的核心技术就是一种浅层的并发图计算技术。PageRank是一种大规模页面或链接排序的算法。
·推特
推特相当于美国的微博。值得一提的是,它于2014年在Github上开源了Flock DB ,但很快就下线了。原因很简单,图计算作为公司的商业与技术核心怎可轻易示人?
·领英
作为专业的职场社交网络,最核心的社交特点就是推荐距离你2层至3层的专家,提供这种推荐服务就必须使用到图计算引擎或图数据库。
·高盛集团
值得一提的是,高盛集团在2008年金融危机中能幸免于难,背后的真相就是其应用了图数据库系统—SecDB ,它成功计算并预测到了即将发生的金融危机。

此外,像贝宝、易趣等以技术驱动的新型互联网电子商务公司,图的核心竞争力更有利于助其揭示出数据的内部关联和深度关系。

括而言之,图技术的应用,正在受到各界人士的广泛关注;“图”领域的发展,正在以蓬勃之势逐渐走进广庭大众。

 

图片

该图展示的是一个典型的局部社交网络图,其是在一张大图上进行的实时路径查询所生成的一张子图。绿色节点为初始顶点,紫色节点为终止顶点,两者间有15层间隔,并有100条关联路径,每条路径上有不同类型的边连接着相邻的两两的顶点,其中不同类型(属性)的边以色彩区分不同类型的社交关系。

 

图数据结构包含几种类型的数据?

图片

1. 顶点(vertex, 复数:vertices),也称作、节点(node),顶点可以有多个属性,下面的边也一样。鉴于此,某个类型的顶点的集合可以看作类似于传统数据库中的一张表,而顶点间的基于路径或属性的关联操作则可看作是传统关系型数据库中的表连接(table-join)操作,区别在于图上面的join操作的效率指数级高于SQL;

2. 边(edge),也被称作关系(relationship),一般情况下一条边会连接2个顶点,两个点的排列顺序可以表明边的方向,而无向边通常通过双向边来表达,所以A-B = A←→B = A→B +B→A。而那种特殊类型的可以关联多个(>=3)顶点的边,一般都被拆解为两两顶点相连的多条边来表达;

3. 路径(path),表达的是一组相连的顶点与边的组合,多条路径可以构成一张网络,也称作子图,多张子图的全集合则构成了一张完整的图数据集,我们称之为“全图”。很显然,点和边这两大基础数据类型的排列、组合就可以表达图上面的全部数据模型。

图数据类型如何表达?

图片

 

· 顶点: v, v, w, a, b, c…
· 边: (u, v)
· 路径: (u, v), (v, w), (w, a), (a, j)… …

上面的边的表达形式 (u, v)通常代表有向边,也就是说边的方向是从u指向v,我们也称u为out-node(出点),v为in-node(入点)。

无向图中的无向边通常通过双向有向边来表达,在数据结构中不同的设计方案有不同的表达方式。例如在二维相邻矩阵中如果行、列相交的顶点中行u为出点,列v为入点,那么矩阵中的对应的交点表达的是从行出点u到列入点v的一条有向边,另一条反方向的边则是从行出点v到列入点u的有向边。

下面将要介绍到的相邻哈希的数据结构中,这个表达相对更为简易(u, v, 1) and (v, u, -1)分别表达了→v和←u 两条边。在相邻链表数据结构中则从u出发存在最终指向v的某条链接(链路),反之亦然。

之所以要表达反向边的一个原因是如果不存在从v到u的边,那么在图上(路径)查询的时候,将不会找到从v出发可以直接到达u的任何边,也就意味着图的连通度受到了破坏 ,或者说数据结构的表达没有反映出真实的顶点间的路网连接情况。

 传统意义上,如何表达图的数据结构

图片

· 相邻矩阵,源自英文Adjacency Matrix
· 相邻链表,英文Adjacency List

简而言之,相邻矩阵是一个二维的矩阵,在计算机科学语境下,一个二维数组中的每个元素都代表了图中是否存在着两个顶点之间的一条边。

图片

用相邻链表来表达有向图(单向边)

相邻链表用了一种迥然不同的方式来表达图上的连接关系,如上图所示,左侧的有向(并带权重的边)图用右侧的相邻链表表达,它包含了第一层的“数组”其中每个元素对应图中的一个顶点,第二层则是每个顶点的边所直接关联的顶点构成的链表 。

值得一提的是,右侧的相邻表中只是表达了有向图中的单向边,如果从顶点4出发,只能抵达顶点5,却无从知道顶点3可以抵达顶点4,除非用全图遍历的方式搜索,那样的话效率会相当低下 。当然,解决这一问题的另一种方式是在链表中也插入反向边和顶点,类似于上文中提及的相邻哈希中如何来表达反向边。

看上图,如果有向图用相邻矩阵来表达,每条边需要用矩阵中的一个元素来对应行、列中一个顶点,其中矩阵是6x6的,并且其中只有7个元素(7条边)是被赋值的。很显然,这是一个相当稀疏的矩阵,占满率只有(7/36) <20%,然而它所需要的最小存储空间则为 36字节(每个字节可以表达每条边的权重)。如果是一张有100万顶点的图,其所需的存储空间至少为100GB(1M*1M = 1万亿字节),而这在工业界中只是属于小图的范畴的。

AM012345
0 35   
1  2   
2   1  
3    48
4     6
5      

                                                                                                                                                                                       用相邻矩阵来表达有向图

也许读者会质疑以上相邻矩阵的存储空间的估算被夸大了,那么我们来探讨一下:如果每个矩阵中的元素可以用1个比特位来表达 ,那么100万顶点的全图存储空间可以降低到125GB。然而,我们是假设用1个字节来表达边的权重,如果这个权重的数值范围超过256,我们或许需要2个字节,4个字节甚至8个字节,如果边还有其它多个属性,那么对于存储空间就会有更大的甚至不可想象的需求。

现代的GPU是以善于处理矩阵运算而闻名的,不过通常二维矩阵的大小被限定在小于 32K(32,768)顶点。这是可以理解的,因为32K顶点的内存存储空间已经达到1GB+了,而这已经占到了GPU内存的25%-50%。换句话说,GPU并不适合用作大图上面的运算,除非使用极其复杂的图上的Map-Reduce的方式来对大图进行切割、分片来实现分而治之、串行的或并发的处理方式。但是,你真的觉得这种分片、切图的处理方式的效率会很高吗?

存储低效性或许是相邻矩阵最大的敌人。这也许可以解释为什么在学术界以外,尤其是工业界,很少用到真正意义上的相邻矩阵来对真实世界的问题进行数据建模。尽管它有着O(1)的访问时间复杂度 ,例如通过数组下标去定位任何一条边或顶点所需的时间是恒定的 O(1)。

相比而言,相邻链表对于存储空间的需求要小得多,在工业界中的应用也更为广泛。例如脸书的社交图谱(其底层的技术架构代码为Tao/Dragon)采用的就是相邻链表的方式。链表中每个顶点表示一个人,而每个顶点下的链表表达的是这个人的朋友或关注者。

这种设计方式很容易被理解,但是它可能会遇到热点问题 ,例如如果一个顶点有1万个邻居,那么链表的长度有10,000步,遍历这个链表的时间复杂度用Big-O Notation来表达为O(10,000)。在链表上的增删改查的操作都是一样的复杂度,更准确地说,平均复杂度为O(5,000)。从另一个角度来看,链表的并发能力很糟糕,你无法对于一个链表进行并发(写)操作!

图片

现在,让我们思考一个方法,一种数据结构可以平衡两件事情:

· 存储空间:相对而言可控的、占用更小的存储空间来存放更大量的数据

· 访问速度:低访问延迟,并且对于并发访问友好

在存储维度,我们要尽量避免使用那种稀疏的、利用率低下的数据结构,因为大量的空数据占用了大量的空闲空间,以相邻矩阵为例,它只适合用于那种拓扑结构非常密集的图,例如全联通图(所谓全联通指的是图中任意两个顶点都直接关联)上面提到的6顶点的图,如果全部联通,则至少存在30条有向边=2*6*5/2,如果还存在自己指向自己的边,则存在36条边,那么用相邻矩阵表达的是数据结构是节省存储空间的。然而,实际应用场景中,绝大多数的图都是非常稀疏的(我们用图的密度=(边数/全联通图的边数)*100%来衡量,大多数图的密度远低于5%),因此相邻矩阵就显得很低效了。

相邻链表在存储空间上是大幅节省的,然而链表的设计存在访问延迟大,并发访问不友好等问题,因此突破点应该在于:如何取代链表为中心的数据结构设计方式。


 

相邻哈希

在这里,我们设计并命名了一种新的数据结构:相邻哈希(英文:Adjacency Hash或Adjacency Hash<*>)。

图片

相邻哈希的特点:

· 定位图中任一顶点的时耗恒定为O(1)· 定位图中任意边的延迟为O(2)

以上时耗的复杂度假设可以通过某种哈希函数来实现,最简单的例如通过数组下标访问具体的点、边元素来实现,对于边而言仅需定位out-node+in-node,时耗为O(1+1)。

在C++中,面向以上的特点的数据结构,最简单粗暴的实现方式为,动态向量数组(Array of Vectors):

// Array of vectors Vector <pair<int,int>> a_of_v[n];

动态向量数组可以实现极低的访问延迟,并且有很低的存储空间浪费,但是却并不能解决另外的几个问题:

· 并发访问支持

· 数据删除时的额外代价(例如存储空白空间回填等)

当我们想要O(1)的时间复杂度时,我们内心深处的呐喊是:“ 哈希!”

图片

 

想一想我们的大脑是如何定位任何知识点的?

例如,你想到了“冰激凌”“豆汁”“牛街豆汁”……

你并不是像关系型数据库一样通过树状索引(时间复杂度O(Log N)来定位“冰”,然后定位“激”,最后再定位“凌”的,而是一步到位——直接定位到“冰激凌”或者是“Icecream”然后再翻译一下)——零延迟!

图片

 

为什么呢?

因为在你的大脑中,天然有一张张经过长年累积而成的哈希表!

受篇幅所限,我们在这里并不准备给读者讲解大脑的神经元网络的构造是多么像一张知识图谱,而其上的计算是如何的哈希化。但是,想必你应该对哈希有些概念了。

图片

在工业界中,典型的高性能哈希表的实现有例如谷歌的Sparse Hash库,它实现了一种叫做dense hash map的哈希表。在C++标准11中实现了unordered map,是一种锁链式的哈希表,它通过牺牲一定的存储空间来获取快速寻址性能。

但是以上两种实现的问题是,他们都没有和底层的硬件(CPU内核)并发算力同步的扩张能力,换句话说是一种单线程哈希表实现,任何时刻只有单读或单写进程占据全部的表资源 ,这或许可以算作是对底层资源的一种浪费吧。

在高性能云计算环境下,通过并发计算可以获得更高的系统吞吐率,通常这也意味着底层的数据结构是支持并发的(concurrent data structure),并且能利用多核CPU、每核多线程,并能利用多机(无论是物理上还是逻辑上的)协同并发的针对一个逻辑上的大数据集进行并发处理。

传统的哈希实现几乎都是单线程、单任务的,意味着它们采用的是阻塞式设计,第二个线程或任务如果试图访问同一个资源池,它会被阻塞而等待,以至于无法(实时)完成任务。

从上面的单写单读向前进化,很自然的一个小目标是单写多读,我们称之为single-writer-multiple-reader的并发哈希,它允许多个读线程去访问同一个资源池里的关键区域(critical section)。当然,这种设计中只允许任何时刻最多存在一个写的线程。

单写多读的设计实现中通常会使用一些技术手段,例如:

· Versioning:中文称为版本号记录

· RCU (Read-Copy-Update):中文称为读—拷贝—更新

· Open-Addressing:中文称为开放式寻址

以RCU为例,Linux操作系统的内核中首先使用了这种技术来支持多读。在MemC3/Cuckoo哈希实现中则使用了开放寻址技术。如下图所示:

图片

Cuckoo哈希的键被映射到了2个桶中以及使用了1个版本计数器

沿着上面的思路继续向前迭代,我们当然希望可以实现多读、多写的真正意义上的高并发数据结构。但是,这个愿景似乎与ACID(数据强一致性)的要求相违背—在商用场景中,多个任务或线程在同一时间对同一个数据进行写、读等操作而可能造成的数据不一致而导致的混乱的问题。

下面我们来把以上的挑战和问题细化后逐一解决。

实现可扩展的高并发哈希数据结构需要克服我们在上面提到的几个主要问题:

图片

无阻塞或无锁式设计(Non-blocking and Lock-Free)

· 无阻塞或无锁式设计(Non-blocking and Lock-Free)

· 精细颗粒度的访问控制(Fine-granularity Access Control)

图片

此外,需要强调的是,要突破并实现上面提到的两条,两者都和并发访问控制高度相关,须考量以下要点:

· 核心区域(访问控制):

· 大小:保持足够的小

· 执行时间(占用时间):同上,保持足够小

 

·通用数据访问:

· 避免不必要的访问(Unnecessary)

·避免无意识的访问(Unintentional)

·并发控制:

· 精细颗粒度的锁实现:例如lock-striping(条纹锁)

·推测式上锁机制:例如交易过程中的合并锁机制(Transactional Lock Elision)

对于一个高并发系统而言,什么样的工作机制能实现充分并发?


图片

对于一个高并发系统而言,它通常会至少包含如下三套机制协同工作才能实现充分的并发:

图片

· 并发的基础架构

· 并发的数据结构

· 并发的算法实现

以上三者,在图数据库、图计算与存储引擎系统的设计中更是缺一不可。

并发的基础架构包含有硬件和软件的基础架构,例如英特尔的中央处理器的TSX (Transactional Synchronization Extensions=交易同步扩展)功能是硬件级别的在英特尔64位架构之上的交易型内存支持。在软件层面,应用程序可以把一段代码声明为一笔交易,而在这段代码执行期间的操作为原子操作。像TSX这样的功能可以实现平均达到140%的性能加速。这也是Intel推出的相对于其它X86架构处理器的一种竞争优势。当然这种硬件功能对于代码而言不完全是透明的,它在一定程度上也增加了编程的复杂度和程序的跨平台迁移复杂度。

图片

随机放置 vs 基于BFS的双向集合关联式哈希 

在软件层面,更多的考量是操作系统本身对于高并发的支持,通常我们认为Linux操作系统在内核到库级别对于并发的支持要好于Windows操作系统,尽管这个并不绝对,甚至是很多的底层实现,例如虚拟化、容器等的实现让上层的应用程序对于底层的直接依赖性得以降低。

另一方面,有了并发的数据结构,在代码编程层面,依然需要设计代码逻辑、算法逻辑来充分的利用和释放并发的数据处理能力。特别是对于图数据集合、图数据结构而言,并发对于程序员而言是一种思路的转变!充分利用并发能力,在同样的硬件资源基础上、同样的数据结构基础上、同样的编程语言实现上,能让你获得成百上千倍的性能提升。永远不要忽略并发实现的意义和价值!

图片

Ultipa的基于高并发哈希实现的实时深度图遍历

上图展示了在Ultipa Graph(实时图数据库服务器)之上,通过高并发架构、数据结构以及算法实现的高性能K邻操作的性能。

K邻(英文K-Hop)操作通常是通过BFS(广度优先搜索)的方式实现的。图中的测试数据和结果是在一个常见的用于性能评测的图数据集上(例如Amazon 0601数据集,有340万条边和41万顶点)实现的,从任一顶点出发计算与它的最短距离为K步的邻居的数量(和邻居集合),直至找完图中最深的邻居后并保证没有新邻居发现后返回。

在商用场景中,图的大小通常在百万、千万、亿甚至十亿以上的数量级,而学术界中用于发论文的图数据集的数量级则经常在千、万的数量级,两者之间存在着量变与质变的区别 —— 特别对于算法复杂度和数据结构的并发驾驭能力而言,读者需要对此注意区分和甄别。此外,以Dijkstra’s最短路径算法为例,它的原生算法完全是串行的,在小图当中或许还可以通过对全图进行全量计算来实现,在大图之上则完全不具有可行性!

图片

K-hop Concurrency Algorithm

BFS是相对于DFS(深度优先搜索)或其它图上算法(例如鲁汶社区识别等)而言比较容易实现并发计算的,上图中形象的解析了如何在图中实现BFS算法并发。我们以BFS为例为读者解读如何实现高并发。

图片

K邻并发算法步骤如下:

图片

1. 在图中定位起始顶点(上图中的绿色顶点),计算其直接关联的具有唯一性的邻居数量。如果K=1,直接返回邻居数量;否则,执行下一步。

2. K>=2, 确定参与并发计算的资源量,并根据第一部中返回的邻居数量决定每个并发线程(任务)所需处理的任务量大小,进入第三步。

3. 每个任务进一步以分而治之的方式,计算当前面对的(被分配)顶点的邻居数量,以递归的方式前进,直到满足深度为K或者无新的邻居顶点可以被返回而退出,结束。

基于以上的算法描述,当K邻计算深度为1—2层的时候,内存计算引擎在微妙级内完成计算。从第3跳开始,返回的邻居数量呈现指数级快速上涨(2-Hop邻居~200,3-Hop邻居~8000,4-Hop邻居接近5万)的趋势,这就意味着计算复杂度也等比上涨。但是,通过饱满的并发操作,系统的延时保持在了相对低的水平,并呈现了线性甚至亚线性的增长趋势(而不是指数级增长趋势!),特别是在搜索深度第6层到第17层的区间内,系统时延几乎稳定在~200ms的范围!

图片

注:第17层(17-Hop)返回的邻居数量为0,因为此时全图(联通子图)已经遍历完毕,没有找到任何深度达到17层的顶点邻居,因此返回结果集合大小为0。

如果我们做一个1比1的对标,同样的数据集,在同样的硬件配置的公有云服务器上用Neo4j来做同样的K邻操作,效果如下:

· 1-Hop:~200毫秒,比Ultipa慢了1,000倍!· 从5-Hop开始,几乎无法实时返回(系统内存资源耗尽前未能返回结果)

· K邻的结果默认情况下没有去重。有大量重复邻居顶点在结果集中。

· 随着搜索深度的增加,返回时间和系统消耗呈现指数级(超线性)增长趋势。

· 最大并发为400%(4线程并发),远低于Ultipa的6400%并发规模。

图片

Neo4J’s Performance on Graph Traversal

基于Neo4j的实验,我们只进行到7-Hop后就不得不终止了,因为7跳的时候系统耗时超过10秒钟,从8跳开始Neo4j几乎不可能返回结果。而最大的问题是计算结果并不正确,这种不正确包含两个维度:

· 重复顶点未被去重

· 顶点深度计算错误

K-hop中返回的应该是最短路径条件下的邻居,那么如果第一层的直接邻居中已经被返回的顶点,不可能也不应该出现在第二层或第三层或其它层级的邻居列表中!很明显Neo4j,还有其它一些图数据库(例如腾讯的星图)在K-hop的实现中没有遵循BFS的原则(或者是实现算法错误),也没有实现去重,甚至没有办法返回(任意深度)全部的邻居。

图片

注:在更大的数据集中,例如Twitter的15亿条边、4200万顶点、26GB大小的那个网上常见社交数据集中,K-hop的操作的挑战更加巨大,我们已知的很多图数据库都无法在其上完成深度(>=3)的K-hop查询,例如Neo4j、Janus Graph、Titan、亚马逊的Neptune、Arango DB、百度的Huge Graph等。而在能完成的为数不多的几个图数据库中,Ultipa Graph也指数级快于其它玩家。具体指标参考下图:

图片

图片

 

性能评测对标 Ultipa vs Neo4J vs Tiger Graph

 

如果读者对于本文中提及的Ultipa Graph的实现中所用到的Adjacency Hash<*>数据结构的设计与实现的细节感兴趣,可以在国家知识产权局的数据库中查询Ultipa团队提交的相关专利(专利号2020****5644)或通过邮箱索取:

图片

图片

图中展示的是Ultipa Manager(Ultipa图数据库集成的可视化前端操作界面&图数据库、图谱管理平台)中的对图上实时计算的2D和3D可视化效果。

 

值得一提的是,尽管性能对于图数据库、图计算而言尤为重要,可视化功能亦不可忽略。因为图是高维度的——图更为直观、可解释。相比于传统的关系型数据库的二维表中的行vs列的结构,图中的运算结果的可视化是非常必要的。这也是为什么我们提出了一个观点:白盒化AI、可解释AI需要通过知识图谱+图数据库来实现,前者本质上就是交互可视化,而它基于的是后者的算力突破

图片

图片

 

 

 

总结

图数据结构演化

更高的吞吐率可以通过更高的并发来实现,而这可以贯穿整个数据的全生命周期:

· 数据导入、加载(Data Ingestion) · 数据转换(Data Transformation) · 数据计算(无论是K邻还是路径还是…) · 基于批处理的操作、图算法等

另外,内存消耗也是一个不可忽略的存储要素。尽管我们这几年都纷纷开始宣称内存就是新的硬盘,它的性能指数级高于固态硬盘或磁盘,但是,它并不是没有成本的,因此审慎的内存使用是必要的,例如以下是降低内存消耗的策略:

· 基于数据加速的数据建模(Data Modeling for Data Acceleration) · 数据压缩与数据去重(Data Compression & De-duplication) · 算法实现与代码编程中避免过多的数据膨胀、数据拷贝等操作

 

·END·

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值