引言
图数据库的「快」不是魔法,而是存储与执行设计的直接结果。本文拆开图数据库的黑盒:先讲它最核心的武器——无索引邻接(index-free adjacency),为什么它让关系查询省掉 JOIN;再深入磁盘上的存储布局(节点、关系、属性的记录结构)与遍历引擎(从图到执行计划);然后是索引与查找、属性存储与压缩、内存图与磁盘图的取舍、事务写入与 WAL、从 Cypher 到物理计划的查询流水线;最后对比 Neo4j / JanusGraph / NebulaGraph 的存储引擎设计。目标:你能解释「图查询为什么快、什么时候不快、瓶颈在哪」。
前置:/graphdb-data-model-basics/(属性图模型)、/graphdb-neo4j-cypher-guide/(Cypher 基础)、/graphdb-transactions-indexing/(事务与索引)。
目录
- 1. 核心武器:无索引邻接
- 2. 存储布局:节点、关系与属性的记录
- 3. 遍历引擎:从图到执行计划
- 4. 索引与查找:标签、属性与全文
- 5. 属性存储与压缩
- 6. 内存图与磁盘图的取舍
- 7. 写入路径:事务与 WAL
- 8. 查询执行流水线:从 Cypher 到物理计划
- 9. 存储引擎对比:Neo4j 与 JanusGraph 与 NebulaGraph
- 10. 速查表
- 延伸阅读
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/ — 生产性能调优
- 数据库专题 — 关系型存储引擎对比
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。