图数据库存储引擎内部:无索引邻接、存储布局与遍历引擎

深入图数据库的存储与执行内核:无索引邻接(index-free adjacency)为何让图查询快、节点/关系/属性在磁盘上的记录布局、遍历引擎与执行计划、标签与属性索引、属性存储与压缩、内存图 vs 磁盘图、事务写入与 WAL、从 Cypher 到物理计划的流水线,以及 Neo4j / JanusGraph / NebulaGraph 的存储引擎对比,帮助理解图数据库快在哪里、瓶颈在哪里。

引言

图数据库的「快」不是魔法,而是存储与执行设计的直接结果。本文拆开图数据库的黑盒:先讲它最核心的武器——无索引邻接(index-free adjacency),为什么它让关系查询省掉 JOIN;再深入磁盘上的存储布局(节点、关系、属性的记录结构)与遍历引擎(从图到执行计划);然后是索引与查找、属性存储与压缩、内存图与磁盘图的取舍、事务写入与 WAL、从 Cypher 到物理计划的查询流水线;最后对比 Neo4j / JanusGraph / NebulaGraph 的存储引擎设计。目标:你能解释「图查询为什么快、什么时候不快、瓶颈在哪」。

前置:/graphdb-data-model-basics/(属性图模型)、/graphdb-neo4j-cypher-guide/(Cypher 基础)、/graphdb-transactions-indexing/(事务与索引)。


目录


1. 核心武器:无索引邻接

关系数据库的 JOIN 是图查询慢的根源:

SQL 查「A 的朋友的朋友」:
  SELECT ... FROM persons p1
  JOIN knows k1 ON p1.id = k1.from
  JOIN persons p2 ON k1.to = p2.id
  JOIN knows k2 ON p2.id = k2.from
  JOIN persons p3 ON k2.to = p3.id
  → 每次 JOIN 都是哈希/排序匹配(O(N) 级)
  → 深度 3 就需要 3 次大 JOIN

无索引邻接(index-free adjacency):

每个节点「物理上」直接存储它的关系列表:
  节点 A → 直接拿到 [关系1, 关系2, ...](指针)
  关系 → 直接指向两端节点
  → 遍历一步 = 一次指针跳转(O(度))
  → 深度 d 遍历 ≈ 沿指针走 d 步,无需 JOIN

关键:
  复杂度随「遍历的图规模」增长,而非「全表规模」
  局部遍历很快;全局聚合(扫描)才会慢

为什么它快:

- JOIN:每次都要「查找匹配」(索引/哈希)
- 指针邻接:相邻关系已经「指向」你(零查找)
- 类比:链表 vs 每次从头遍历数组找邻居
→ 无索引邻接把「关系查询」从查找变成「跟随指针」

无索引邻接的代价:

- 写入:插入关系要维护双向指针(两端都更新)
- 局部快、全局慢:全图扫描仍需遍历所有节点
- 存储:关系记录要存指针(内存/磁盘开销)
→ 它不是免费午餐,是「换存储结构换时间」

心智:无索引邻接 = 节点直接持有关系指针、遍历即指针跳转——把关系查询从 JOIN 的「查找」变成「跟随指针」,复杂度随遍历图规模而非全表规模;代价是写入维护与全局扫描慢。


2. 存储布局:节点、关系与属性的记录

Neo4j 的磁盘记录模型(固定大小记录):

节点记录(固定大小,如 15 字节):
  - 首关系指针(第一个关系记录的 ID)
  - 属性指针(第一个属性记录)
  - 标签指针(标签链)
  - 其他元数据

关系记录(固定大小,如 33 字节):
  - 起节点指针、止节点指针
  - 关系类型
  - 双向链:prev/next(同起点的兄弟关系、同终点的兄弟关系)
  - 属性指针

属性记录(链式):
  - 属性名(属性字典 id)+ 值(定长/变长编码)
  - next 指针(链到下一个属性)

固定大小记录的意义:

- 定位 O(1):记录 ID → 偏移 = ID × 记录大小
  (无需索引,直接算地址)
- 链式遍历:关系沿「链表」走,天然支持遍历
- 局部性:朋友节点分布在不同页 → 依赖 page cache
→ 「ID → 偏移」是存储效率的基石

双向关系链怎么工作:

每个节点存「首关系」→ 沿关系记录的 next 遍历该节点的所有关系
  (按关系类型分组,可跳过无关类型)
  关系记录同时挂两个方向的链(from 链 / to 链)
→ 从任意一端都能遍历,方向无关(未定向遍历)

属性记录的取舍:

- 属性按需加载:先读节点/关系骨架,属性链惰性读取
- 属性多 → 链长 → 读取开销大
- 大属性(长文本/数组)→ 可外置到 blob 存储
→ 查询只读「需要」的属性,是性能设计的重要一环

心智:图数据库用固定大小记录 + ID 直接算偏移(O(1) 定位),节点持首关系指针、关系双向链、属性链式惰性加载——「记录骨架 + 按需属性」让局部遍历只触碰需要的数据。


3. 遍历引擎:从图到执行计划

遍历是图查询的执行核心:

一个 Cypher 查询:
  MATCH (a:Person)-[:KNOWS]->(b)-[:LIKES]->(c) RETURN c
  → 遍历引擎执行:
    1. 找到起始节点 a(用索引或全扫)
    2. 沿 KNOWS 关系展开 → 邻居 b
    3. 沿 LIKES 关系展开 → 邻居 c
    4. 输出 c

执行计划 = 「从哪开始、按什么顺序遍历、如何剪枝」

遍历算法的选择:

- 深度优先(DFS)/ 广度优先(BFS):取决于查询与统计
- 可变长度路径:BFS/DFS + 剪枝(限制长度、唯一性)
- 图算法(最短路径等):Dijkstra/BFS 专用遍历器
- 剪枝:按关系类型、标签、属性谓词提前过滤
→ 好的执行计划 = 选对起点 + 高效剪枝

起点选择决定性能:

- 用索引找起点(label + 属性)→ 避免全扫
- 选择度低(邻居少)的节点先展开
- 统计信息:度数分布、关系类型频率
→ 代价优化器据此排序遍历顺序

遍历的复杂度直觉:

- 单点局部遍历:O(展开的邻居数)(通常很小)
- 全图分析:O(V+E)(扫全图)
- 最坏情况:超节点(上百万关系)→ 展开爆炸
→ 遍历快的前提是「局部性」;超节点是遍历的杀手

心智:遍历引擎把 Cypher 变成「起点 → 沿关系展开 → 剪枝 → 输出」的执行计划——选对起点(索引)、高效剪枝、按度数排序决定性能,超节点是遍历爆炸的元凶。


4. 索引与查找:标签、属性与全文

索引负责「找到起点」,遍历负责「找邻居」:

索引的类型(Neo4j):
  - 标签 + 属性索引(等值/范围/存在性)
    CREATE INDEX FOR (n:Person) ON (n.name)
  - 复合索引:多属性(label + 多属性等值)
  - 全文索引(Lucene):文本搜索(CONTAINS/MATCH)
  - 向量索引:图嵌入/向量检索(GraphRAG 用)
→ 索引让「找起点」不用全扫

索引的选择与代价:

- 等值匹配:唯一/普通索引 → 精确定位
- 范围查询:有序索引(B-tree)→ 区间扫描
- 全文:倒排索引(Lucene)
- 索引维护:写入时更新 → 写放大
- 索引缺失:查询退化为全图扫描(慢)
→ 索引是「读快写慢」的权衡,要为热点查询建

标签的作用:

- 标签 = 图的「表名」:快速定位一类节点
- 标签索引:扫「某标签」而非全图
- 复合:label + 属性 → 更精确的起点选择
→ 建模时「标签设计」直接影响查询性能

索引失效的场景:

- 函数包裹(WHERE lower(n.name) = ...)→ 无法用索引
- 存在性/可选属性 → 用「存在性索引」
- 通配前导(%name)→ 全文更合适
→ 查询写法要与索引设计匹配

心智:索引解决「起点查找」(等值/范围/全文/向量),遍历解决「邻居展开」——建对索引查询快、写放大少;函数包裹与通配前导会让索引失效,查询写法需与索引匹配。


5. 属性存储与压缩

属性值的存储策略:

- 定长类型(int/float/bool):固定长度直接存
- 变长类型(string/array/map):变长编码,指针 + 长度
- 短字符串:内联在属性记录(不额外分配)
- 大对象:外置到 blob 文件(懒加载)
→ 按值大小分级存储,控制记录膨胀

属性字典(property dictionary):

- 属性名不重复存储字符串,而是「属性字典 ID」
- 全局属性字典:名字 → 短整型 ID
- 记录里存 ID 而非名字 → 大幅节省空间
→ 属性名重复是图数据常态,字典化是核心压缩手段

压缩策略:

- 页/块压缩:相邻记录压缩(LZ4/ZSTD)
- 数值压缩:varint(小整数占 1 字节)
- 前缀压缩:相邻字符串公共前缀(字段/URL)
- 只读层/归档层:批量压缩冷数据
→ 压缩换来空间,但解压有 CPU 代价

属性读取的权衡:

