图数据库常被误解为"带关系的表"——但真正决定它生死的,不是 Cypher 语法有多优雅,而是引擎内部如何把"点、边、属性"这件事在磁盘与内存上落地。当一次查询需要顺着百万级边做多跳遍历时,用关系型 JOIN 还是原生图指针,性能差距可达几个数量级。本文钻进图数据库引擎的肚子里,从存储布局、遍历执行、查询优化到分布式切分,逐一拆解那些被"产品对比文"一笔带过的内部机制。

站内已有关于 LSM-Tree 存储引擎、CRDT、分布式系统、向量数据库的大量实战,本文把视角切到"图"这一最不适合关系模型的数据形态:为什么邻接矩阵在稀疏图上浪费空间、原生图存储的 index-free adjacency 凭什么让多跳查询变快、supernode 如何让遍历引擎雪崩,以及边/点切分下跨分区 BFS 如何避免网络风暴。这是一组与现有内容相互咬合的系统工程知识。

一、图模型与"原生 vs 非原生"之争

图数据有两种主流模型:属性图(Property Graph)——点/边可带键值属性、边有方向、可有类型;以及 RDF 三元组(主语-谓词-宾语)。无论哪种,引擎要回答的根本问题只有一个:给定一个顶点,如何在常数或近常数时间内拿到它的全部邻居(及其属性)?

这正是"原生(native)图存储"与"非原生(将图映射进关系表/文档库)"的分水岭:

  • 非原生:把点存一张表、边存一张表,遍历一次邻居 = 一次 JOIN 或一次索引回表;多跳遍历的代价随跳数指数级放大(每跳都重新解析查询计划、重新走索引);
  • 原生:物理存储上让"每个顶点的邻居在磁盘/内存中连续或经固定指针直接可达",遍历即"顺着指针走",复杂度与图规模无关,只与本次遍历触及的子图有关。

下面的章节会给出两种路线的具体存储结构与代码实现,你会直观看到 index-free adjacency 为什么是图遍历的命门。

二、存储引擎:图的物理布局

如何在计算机里"摆放"一张图,决定了后续所有查询的代价。三种经典结构各有取舍。

2.1 邻接矩阵、邻接表与 CSR

邻接矩阵用一个 V×V 的二维数组表示边,查边 O(1) 但空间 O(V²)——对稀疏图(现实里绝大多数图)是灾难性的浪费。邻接表用"每个顶点挂一个邻居链表"换取 O(V+E) 空间,但链表分散在堆上、缓存不友好。CSR(Compressed Sparse Row,压缩稀疏行)是邻接表的工程化升级:用两个数组把邻居物理连续地排布,兼顾空间与缓存局部性,是多数图计算框架(如 Ligra、GraphX 的底层)的默认格式。

from typing import List

class CSRGraph:
    """压缩稀疏行: 用 offsets + neighbors 两个数组连续存放邻接"""
    def __init__(self, edges: List<tuple>, n: int):
        self.n = n
        # 统计每个顶点的出度
        deg = [0] * (n + 1)
        for u, v in edges:
            deg[u + 1] += 1
        # 前缀和 -> offsets[i] 是顶点 i 的邻居在 neighbors 中的起点
        for i in range(1, n + 1):
            deg[i] += deg[i - 1]
        self.offsets = deg            # 长度 n+1
        self.neighbors = [0] * (len(edges))
        cursor = self.offsets[:]     # 临时写指针
        for u, v in edges:
            self.neighbors[cursor[u]] = v
            cursor[u] += 1

    def out_neighbors(self, u: int) -> List<int>:
        # O(1) 定位区间, 连续内存读取 -> 缓存友好
        return self.neighbors[self.offsets[u]:self.offsets[u + 1]]

g = CSRGraph([(0, 1), (0, 2), (1, 2), (2, 3)], n=4)
print(g.out_neighbors(0))   # [1, 2]
print(g.out_neighbors(3))   # []

