查询缓存与物化视图

图库不像关系型库那样自带成熟的物化视图,多跳查询的代价又特别高,缓存因此成为必需品。本文讲清 Neo4j 的计划缓存与结果缓存边界、应用层缓存的键设计与失效策略、物化视图的三种图原生实现(预计算节点、预聚合属性、独立视图图)、增量维护、写时失效与版本号一致性、GDS 预计算结果的落盘复用,以及缓存穿透与雪崩的防护。

引言

图数据库最贵的一类查询是多跳遍历:三跳、四跳的展开代价随扇出指数增长,即使有索引和剪枝,一次查询几十毫秒到几百毫秒也很常见。更麻烦的是,这类查询的结果往往高度可复用——「Alice 的二度人脉」「这个商品的三跳关联推荐」在一段时间内是稳定的,每次重算纯属浪费。

关系型数据库对此有成熟答案:物化视图(Materialized View)由数据库自动维护、查询优化器自动改写。图数据库这一层几乎是空白:Neo4j 没有原生物化视图,缓存要么靠应用层,要么靠把结果写回图里变成节点和属性。而一旦走「写回图里」这条路,就又回到了经典的缓存一致性问题——什么时候失效、怎么增量更新、写多读少时值不值得。

本文把这个问题拆成三层来解:第一层是数据库自带的缓存(计划缓存,理解它的边界能避免很多误解);第二层是应用层缓存(键设计、失效策略、穿透防护);第三层是图原生物化(预计算节点、预聚合属性、独立视图图)。每层都给出具体的实现与取舍,最后一节用「热门推荐结果物化」串起完整流程。前置阅读:性能调优 与 图查询优化 。通用缓存模式可横向对照 缓存策略与模式 ,列存侧的物化视图实现可参考 物化视图实践 。

1. 图库自带什么缓存

先澄清一个高频误解:Neo4j 的「查询缓存」缓存的是执行计划,不是结果。

1.1 计划缓存

dbms.query_cache_size 控制的是计划缓存的条目数。参数化查询命中计划缓存后,省掉的是解析与规划的开销,而执行(实际的遍历与读取)仍然每次都跑。

// 参数化 → 命中计划缓存(省规划,不省执行)
MATCH (u:User {id: $id})-[:FOLLOWS*1..3]->(f:User)
RETURN DISTINCT f.id;

// 字面量 → 每条都是新计划 → 计划缓存被填满 → 热查询计划被挤出
MATCH (u:User {id: 'u-1'})-[:FOLLOWS*1..3]->(f:User) RETURN f.id;

所以「加了计划缓存查询就快了」是错的——它只消除规划开销(微秒级),对遍历耗时(毫秒级)毫无帮助。

1.2 页面缓存(Page Cache)

真正影响查询速度的是页面缓存:dbms.memory.pagecache.size 决定多少图数据能常驻内存。命中页面缓存的遍历比走磁盘快两三个数量级。

配置建议:
  dbms.memory.pagecache.size = 可用物理内存 × 50%~70%
  (集群中每个实例都要留足,别按总数算)

观察:
  CALL dbms.listConfig() YIELD name, value
  WHERE name CONTAINS 'pagecache' RETURN name, value;

页面缓存不是「结果缓存」,但它对重复查询的效果类似——第二次访问同一片图数据时不用读盘。如果图数据总量小于可用内存,绝大多数性能问题其实来自查询写法而不是缓存不足,先优化查询再加缓存。

缓存层缓存什么命中省下什么失效时机
计划缓存执行计划解析与规划(微秒)缓存满、schema 变更
页面缓存磁盘页磁盘 IO(毫秒级)内存压力、数据变更
应用层缓存查询结果整个查询执行(毫秒~秒)TTL / 写时失效
图原生物化预计算结果整个遍历增量维护 / 重建

结论:想要「不重复执行查询」,只能靠后两层。

2. 应用层缓存

2.1 键设计

缓存键必须唯一标识一次查询的全部输入,包括参数与「图版本」。漏掉任何输入都会导致读到过期结果:

