关于图数据库的一些思考

图数据库的崛起与挑战

过去十年,图数据库从学术研究的边缘地带走向了工业界的舒适区。社交网络、欺诈检测、推荐系统、知识图谱——这些天然以”关系”为核心的场景,让图数据库找到了它的舞台。然而,当我们深入存储引擎层面去审视图查询的行为特征时,会发现一个耐人寻味的现象:图查询在不同的”深度”下,展现出截然不同的负载特征。

具体来说:一度查询(即查找某个顶点的直接邻居)的行为更接近于 OLTP 系统——低延迟、小范围随机访问、高并发;而 K 度查询(深度遍历)则更像 OLAP 系统——大范围数据扫描、计算密集、内存消耗高。这种”TP/AP 二象性”是图数据库设计中的核心矛盾之一。


一度查询 vs K度查询:TP 与 AP 的二象性

一度查询(One-hop Query)是图数据库最基础也是最高频的操作:给定一个顶点,查找它的所有直接邻居及其属性。在存储引擎层面,这类查询具有以下特征:

在 NebulaGraph 的实现中,一度查询的典型操作是 getNeighbors:给定一组顶点 ID,查找它们的出边或入边及对应属性。这个操作在 RocksDB 上被翻译为一次前缀扫描(Prefix Scan),因为同一个顶点的所有边在 Key 编码上共享相同的前缀。这与关系型数据库中基于 B-Tree 索引的范围查询非常相似。

K度查询(K-hop Query)是指从一个起点出发,沿着边进行 K 层深度的遍历。随着 K 的增长,查询的行为特征发生了质变:

特征维度一度查询 (1-hop)K度查询 (K-hop)
数据访问范围小,单顶点邻居大,指数级增长
延迟要求毫秒级秒级到分钟级
并发模式高并发、多查询独立低并发、单查询重资源
I/O 特征点查找 + 前缀扫描大范围扫描 + 随机读
内存消耗高,需维护遍历状态
类似系统OLTP / 事务处理OLAP / 分析处理
典型场景实时推荐、欺诈检测PageRank、社区发现、路径分析

为什么会出现这种二象性?

这种二象性的根源在于图数据的拓扑结构与底层存储的线性化映射之间的张力。

一度查询时,我们只需要访问一个顶点的”星形子图”(Star Graph)——即这个顶点及其所有直接连接的边。在 RocksDB 的 LSM-Tree 存储中,由于 Key 编码设计将同一顶点的数据聚集在一起,这个星形子图可以通过一次高效的前缀扫描获得,非常类似于 OLTP 中的索引查找。

但当遍历深度增加时,每一层的邻居节点可能分散在不同的分区(Partition)上,进而分散在不同的物理机器上。每次跨分区访问都意味着网络开销。同时,由于真实世界图的”幂律分布”特性,部分超级顶点(Super Vertex)可能拥有数万甚至数百万条边,这使得深度遍历的数据量爆炸式增长,负载特征完全转变为 OLAP 式的大规模数据处理。


NebulaGraph: 如何在 RocksDB 上构建图

架构

NebulaGraph 采用了存储与计算分离的分布式架构,由三个核心服务组成:

整个存储层的层次结构为:

Store Engine (RocksDB 实例)
    → Raft 共识层 (Multi-Group Raft)
        → Storage Service 接口 (getNeighbors, addVertices, addEdges ...)

数据按照 Graph Space 进行逻辑隔离,每个 Space 内部再通过 Hash 分片分成多个 Partition。每个 Partition 对应一个 Raft Group,确保数据的强一致性。

Key 编码设计:图语义到 KV 的映射

理解图数据库 TP/AP 二象性的关键,在于理解图数据如何被编码为 KV 对。NebulaGraph 的 Key 编码设计精妙地将图的拓扑关系映射到了 RocksDB 的有序 KV 结构上:

顶点 Key 格式:

| Type (1B) | PartID (3B) | VertexID (nB) | TagID (4B) |

边 Key 格式(出边):

| Type (1B) | PartID (3B) | SrcVID (nB) | EdgeType (4B) | Rank (8B) | DstVID (nB) |

边 Key 格式(入边):

| Type (1B) | PartID (3B) | DstVID (nB) | EdgeType (4B) | Rank (8B) | SrcVID (nB) |

这里有几个关键的设计要点:

数据局部性原则。 NebulaGraph 通过对 VertexID 进行 Hash 取模来确定 Partition,确保同一个顶点的所有 Tag 数据、出边和入边都落在同一个 Partition 上。这意味着一度查询可以在单个 Partition 内完成,不需要跨网络通信。这是一度查询能达到 OLTP 级别性能的根本保障。

前缀扫描友好。 Key 的编码顺序使得同一顶点的所有边在 RocksDB 中物理上相邻存储。查找某个顶点的所有出边,只需以 (Type + PartID + SrcVID) 为前缀进行一次 Prefix Scan,就能高效地获取所有结果。这就是一度查询能达到 OLTP 级别性能的核心原因。

双向边存储。 每条边被存储为两个 KV 对(出边和入边),分别以源顶点和目标顶点为 Key 前缀。这是用存储空间换取查询性能的典型设计,保证无论从哪个方向遍历都能快速定位。

固定长度 VertexID。 NebulaGraph 2.0 引入了 FIXED_STRING 类型的 VertexID,用户在创建 Graph Space 时指定固定长度,不足的用 \0 填充。这确保了所有顶点和边的前缀长度一致,使得 RocksDB 的 Prefix Bloom Filter 能够正确工作,进一步加速前缀查询。