注意 out_neighbors 的代价与"顶点 u 的出度"成正比,而与全图规模 V、总边数 E 无关——这就是多跳遍历能保持线性于子图规模的关键。

2.2 原生图存储:index-free adjacency

工业级图数据库(Neo4j、NebulaGraph、JanusGraph 的某些后端)在 CSR 思想之上更进一步:把"顶点记录"与"它指向的边的物理位置"直接内联为指针,省去任何中间索引查找。顶点记录里就存着"我的第一条边在哪、下一条边在哪",遍历时只需顺着记录内部的指针链走,完全不需要去查一张全局索引表——这就是 index-free adjacency。

// 原生图存储的简化结构: 顶点记录内联指向它的边区间
struct NativeGraph {
    nodes: Vec<Node>,
    // offsets[i]..offsets[i+1] 是节点 i 的邻边在 edges 中的区间
    offsets: Vec<usize>,
    edges: Vec<Edge>,
}

impl NativeGraph {
    // 取邻居即一次切片, 无索引回表
    fn neighbors(&self, v: usize) -> &[Edge] {
        &self.edges[self.offsets[v]..self.offsets[v + 1]]
    }
}

对比关系型存储:同样"取邻居"在关系库里要发一条 SQL、优化器生成计划、走边表索引、回表——每一步都是额外开销;而原生图存储把这道工序在写入时一次性付清,读取时近乎"零成本指针跳转"。代价是写入/更新时需要维护这些连续结构与指针,因此图数据库的写放大通常高于 LSM-Tree 型 KV 库。

2.3 属性图的落地

真实图数据库还要存属性。典型做法是"顶点表 + 边表 + 属性列":顶点按 ID 顺序存、边带 (src, dst, type, props);NebulaGraph 还把"同一个点的出边按目标分区"连续存放,进一步把多跳遍历局部化。下表对比三种存储结构的取舍:

结构空间取邻居代价适合场景
邻接矩阵O(V²)O(1) 查边稠密图、需要频繁判边
邻接表(链表)O(V+E)O(deg) 但缓存差简单原型、稀疏图
CSR / 原生图O(V+E)O(deg) 连续内存图计算、多跳遍历、生产引擎

三、遍历执行引擎:从 BFS/DFS 到双向搜索

图的查询本质是"遍历"。引擎的遍历算子(traversal operator)就是按某种顺序沿着边"跳"到邻居。最基础的两种是 BFS(按层扩散,适合最短跳数)与 DFS(一条路走到黑,适合路径枚举)。

from collections import deque

def bfs(graph, start):
    """广度优先遍历, 返回 (访问顺序, 跳数)"""
    seen, order, q = {start}, [start], deque([start])
    dist = {start: 0}
    while q:
        u = q.popleft()
        for v in graph.out_neighbors(u):
            if v not in seen:
                seen.add(v); order.append(v); q.append(v)
                dist[v] = dist[u] + 1
    return order, dist

import heapq
def dijkstra(graph, start, goal):
    """带权最短路径: 优先队列驱动, 遍历即松弛边"""
    dist = {start: 0}; pq = [(0, start)]
    while pq:
        d, u = heapq.heappop(pq)
        if u == goal: return d
        if d > dist.get(u, float('inf')): continue
        for v in graph.out_neighbors(u):
            nd = d + 1   # 边权此处取 1, 可替换为权重属性
            if nd < dist.get(v, float('inf')):
                dist[v] = nd; heapq.heappush(pq, (nd, v))
    return float('inf')

3.1 supernode 问题与遍历熔断

真实图里常有一个顶点连着上百万条边(社交网络的"大 V"、知识图谱的"实体")——这就是 supernode。一次无约束的 BFS 一旦踏入 supernode,邻接展开就会把内存与网络瞬间打爆。工程上的对策:

  • 度数截断:对超高度数顶点只取前 K 个邻居(或按权重采样);
  • 遍历深度/扇出上限:每跳扇出封顶,超出的分支熔断并记录为"已剪枝";
  • 边过滤下推:在展开邻居的同时就按类型/属性过滤,避免先展开再过滤的浪费。

3.2 双向搜索

求两点间最短路径时,从起点和终点同时做 BFS,在中间相遇——搜索空间从 O(b^d) 降到约 O(b^(d/2))(b 为分支因子、d 为跳数),对社交网络"六度分隔"这类查询是数量级的加速。这是遍历执行引擎里最经典、也最该默认开启的优化之一。

四、图查询优化:从 Cypher 到执行计划

Cypher 这类声明式语言写起来爽,但引擎必须把它编译成执行计划:先匹配哪个点/边、走不走索引、用 Hash Join 还是循环嵌套、遍历顺序怎么排。优化器在多个候选计划里挑代价最小的——核心输入是基数估计(cardinality estimation):某一步会产出多少中间结果。

def plan_match(where_selectivity, has_index, est_vertex_count):
    """极简 Cypher MATCH 优化器: 选 '索引扫描' 还是 '全图扫描'"""
    # 选择性 = 命中比例; 索引扫描代价 ~ 选择性 * log(N)
    idx_cost = where_selectivity * est_vertex_count * 0.2 + 8
    scan_cost = est_vertex_count                       # 全表扫描线性代价
    if has_index and idx_cost < scan_cost:
        return "IndexScan", idx_cost
    return "FullScan", scan_cost

# 例: 4600 万顶点, 选择性 0.001%, 有索引
print(plan_match(0.00001, True, 46_000_000))   # ('IndexScan', ~14)
# 无索引时退化为全表扫描
print(plan_match(0.5, False, 46_000_000))       # ('FullScan', 46000000)

现实优化器远比这复杂:它要考虑连接顺序(先扩张低基数的点能大幅缩小中间集)、索引选择(点索引还是边索引)、遍历算子下推(把过滤放进邻居展开阶段)。一个常见反模式是"先 MATCH 全部边再 WHERE 过滤"——把过滤下推到遍历算子之前,往往能救回几个数量级的算子调用。

五、分布式图:切分与跨分区遍历

单机能装下的图终究有限。把图拆到多机有两派切分法,直接决定跨分区遍历的成本。

5.1 边切分 vs 点切分

切分法做法优点缺点
边切分每条边存一份,顶点可能多副本实现简单、负载易均顶点多副本、跨分区边多
点切分顶点分片,边按其两端分布边局部性好、遍历更本地需处理顶点镜像、实现复杂

NebulaGraph 采用"按顶点 ID 哈希分区、同分区的出边连续存放",让单跳遍历大多落在本地;跨分区跳才触发 RPC。下面用一致性哈希做一个可复现的分区器,并演示跨分区 BFS:

import hashlib

class ConsistentPartitioner:
    """一致性哈希: 把顶点映射到固定数量的分区, 增删节点只动少量数据"""
    def __init__(self, replicas, partitions):
        self.parts = partitions
        self.ring = []
        for p in range(partitions):
            for r in range(replicas):
                h = int.from_bytes(hashlib.md5(b"part%d-%d" % (p, r)).digest()[:4], "big")
                self.ring.append((h, p))
        self.ring.sort()

    def partition_of(self, vid):
        h = int.from_bytes(hashlib.md5(b"v%d" % vid).digest()[:4], "big")
        # 顺时针找第一个 >= h 的虚拟节点
        for key, p in self.ring:
            if key >= h:
                return p
        return self.ring[0][1]

# 跨分区 BFS: 每次展开邻居时, 本地分区直接取, 远端入 RPC 队列
def cross_partition_bfs(graph, part, start):
    rpcs = 0; local = 0; seen = {start}; q = [start]
    while q:
        u = q.pop(0)
        for v in graph.out_neighbors(u):
            if v in seen: continue
            seen.add(v)
            if part.partition_of(v) == part.partition_of(u):
                local += 1; q.append(v)
            else:
                rpcs += 1   # 触发一次跨分区 RPC
    return len(seen), local, rpcs

part = ConsistentPartitioner(replicas=20, partitions=4)
print(part.partition_of(10086))   # 0..3 之间的分区号

跨分区遍历的代价正比于"跨分区的边数"。因此选型时要在分区键上做文章:让业务上常一起遍历的点尽量落同一分区(例如社交图按"社区"而非纯哈希分区),可把 RPC 次数压到最低。

六、经典图算法工程落地:PageRank

PageRank 是图算法里最常被拿来压测引擎的基准,它天然契合"顶点 → 邻居"的遍历模式,也是 CSR 结构的绝佳用武之地:每一轮迭代都只是"沿出边把权重分发出去再汇总"。

def pagerank(graph, iters=20, damping=0.85):
    """在 CSR 图上迭代 PageRank, 每次沿出边分配权重"""
    n = graph.n
    pr = [1.0 / n] * n
    out_deg = [len(graph.out_neighbors(i)) for i in range(n)]
    for _ in range(iters):
        new = [0.0] * n
        for u in range(n):
            if out_deg[u] == 0:
                # 悬挂节点: 把权重均分给所有人
                share = pr[u] / n
                for v in range(n): new[v] += share
                continue
            share = damping * pr[u] / out_deg[u]
            for v in graph.out_neighbors(u):
                new[v] += share
        # 补偿随机跳转的 (1-damping) 部分
        base = (1 - damping) / n
        pr = [base + x for x in new]
    return pr

# 在前面构造的 4 点图上跑
g = CSRGraph([(0, 1), (0, 2), (1, 2), (2, 3)], n=4)
print([round(x, 3) for x in pagerank(g)])
# -> 与 PageRank 收敛值一致: 入度高的点(2,3)权重更高

注意 PageRank 是幂迭代:每轮都全量扫一遍所有边。这恰好暴露图计算的真实瓶颈——不是 CPU 算力,而是"边数据的访问局部性"。因此工业图计算引擎(Pregel、GraphX、Gemini)大量使用 CSR/边数组 + 消息聚合 + 分区本地化来把每轮迭代的网络与内存带宽吃掉的部分压到最低。

七、工程陷阱与经验

  • 别用 JOIN 当图遍历:关系型多跳查询的代价随跳数指数上升;超过 3 跳就应考虑原生图存储,或把常用多跳预计算成物化路径;
  • supernode 必须设防:任何"无度数上限"的遍历在真实图上都会雪崩,度数截断 + 扇出上限是生产必选项;
  • 过滤下推到遍历算子:先展开邻居再 WHERE 过滤是最大性能杀手,让存储层在展开时就按属性剪枝;
  • 分区键决定跨分区 RPC 数:纯哈希分区会让多跳遍历频繁跨机;按"社区/业务域"分区能显著本地化;
  • 基数估计驱动优化器:执行计划好坏取决于中间结果规模估计,缺少统计信息时会盲目选 FullScan——定期维护图统计元数据;
  • 读写权衡:原生图存储把查询成本在写入时预付,写放大高于 KV 库;高频写入 + 低频深层遍历的负载要重新评估是否值得上图库。

结语

图数据库的真正差异不在"能不能存关系",而在引擎内部如何为"沿边跳转"这件事榨干每一字节的局部性与每一次指针的成本。邻接表给了 O(V+E) 的空间,CSR 把邻居排进连续内存,原生图存储用 index-free adjacency 把遍历变成指针跳跃,而双向搜索、过滤下推、按社区分区则把多跳查询从指数灾难拉回线性于子图规模。理解这套从存储到执行再到分布式切分的内部机制,是在知识图谱、社交网络、风控关系链、IT 拓扑等场景里把"几秒的查询"做成"几毫秒的体验"的前提——这也是为什么它和站内已有的 LSM-Tree、CRDT、分布式系统文章,构成了同一套系统思维的闭环。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部