在讨论某个数据库时,存储 ( Storage ) 和计算 ( Query Engine ) 一般是讨论的热点,也是爱好者们了解某个数据库不可或缺的部分。每一个数据库都有其独有的存储、计算方式,今天就和图图来学习下图数据库 Nebula Graph 的存储部分。git
Nebula 的 Storage 包含两个部分, 一是 meta 相关的存储, 咱们称之为 Meta Service
,另外一个是 data 相关的存储, 咱们称之为 Storage Service
。 这两个服务是两个独立的进程,数据也彻底隔离,固然部署也是分别部署, 不过二者总体架构相差不大,本文最后会提到这点。 若是没有特殊说明,本文中 Storage Service 代指 data 的存储服务。接下来,你们就随我一块儿看一下 Storage Service 的整个架构。 Let's go~github
图一 storage service 架构图数据库
如图1 所示,Storage Service 共有三层,最底层是 Store Engine,它是一个单机版 local store engine,提供了对本地数据的 get
/ put
/ scan
/ delete
操做,相关的接口放在 KVStore / KVEngine.h 文件里面,用户彻底能够根据本身的需求定制开发相关 local store plugin,目前 Nebula 提供了基于 RocksDB 实现的 Store Engine。安全
在 local store engine 之上,即是咱们的 Consensus 层,实现了 Multi Group Raft,每个 Partition 都对应了一组 Raft Group,这里的 Partition 即是咱们的数据分片。目前 Nebula 的分片策略采用了 静态 Hash
的方式,具体按照什么方式进行 Hash,在下一个章节 schema 里会说起。用户在建立 SPACE 时需指定 Partition 数,Partition 数量一旦设置便不可更改,通常来说,Partition 数目要能知足业务未来的扩容需求。微信
在 Consensus 层上面也就是 Storage Service 的最上层,即是咱们的 Storage interfaces,这一层定义了一系列和图相关的 API。 这些 API 请求会在这一层被翻译成一组针对相应 Partition 的 kv 操做。正是这一层的存在,使得咱们的存储服务变成了真正的图存储,不然,Storage Service 只是一个 kv 存储罢了。而 Nebula 没把 kv 做为一个服务单独提出,其最主要的缘由即是图查询过程当中会涉及到大量计算,这些计算每每须要使用图的 schema,而 kv 层是没有数据 schema 概念,这样设计会比较容易实现计算下推。架构
图存储的主要数据是点和边,但 Nebula 存储的数据是一张属性图,也就是说除了点和边之外,Nebula 还存储了它们对应的属性,以便更高效地使用属性过滤。并发
对于点来讲,咱们使用不一样的 Tag 表示不一样类型的点,同一个 VertexID 能够关联多个 Tag,而每个 Tag 都有本身对应的属性。对应到 kv 存储里面,咱们使用 vertexID + TagID 来表示 key, 咱们把相关的属性编码后放在 value 里面,具体 key 的 format 如图2 所示:分布式
图二 Vertex Key Formatpost
Type
: 1 个字节,用来表示 key 类型,当前的类型有 data, index, system 等Part ID
: 3 个字节,用来表示数据分片 Partition,此字段主要用于 Partition 从新分布(balance) 时方便根据前缀扫描整个 Partition 数据Vertex ID
: 4 个字节, 用来表示点的 IDTag ID
: 4 个字节, 用来表示关联的某个 tagTimestamp
: 8 个字节,对用户不可见,将来实现分布式事务 ( MVCC ) 时使用在一个图中,每一条逻辑意义上的边,在 Nebula Graph 中会建模成两个独立的 key-value,分别称为 out-key 和in-key。out-key 与这条边所对应的起点存储在同一个 partition 上,in-key 与这条边所对应的终点存储在同一个partition 上。一般来讲,out-key 和 in-key 会分布在两个不一样的 Partition 中。性能
两个点之间可能存在多种类型的边,Nebula 用 Edge Type 来表示边类型。而同一类型的边可能存在多条,好比,定义一个 edge type "转帐",用户 A 可能屡次转帐给 B, 因此 Nebula 又增长了一个 Rank 字段来作区分,表示 A 到 B 之间屡次转帐记录。 Edge key 的 format 如图3 所示:
图三 Edge Key Format
Type
: 1 个字节,用来表示 key 的类型,当前的类型有 data, index, system 等。Part ID
: 3 个字节,用来表示数据分片 Partition,此字段主要用于 Partition 从新分布(balance) 时方便根据前缀扫描整个 Partition 数据Vertex ID
: 4 个字节, 出边里面用来表示源点的 ID, 入边里面表示目标点的 ID。Edge Type
: 4 个字节, 用来表示这条边的类型,若是大于 0 表示出边,小于 0 表示入边。Rank
: 4 个字节,用来处理同一种类型的边存在多条的状况。用户能够根据本身的需求进行设置,这个字段可_存放交易时间_、交易流水号、或_某个排序权重_Vertex ID
: 4 个字节, 出边里面用来表示目标点的 ID, 入边里面表示源点的 ID。Timestamp
: 8 个字节,对用户不可见,将来实现分布式作事务的时候使用。针对 Edge Type 的值,若若是大于 0 表示出边,则对应的 edge key format 如图4 所示;若 Edge Type 的值小于 0,则对应的 edge key format 如图5 所示
图4 出边的 Key Format
图5 入边的 Key Format
对于点或边的属性信息,有对应的一组 kv pairs,Nebula 将它们编码后存在对应的 value 里。因为 Nebula 使用强类型 schema,因此在解码以前,须要先去 Meta Service 中取具体的 schema 信息。另外,为了支持在线变动 schema,在编码属性时,会加入对应的 schema 版本信息,具体的编解码细节在这里不做展开,后续会有专门的文章讲解这块内容。
OK,到这里咱们基本上了解了 Nebula 是如何存储数据的,那数据是如何进行分片呢?很简单,对 Vertex ID 取模
便可。经过对 Vertex ID 取模,同一个点的全部_出边_,_入边_以及这个点上全部关联的 _Tag 信息_都会被分到同一个 Partition,这种方式大大地提高了查询效率。对于在线图查询来说,最多见的操做即是从一个点开始向外 BFS(广度优先)拓展,因而拿一个点的出边或者入边是最基本的操做,而这个操做的性能也决定了整个遍历的性能。BFS 中可能会出现按照某些属性进行剪枝的状况,Nebula 经过将属性与点边存在一块儿,来保证整个操做的高效。当前许多的图数据库经过 Graph 500 或者 Twitter 的数据集试来验证本身的高效性,这并无表明性,由于这些数据集没有属性,而实际的场景中大部分状况都是属性图,而且实际中的 BFS 也须要进行大量的剪枝操做。
为何要本身作 KVStore,这是咱们无数次被问起的问题。理由很简单,当前开源的 KVStore 都很难知足咱们的要求:
基于上述要求,Nebula 实现了本身的 KVStore。固然,对于性能彻底不敏感且不太但愿搬迁数据的用户来讲,Nebula 也提供了整个KVStore 层的 plugin,直接将 Storage Service 搭建在第三方的 KVStore 上面,目前官方提供的是 HBase 的 plugin。
Nebula KVStore 主要采用 RocksDB 做为本地的存储引擎,对于多硬盘机器,为了充分利用多硬盘的并发能力,Nebula 支持本身管理多块盘,用户只需配置多个不一样的数据目录便可。分布式 KVStore 的管理由 Meta Service 来统一调度,它记录了全部 Partition 的分布状况,以及当前机器的状态,当用户增减机器时,只须要经过 console 输入相应的指令,Meta Service 便可以生成整个 balance plan 并执行。(之因此没有采用彻底自动 balance 的方式,主要是为了减小数据搬迁对于线上服务的影响,balance 的时机由用户本身控制。)
为了方便对于 WAL 进行定制,Nebula KVStore 实现了本身的 WAL 模块,每一个 partition 都有本身的 WAL,这样在追数据时,不须要进行 wal split 操做, 更加高效。 另外,为了实现一些特殊的操做,专门定义了 Command Log 这个类别,这些 log 只为了使用 Raft 来通知全部 replica 执行某一个特定操做,并无真正的数据。除了 Command Log 外,Nebula 还提供了一类日志来实现针对某个 Partition 的 atomic operation,例如 CAS,read-modify-write, 它充分利用了Raft 串行的特性。
关于多图空间(space)的支持:一个 Nebula KVStore 集群能够支持多个 space,每一个 space 可设置本身的 partition 数和 replica 数。不一样 space 在物理上是彻底隔离的,并且在同一个集群上的不一样 space 可支持不一样的 store engine 及分片策略。
做为一个分布式系统,KVStore 的 replication,scale out 等功能需 Raft 的支持。当前,市面上讲 Raft 的文章很是多,具体原理性的内容,这里再也不赘述,本文主要说一些 Nebula Raft 的一些特色以及工程实现。
因为 Raft 的日志不容许空洞,几乎全部的实现都会采用 Multi Raft Group 来缓解这个问题,所以 partition 的数目几乎决定了整个 Raft Group 的性能。但这也并非说 Partition 的数目越多越好:每个 Raft Group 内部都要存储一系列的状态信息,而且每个 Raft Group 有本身的 WAL 文件,所以 Partition 数目太多会增长开销。此外,当 Partition 太多时, 若是负载没有足够高,batch 操做是没有意义的。好比,一个有 1w tps 的线上系统单机,它的单机 partition 的数目超过 1w,可能每一个 Partition 每秒的 tps 只有 1,这样 batch 操做就失去了意义,还增长了 CPU 开销。 实现 Multi Raft Group 的最关键之处有两点,** 第一是共享 Transport 层**,由于每个 Raft Group 内部都须要向对应的 peer 发送消息,若是不能共享 Transport 层,链接的开销巨大;第二是线程模型,Mutli Raft Group 必定要共享一组线程池,不然会形成系统的线程数目过多,致使大量的 context switch 开销。
对于每一个 Partition来讲,因为串行写 WAL,为了提升吞吐,作 batch 是十分必要的。通常来说,batch 并无什么特别的地方,可是 Nebula 利用每一个 part 串行的特色,作了一些特殊类型的 WAL,带来了一些工程上的挑战。
举个例子,Nebula 利用 WAL 实现了无锁的 CAS 操做,而每一个 CAS 操做须要以前的 WAL 所有 commit 以后才能执行,因此对于一个 batch,若是中间夹杂了几条 CAS 类型的 WAL, 咱们还须要把这个 batch 分红粒度更小的几个 group,group 之间保证串行。还有,command 类型的 WAL 须要它后面的 WAL 在其 commit 以后才能执行,因此整个 batch 划分 group 的操做工程实现上比较有特点。
Learner 这个角色的存在主要是为了 应对扩容
时,新机器须要"追"至关长一段时间的数据,而这段时间有可能会发生意外。若是直接以 follower 的身份开始追数据,就会使得整个集群的 HA 能力降低。 Nebula 里面 learner 的实现就是采用了上面提到的 command wal,leader 在写 wal 时若是碰到 add learner 的 command, 就会将 learner 加入本身的 peers,并把它标记为 learner,这样在统计多数派的时候,就不会算上 learner,可是日志仍是会照常发送给它们。固然 learner 也不会主动发起选举。
Transfer leadership 这个操做对于 balance 来说相当重要,当咱们把某个 Paritition 从一台机器挪到另外一台机器时,首先便会检查 source 是否是 leader,若是是的话,须要先把他挪到另外的 peer 上面;在搬迁数据完毕以后,一般还要把 leader 进行一次 balance,这样每台机器承担的负载也能保证均衡。
实现 transfer leadership, 须要注意的是 leader 放弃本身的 leadership,和 follower 开始进行 leader election 的时机。对于 leader 来说,当 transfer leadership command 在 commit 的时候,它放弃 leadership;而对于 follower 来说,当收到此 command 的时候就要开始进行 leader election, 这套实现要和 Raft 自己的 leader election 走一套路径,不然很容易出现一些难以处理的 corner case。
为了不脑裂,当一个 Raft Group 的成员发生变化时,须要有一个中间状态, 这个状态下 old group 的多数派与 new group 的多数派老是有 overlap,这样就防止了 old group 或者新 group 单方面作出决定,这就是论文中提到的 joint consensus
。为了更加简化,Diego Ongaro 在本身的博士论文中提出每次增减一个 peer 的方式,以保证 old group 的多数派老是与 new group 的多数派有 overlap。 Nebula 的实现也采用了这个方式,只不过 add member 与 remove member 的实现有所区别,具体实现方式本文不做讨论,有兴趣的同窗能够参考 Raft Part class 里面 addPeer
/ removePeer
的实现。
Snapshot 如何与 Raft 流程结合起来,论文中并无细讲,可是这一部分我认为是一个 Raft 实现里最容易出错的地方,由于这里会产生大量的 corner case。
举一个例子,当 leader 发送 snapshot 过程当中,若是 leader 发生了变化,该怎么办? 这个时候,有可能 follower 只接到了一半的 snapshot 数据。 因此须要有一个 Partition 数据清理过程,因为多个 Partition 共享一份存储,所以如何清理数据又是一个很麻烦的问题。另外,snapshot 过程当中,会产生大量的 IO,为了性能考虑,咱们不但愿这个过程与正常的 Raft 共用一个 IO threadPool,而且整个过程当中,还须要使用大量的内存,如何优化内存的使用,对于性能十分关键。因为篇幅缘由,咱们并不会在本文对这些问题展开讲述,有兴趣的同窗能够参考 SnapshotManager
的实现。
在 KVStore 的接口之上,Nebula 封装有图语义接口,主要的接口以下:
getNeighbors
: 查询一批点的出边或者入边,返回边以及对应的属性,而且须要支持条件过滤;Insert vertex/edge
: 插入一条点或者边及其属性;getProps
: 获取一个点或者一条边的属性;这一层会将图语义的接口转化成 kv 操做。为了提升遍历的性能,还要作并发操做。
在 KVStore 的接口上,Nebula 也同时封装了一套 meta 相关的接口。Meta Service 不但提供了图 schema 的增删查改的功能,还提供了集群的管理功能以及用户鉴权相关的功能。Meta Service 支持单独部署,也支持使用多副原本保证数据的安全。
这篇文章给你们大体介绍了 Nebula Storage 层的总体设计, 因为篇幅缘由, 不少细节没有展开讲, 欢迎你们到咱们的微信群里提问,加入 Nebula Graph 交流群,请联系 Nebula Graph 官方小助手微信号:NebulaGraphbot。
Nebula Graph:一个开源的分布式图数据库。