一度查询的执行路径

我们来具体看一下一个典型的一度查询在 NebulaGraph 中是如何执行的:

GO FROM "player100" OVER follow YIELD dst(edge) AS id;
  1. Graph Service 接收查询,解析后确定起始顶点 player100,对其 ID 进行 Hash 取模确定目标 Partition
  2. 将请求发送到该 Partition 的 Leader 节点,Storage Service 执行 getNeighbors 操作
  3. RocksDB 以 (VertexType + PartID + player100 + follow的EdgeType) 为前缀进行 Prefix Scan
  4. 所有匹配的 KV 对被读取,解码后提取目标顶点 ID 和属性,返回结果

整个过程的核心是一次网络请求 + 一次前缀扫描,完美契合 OLTP 的行为特征。RocksDB 的 Block Cache 和 Bloom Filter 进一步优化了这个过程,当数据在缓存中时可以实现微秒级的响应。

K度查询的执行路径

再来看一个三度查询:

GO 3 STEPS FROM "player100" OVER follow YIELD dst(edge) AS id;

这个查询需要迭代执行三次”扩展”操作。假设 player100 有 50 个关注者,平均每人又有 50 个关注者,那么:

更关键的是,这 125,000 个顶点很可能分布在几十个不同的 Partition 上,跨越多台物理机器。每层遍历的结果需要汇聚回 Graph Service,然后再发起下一层的批量查询。

此时的负载特征已经完全是 OLAP 式的:大量的网络 RPC 调用、大规模的数据扫描、Graph Service 上消耗大量内存来维护中间状态。尤其当遇到超级顶点时,单个顶点的边数可能达到数万级,一次 Prefix Scan 就需要扫描大量的 SST 文件 Block,这与 OLAP 系统中的全表扫描已经没有本质区别。


RocksDB 上的图存储:优势与挑战

为什么选择 RocksDB?

RocksDB 作为图数据库的底层存储引擎,有其内在的逻辑:

4.2 LSM-Tree 对图查询的影响

LSM-Tree 的核心特点是写入缓冲(MemTable)和多层文件合并(Compaction)。这对图查询有两个重要影响:

对一度查询的积极影响: Compaction 后,同一顶点的数据在 SST 文件中物理相邻,前缀扫描可以享受顺序读取的性能优势。配合 Block Cache,热点数据的查询可以完全在内存中完成。

对 K度查询的消极影响: 当遍历的顶点分布在不同的 SST 文件和不同的 Level 时,读放大(Read Amplification)问题变得突出。一个 K度查询可能需要读取跨越多个 Level 的 SST 文件,产生大量的随机 I/O。对于超级顶点,Compaction 不充分时,其边数据可能分布在多个 SST 文件中,导致前缀扫描性能严重下降。

超级顶点问题

超级顶点(Super Vertex)是图数据库中的经典难题。在真实世界的图数据中,幂律分布是普遍现象——少数顶点拥有大量的边。社交网络中的大V、银行系统中的四大行、交通网络中的枢纽节点,都可能拥有数万甚至数百万条边。

在 RocksDB 上,超级顶点意味着一个 Prefix Scan 需要读取巨量的 KV 对。即使是一度查询,对超级顶点的访问也可能退化为 OLAP 式的负载。NebulaGraph 文档中建议,当一个顶点的边数超过 10,000 时就应该被视为超级顶点,需要特殊处理。常见的优化策略包括:


从存储引擎视角看图数据库的演进方向

原生图存储 vs KV 上的图存储

当前图数据库的存储引擎大致可以分为两类:

一类是以 Neo4j 为代表的原生图存储,使用 Index-Free Adjacency 设计,每个节点直接存储指向邻居的物理指针,一度查询是 O(1) 复杂度的指针追踪。

另一类是以 NebulaGraph、JanusGraph 为代表的基于 KV 存储的图数据库,通过精心设计的 Key 编码将图语义映射到 KV 结构上。

两种方案各有优势。原生图存储在一度查询时理论上更快,因为不需要索引查找;但 KV 基础的方案更容易实现分布式、水平扩展、以及复用成熟的存储引擎生态。而且在实际场景中,当数据规模达到千亿级边时,分布式能力往往比单机性能更重要。

HTAP 融合趋势

图数据库的 TP/AP 二象性自然地引出了一个问题:能否像关系型数据库的 HTAP 趋势一样,在一个系统中同时支持 TP 和 AP 负载?

NebulaGraph 的做法是将 OLTP(GO、MATCH 等查询语句)和 OLAP(PageRank、Louvain、WCC 等图算法)通过 Dag Controller 编排系统进行联动:可以用 MATCH 语句提取子图作为 PageRank 的输入,PageRank 的结果再写回图数据库继续执行其他查询。这种”积木式”的组合方式,虽然不是真正的存储层 HTAP,但在工程实践上已经能够解决大部分问题。

也有一些新兴项目在探索更深层次的融合。例如 FalkorDB 采用了基于稀疏邻接矩阵的存储架构,将图遍历转化为矩阵运算,试图在单一引擎中同时实现行存储的 OLTP 精确性和列存储的 OLAP 速度。这种”矩阵原生”的思路为 AI 与图计算的深度融合提供了新的可能。

存储引擎的可能演进

基于对 TP/AP 二象性的理解,图数据库的存储引擎可能向以下方向演进:

emacs 用户的 tmux 生存指南
io_uring 学习总结