import hashlib, json

def cache_key(query_name: str, params: dict, graph_version: int) -> str:
    payload = json.dumps({"q": query_name, "p": params, "v": graph_version},
                         sort_keys=True, ensure_ascii=False)
    digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
    return f"gdb:{query_name}:{digest}"

三个要点:

  • sort_keys=True:参数字典顺序不同会生成不同键,导致缓存命中率虚低。
  • graph_version:见第 5 节的版本号机制,它是「图变了但键没变」的唯一防线。
  • 前缀 gdb::便于按前缀批量失效(SCAN + DEL)。

2.2 缓存什么、不缓存什么

不是所有查询都值得缓存。判断标准是**「重算成本 × 复用次数 > 缓存开销」**:

查询类型重算成本复用度是否缓存
多跳人脉(3 跳以上)高高缓存
单点属性查询极低高不缓存(DB 本身够快)
图算法结果(社区/PageRank)极高极高物化(第 6 节)
实时性要求高的查询(余额)中低不缓存
个性化推荐高中短 TTL 缓存

单点查询不缓存是一条常被违反的纪律:MATCH (u:User {id: $id}) RETURN u 在有索引的情况下是微秒级,加一层 Redis 反而引入网络往返,净亏。

2.3 失效策略

三种失效方式,按一致性强度排序:

1. 写时失效(Write-Invalidate):写入时删除相关键
   - 一致性最强,实现最复杂(要知道「哪些键受影响」)
   - 适合:写少读多、关联明确

2. TTL 过期:设置生存时间,到期自动失效
   - 实现最简单,一致性最弱(TTL 内可能读到旧值)
   - 适合:能容忍短暂陈旧的场景(推荐、统计)

3. 版本号:键里带版本号,版本变了自然 miss
   - 无需显式删除,但旧键会占空间直到 TTL
   - 适合:全局变更(批量导入后整体失效)

实践中组合使用:写时失效保证强一致的关键查询,TTL 兜底防止漏删,版本号处理批量变更。

3. 图原生物化视图

图库里没有 CREATE MATERIALIZED VIEW,但可以用三种图原生方式达到同样效果。

3.1 方式一:预计算节点

把计算结果物化成独立节点,用关系关联到源节点:

// 物化「用户的二度人脉」(每 6 小时重算一次)
MATCH (u:User)
CALL {
  WITH u
  MATCH (u)-[:FOLLOWS*2..2]->(f:User)
  WHERE f <> u
  RETURN collect(DISTINCT f.id) AS ids
}
MERGE (m:Materialized {name: 'second_degree', key: u.id})
SET m.ids = ids, m.updatedAt = datetime()
MERGE (u)-[:HAS_MATERIALIZED]->(m);

查询时直接读物化节点,把多跳遍历降为单跳:

// 查询从「两跳展开」变成「单跳读取」
MATCH (u:User {id: $id})-[:HAS_MATERIALIZED]->(m:Materialized {name: 'second_degree'})
UNWIND m.ids AS fid
MATCH (f:User {id: fid})
RETURN f.name;

优点:完全在图内,事务一致(物化节点与源数据同一事务提交)。缺点:物化节点占用存储,且 ids 数组是非结构化的——无法在数组上建索引做进一步过滤,只能整体取出。

3.2 方式二:预聚合属性

把聚合结果直接写成源节点的属性:

// 预聚合:把「关注者数」「平均互动度」写回节点属性
MATCH (u:User)
CALL {
  WITH u
  OPTIONAL MATCH (u)<-[:FOLLOWS]-(f:User)
  RETURN count(f) AS followers
}
SET u.followerCount = followers, u._statsUpdatedAt = datetime();

优点:可以直接在属性上建索引、排序、范围过滤(WHERE u.followerCount > 1000),这是方式一做不到的。缺点:热点属性会引发写争用(大量写入同一节点),且属性更新会触发索引更新。