- 懒加载:只有查询「需要」才读属性链
- 投影:只取 RETURN 需要的属性
- 大属性避免热路径:图关系遍历别带大文本
→ 属性是「附属数据」,别让它拖慢遍历

心智:属性存储按类型分级(定长内联/变长外置)、属性名字典化(ID 替代字符串)、页级压缩省空间——懒加载与投影让遍历不被属性拖慢,压缩换空间但付 CPU 代价。


6. 内存图与磁盘图的取舍

两类图数据库架构:

磁盘图(如 Neo4j、JanusGraph):
  - 数据在磁盘,page cache 缓存热页
  - 可承载 TB 级图,持久化可靠
  - 遍历走 page cache(快页=内存级,未命中=磁盘)

内存图(如 RedisGraph、Memgraph):
  - 数据常驻内存(或 mmap)
  - 遍历极快(无磁盘 IO)
  - 受内存上限约束,需要持久化策略

page cache 是磁盘图的性能命门:

- 图遍历是「随机访问」模式(跳指针)
- 随机访问命中 cache → 快;未命中 → 磁盘随机读(慢)
- cache 命中率决定遍历性能
- 调大 cache(如 Neo4j page cache)→ 热数据驻留
→ 磁盘图 = 在「cache 友好度」上做文章

内存图的适用场景:

- 小到中规模图(数百万~千万节点)
- 极低延迟(实时推荐、风控在线查询)
- 图分析工作负载(短查询多)
- 持久化:WAL + 快照(避免纯内存)
→ 内存图用「容量换延迟」

混合策略:

- 热数据内存、冷数据磁盘(分层)
- 内存图 + 异步持久化(Memgraph 模式)
- 副本内存图(查询)、主本磁盘(可靠)
→ 架构设计 = 延迟/容量/可靠性的三方权衡

心智:磁盘图靠 page cache 缓存随机访问的热页、可承载 TB 级;内存图零磁盘 IO、延迟极低但受容量约束——性能命门是「cache 命中率 vs 内存预算」,混合分层是现实架构。


7. 写入路径:事务与 WAL

图写入的事务路径:

写操作(创建节点/关系/更新属性):
  1. 事务开始:分配事务 ID
  2. 修改记录:在内存/页中更新节点与关系记录
  3. 写 WAL(Write-Ahead Log):记录变更(先写日志)
  4. 提交:日志落盘 + 记录页标记提交
  5. 定期 checkpoint:合并日志到数据文件
→ WAL 保证崩溃可恢复(先记后改)

为什么图写入要维护双向指针:

- 创建关系:同时更新两端节点的「首关系」链
- 删除关系:从两端链中摘除(两个方向都要维护)
- 关系类型的索引更新
→ 一个关系的写入 = 多处记录更新(写放大)

写放大与并发:

- 超节点写关系:链很长 → 更新链头开销可控(改指针)
- 并发写同一节点/关系 → 锁竞争
- 事务冲突:写-写冲突需重试
- 批导入 vs 在线写入:批处理绕过事务开销
→ 图写入的瓶颈 = 指针维护 + 锁竞争

WAL 与恢复:

- 崩溃后:从 WAL 重放未提交/未落盘的变更
- checkpoint 后:截断日志(避免无限增长)
- 恢复一致性:半写状态 → 回滚或补全
→ WAL 是「快(异步刷盘)又可靠(可恢复)」的关键

心智:图写入 = 改记录 + 写 WAL + 维护双向指针——一个关系写多处(写放大),超节点与并发写是锁竞争热点;WAL + checkpoint 保证崩溃可恢复,批导入绕过在线事务开销。


8. 查询执行流水线:从 Cypher 到物理计划

一条 Cypher 查询的完整旅程:

Cypher 文本
  → 解析(Parser):语法树
  → 语义分析(Semantic):变量/类型/模式验证
  → 逻辑计划(Logical Plan):模式匹配、展开的抽象步骤
  → 物理计划(Physical Plan):选择执行算子、顺序、索引
  → 执行(Runtime):遍历器、filter、projection、聚合
  → 结果流式返回

模式匹配如何变成执行算子:

MATCH (a:Person)-[:KNOWS]->(b) WHERE a.age > 30
  → 逻辑:扫描 Person → 过滤 age>30 → 展开 KNOWS
  → 物理:NodeIndexSeek(Person.age) → ExpandInto(b) → Projection
  → 也可:先全扫再过滤(无索引时)
→ 物理计划决定「先索引还是先展开、顺序如何」

Eager vs Lazy 评估:

- Eager(急切):一次算完(聚合/排序/DISTINCT 需要)
- Lazy(懒惰):流式(filter/map 边算边出)
- 优化器把「不必急切」的操作延后,减少中间物化
- 计划重排:过滤下推(先 filter 再展开)
→ 计划质量 = 减少中间集合 + 下推过滤

执行统计与诊断:

- PROFILE:各算子实际行数与时间
- 瓶颈识别:某算子 rows 巨大(展开爆炸)或耗时
- 优化方向:加索引、改顺序、加过滤、拆查询
→ PROFILE 是「图查询调优的第一工具」

心智:Cypher 经解析→逻辑计划→物理计划→执行——物理计划选起点/算子顺序/索引,过滤下推与 Lazy 评估减少中间物化;PROFILE 暴露瓶颈算子,是调优第一工具。


9. 存储引擎对比:Neo4j 与 JanusGraph 与 NebulaGraph

三种主流引擎的存储设计:

Neo4j(原生图存储):
  - 固定大小记录 + 无索引邻接(最彻底)
  - 单机/集群(因果复制)
  - 遍历性能最强,写入维护双向链

JanusGraph(基于 KV 存储):
  - 底层用 Cassandra/HBase/BigTable(无索引邻接弱化)
  - 关系以「邻接表」存进 KV(按类型分区)
  - 可水平扩展,但遍历需查 KV(多点读)
  - 查询经索引(ElasticSearch/混合索引)

NebulaGraph(分布式原生图):
  - 自研存储(存储 + 计算分离)
  - 顶点/边分片存储,索引可选
  - 三点元数据(leader 协商)→ 强一致
  - 遍历走存储层局部扫描,水平扩展好

对比维度:

- 无索引邻接:Neo4j 最彻底,JanusGraph 依赖 KV 弱化
- 扩展性:JanusGraph/NebulaGraph 水平扩展强,Neo4j 集群次之
- 一致性:Neo4j 因果、Nebula 强一致、JanusGraph 受底层 KV
- 遍历延迟:原生存储 < KV 支撑
→ 选型 = 遍历性能 vs 扩展性 vs 一致性

存储引擎的共性规律:

- 都在「数据局部性」上做文章(页/分片/邻接表)
- 都在「索引辅助起点查找」
- 差异在「关系如何存储、能否指针直连」
- 新引擎常把「邻接 + 分片 + 索引」混合
→ 理解无索引邻接的原理,就能看懂各家取舍

心智:Neo4j 原生无索引邻接遍历最强、JanusGraph 建在 KV 上可水平扩展但遍历多点读、NebulaGraph 分布式原生存储兼顾扩展与一致——选型 = 遍历性能 vs 扩展性 vs 一致性。


10. 速查表

全篇速查:

主题结论
无索引邻接指针直连,遍历 O(度)
记录布局固定大小 + ID→偏移 O(1)
遍历引擎起点选择 + 剪枝决定性能
索引起点查找,建对才快
属性字典化 + 分级存储 + 懒加载
内存 vs 磁盘cache 命中率 vs 容量
写入WAL + 双向指针维护
执行流水线解析→逻辑→物理→执行
调优工具PROFILE 定位瓶颈算子
引擎对比原生遍历 vs 水平扩展

一句话记忆:图数据库快的根源是无索引邻接——节点直接持有关系指针、遍历即指针跳转,把关系查询从 JOIN 的「查找」变成「跟随指针」,复杂度随遍历规模而非全表规模;磁盘上用固定大小记录 + ID 直接算偏移(O(1) 定位)+ 双向关系链 + 属性字典化/懒加载,page cache 命中率是磁盘图的性能命门,内存图用容量换延迟;写入靠 WAL + 双向指针维护(写放大与锁竞争是瓶颈),查询经解析→逻辑→物理计划的流水线执行、PROFILE 暴露瓶颈算子;Neo4j 原生遍历最强、JanusGraph 建在 KV 上可扩展但遍历多点读、NebulaGraph 分布式原生存储——选型 = 遍历性能 vs 扩展性 vs 一致性。


延伸阅读

  • /graphdb-data-model-basics/ — 属性图模型与图论基础
  • /graphdb-neo4j-cypher-guide/ — Cypher 查询语言
  • /graphdb-transactions-indexing/ — 事务、Schema 与索引调优
  • /graphdb-graph-query-optimization/ — 查询优化与执行计划
  • /graphdb-performance-tuning/ — 生产性能调优
  • 数据库专题 — 关系型存储引擎对比

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「graphdb」更多文章

  1. 自定义过程与 APOC:Neo4j 过程库与 Java 扩展
  2. 知识图谱推理:规则、OWL 与推理机
  3. 子图模式挖掘:Motif、子图同构与频繁模式