Skip to content

Instantly share code, notes, and snippets.

@imbajin
Last active March 31, 2024 11:50
Show Gist options
  • Save imbajin/db4bb02fdd1cf03844ed528108d1dd22 to your computer and use it in GitHub Desktop.
Save imbajin/db4bb02fdd1cf03844ed528108d1dd22 to your computer and use it in GitHub Desktop.
HugeGraph 读写流程简介 3 篇 (基于 0.11 版, 更新于 2021 年, 仅供参考)

HugeGraph背景与存储结构(一)

这是图分享的第一篇文档, 主要讲述它的背景和设计. 后续会介绍图的读写流程和基本特性

0x00. 前世今生

关于图计算, 以及图存储的整体介绍,应用场景, 详见Wiki-TODO和各类宣传PPT, 这里不再重复. 先简单介绍一下图的背景中常见的名词概念, 避免后续混淆

1. 原始概念

图(Graph), 点(Vertex), 边(Edge), 属性(Property), 这四个概念可以构成常见的结构组合 G = (V, E), 或者是实现中常说的属性图的写法, G =(V, E, P), 其实后者也是前者的一个子集写法, 下面再说一些实际应用中的名词:

图的方向: 经典数据结构的“图”里, 可根据边是否有明确方向分为有向图和无向图, 但实际上无向图其实就是默认双向的意思, 实际的图数据库中, 基本都是有向图, 所以任何时候我们都需要关注边的方向.

权重: 同样来自经典数据结构中“图”的概念, 分为带权和无权图, 但在属性图中, 权重不过是属性中的一个可选项, 所以我们同样认为只存在带权图 (如果不使用权重属性, 则认为所有边权重全为1).

入度/入边: 这两者都是同一含义, 但是由于入度(in-degree)这里‘度’的描述, 容易和K步邻居中的 “3邻居” 混淆, 所以我倾向用出/入边来描述, 也更直观易懂. 然后入边就是其他顶点指向当前顶点的边集合 (E.g: XYZ三人喜欢Tom, 那么XYZ 就是 Tom的入边)

出度/出边: 同上, 代表某一个点出发, 指向其他点的集合 (E.g: Tom喜欢了ABCDE, 那么ABCDE就是Tom的出边)

子图: 类似数据结构中子树的概念, 简单说就是一个大图中截取某一部分的图结构, 就是一个子图, 类似子树判断/匹配, 也有子图匹配的场景, 相对复杂, 目前应用较少. 可暂略. (主要用于AP场景)

下图就引用了一个 Neo4J 典型的推特关注属性图, 作为入门参考:

graph-demo00

2. 引申概念

HugeGraph 中, 我们基于原始简单的图数据结构, 又进一步补充和分化出了几个元信息的定义, 比如原本的Vertex/Edge是一个很宽泛的概念, 我们把它按类型进行切分为多类, 就相当于进一步的限制和细化, 就有了以下四类元信息:

  1. VertexLabel:代表顶点名字, 比如 Person 代表人类点, Doge代表狗类点, 这样就把顶点拆分为两种类型
  2. EdgeLabel:代表边的名字, 比如 likes , dislike 代表喜欢/不喜欢
  3. PropertyKey:代表属性的名字, 比如 name, age, gender 这种传统关系型数据里的, 包括“权重”, 都是属性名
  4. IndexLabel:引入了一个可选的项索引, 类似传统DB里的二级索引, 可以针对某个属性建立, 从而避免某些查询扫全图

我们把这种必须预先定义图结构(Schema)的方式成为 “强 Schema”, 而允许不预先定义具体类型, 甚至更松散的限定的方式称为 “弱 Schema 或 Schema Free ”. 需要注意的, 强 Schema 好处是整个图的结构会更加清晰易划分, 缺点自然是限制了一定的灵活性, 对上层业务入图数据有更严格的要求, 这有点程序语言可划分为强类型, 和弱类型语言, 各有利弊.

另外还有一个比较常见的宣传概念 “原生图”, 对应也就是非原生图, 这里业内没有一个统一的定义, 我简单理解为以下几点:

  • 以 Neo4j 为开源代表的图存储实现, 它完全自己管理所有的点边索引数据, 不依赖通用的 KV 存储, 持久化到磁盘上的文件都是按图的类别去划分, 甚至可自己管理磁盘块的映射. 好处是更方便定制化的做存储优化, 你甚至可以把点边 id 单独存储为一个文件, 属性分开, 从而应用不同的 OLTP/OLAP 场景, 缺点是开发成本非常高, 稳定性和分布式分片很难设计.
  • 大部分开源实现都是非原生图, 也就是说实际的存储都交给已有的成熟稳定的 KV 数据库, 而不用去管存储层的细节, 这样的好处是聚焦于开发图 Server 和其他组件本身, 并且可以尽可能图 Server 这层是无状态的, 缺点主要在于计算下推不好做, 以及受限于后端存储的具体设计和实现.