// 建索引让预聚合属性可查询
CREATE INDEX user_followers IF NOT EXISTS FOR (u:User) ON (u.followerCount);

3.3 方式三:独立视图图

把物化结果放到一个独立的标签空间,与业务数据隔离:

// 视图图:所有物化结果挂在 :View 命名空间下
MERGE (v:View:SecondDegree {userId: $uid})
SET v.ids = $ids, v.updatedAt = datetime(), v.version = $graphVersion;

// 查询只读视图图,完全不动业务数据
MATCH (v:View:SecondDegree {userId: $uid})
RETURN v.ids, v.version;

优点是清理方便(MATCH (v:View) DETACH DELETE v 一条搞定)、不影响业务标签的查询计划。缺点是多了一层间接,且需要自己维护版本号。

方式查询形态可索引存储开销清理难度
预计算节点单跳读取否(数组)中中
预聚合属性属性过滤是低低
独立视图图独立标签查部分中低

4. 增量维护

全量重算物化结果的成本随图规模线性增长,大图上不可接受。增量维护只更新受影响的部分。

4.1 变更驱动的增量

前提是知道哪些节点受影响了。用变更日志或 CDC 捕获写入,只重算相关的源节点:

def on_follow_created(from_id, to_id, session):
    # 新增一条 FOLLOWS 边 → 只影响 from 的二度人脉
    session.run("""
        MATCH (u:User {id: $fromId})
        CALL {
          WITH u
          MATCH (u)-[:FOLLOWS*2..2]->(f:User)
          WHERE f <> u
          RETURN collect(DISTINCT f.id) AS ids
        }
        MERGE (m:Materialized {name: 'second_degree', key: $fromId})
        SET m.ids = ids, m.updatedAt = datetime()
    """, fromId=from_id)

关键在于影响面分析:新增 A→B 这条边,影响的是「所有能两跳到达 A 的节点」的…不对,准确地说,影响的是 A 自己的二度人脉(因为 A 的出边变了),以及「能两跳到 A 的节点」的二度人脉(它们的二度人脉可能新增了 B 的邻居)。影响面往往是一跳邻域的两倍半径,在小度数图上可接受,在超级节点上会爆炸。

4.2 批量重算 + 分片

影响面太大时,退化为「定期批量重算 + 分片并行」:

// 按 id 取模分片,多个 worker 并行处理不同分片
MATCH (u:User)
WHERE u.id % $shards = $shard
CALL {
  WITH u
  MATCH (u)-[:FOLLOWS*2..2]->(f:User)
  WHERE f <> u
  RETURN collect(DISTINCT f.id) AS ids
}
MERGE (m:Materialized {name: 'second_degree', key: u.id})
SET m.ids = ids, m.updatedAt = datetime();

分片数取 CPU 核数或 worker 数的整数倍,避免某些分片特别大。每个 worker 处理独立分片,无锁冲突(它们写的物化节点不相交)。

4.3 增量 vs 全量的取舍

维度增量全量批量
延迟秒级取决于周期(小时)
计算量与变更量成正比与图规模成正比
实现复杂度高(影响面分析)低
正确性风险影响面漏算无
适用变更少、要求新鲜变更多、可容忍延迟

经验法则:日变更率低于 1% 用增量,高于 10% 用全量批量,中间用「增量 + 每日全量兜底」——增量保证新鲜度,全量修正增量的累积误差。

5. 一致性:版本号机制

无论用哪种缓存,都要解决「图变了但缓存没变」的问题。版本号是最通用的解法。

5.1 全局图版本号

维护一个全局版本号,任何写入都递增它:

// 写入后递增版本号
MERGE (v:GraphMeta {name: 'version'})
ON CREATE SET v.value = 0
SET v.value = v.value + 1, v.updatedAt = datetime();

缓存键里带上版本号(见 2.1),版本一变所有键自然失效。缺点是全局版本号粒度太粗:改一个用户会让所有缓存失效,命中率骤降。

5.2 分域版本号

按业务域拆分版本号,缩小失效范围:

MERGE (v:GraphMeta {name: 'version', domain: 'user'})
ON CREATE SET v.value = 0
SET v.value = v.value + 1;

缓存键用 domain:version,用户域的变更只失效用户域的缓存。分域粒度要按变更关联性设计:关注关系变了只影响社交域,不影响商品域。

5.3 实体级版本号

最细粒度:给每个实体加版本号,物化结果里记录依赖的实体版本集合。查询时校验版本,任一变了就重算:

// 物化结果里记录依赖快照
MERGE (m:Materialized {name: 'second_degree', key: $uid})
SET m.ids = $ids,
    m.dependsOn = $dependencyVersions,   // {"u-1": 3, "u-2": 7, ...}
    m.updatedAt = datetime();

// 查询时校验
MATCH (m:Materialized {name: 'second_degree', key: $uid})
MATCH (u:User {id: $uid})
WHERE u._version = m.dependsOn[$uid]     // 校验关键依赖
RETURN m.ids;

粒度越细命中率越高,但维护依赖集合的成本也越高(每次读取都要校验一批版本)。实践中大多数场景用分域版本号就够了。

粒度命中率维护成本适用
全局低极低小系统
分域中高低大多数场景
实体级高高热点实体

6. GDS 预计算结果的复用

图算法(PageRank、Louvain、Betweenness)计算成本极高,但结果变化很慢,是物化的最佳对象。

6.1 结果落盘

GDS 的 stream 模式把结果流出来,write 模式直接写回图。物化用 write:

// 用 write 模式把 PageRank 写回节点属性(一次计算,长期复用)
CALL gds.graph.project('userGraph', 'User', {FOLLOWS: {type: 'FOLLOWS', orientation: 'NATURAL'}});

CALL gds.pageRank.write('userGraph', {
  maxIterations: 20,
  dampingFactor: 0.85,
  writeProperty: 'pagerank'          // 写到节点属性上
})
YIELD nodePropertiesWritten, ranIterations, computeMillis;

// 建索引让 pagerank 可排序/过滤
CREATE INDEX user_pagerank IF NOT EXISTS FOR (u:User) ON (u.pagerank);

CALL gds.graph.drop('userGraph');

写回后,查询就变成了普通的属性排序——从「跑算法」变成「查属性」:

MATCH (u:User)
RETURN u.id, u.pagerank
ORDER BY u.pagerank DESC
LIMIT 100;

6.2 版本与新鲜度标记

算法结果必须带「计算时间」和「图版本」,否则无法判断新鲜度:

MATCH (u:User)
SET u.pagerank = $value,
    u._algoRun = $runId,
    u._algoAt = datetime();

应用层读取时校验 _algoRun 是否等于当前期望的批次号,不等则触发重算或降级。

6.3 增量算法的可能性

部分算法支持增量计算(GDS 的 gds.pageRank.stream 配合 delta 图,或 gds.alpha.* 系列),但增量结果与全量结果可能有偏差。工程建议:增量用于日常刷新,每周做一次全量校准,防止偏差累积。

7. 实战:热门推荐结果的物化

把前面几节串起来,看一个完整场景:为每个用户物化「你可能认识的人」(二度人脉按共同关注数排序)。

需求:
  - 查询要 < 10ms(当前多跳查询 p99 是 400ms)
  - 结果可容忍 1 小时陈旧
  - 每天新增关注边约 5 万条(占总量 0.5%)

方案选择:
  - 陈旧容忍 1h → 不需要实时,可用 TTL 1h + 全量批量重算
  - 变更率 0.5% < 1% → 增量也划算,但实现复杂度高
  - 结论:全量批量(每小时)+ TTL 缓存(10 分钟),先简单跑起来

物化脚本:

// 每小时跑一次:为每个用户物化前 50 个推荐
MATCH (u:User)
CALL {
  WITH u
  MATCH (u)-[:FOLLOWS]->(:User)-[:FOLLOWS]->(cand:User)
  WHERE cand <> u AND NOT (u)-[:FOLLOWS]->(cand)
  WITH cand, count(*) AS mutual
  ORDER BY mutual DESC
  LIMIT 50
  RETURN collect({id: cand.id, mutual: mutual}) AS recs
}
MERGE (m:Materialized:Recommendation {key: u.id})
SET m.recs = recs, m.updatedAt = datetime(), m.version = $graphVersion;

查询侧:

MATCH (m:Materialized:Recommendation {key: $uid})
WHERE m.version = $graphVersion       // 版本校验,避免读到过期批次
UNWIND m.recs AS r
RETURN r.id, r.mutual;

应用层再加一层 TTL 10 分钟的 Redis 缓存,挡住重复请求:

def get_recommendations(uid, graph_version):
    key = cache_key("recommend", {"uid": uid}, graph_version)
    cached = redis.get(key)
    if cached:
        return json.loads(cached)
    rows = run_cypher(QUERY, uid=uid, graphVersion=graph_version)
    redis.setex(key, 600, json.dumps(rows))     # TTL 10 分钟
    return rows

预期收益:查询从 400ms 降到「Redis 命中 < 1ms / 未命中 < 10ms」。

8. 排错与防护

8.1 缓存穿透

查询一个不存在的键,每次都穿透到数据库:

# 防护:对空结果也做短 TTL 缓存(防穿透)
rows = run_cypher(QUERY, uid=uid)
if not rows:
    redis.setex(key, 60, "[]")     # 空结果缓存 60 秒
    return []

8.2 缓存雪崩

大量键同时过期,请求全部压到数据库。防护:TTL 加随机抖动:

import random
ttl = 600 + random.randint(0, 120)      # 600~720 秒
redis.setex(key, ttl, json.dumps(rows))

8.3 缓存一致性排查

现象:用户改了资料,但页面上还是旧值
排查顺序:
  1. 写入时有没有删缓存?(写时失效是否覆盖了所有相关键)
  2. 键里的版本号有没有随写入递增?
  3. 缓存 TTL 是不是设得太长?
  4. 是不是「先删缓存再写库」的经典竞态?
     - 先删缓存 → 并发读把旧值写回缓存 → 再写库 → 缓存里是旧值
     - 修正:先写库 → 再删缓存(Cache-Aside 的标准顺序)
  5. 是否有多个缓存层(本地 + Redis)?本地缓存的失效是否被忽略?

第 4 条的经典竞态值得展开:Cache-Aside 的写入顺序必须是先写数据库、再删缓存,而不是反过来。反过来会留下一个「旧值被并发读写回缓存」的窗口。如果删除失败(网络抖动),加一个延迟双删(写库后删一次,延迟几百毫秒再删一次)作为兜底。

8.4 缓存命中率监控

必须监控的指标:
  - 命中率(hit / (hit + miss)):低于 70% 说明键设计或 TTL 有问题
  - 平均响应时间(命中 vs 未命中分开看)
  - 缓存大小与淘汰速率(淘汰快 = 容量不足或键太细)
  - 版本号变更频率(变更太频繁 = 粒度太粗)

命中率低的常见原因:键里带了随机值、TTL 太短、键粒度过细(每个用户一个键但用户量巨大)、或者根本不该缓存的查询被缓存了。

小结

图库的缓存要分三层理解:计划缓存只省规划、页面缓存只省 IO,想省掉查询执行只能靠应用层缓存与图原生物化。落地时抓四条:物化优先选「预聚合属性」(可索引)而不是「数组属性」(不可索引);一致性用分域版本号,别用全局版本号(命中率会崩);算法结果用 GDS 的 write 模式落盘成属性,把「跑算法」变成「查属性」;失效顺序永远是「先写库、再删缓存」,并对空结果与 TTL 做防穿透、防雪崩处理。最后记住一条判断标准——单点索引查询不要缓存,多跳遍历才值得缓存。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「graphdb」更多文章

  1. 图数据测试策略与回归验证
  2. 图数据库并发控制与批量更新
  3. Cypher 反模式与性能陷阱