其他的一些概念, 例如 OLAP/OLTP, 事务, RDF 都属于进阶或目前不核心的内容, 可以参考最后[0x03. 进阶阅读](#0x03. 进阶阅读), 接下来说说 HugeGraph 中的数据和存储结构

0x01. 数据结构

这里关于传统数据结构中图的存储不再多描述, 参考Wiki中的文档, (例如邻接/十字链表/三元组等), 主要说一下实际生产中的两个存储模式, 单行多行存储某个点的出边 (入边是类似的, 就不列了)

A. 单行存储所有出边

一个KV保存某点的所有出边. 等价于图的邻接表实现 (数组+"链表"法, Titan/JanusGraph采用的这种), 如下图所示:

graph-adj00

这样设计有什么好处呢, 很显然它可以一次性读出某个点的所有出边, 读次数是最少

  • 但是如果侧重读场景, 主要是比较适合全量边读/或者说OLAP吧 (一次性取出了所有出边) 其他读场景等于是读放大

  • 写入放也更明显, 因为新增一条边, 也需要重新写一整行. 同时在更新数据的时候更为严重(此时读放大也高). 以JanusGraph为例:

    1. 读取/更新某个点的某条边, 需要O(n)的遍历, 更新噩梦, 多度查询性能指数下降
    2. 读取的时候, 把点属性和出入边都读了出来, g.V("A") 几乎和g.V("A").outE().limit(1)g.V("A").BothE() 一样的磁盘IO.

这里根本原因在于, 行/表格很难表示链式的存储, 所以只好把原本链式的结构实际打包成一个整体结构(类似blob)存储, 天然就有很大的读写放大.

B. 多行存储所有出边

分拆出边为多行KV存储, 等价于图的邻接表 (数组+"数组"法, Huge/Nebula采用此)

  • 这里说是数组+数组, 其实严谨来说, 应该说它是一颗两层多叉树.. 整体来看也可以视为森林, 所有的父节点都是vid, 叶子节点是出点+边属性 (如下所示)
graph LR

v1 -- outE --> v0
v1 -- outE --> v3
v1 -- outE --> v4

v2 -- outE --> v4
v2 -- outE --> v5
v2 -- outE --> v7
Loading

所以总的来说, 常见的开源分布式图数据库采用的存储方案, 基本都是邻接表的范畴, 只是具体采用的实现方式不同 (类似 Neo4j 单机采用十字链表的设计暂略). 下面再来看看以常见的分布式存储后端 Hbase 为例, 看看它的具体表存储结构, 以及它是如何与图的数据结构相结合的.

0x02. 存储结构

为了避免有同学完全没有一个图的存储概念对照, 先概览的说一下背景:

  1. 图中的所有数据, 比如, . 索引, 或者元信息, 都会被映射为一个后端 DB 表中的实体(Entry)数据
  2. 图的数据结构, 在 DB 的表中也是以邻接数组, 或者邻接链表的方式来存储的, 至于底下磁盘到底是什么方式并未关心.
  3. 图数据库名 <—> Database名, 图数据库的存储表 <—> DB 表名, 图数据库数据 <—> 一行/多行数据
  4. 查询语句从图(上层)会转化为具体的后端查询语句, 比如 CQL, SQL, 或者*Filter 这种具体实现. (详见读流程)
  5. 从而可知, 图这层较容易抽取出一个统一的序列化接口, 然后不同的后端存储去各自实现对应 I/O 接口即可, 从而可以支持各类 DB

说了一下整体背景, 然后下面主要是说二进制存储数据的表结构 (HbaseRocksdb为代表), 因元信息表数据极少, 暂略. 核心的数据表仅三类:

  • 顶点表
  • 出/入边表
  • 索引表

因为索引是可选的, 入边表的查询次数远小于出边表, 且结构基本完全一致, 所以我们重点看看点表和出边表, 最后看看索引表.

A. 表结构组成

首先如果是第一次上手学习图数据库, 比如 HugeGraph, 你可以使用明文存储的后端, 比如MySQL/Cassandra, 这样易于你快速理解转换, 字节存储的后端直接阅读表数据可能不太友好.

1. 点表

最新版的设计里点边表都只有1个CF, 也就是说所有属性都被序列化到一个column value中, 也就只有一个固定的column name, 拆开来看, value内组成方式其实也很清晰简单, 按顺序依次(序列化)排列如下:

vertexTable00

备注: 早些版本的存储中(0.10前), 不同属性分拆为多个CF, 现在已经统一整合.

2. 边表

同样, 边表的结构也很清晰. 整体看如下:

edgeTable00.jpeg

注意, 边表的存储中, 在 rowkey 里有一个特殊的概念sortkey (属性排序键)设计, 它是做什么用的呢? 用通话时间(属性)来举例就很好理解, 同时它也是缓解超级顶点查询的一个主要手段, 但要注意的是, 使用它之后存储可能有不少的膨胀, 要注意选取合适的属性并控制个数, 对于时序类的属性, 最好再上层做预合并.

3. 索引表

单纯从表结构来说, Huge的索引表基本是经典的二级索引实现方式, 完全使用空间去换取部分查询下的时间, 用主表的属性作为索引表的主键, 用主表的rowkey作为索引表的value. 但是, 不同的索引实现方式仍然有一些区别, 包括组合的索引设计, 以及特殊情况下的存储规则, 先说一下总体的:

  • 属性/前缀索引(secondary-index):加速通过属性来查点 / 边, 可以支持属性的前缀匹配
  • 数值范围索引(range-index):加速根据数值范围来查点 / 边,比如age > 18的人
  • 根据前缀 + 比较大小来检索的属性索引(shard-index):加速根据属性范围 + 名字来查点 / 边,比如city = Beijing && age > 18的人。
  • 全文索引(search-index):加速根据属性值中的任意文本段词语来查点 / 边,比如city contains Bay的人, 这里Huge是对属性文本分词后进行存储索引, 使用开源分词器, 并不会太完整. (简版)

可以看到核心就是二级索引的实现方式, 互换存储位置, 要注意的是这里直接存储到 qualifier 上, 大部分时候 value 不存值 (除非属性是hash值, 或者超长数据), 此时rowkey因为长度限制, 不适合存储过长字符, 就存储 hash(propertyValue), 然后把原始value存储在默认为空的地方.

indexTable00

TODO: 上面的三个存储结构图之后重新画一下, 现在的画法直接用表格对应, 有点不太好理解

至此基本的 Hbase/RocksDB (分布式/单机) 的两大场景的表结构就熟悉了, 那么整个图的存储结构和数据结构就相对都比较熟悉了, 然后再看看图的设计模型

0x03. 设计模型

之前在第一节有提到 Hugegraph 中有一个扩展的元信息设计, 那么他完整一些的模型是什么样的呢? (参考简图, 是具体的接口/实现类)

img

首先图中左边是数据部分, 右边是元信息(Schema)部分, 这里有几个接口需要单独解释一下, 会容易理解许多:

  • Namifable: 我拆分为name-if-able(可命名), 说人话的意思就是实现这个接口的类, 都应该满足拥有一个全局唯一的名字, 也就是说Schema名唯一 (比如Person, likes这些字符串都能唯一标识一个元信息)
  • Typifiable: 同理拆分为type-if-able(可定义类型), 简单说就是所有的元信息都应该有一个具体类型, 比如把元信息划分为 Schema TypeData Type等.

然后再说一下几个可能有点费解的父类(抽象类), 但是也同样比较重要:

  • HugeElement: 点边的抽象也都是一个元素, 这样想是符合OOP的思想的, 当然实际抽取出来的意义主要是以下
    1. 点和边对象中有许多相同的属性或方法, 比如都有 id, name, props, ttl 等, 那么自然想到可以抽取到父类中, 然后直接继承
    2. 传入对象的时候, 可以不限定死具体是点还是边, 可以传入父类来进行方法通用化
  • HugeProperty: 这个概念比较好理解, 它对属性又做了一层抽象, 分为点/边属性, 但是可能有人要问, 属性不是隶属于点或者边, 是一个二级公民么? 为何这里又需要单独有一个类单独标识点边属性呢? (这里有Tinkerpop的历史设计原因, 你必须实现它的设计才能满足 Gremlin 要求)

另外要注意的是, 在如何自己设计一个图数据库系统中, 有提到过, Tinkerpop 作为一个数据结构定义框架和图的查询语言(Gremlin)具体实现, 它是要求你实现它原生定义的 “点/边/属性” 等基本数据结构的, 这里 Huge 中通用也有实现, 否则是无法使用许多 Gremlin 语法的, 这点在 HGraphDB 上就有很明显体现, 因为实现接口不完整, 许多 Gremlin 的语法它都没法直接使用.

0x03. 进阶阅读

图数据库/RDF, 以及图计算这因为学术和工业背景都不少, 初步理解概念后, 想进一步深入 需要阅读大量参考资料, 这部分资料打包整理为了一个文件夹, 放在 RefferDocs 中, 上面详细的罗列了图的资料, 可以按排好的顺序去阅读. 这样可以对图相关概念有更深入的认识和理解.


参考资料:

  1. HugeGraph 官方源码
  2. Wiki百科
  3. Neo4j-官方文档
  4. HugeGraph RocksDB & Hbase 后端表结构设计
  5. 其他文档链接待补

HugeGraph写流程(二)

这是图数据库 HugeGraph 分享的第二篇文档, 主要讲述它的写流程, 也是图另一个重心, 这篇结合上一篇的存储结构, 就对图数据库的存储有了一个整体的认识.

0x00. 前言

存储系统的核心一般都是两块, 一块是数据结构/存储结构设计, 另一块是读写流程(I/O), 上一篇已经讲了 Hugegraph 的存储设计, 这篇接着来看看这几个问题:

  1. 图数据库到底是怎么写点/边, 或者索引数据的呢
  2. 图数据库读流程有哪些步骤? 如何解析一次图查询语句
  3. 图的读写瓶颈在什么地方, 可能如何去改善?

其中读的部分详见下一篇读流程, 这里先来说说写相关的核心流程, 包括两个部分: 图的点边对象如何构建, 图 Server 如何向后端存储写入具体数据.

0x01. 构造点/边对象

先看看如果没有 Loader/Client, Server端如何处理一条点边数据, 参考 HugeGraph 接口, 它定义了图的核心CURD接口, 简单来说, 图数据库写入点边数据之前, 是先把一行数据解析为一个图的点/边对象, 然后再去序列化这个点/边对象到后端存储中. 那么以点对象为例, 它有几个必要元素: (采用主键模式)

  1. 点Label (VertexLabel)
  2. 属性 K-V 对

核心流程图如下:

graph LR

start(三种 vid 策略) --属性主键模式--> a(获取点/边Label + 属性值)--生成-->a2(点ID)--设置-->a3(点/边属性)--返回-->a4(完整的点/边对象-Java)
start --自定义整形 or 字符串--> b(点id)--设置-->a3
start --随机生成--> c(点id) --设置--> a3
Loading
    // 在GraphTransaction类中, 构造一个点的 "整体过程" kvs如下: (按顺序成对传入)
    // constructVertex(T.label, "fan", "name", "Baby1", "age", 3, "city", "Beijing");
    public HugeVertex constructVertex(boolean verifyVL, Object... keyValues) {
        HugeElement.ElementKeys elemKeys = HugeElement.classifyKeys(keyValues);

        VertexLabel vertexLabel = this.checkVertexLabel(elemKeys.label(),
                                                        verifyVL);
        Id id = HugeVertex.getIdValue(elemKeys.id());
        List<Id> keys = this.graph().mapPkName2Id(elemKeys.keys());

        // Check whether id match with id strategy
        this.checkId(id, keys, vertexLabel);

        // Create HugeVertex
        HugeVertex vertex = HugeVertex.create(this, null, vertexLabel);

        // Set properties
        ElementHelper.attachProperties(vertex, keyValues);

        // Assign vertex id
        vertex.assignId(id);

        return vertex;
    }

0x02. 序列化 (写入)

在上面我们已经了解了在 Hbase 后端表存储中, 核心的点/边的组成方式, 下面简单结合核心代码看看细节一点的逻辑. 首先看看写入的核心—-序列化

graph

A(四大序列化)--传入-->A1(点对象)--解析-->A2(字节流 Entry)
A(四大序列化)--传入-->B1(边对象)--解析-->A2
A(四大序列化)--传入-->C1(索引对象)--解析-->A2
A(四大序列化)--传入-->D1(查询对象)--解析-->A3(具体查询)
Loading
public interface GraphSerializer {
    // 1. 写点 + (单独)写点属性 + 读点
    BackendEntry writeVertex(HugeVertex vertex);
    BackendEntry writeVertexProperty(HugeVertexProperty<?> prop);
    HugeVertex readVertex(HugeGraph graph, BackendEntry entry);

    // 2. 写边 + (单独)写边属性 + 读边
    BackendEntry writeEdge(HugeEdge edge);
    BackendEntry writeEdgeProperty(HugeEdgeProperty<?> prop);
    HugeEdge readEdge(HugeGraph graph, BackendEntry entry);

    // 3. 写索引 + 读索引
    BackendEntry writeIndex(HugeIndex index);
    HugeIndex readIndex(HugeGraph graph, ConditionQuery query, BackendEntry entry);

    // 4. 构造Id + 构造查询语句
    BackendEntry writeId(HugeType type, Id id);
    Query writeQuery(Query query);
}

由于新旧版本的索引表有了比较大改动, 而且索引本身不是原生数据, 还可能存在脏数据, 所以先着重看 点 & 边 的序列化. (Hbase/RocksDB 采用类似序列化方式)

graph LR

full((3种类型 vid)) --字符串 vid--> a(1 Byte vid长度)-->a1(1-128 Byte 不定长字符串 vid)-->a2(完整rowkey: 2-129 字节)
full --数值 vid--> b(1 Byte 标识长度)-->b1(1-8 Byte-变长)-->b2(完整rowkey: 2-8 字节)
full --UUID 类 vid--> c(1 Byte 标识UUID)-->c1(写入固定16 Byte)-->c2(完整rowkey: 17 字节)

A(初始化字节 entry 对象)--预分配空间-->B(写入上面 rowkey 字节数组)--写入-->C(点的所有属性到一个或多个column)--带TTL-->D(TTL的过期时间)--写入--> E(column name)
C --不带TTL-->E
Loading

然后看完上面的完整分类, 再来看看业务最常用的主键模式下的结构图:

hgVertexHbase00

边Id的写入也非常类似, 它主要由点Id组成, 也就是上述步骤的结合, 如下图所示:

graph LR
d0(edgeId 组成:)--写入--> d(1字节边标识)--写入-->d1(n字节起始点id)--写入-->d2(1字节方向) --写入--> d3(2字节 labelId) --有排序键-->d4(排序字符 + 1字节)-->d5(n字节终点id)
d3 --无排序键--> d42(1字节)-->d5

Loading

与之对应的主键模式下边的结构图如下(类似, TODO):

核心代码如下:

    // BinarySerializer 实现了Graph序列化方法, 略去部分不需要的部分
    public BackendEntry writeVertex(HugeVertex vertex) {
        // 0. 将原始id转换为序列化后的id
        BinaryBackendEntry entry = newBackendEntry(vertex);
        // ↑ 等同: BytesBuffer.allocate(1 + vid.length()).writeId(vid)

        // 1.1 计算点属性个数, 预分配Buffer大小
        int propsCount = vertex.getProperties().size();
        BytesBuffer buffer = BytesBuffer.allocate(8 + 16 * propsCount);

        // 1.2 首先写labelId (数值?)
        buffer.writeId(vertex.schemaLabel().id());

        // 2.1 然后把所有点属性组合到一起
        this.formatProperties(vertex.getProperties().values(), buffer);

        // 2.2 (可选) 开启TTL后记录过期时间写入, 但hbase实际使用原生的ttl设置
        if (vertex.hasTtl()) {
            entry.ttl(vertex.ttl());
            this.formatExpiredTime(vertex.expiredTime(), buffer);
        }

        // 3. Hbase的vid存在rowkey中, rocksdb存columnName里, 数据写入一个column中
        byte[] name = isRocksdb ? entry.id().asBytes() : new byte[0];
        entry.column(name, buffer.bytes());

        return entry;
    }

上面的过程很清晰简单, 关键是看看如何序列化属性, 以及 ID 的过程

    // 对应上面组合所有属性
    protected void formatProperties(Collection<HugeProperty<?>> props,
                                    BytesBuffer buffer) {
        // 1. 先写属性总个数
        buffer.writeVInt(props.size());

        // 2. 再写属性数据 (属性keyId+ 属性值)
        for (HugeProperty<?> property : props) {
            PropertyKey pkey = property.propertyKey();
            buffer.writeVInt(SchemaElement.schemaId(pkey.id()));
            buffer.writeProperty(pkey, property.value()); // list/set循环写入
        }
    }

    // 属性的序列化, 主要是通过原生的bytes写入, 旧版是用Kryo序列化框架
    // writeVInt/VLong 等细节是一些位运算处理, 比较繁杂, 暂时略去
    public void writeProperty(DataType dataType, Object value) {
        switch (dataType) {
            case BOOLEAN:
                this.writeVInt(((Boolean) value) ? 1 : 0);
                break;
            case BYTE:
                this.writeVInt((Byte) value);
                break;
            case INT:
                this.writeVInt((Integer) value);
                break;
            case FLOAT:
                this.writeFloat((Float) value);
                break;
            case LONG:
                this.writeVLong((Long) value);
                break;
            case DATE:
                this.writeVLong(((Date) value).getTime()); // 写时间戳
                break;
            case DOUBLE:
                this.writeDouble((Double) value);
                break;
            case TEXT:
                this.writeString((String) value); // 长度 + 数据bytes
                break;
            case BLOB:
                byte[] bytes = value instanceof byte[] ?
                               (byte[]) value : ((Blob) value).bytes();
                this.writeBigBytes(bytes);
                break; // UUID略
            default: 
                this.writeBytes(KryoUtil.toKryoWithType(value));
                break;
        }
    }

因为Hbase在新版合并多column为一个后, 也不再能支持单独修改/写入某个点边属性, 所以 writeVertex/EdgeProperty() 暂时略过, 然后序列化做好了, 跳到 Hbase 写入的地方, 这里也就是最后调用Hbase-Client的终点, 可以看到 TTL 在这通过原生支持来实现.

  /** HbaseSessions类中的put方法, 其中:
   *  1. rowkey = entry.id().asBytes()
   *  2. family = "f".getBytes()
   *  3. columnName = new byte[0]
   *  4. columnValue = propertiesFormat().Bytes()
   */
    public void put(String table, byte[] family, byte[] rowkey,
                    Collection<BackendColumn> columns, long ttl) {
            Put put = new Put(rowkey);
            for (BackendColumn column : columns) { // 当前应只有一个column
                put.addColumn(family, column.name, column.value);
            }
            put.setTTL(ttl); // 如果没有开启TTL则去掉此行
            this.batch(table, put);
        }

类似的, RocksDB的最终写入接口也比较类似, 不同的是它的 TTL实现没有原生支持而要通过 hook 的方式触发(非当前重点, 暂时跳过), 相关代码

        /** RocksDBStdSessions 类中的 put(cf, k, v)
         * cf: 对应 table (内置ColumnFamilyHandle)
         * batch: 同样是 rksdb-client 提供的内置批写封装, 直接塞入单条kv
         */
        @Override
        public void put(String table, byte[] key, byte[] value) {
            try (CFHandle cf = cf(table)) {
                this.batch.put(cf.get(), key, value);
            }
        }

        /* 在 commit 时把 batch 一起提交 */
        public Integer commit() { // 省略无关代码
            try {
                rocksdb().write(this.writeOptions, this.batch);
            } catch (RocksDBException e) {
                //this.batch.rollbackToSavePoint();
                throw new BackendException(e);
            }

            // Clear batch if write() successfully (retained if failed)
            this.batch.clear();
            return count; // 返回当前批的条数
        }

至此, 一个图的点对象(Vertex) 的 rowkey + column 是如何被序列化和传入 Hbase 的基本就很清楚了, 下面我们再来关注一下, 如何将一条文本数据转换为一个图的点/边对象, 还是说自己来单独实现

扩展思考:

下面是同样开源的图 Nebula Graph 的再 V2.0 中的顶点 rowkey 设计:

Nebula Graph 2.0 点的格式

需要注意的是, 它这里的 vid 是定长的, 不仅数值是固定的, 字符串也需要业务提前指定固定长度的字符串, 不足长度会填充/0, 这样设计的目的是什么呢?

A: 为什么 string ID 要使用 FIXED_STRING ? 如果不使用固定长度,则无法使用前缀进行扫描。通过长度不足补齐,使得所有点之间边之间的各个前缀长度相同,从而进行相应的前缀查询。

再来看看Nebula 的边rowkey 设计, 可以看到与 Huge 的设计也比较接近 (最后1字节预留分布式事务)

Nebula Graph 2.0 边的格式

0x03. 其他后端存储

HugeGraph 在其他的存储系统例如 Cassandra/MySQL 等, 以及 RocksDB 中也有一些区别, 以后再补充

0x04. 进阶阅读

图数据库, 以及图计算这因为学术和工业背景都不少, 初步理解概念后, 想进一步深入 需要阅读大量参考资料, 这部分资料打包整理为了一个文件夹, 放在Git-Doc上, 上面详细的罗列了图的资料, 推荐按排好的顺序阅读. 这样可以对图相关概念有更深入的认识和理解.


参考资料:

  1. HugeGraph-Server 官方源码
  2. zhihu-专栏-图数据库
  3. Nebula官方博客-Nebula2.0存储设计

HugeGraph读流程与缓存设计(三)

这是图数据库 HugeGraph 分享的第三篇文档, 主要讲述它的读和缓存流程, 它是读写流程中另一个重心, 结合上一篇的写流程, 就对图数据库有了一个整体的读写认识, 其他的细节点(例如事务, 并发, 权限等) 可以根据需要再单独阅读了.

0x00. 前言

1. 读流程简介

图的读流程主要分两块:

第一部分抽经典例子来举例, 第二部分同样以经典的API来说明, 其他图算法实现是相似的, 目前的读流程都是单点计算和查询, 所以大规模的查询效率会有受限

2. 基本概念

首先要知道, 图的读取最后落到后端本质就两类: 一类是单点查询rowkey. 另一种是通过前缀去扫表, 后者因为不具有普适性, 所以基本都是可以认为是离散的点查(尤其是OLTP场景), 但是如果这么说那图的查询似乎就太简单了, 实际不然, 图的查询反而是相当复杂的一环, 因为涉及到大量语法, 路径优化, 解析优化, 算法优化等…

但是这里先不提进阶和复杂的地方, 只说一下 HugeGraph 中核心的查询设计情况, 一个普通的查询语句, 如何被组装为一个通用的 Query 对象的. 以下是一点铺垫概念:

  • Query: 基础抽象, 所有的查询都可认为是一种 query, 它又分化为几种常见的子查询, 比如点查, 条件查询, 范围查询
  • Path: 一般指特定的路径查询抽象, 比如最短路径, 全路径查询类, 两点或多点之间的路径都可归为这个范畴
  • Step: 简单可理解为 Gremlin 中的每个函数算子, 比如 V(), count(), both() 都是一个查询的步骤.
  • Condition: 查询条件, 常见大小等于的条件

图里面的术语比较多, 为了避免开始绕进概念, 初识就只列这4种设计, 其中 Query 作为每次查询的一个抽象代表, 贯穿始终, 每一次查询都可以认为是一个Query对象, 然后结合具体的 Gremlin 查询语句再来理解.

3. Huge的缓存

因为读流程里重复的查询可能会偶有发生, 虽然网络安全场景下图的缓存命中率不高, 但是其他场景, 特别是社交推荐, 缓存命中次数就相当可观了, Huge 的缓存设计思路采用的是经典的 LRU 淘汰模式, 相当于自己模拟实现了一个带定时功能的 LRU, 以及新版支持的堆外缓存(暂略)

注: LRU是每次从缓存中把最长时间没人访问的数据T掉, 和LFU最大区别在于, LRU根本判断条件是时间. 而LFU判断根本是使用次数.

先来看看普通读流程, 再看看缓存设计和使用

0x01. Gremlin读

首先这是最常见, 通用的实现方式, 依靠于上层的 Tinkerpop-Gremlin 实现, 下层的图数据库系统只需要实现对应的图数据结构接口, 就能使用大部分 gremlin 的查询转换, 那么以最常见的查询语句为例, 它是如何做转换的呢:

// 1. 查询vid为tom的点
g.V("tom")

// 2. 查询vid为tom的点的所有出边(邻居)
g.V("tom").outE()

// 3. 查询vid为tom的点的所有出边(邻居)点中, 名字叫jin的
g.V("tom").outE().has(name, "jin")

// 4. 查询所有点里名字是jin的点, 复杂度多少O(V)?还是?
g.V().has(name, "jin")

// 5. 简单查询tom的两层邻居
g.V("tom").outE().bothV().outE()

首先, 最简单的点/边id查询, 那自然是序列化为一个获得指定rowkey的查询, 在Hbase层来看, 就是一个单点 get , 然后我们基于这个出发点, 再去做一系列的扩展, 实际就可能反复与图server之间有多次RPC交互了, 比如以查询2为例:

  1. 我们已经知道了从 tom 出发, 查询它的出边, 那么只需要访问出边表
  2. 因为边的 rowkey 是由 vid构成, 我们知道了起点, 就可以构造前缀查询条件, 扫出 tom* 的边数据

然后递进到查询3, 其实就是从边中截取出出点id, 在去点表中, 多个 Get 得到对应的数据, 由此可见仍然是离散的点查, 上面都是很简单的 scan/get 例子, 下面看看第四个查询, 它就代表典型的底层图实现优化了.

首先, 如果你直接继承 Tinkerpop 的遍历逻辑, 查询④是会扫全(点)表的, 它的逻辑是通用的一个个判断每个点是否属性包含 name , 然后再看看它是否值为 jin, 效率自然巨低, 那么你可以自己继承修改 has() 或者 hasLabel()的策略, 让它去尝试读取索引, 如果没有可以禁止此类查询, 有索引去索引表查询再返回, 这样就可以把查询效率变为 O(logV) 了.

然后大部分的 count(), avg(), 或者去重等函数(step), 都是在图server内存中进行筛选计算的, 包括filter/字符串匹配等, 整个gremlin的执行流程比较复杂, 串行和并行都可能混杂, 语法本身也会被重组排序或者优化, 但是核心都是用迭代器在依次遍历迭代. 这里就不细说, 感兴趣需要阅读 Tinkerpop 的源码部分.

0x02. API读

1. 简介

K步邻居之所以在实际业务场景中使用评率很高, 有很大一部分原因是因为它是最裸的一种图查询算法, 业务就算没有学习过任何图查询算法, 因为它的广遍历特点, 也能快速从K步邻居的结果里提取到需要的信息 (简单直观).

它的实际效果是: 从某个点出发, 遍历出K步(层)内的所有关联点. 再简单一点说, 就像是从某个点出发的K次迭代循环. 举个实际的例子就像你的微信里有140个好友, 每个好友又分别有数百个好友, 通过K步邻居, 就能一次得到从你出发, 所有好友的展开关系网, 然后从中就可以进行筛选和分析, 得到想要的信息.

官方文档中可以看到大体的访问方式, 最简单的查询某同学的3层所有朋友的写法可以是:

// Get请求,URL如下
http://ip/graphs/graphName/traversers/kneighbor?source=“1:jin”&max_depth=3

// 返回值,仅有点IDs
{
    "vertices":[
        "2:doge",
        "1:tom",
        "1:tom2",
        "1:tom3",
        "1:jerry",
        "2:cat",
        .....
    ]
}

但是你会发现这个返回结果很有点摸不着头脑, 因为这并不是之前预期的那样----它告诉我1层(自己)有哪些朋友, 我2层(朋友的朋友)是哪些, 而是一股脑把所有名字都输出给了我, 并且没有点对应的边的信息. 这都是后续可以改进的地方~

2. 思考

通过之前性能测试就已经发现, 当深度变大(>3层)的时候, K步邻居的查询速度会明显变长, 甚至于出错无法得到结果. 那么分析的核心自然就是找到它的耗时点, 然后分析瓶颈到底在什么地方, 是HugeServer , 还是后端存储?

通过对比JanusGraph和Tinkerpop的实现方式, 对K步邻居性能上的关注点集中在以下几个地方:

  1. 通过gremlin查询K步邻居和使用kneighbor查询本质区别, gremlin查询主要瓶颈是在?
  2. 通过kneighbor查询的时候, 每一层是通过上一层缓存的点一次性去查下一层(1次RPC), 还是说每个点依次迭代(N次RPC ).

然后带着这些问题, 再结合源码去验证和进一步思考.

3. 对比

首先, 所有的定制图算法在结构上都是属于Traverser 的. 包括最短路径, 全路径, PageRank, 三角计数等等... 而在Huge中这个结构也挺清晰, 分为以下几个核心步骤:

  1. 构造API
  2. 编写Traverser算法 (核心)
  3. 针对后端定制 (可选)

就以Kneighbor为例, 首先在hugegraph-api 模块中, 它会有一个KneighborAPI , 然后提供网络I/O的REST接口, 解析通过HTTP请求发送的Kneighbor参数, 调用kneighbor算法, 最后序列化返回结果, 这个过程简化的K步邻居的整个逻辑:

  1. 给定两个Set集合, 一个记录每层遍历的点(latest), 一个记录整个过程遍历的点(all).

  2. 从第一层开始, 依次遍历

    当前层

    的所有点 + 之前层的点.

    • 首先获得当前点的所有边(方向可指定)
    • 遍历每一条边, 然后获得边指向的另一个顶点
    • 判断指向的点是否记录过, 没有就加进去.
  3. 依次循环, 直到遍历数达到上限, 或遍历完成.

过程可以理解为: 用两个Set替代了传统队列的BFS遍历, 只不过目前的Set集合只进不出, 只是能自动去重. 所以在多层迭代遍历的时候效率较低, 因为每次遍历不仅要遍历当前层所有点, 还有遍历之前已经遍历过的所有点 (虽然有跳过逻辑)

整个遍历过程:

  • 算法复杂度接近: O(n) + O(n+n^2^) +.... O(n+n^2^+....n^k^) ≈ O(n^k^) (K代表遍历层数, 当然因为图缓存的关系, 所以低层数并不会很慢.)
  • 时间复杂度: S(2n), 来自 set 的存储消耗

并且当前只是一股脑把所有点去重获得了, 并不知道实际用途... 所以如果要用它替代目前gremlin自己实现的K步邻居, 还有比较多需要改动的地方,

4. 源码

上面是说的核心的逻辑, 如果还是不太确定, 可以看看具体的源码, 其中degree指单个顶点最大可遍历边数目 (也就是个单点limit)

public Set<Id> kneighbor(Id sourceV, Directions dir,
                         String label, int depth, long degree, long limit) {
        checkParams(sourceV,dir....);
        // Q1:如果不传,则查所有边是在哪实现的... ("" or null)
        Id labelId = this.getEdgeLabelId(label);
        
        // 初始化两个的Set, 添加初始顶点
        Set<Id> latest = newSet(sourceV);
        Set<Id> all = newSet(sourceV);
    
        // 按每层遍历
        while (depth-- > 0) {
            long remaining = limit == NO_LIMIT ? NO_LIMIT : limit - all.size();
            // 每次更新latest集合加入相邻顶点(核心)
            latest = adjacentVertices(latest, dir, labelId, all, degree, remaining);
            all.addAll(latest);
            
            if (limit != NO_LIMIT && all.size() >= limit) break; //遍历点数超过上限则跳出
        }
        return all;
    }

private Set<Id> adjacentVertices(Set<Id> vertices, Directions dir,Id label,
                                 Set<Id> excluded, long degree, long limit) {
        if (limit == 0) return ImmutableSet.of();

        Set<Id> neighbors = newSet();
        // 依次遍历latest顶点 (比如在第二层, 则遍历第一层的所有点+起点)
        for (Id source : vertices) {
            // 拿到从这个点出发的所有边,,时间复杂度至少O(n)?
            Iterator<Edge> edges = edgesOfVertex(source, dir,label, degree);
            while (edges.hasNext()) {
                HugeEdge e = (HugeEdge) edges.next();
                // 获得每条边指向的顶点ID
                Id target = e.id().otherVertexId();
                // 跳过or添加这个点
                if (excluded != null && excluded.contains(target)) continue;
                neighbors.add(target);
                
                if (limit != NO_LIMIT && neighbors.size() >= limit) return neighbors;
            }
        }
        return neighbors;
}

Iterator<Edge> edgesOfVertex(Id source, Directions dir, Id label, long limit) {
        Id[] labels = {};
        if (label != null) labels = new Id[]{label}; //Q2:为何下面不直接传label
    
        //通过"fromV + 方向 + 边label"查询边, 这里确定是一个ConditionQuery (查的细节在后面.)
        Query query = GraphTransaction.constructEdgesQuery(source, dir, labels);
        if (limit != NO_LIMIT) query.limit(limit);
    
        return this.graph.edges(query);
    }

然后你会发现这里有一个核心操作被封装了, 就是如何获取某个点的所有边呢? 类似v.outE(), 可以看看如下的源码, 以及在 Hbase 中对应的构造查询条件:

    //关键查询后端在于此,后续具体分不同后端操作,这里以Hbase/Binary序列化为例
    @Override
    public Iterator<BackendEntry> query(Query query) {
        if (!(query instanceof ConditionQuery)) {
            return super.query(query); //非条件查询,writeQueryEdgePrefixCondition
        }

        QueryList queries = new QueryList(this.graph(), query,
                                          q -> super.query(q));
        for (ConditionQuery cq: ConditionQueryFlatten.flatten(
                                (ConditionQuery) query)) {
            Query q = this.optimizeQuery(cq);
            /*
             * NOTE: There are two possibilities for this query:
             * 1.sysprop-query, which would not be empty.
             * 2.index-query result(ids after optimization), which may be empty.
             */
            if (q == null) {
                queries.add(this.indexQuery(cq));
            } else if (!q.empty()) {
                queries.add(q);
            }
        }

        return !queries.empty() ? queries.fetch() : Collections.emptyIterator();
    }

    //上面的query()方法,到Hbase前的查询条件如下(可以看到总共有5种方式)
    @Override
    public Iterator<BackendEntry> query(Session session, Query query) {
        if (query.limit() == 0 && query.limit() != Query.NO_LIMIT) return ImmutableList.<BackendEntry>of().iterator();

        // Query all (扫表)
        if (query.empty()) return newEntryIterator(this.queryAll(session, query), query);

        // Query by prefix  (前缀查询, 查"某点的所有出边"属于这种,比如"1:jin"的出边 )
        if (query instanceof IdPrefixQuery) {
            IdPrefixQuery pq = (IdPrefixQuery) query;
            return newEntryIterator(this.queryByPrefix(session, pq), query);
        }

        // Query by range (数值范围查询)
        if (query instanceof IdRangeQuery) {
            IdRangeQuery rq = (IdRangeQuery) query;
            return newEntryIterator(this.queryByRange(session, rq), query);
        }

        // Query by id (非条件查询)
        if (query.conditions().isEmpty()) {
            assert !query.ids().isEmpty();
            RowIterator rowIterator = null;
            if (query.ids().size() == 1) {
                Id id = query.ids().iterator().next();
                rowIterator = this.queryById(session, id);
            } else {
                rowIterator = this.queryByIds(session, query.ids());
            }
            return newEntryIterator(rowIterator, query);
        }

        // Query by condition (or condition + id)  
        ConditionQuery cq = (ConditionQuery) query;
        return newEntryIterator(this.queryByCond(session, cq), query);
    }

// 条件查询到Hbase使用的是scan by rowkey查询
public RowIterator scan(String table, byte[] startRow,
                        boolean inclusiveStart, byte[] prefix) {          
  // Hbase-client的scan, 这里filter就是序列化后的通过id去前缀匹配rowkey
  Scan scan = new Scan().withStartRow(startRow, inclusiveStart).setFilter(new PrefixFilter(prefix));
  return this.scan(table, scan);
}

所以可以确定这里只需要一次RPC, 就能从Hbase中读取到一批数据, 不过这里starRow是在哪设置的什么来着....

至于改进和展望就可以单独看k步邻居的改进分析, 不单独细说了.

0x03. 缓存设计

当然, LRU也是一个大的分类, 可以结合一些其他的条件综合实现, 从而获得更好的缓存命中率, 比如相同时间删除数据大小更大的, 以及"使用时间+次数" 的LRU-K. , 或者是FIFO+LRU 的双队列实现, 详情可以参考一下LRU多种实现的对比 .

在Huge里有一个单独的cache 包, 里面有1个Cache 接口 + 3个封装 + 1个RamCache实现. 三个封装分别是:

  • CachedSchemaTransaction (缓存图的Schema信息)
  • CachedGraphTransaction (缓存图的点边数据)
  • CacheManager (缓存管理器)

那么这样看整个设计就很清晰了: (估计)

  1. "接口+实现"负责实现缓存本身
  2. 点/边数据通过缓存管理器调用CachedGraphTransaction的方法进行缓存
  3. Schema数据通过缓存管理器调用CachedSchemaTransaction缓存.
  4. CacheManger类似一个守护进程, 每隔一段时间去观察/更新一下缓存状态

先看缓存本身的实现, 再看如何调用. 在Huge里, 定义了一个Cache 接口, 确定之后缓存实现所需的方法 (如下图) :

hugeCache00

可以看到核心就是缓存的CURD操作, 以及设置一些核心参数(缓存大小, 过期时间), 然后接着看看唯一的实现类RamCache的设计吧: (先看看概览图)

hugeCache01

  1. 初始化一个map, 一个队列: (核心结构)
    • map是 <100MB (100*1024*1024个)的ConcurrentHashMap<Id, LinkNode<Id, Object>>
    • 队列是自己实现的的双向链队列 (名为LinkedQueueNonBigLock<Id, Object>, 其中每个节点(LinkNode)都带有当前时间戳)
  2. 缓存的本质某个对象的Id和对象本身. 但是要注意的是, 这里Id除了常见的点/边Id, 还有比如某次查询对应的QueryId ,这样重复的查询, 就无需去缓存中多次查找每个点边, 而是一次把整个Query 对象都取出来.
  3. 通过包装的两个类来间接调用缓存的增删改查方法, 通过守护进程定时清理过期缓存.

0x04. 缓存读写

先看看缓存最核心读写实现 (修改本质是覆盖写, 删除就是出队, 不单独说了~) :

1. 写入缓存

以下是精简后的核心过程.

  private final void write(Id id, Object value) {
        final Lock lock = this.keyLock.lock(id);
        try { // 如果超过缓存个数限制,移除队头或清空map (capacity默认1024*1024个)
            while (map.size() >= capacity) {
                // 1.先从队列移出最长时间未访问元素
                // 注意: 如果其他线程正在做出队操作或队列可能为空, 那么可能就会返回null(出队失败)
                LinkNode<Id, Object> removed = queue.dequeue();
                if (removed == null) {
                // 如果与此同时有其他添加操作,map就会先被清空,然后跳出循环 (Q:why clear map?)
                    map.clear();
                    break;
                }
                // 2.然后从map里移出刚才的元素
                map.remove(removed.key());
            }

            // 3.旧元素出队(如果存在)
            LinkNode<Id, Object> node = map.get(id);
            if (node != null) queue.remove(node);
            // 4.新元素入队,然后放入map
            map.put(id, queue.enqueue(id, value));
        } finally {lock.unlock();}
    }

这里其他过程都很清晰, 就是如果其他线程同一时间写入, 则导致出栈元素为空时, 为何要清空整个map. 这里还不太理解... 待确定

2. 读取缓存

  private final Object access(Id id) {
        // map中元素 < 缓存上限一半, 则直接返回元素值, 不移动队列
        if (map.size() <= this.halfCapacity) {
            LinkNode<Id, Object> node = map.get(id);
            if (node == null) return null;
            return node.value();
        }

        final Lock lock = this.keyLock.lock(id);
        try {
            LinkNode<Id, Object> node = map.get(id);
            if (node == null) return null;

            // 如果map中元素 > 缓存上限数一半, 则需要更新队列,读取的元素重新入队
            if (map.size() > halfCapacity) {
              // 把元素从中间移至尾部 (元素可能被其他线程通过出队调用移除)
                if (queue.remove(node) == null) return null;
                queue.enqueue(node);
            }
            return node.value();
        } finally {lock.unlock();}
    }

这里跟标准的LRU的区别就在于设置了一个halfCapacity . 原本LRU每次读取都会在链表里移动这个元素到队尾, 而链表的查询效率是很低的. 如图所示:

hugeCacheLRU00

可能这里为了减少移动次数, 就设定当缓存还算充裕(多于一半)的时候, 退化FIFO... 不去重新移动和计算队列, 也不加锁, 这样读取效率理论上会提高许多, 它的主要缺点是? 待确定

3. 缓存过期

之前接口里其实有一个方法名比较奇怪, 名为tick() (Tick-Tok) , 我想了想, 翻译为标记(过期元素数)....可能比较合适?

LinkNode 的结构里, 每个节点初始化的时候都会携带一个当前时间戳, 它就是在tick() 函数中被用来计算过期时间的, 然后它又是CacheManager 初始化的时候调用的, 借助Timer对象后台执行, 达到定时检查缓存是否过期, 以及移除过期对象的效果 :

   public long tick() {
        long expireTime = this.expire; //默认过期时间
        int expireItems = 0; //过期元素个数
        long current = now();
        if (expireTime <= 0) return 0L;
        
        for (LinkNode<Id, Object> node : map.values()) {
            if (current - node.time() > expireTime) {
                remove(node.key()); //删除缓存元素
                expireItems++;
            }
        }
        return expireItems;
    }

  // CacheManager的核心定时执行方法
 private TimerTask scheduleTimer(float period) {
        TimerTask task = new TimerTask() {
            @Override
            public void run() {
            for (Entry<String, Cache> entry : caches().entrySet()) tick(entry.getKey(), entry.getValue());
            }

            private void tick(String name, Cache cache) {
                long start = System.currentTimeMillis();
                long items = cache.tick(); //调用上面的tick清理过期元素
                long cost = System.currentTimeMillis() - start;
                LOG.debug("Cache '{}' expiration tick cost {}ms", name, cost);
            }
        };
        // 30s定时执行一次
        timer.schedule(task, 0, (long) (period * 1000.0));
        return task;
    }

至于双向链队的结构, 除了加了一个自己封装的KeyLock 锁对象, 其他暂时没看到有什么特殊设计, 就不单独讲数据结构了.. 有兴趣可以参考源码1,源码2

0x05. 图使用缓存

上面已经把Hugegraph中的缓存设计和实现大体说完了, 这里再举个具体的例子, 比如之前说的K步邻居查询, 或者某条gremlin查询后, 在Huge里是如何缓存的? 当然这里其实只是一个函数调用了.

以K步邻居核心过程, 查某个点的所有边为例, 最后会调用queryEdgesFromBackend() , 这种操作都是属于GraphTransaction ,而这些都在CachedGraphTransaction 中进行了重写, 例如下面就同时展示了读写缓存在图里的使用方式:

@Override
protected Iterator<HugeEdge> queryEdgesFromBackend(Query query) {
        // 通过page查询的边不存入缓存.直接使用父类
        if (query.empty() || query.paging()) return super.queryEdgesFromBackend(query);

        Id id = new QueryId(query);
        List<HugeEdge> edges = (List<HugeEdge>) this.edgesCache.get(id); //先从缓存里查,存在则返回
        if (edges == null) {
            // 迭代器不方便直接缓存,转为集合缓存.
            edges = ImmutableList.copyOf(super.queryEdgesFromBackend(query));
            if (edges.size() <= MAX_CACHE_EDGES_PER_QUERY) {
                this.edgesCache.update(id, edges);  //缓存这次查询的所有边.标记为一个QueryId
            }
        }
        return edges.iterator();
    }

至于Schema的缓存读写, 大体也是类似的, 细节稍有不同, 就不重复说了, 它的命中率和使用次数最高, 基本等于常驻内存

总体来说, Hugegraph里的缓存设计是实现了传统的LRU的淘汰策略, 并做了一些小改进, 然后缓存过期通过原生的Timer 定时检查并剔除的, 那么我们之前的假想, 如果想通过内存(空间)换时间. 就等于是给了一个非常大的HashMap ,而Java在处理一个如此大的HashMap对象的时候, JVM在内存管理应该会变得很难处理, 也容易出现问题.. (待确认)

所以如果想在Huge里比如用很大(100G+)内存来做缓存, 可能还是用redis/memcache 这样的分布式缓存, 或者存储自身的缓存要好的多, 图本身则可以用来缓存更结构化的数据, 例如:

  1. 图的Schema信息. (缓存命中率极高, 频繁)
  2. 图的Query/Path/Traverser信息. (比如上面的例子, 但是不缓存实际大量的点边数据)
  3. 子图信息 (...如果之后支持子图相关算法, 包括社群发现, Pagerank迭代的时候复用?)

未完待续, 此文应该补充更细粒度的通用 gremlin 读解析, 拆借为第四篇

参考资料:

  1. Gremlin 官方文档
  2. HugeGraph-Server-API 相关源码
  3. 缓存读流程
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment