数据库基础体系 · 第 53/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Neo4j 查询优化与运维:遍历、索引、执行计划、集群和备份

Neo4j 是以图为核心的数据管理系统。它的查询性能通常不取决于“表连接写得是否复杂”,而取决于三个连续问题:

  1. 是否能快速找到遍历起点;
  2. 是否沿着正确的关系类型、方向和属性进行扩展;
  3. 中间结果是否在扩展、过滤、排序和聚合过程中失控。

因此,Neo4j 的查询优化不能只理解为“给属性加索引”。索引主要负责定位起点和执行部分属性谓词,图遍历本身依赖节点与关系之间的存储结构;执行计划决定这些操作的顺序;集群和备份则决定查询在故障、并发和数据恢复场景中的行为。

本文以 Neo4j 5.x 的公开稳定语义为主要范围。具体的小版本可能增加索引类型、执行计划算子或管理命令参数,生产环境应以对应版本的 Cypher Manual 和 neo4j-admin ... --help 输出为准。


一、先建立性能模型:一次 Cypher 查询到底做了什么

考虑一个社交图:

(:Person {id: "p1", name: "Alice"})
  -[:FOLLOWS]->
(:Person {id: "p2", name: "Bob"})

查询 Alice 直接关注的人:

MATCH (p:Person {id: $id})-[:FOLLOWS]->(friend:Person)
RETURN friend.id, friend.name;

它可以抽象成以下步骤:

  1. 在标签为 Person 的节点中找到 id = $id 的节点;
  2. 从这个节点沿出方向 FOLLOWS 关系扩展;
  3. 对每个到达节点确认其标签或执行其他谓词;
  4. 投影出 friend.idfriend.name

设:

  • SS 是起点候选节点数;
  • dd 是起点的平均出度;
  • pp 是后续过滤条件的选择率,取值为 0011
  • hh 是遍历层数。

一跳遍历的候选规模大致为:

R1S×dR_1 \approx S \times d

两跳遍历大致为:

R2S×d1×d2R_2 \approx S \times d_1 \times d_2

如果每一跳平均出度为 dd,则:

RhS×dhR_h \approx S \times d^h

这不是 Neo4j 的精确成本模型,而是理解查询爆炸的简化模型。关键结论是:

  • 起点从一个节点变成十万个节点,后续遍历成本会整体放大;
  • 平均度数从 5 增长到 100,三跳遍历的候选规模可能放大 20320^3 倍;
  • 一个索引可以将 SS 从“全图扫描”降到很小,但不能自动降低关系扩展产生的 dhd^h

例如下面两个查询的语义并不等价于“都只是查三跳关系”:

// 从一个已知用户出发
MATCH (p:Person {id: $id})-[:FOLLOWS*1..3]->(x)
RETURN x;

// 从所有 Person 节点出发
MATCH (p:Person)-[:FOLLOWS*1..3]->(x)
RETURN x;

第二个查询的起点可能是整个 Person 集合。即使每个用户只有少量关注者,起点基数也可能使结果规模迅速失控。


二、遍历:索引定位起点,关系存储负责扩展

2.1 图遍历的基本单位

Cypher 中常见的模式由三部分组成:

(start:Label {property: value})
  -[r:REL_TYPE]-> 
(end:OtherLabel)

其中:

  • startend 是节点变量;
  • Label 是节点标签;
  • r 是关系变量;
  • REL_TYPE 是关系类型;
  • -> 表示方向;
  • 属性谓词限制节点或关系。

方向不是装饰信息。以下两个模式含义不同:

(a)-[:FOLLOWS]->(b)
(a)<-[:FOLLOWS]-(b)

如果业务关系建模为“关注者指向被关注者”,方向写反会导致结果错误,或者迫使查询尝试另一种扩展方式。

关系类型也有选择性作用:

MATCH (p:Person {id: $id})-[:FOLLOWS]->(x)
RETURN x;

通常比下面这种查询更容易控制候选规模:

MATCH (p:Person {id: $id})-[r]->(x)
WHERE type(r) = 'FOLLOWS'
RETURN x;

后者把关系类型从模式中的结构限制变成了过滤条件。优化器是否能够把它完全等价重写,不能作为应用层假设;应直接在模式中写明关系类型。

2.2 关系属性过滤不是起点索引的替代品

例如:

MATCH (p:Person {id: $id})-[r:FOLLOWS]->(x)
WHERE r.since >= date($date)
RETURN x;

这里通常先找到 p,再扩展 FOLLOWS 关系,最后检查 r.since。如果 p 的出度很大,关系属性过滤可能仍然需要检查大量关系。

Neo4j 支持针对关系属性建立索引,但关系索引是否被采用,取决于谓词、索引类型、统计信息和计划成本。更重要的是,关系索引主要帮助“按关系属性寻找关系”,并不意味着所有从一个已知节点出发的邻接遍历都必然通过关系索引完成。

应区分两类问题:

// 典型邻接遍历:已知起点,找其所有关系
MATCH (p:Person {id: $id})-[:FOLLOWS]->(x)
RETURN x;

// 典型关系属性查找:关系属性本身是选择条件
MATCH (a)-[r:FOLLOWS]->(b)
WHERE r.since >= date($date)
RETURN a, b;

第一类的主要成本是起点和出度;第二类可能受益于关系属性索引,但仍需要评估命中的关系数量以及回到两端节点的成本。

2.3 可变长度遍历的结果不是“每个终点一行”

下面的查询返回路径:

MATCH p = (a:Person {id: $id})-[:FOLLOWS*1..3]->(b)
RETURN p;

如果从 Alice 到 Carol 存在多条不同路径,Carol 可能出现多次,因为查询匹配的是路径,而不是自动对终点去重。

若业务只需要终点节点,可以明确表达这一点:

MATCH (a:Person {id: $id})-[:FOLLOWS*1..3]->(b)
RETURN DISTINCT b;

DISTINCT 不是免费的。它需要对中间结果进行去重,可能引入哈希或排序相关开销。更好的优化通常是先降低路径数量:

MATCH (a:Person {id: $id})-[:FOLLOWS*1..3]->(b:Person)
WHERE b.active = true
RETURN DISTINCT b;

还要注意以下边界:

  • 深度上限越大,候选路径通常呈指数增长;
  • 高度连接的“超级节点”会使单次扩展产生大量结果;
  • 业务若只需要“是否可达”,应避免返回完整路径;
  • 业务若只需要最短路径,应使用相应的最短路径语义,而不是枚举大量路径后在应用层计算;
  • 业务若需要固定步数,固定长度模式通常比不必要的范围遍历更容易估计和控制。

例如,查询二跳邻居时不要写成无限扩展:

// 有明确边界
MATCH (a:Person {id: $id})-[:FOLLOWS*2]->(b)
RETURN DISTINCT b;

// 风险更高:无限扩展
MATCH (a:Person {id: $id})-[:FOLLOWS*]->(b)
RETURN b;

无限长度遍历在生产查询中尤其危险。即使图中关系不会无限循环,路径数量也可能非常大。

2.4 OPTIONAL MATCH 会保留空值行

OPTIONAL MATCH 类似于保留左侧结果的可选扩展:

MATCH (p:Person {id: $id})
OPTIONAL MATCH (p)-[:FOLLOWS]->(friend)
RETURN p.id, friend.id;

如果 Alice 没有关注任何人,查询仍然返回 Alice,但 friendnull

过滤条件的位置会改变语义:

MATCH (p:Person {id: $id})
OPTIONAL MATCH (p)-[:FOLLOWS]->(friend)
WHERE friend.active = true
RETURN p.id, friend.id;

这里的 WHEREOPTIONAL MATCH 关联,可能使没有活跃关注者的可选匹配失去结果。若希望先保留可选关系,再单独处理空值,应明确使用 CASEcoalesce 或调整模式结构,而不能把 OPTIONAL MATCH 当作普通 MATCH 的语法变体。


三、索引与约束:先保证语义,再优化定位

3.1 索引解决什么问题

索引的主要作用是根据一个或多个属性快速找到节点或关系,而不是替代图遍历。

典型查询:

MATCH (p:Person {id: $id})
RETURN p;

如果没有合适的索引,数据库可能扫描所有 Person 节点并检查 id。建立索引后,执行计划可以使用节点索引查找。

在 Neo4j 5.x 中,常见索引能力包括:

  • 节点和关系的范围索引,用于相等、范围等常见谓词;
  • 文本索引,用于文本搜索场景;
  • 点索引,用于空间点相关查询;
  • 向量索引,用于向量相似度检索。

具体索引类型和谓词支持应以当前版本文档为准。不能因为某个属性已有范围索引,就假定 CONTAINS、全文搜索或向量相似度都能使用它。

3.2 创建、查看和删除索引

一个基本的节点范围索引可以这样创建:

CREATE RANGE INDEX person_id_index
FOR (p:Person)
ON (p.id);

创建后查看:

SHOW INDEXES;

需要关注的字段通常包括:

  • 索引名称;
  • 索引类型;
  • 索引实体类型和属性;
  • 状态;
  • 进度;
  • owning constraint(如果索引由约束拥有)。

索引创建是异步过程。索引尚未在线时,查询不能依赖它。应先确认状态已经可用,再用 EXPLAINPROFILE 检查计划。

删除索引:

DROP INDEX person_id_index;

删除前应确认:

  1. 没有依赖它的关键查询;
  2. 它不是由约束拥有的索引;
  3. 其他同义索引没有被误认为替代品;
  4. 删除操作在目标数据库上执行,而不是误操作到其他数据库。

3.3 唯一性约束通常比单独索引更符合业务语义

如果 Person.id 是业务唯一标识,应优先表达为唯一约束:

CREATE CONSTRAINT person_id_unique
FOR (p:Person)
REQUIRE p.id IS UNIQUE;

这不仅可以帮助定位,还能阻止重复数据写入。

写入示例:

MERGE (p:Person {id: $id})
ON CREATE SET p.name = $name,
              p.createdAt = datetime()
ON MATCH SET  p.name = $name;

这里:

  • MERGE 试图匹配整个模式;
  • 匹配到时执行 ON MATCH
  • 没匹配到时创建并执行 ON CREATE
  • 唯一约束使 id 的业务语义得到数据库层保证。

MERGE 不是“无条件安全的并发去重工具”。如果没有适当的唯一约束,并发事务可能各自看不到对方尚未提交的数据,最终产生重复节点。若模式包含多个属性:

MERGE (p:Person {id: $id, name: $name})

它匹配的是同时满足这两个属性的节点,而不是“先按 id 找到,再更新 name”。如果 id 才是身份属性,通常应拆成:

MERGE (p:Person {id: $id})
SET p.name = $name;

3.4 索引不会自动消除数据建模问题

以下模式可能导致大量重复业务关系:

MATCH (a:Person {id: $from})
MATCH (b:Person {id: $to})
CREATE (a)-[:FOLLOWS]->(b);

如果同一个请求重复执行,就会创建多条关系。若业务要求关系唯一,可以使用:

MATCH (a:Person {id: $from})
MATCH (b:Person {id: $to})
MERGE (a)-[:FOLLOWS]->(b);

如果关系唯一性还依赖关系属性,不能简单假设 MERGE 自动按属性建立全局关系唯一约束;应根据模型设计检查重复关系、引入中间节点,或在事务中实现明确的幂等逻辑。


四、执行计划:从语义到实际算子

4.1 EXPLAINPROFILE

EXPLAIN 只生成执行计划,不执行查询:

EXPLAIN
MATCH (p:Person {id: $id})-[:FOLLOWS]->(friend:Person)
RETURN friend.id;

适合检查:

  • 是否使用索引;
  • 从哪个模式开始;
  • 是否出现全标签扫描;
  • 是否有不必要的过滤、排序或去重;
  • 计划中间结果的估计数量。

PROFILE 会实际执行查询,并统计执行过程:

PROFILE
MATCH (p:Person {id: $id})-[:FOLLOWS]->(friend:Person)
RETURN friend.id;

它适合诊断实际成本,但会真正读取数据、消耗资源,不能在高峰期对高成本查询随意执行。

计划输出中的字段名称和展示形式可能随版本、客户端而变化,但应重点理解以下算子。

4.2 常见算子及其含义

NodeByLabelScan

扫描某个标签下的节点:

NodeByLabelScan

它不一定是错误。例如:

MATCH (p:Person)
RETURN count(p);

如果需要访问几乎所有 Person 节点,全标签扫描可能就是合理计划。

但对于高选择性的查询:

MATCH (p:Person {id: $id})
RETURN p;

若仍然是 NodeByLabelScan 后再 Filter,通常说明:

  • 没有对应索引;
  • 索引尚未在线;
  • 索引定义与查询标签或属性不匹配;
  • 统计信息或成本估计使优化器选择了其他计划;
  • 查询实际没有使用预期的属性谓词。

NodeIndexSeek

通过索引查找节点:

NodeIndexSeek

这是典型的起点定位算子。理想的简单查询可能呈现为:

NodeIndexSeek
  Expand(All)
    Projection

它表示先索引定位节点,再扩展关系。

Expand(All)

从当前节点扩展关系:

Expand(All)

其成本通常与当前输入行数和每个节点的相关关系数量有关。如果上游已经产生大量节点,扩展会被放大。

Filter

执行谓词过滤:

Filter

过滤越晚,中间结果越大。优化器通常会尝试把谓词下推到更早位置,但查询写法、变量依赖和语义边界可能限制下推。

SortDistinct 和聚合

以下查询需要排序或去重:

MATCH (p:Person)-[:FOLLOWS]->(x)
RETURN DISTINCT x
ORDER BY x.name;

DISTINCT 需要维护去重状态,ORDER BY 需要排序。它们都可能使用额外内存,并在结果规模大时成为瓶颈。

Eager

Eager 表示执行边界,需要先物化上游结果,再继续后续操作。更新查询中尤其要注意它:

MATCH (p:Person)
SET p.flag = true
RETURN count(p);

涉及读写交错、变量依赖或语义隔离时,计划可能插入 Eager。它不是简单的“坏算子”,但如果上游结果巨大,物化会造成显著内存压力。

4.3 用一个完整例子读计划

查询:

PROFILE
MATCH (p:Person {id: $id})-[:FOLLOWS]->(friend:Person)
WHERE friend.active = true
RETURN friend.id, friend.name;

假设参数 $id 命中一个节点,且该节点关注 500 人。可能出现如下逻辑结构:

NodeIndexSeek       estimated rows: 1
  Expand(All)       actual rows: 500
    Filter          actual rows: 120
      Projection    actual rows: 120

应按数据流逐步解释:

  1. NodeIndexSeek 产出 1 行,说明起点定位成功;
  2. Expand(All) 读取起点的 FOLLOWS 邻接关系,产生 500 个候选;
  3. Filter 检查 friend.active = true,留下 120 个;
  4. Projection 只返回所需字段。

这个查询的主要成本可能是 500 条关系扩展,而不是起点查找。此时给 Person.id 建索引已经解决了起点问题,再给 friend.active 建索引未必能显著降低成本,因为 friend 是由关系扩展得到的,而不是从整个 Person 集合中独立查找。

反过来,若查询是:

PROFILE
MATCH (friend:Person)
WHERE friend.active = true
  AND friend.name STARTS WITH $prefix
RETURN friend;

这时没有固定起点,节点属性谓词本身决定候选集,节点索引可能对计划更重要。

4.4 估计行数与实际行数的偏差

执行计划通常包含估计行数和实际行数。设:

  • R^\hat{R} 为优化器估计的行数;
  • RR 为实际行数;
  • 估计误差可以粗略表示为:

E=Rmax(R^,1)E = \frac{R}{\max(\hat{R}, 1)}

EE 很大或很小时,说明基数估计可能失真。

例如:

Expand(All)
estimated rows: 20
actual rows:    200000

后续算子若基于“只有 20 行”的假设选择了排序、连接或物化策略,实际执行就可能明显变慢。

常见原因包括:

  • 数据分布高度倾斜;
  • 超级节点的度数远高于平均值;
  • 多个属性之间存在相关性;
  • 统计信息尚未反映最新数据;
  • 查询谓词选择率难以从现有统计信息推断;
  • 可变长度路径的结果数量被低估。

诊断时不能只看总耗时,还应逐算子比较:

  1. 哪个算子开始产生大量实际行;
  2. 估计值与实际值首次严重偏离的位置;
  3. 偏离之后是否出现 SortDistinctEager 或大范围扩展;
  4. 是否能通过更精确的起点、关系类型、方向或深度边界减少中间结果。

4.5 索引提示不是默认优化手段

在确实需要时,可以使用索引提示:

MATCH (p:Person {id: $id})
USING INDEX p:Person(id)
RETURN p;

提示的含义是要求查询使用指定索引。它适用于:

  • 已确认某索引适合该查询;
  • 优化器因统计信息或成本估计选择了不理想计划;
  • 经过版本和数据分布验证。

风险是:数据规模、分布、索引和 Neo4j 版本变化后,原来的强制选择可能变成次优计划。应先用 EXPLAIN/PROFILE 证明问题,再考虑提示,而不是把 USING INDEX 当作“有索引就必须加”的固定模板。

4.6 参数化与查询缓存

应用应使用参数:

MATCH (p:Person {id: $id})
RETURN p;

而不是拼接字符串:

MATCH (p:Person {id: "..."})

参数化可以:

  • 防止 Cypher 注入;
  • 避免为不同字面值产生大量结构相同但文本不同的查询;
  • 使驱动程序更容易复用查询结构和事务逻辑。

参数化不保证所有参数值都拥有相同成本。比如某个用户是超级节点,另一个用户只有两个邻居,实际执行成本仍可能差异很大。需要对具有代表性的参数分布进行 PROFILE,不能只用一个普通用户测试。


五、如何系统优化一个慢遍历查询

设原始查询为:

MATCH (a:Person)-[:FOLLOWS*1..4]->(b:Person)
WHERE a.country = $country
  AND b.active = true
RETURN DISTINCT b.id;

第一步:确认业务起点

如果业务实际上是“从某个用户出发”,应把起点写成唯一标识:

MATCH (a:Person {id: $id})-[:FOLLOWS*1..4]->(b:Person)
WHERE b.active = true
RETURN DISTINCT b.id;

这一步把起点基数从“所有符合国家条件的用户”变成一个用户。

第二步:限制方向、关系类型和深度

不要使用任意关系:

(a)-[*1..4]->(b)

除非业务确实允许所有关系类型。明确关系类型可以减少扩展分支:

(a)-[:FOLLOWS*1..4]->(b)

如果业务只需要一跳或两跳,使用精确深度:

MATCH (a:Person {id: $id})-[:FOLLOWS*2]->(b:Person)
RETURN DISTINCT b.id;

第三步:只返回需要的数据

返回路径会保留更多信息:

RETURN p;

如果只需要终点 ID,应返回:

RETURN DISTINCT b.id;

结果投影不是主要优化手段,但它可以减少网络传输和客户端反序列化成本。

第四步:检查中间结果而非只看最终行数

下面两个查询最终都可能返回少量数据:

MATCH (a:Person {id: $id})-[:FOLLOWS*1..4]->(b)
WHERE b.active = true
RETURN DISTINCT b.id;
MATCH (a:Person {id: $id})-[:FOLLOWS*1..4]->(b)
RETURN DISTINCT b.id;

前者仍可能先产生大量路径,再过滤和去重。应通过 PROFILE 查看可变长度扩展输出的实际行数,而不是因最终返回 20 行就认为查询便宜。

第五步:确认是否真的需要“路径枚举”

如果业务是判断两点是否连通,返回全部路径通常是错误的抽象。应使用适合该业务的可达性或最短路径查询,并设置明确的节点、关系和深度边界。

例如,“是否存在一条不超过三跳的关注链”与“返回所有一到三跳路径”是两个不同问题:

// 返回所有路径,可能产生大量结果
MATCH p = (a:Person {id: $from})-[:FOLLOWS*1..3]->(b:Person {id: $to})
RETURN p;

若只关心是否存在路径,应避免把所有路径发送给应用。具体最短路径或路径过滤语法应依据目标 Neo4j 版本和业务语义选择,并通过 PROFILE 验证。


六、事务、并发与错误处理

6.1 单条查询不等于单个业务事务

驱动程序通常提供:

  • 自动提交事务;
  • 显式读写事务;
  • 事务函数,由驱动程序在特定可重试错误下重新执行。

业务写入应尽量在一个明确的事务中完成。例如,创建用户及其关系:

from neo4j import GraphDatabase

driver = GraphDatabase.driver(
    "neo4j://router.example.com",
    auth=("app", "password"),
)

def create_follow(tx, from_id, to_id):
    result = tx.run(
        """
        MATCH (a:Person {id: $from_id})
        MATCH (b:Person {id: $to_id})
        MERGE (a)-[:FOLLOWS]->(b)
        RETURN a.id AS from_id, b.id AS to_id
        """,
        from_id=from_id,
        to_id=to_id,
    )
    return result.single()

with driver.session(database="neo4j") as session:
    record = session.execute_write(
        create_follow,
        "p1",
        "p2",
    )
    print(record["from_id"], record["to_id"])

driver.close()

这个例子成立的前提是:

  • Person.id 已有唯一约束;
  • 两个节点都存在;
  • 驱动程序版本支持这里使用的事务 API;
  • 连接地址和认证信息有效。

execute_write 可能在特定瞬态错误下重试事务函数。因此事务函数必须尽量是可重试的。若事务中包含发送邮件、扣款、调用外部 HTTP 服务等副作用,重试可能造成重复操作。外部副作用应使用幂等键、事务状态表或将副作用移出可重试事务。

6.2 并发写入与锁

Neo4j 的事务隔离、锁和冲突行为由数据库事务实现决定。两个事务同时更新相同实体时,可能出现:

  • 一个事务等待锁;
  • 锁超时;
  • 写写冲突;
  • 瞬态错误,需要重试;
  • 事务因约束违反而失败。

应用不能把“提交成功”与“网络请求成功”简单等同。网络在提交后断开时,客户端可能无法判断数据库是否已经提交。重试前要设计幂等写入,例如通过唯一键和 MERGE 降低重复写入风险。

6.3 大批量写入不要把全部数据放进一个事务

下面的模式可能造成巨大事务:

UNWIND $rows AS row
CREATE (p:Person {id: row.id, name: row.name});

如果 $rows 包含数百万条数据,会带来事务内存、锁持有时间、事务日志和失败重做成本问题。

可以使用分批提交。Neo4j 提供批处理能力时,应根据目标版本使用对应语法或驱动端分批事务;核心原则是:

  • 每批大小应通过实际内存和事务耗时验证;
  • 失败后能够定位和重放某一批;
  • 不要在单个事务中同时创建无限量节点、关系和索引;
  • 导入前后分别验证数量、约束和关键关系。

七、集群:读写路由、复制和故障路径

7.1 集群解决什么问题

Neo4j Enterprise 的集群部署通常包含多个数据库副本。集群的目标包括:

  • 高可用;
  • 故障切换;
  • 读扩展;
  • 通过复制保持数据库副本的一致状态。

集群不是把一条 Cypher 查询自动拆成多个节点并行执行的查询计算网格。一个查询通常仍由一个数据库实例执行;把查询发到多个实例并不会自动把单次遍历分片。

7.2 写入路径

在复制数据库集群中,写事务需要由拥有相应写角色的服务器协调,通常称为 leader。粗略数据流如下:

应用
  -> 驱动路由
  -> 当前可写服务器
  -> 本地事务提交
  -> Raft/复制日志传播
  -> 其他副本应用日志
  -> 根据提交和应用状态提供服务

这里需要区分:

  • 事务提交:写事务在协调节点上成功提交;
  • 复制应用:其他副本接收并应用复制日志;
  • 客户端后续读取:可能连接到另一个服务器。

如果客户端写入后立即通过另一个读连接查询,是否能看到刚才的数据,取决于路由和因果一致性配置。

7.3 读路径和路由

应用不应把某个固定服务器地址永久当作“读节点”或“写节点”。角色可能因故障和选举变化。

Neo4j 驱动通常通过路由 URI 获取集群拓扑,并根据读写事务选择合适服务器。常见 URI 形式是:

neo4j://router.example.com

而不是把 bolt://host:port 当作具备自动集群路由的地址。具体 URI、端口、TLS 和路由配置要以部署方式为准。

事务应明确读写意图:

with driver.session(database="neo4j", default_access_mode="READ") as session:
    result = session.run(
        "MATCH (p:Person {id: $id}) RETURN p.name AS name",
        id="p1",
    )

API 名称和参数可能随 Python Driver 版本变化;原则是不把读写意图隐藏在连接字符串或业务约定中。

7.4 因果一致性和 bookmark

考虑以下流程:

  1. 客户端在写服务器提交创建 p1
  2. 客户端收到提交结果;
  3. 客户端马上向读服务器查询 p1
  4. 读服务器尚未应用相关复制日志。

如果没有因果协调,第三步可能暂时读不到刚写入的数据。驱动程序可以通过 bookmark 把前一事务的因果位置传递给后续会话,使后续读取等待或路由到能够满足该位置的服务器。

因此:

  • 普通最终一致读取可以接受短暂延迟时,不一定需要显式管理 bookmark;
  • “写入后必须立即读到”时,应使用驱动程序提供的因果一致性机制;
  • 不应仅依赖“写请求返回成功”推断任意服务器上的后续查询立即可见。

7.5 故障路径

写服务器故障

可能发生:

  1. 客户端连接中断;
  2. 集群重新选举可写角色;
  3. 驱动刷新拓扑;
  4. 事务函数遇到瞬态错误并重试;
  5. 若原事务是否提交不确定,应用需要保证重试逻辑幂等。

不能无条件重试所有写事务。数据库返回的错误需要区分:

  • 事务瞬态错误:通常可以在事务函数层重试;
  • 约束违反:修正数据或业务逻辑;
  • 语法错误:修正查询;
  • 权限错误:修正授权;
  • 锁超时:调整并发、事务大小或重试策略。

读服务器故障

读请求通常可以由驱动选择其他可用服务器,但正在执行的事务仍可能失败,需要重新创建会话或事务。游标和结果流不能假设在连接切换后自动续接。

副本滞后

副本如果暂时落后,可能导致:

  • 路由器不把它选为满足因果位置的读取目标;
  • 读请求等待;
  • 集群健康状态显示异常;
  • 负载被转移到其他副本。

排查时应结合集群状态、数据库状态、日志和复制延迟,而不是只看应用端查询耗时。

可以使用管理查询查看数据库和服务器状态:

SHOW DATABASES;

在支持相关管理命令的 Enterprise 部署中,还可以查看服务器状态:

SHOW SERVERS;

输出字段和权限要求取决于 Neo4j 版本及当前用户权限。执行管理查询前要确认当前连接的是哪个数据库、用户是否具有相应权限。


八、集群下的查询优化取舍

集群不会改变单条 Cypher 的基本成本模型:

总成本起点定位+关系扩展+过滤+聚合/排序+结果传输\text{总成本} \approx \text{起点定位} + \text{关系扩展} + \text{过滤} + \text{聚合/排序} + \text{结果传输}

但它会增加以下成本:

  • 路由和连接建立;
  • 副本选择;
  • 事务因果等待;
  • 跨节点故障重试;
  • 读副本之间的负载不均;
  • 写事务复制。

因此不能用“把查询发到读副本”替代查询优化。如果查询本身从全图开始并产生数百万路径,读副本只会把高成本工作转移到另一台服务器。

生产诊断应同时记录:

  • 查询文本或查询标识;
  • 参数规模和代表性参数;
  • 目标数据库;
  • 事务是读还是写;
  • 路由到的服务器;
  • 执行耗时;
  • 返回行数;
  • 数据库端执行时间和客户端网络时间;
  • 是否发生重试、等待或连接切换。

尤其要区分“数据库执行慢”和“客户端消费结果慢”。如果客户端逐行读取缓慢,数据库可能长期保持结果流、事务和相关资源;应用应及时消费或关闭结果与会话。


九、备份:备份的是可恢复数据库,不是简单复制目录

9.1 为什么不能直接复制在线数据库目录

Neo4j 数据库由数据文件、事务日志、索引和其他内部状态共同组成。在线运行时直接复制 store 目录可能得到:

  • 数据文件与事务日志不匹配;
  • 未完成事务状态不一致;
  • 缺少正确恢复所需的日志;
  • 复制过程中不同文件来自不同时间点;
  • 恢复后出现一致性或启动问题。

因此,运行中的数据库应使用 Neo4j 官方备份机制,而不是把在线 store 目录当成普通文件夹复制。

9.2 在线备份的基本流程

Neo4j Enterprise 支持在线数据库备份。命令行工具语法会随 Neo4j 版本和部署配置变化,建议先查看:

neo4j-admin database backup --help

Neo4j 5.x 中常见形式类似:

neo4j-admin database backup \
  --from=backup-source.example.com:6362 \
  --to-path=/var/backups/neo4j \
  neo4j

其中:

  • --from 指定备份源地址;
  • --to-path 指定备份目标目录;
  • 最后的 neo4j 是数据库名;
  • 备份端口、TLS、认证和网络访问必须按部署配置提供;
  • 执行用户必须有读备份所需的权限;
  • 备份目标磁盘必须有足够空间。

不要把示例端口、认证方式或地址直接复制到生产环境。应根据当前版本帮助输出和集群暴露的备份服务配置确定参数。

备份成功的含义不是“命令打印了几行日志”,而应包括:

  1. 命令退出码成功;
  2. 备份目录中出现完整备份文件或备份链;
  3. 日志中没有校验、连接或传输错误;
  4. 备份大小和文件数量符合预期;
  5. 能在隔离环境执行恢复验证。

9.3 全量备份与增量备份链

备份通常可以形成全量备份和后续增量备份。增量备份依赖之前的备份基线及连续的事务日志关系。

恢复时,必须保证:

  • 增量备份链没有缺失;
  • 备份未被覆盖或破坏;
  • 保留周期覆盖目标恢复点;
  • 备份源和目标版本满足官方兼容性要求;
  • 备份与目标数据库名、数据库配置相匹配。

如果中间的增量链损坏,后续备份可能无法独立恢复。生产系统应定期执行备份链校验和恢复演练,而不是只检查文件是否存在。

9.4 恢复流程和风险

恢复通常要求目标数据库停止或处于允许恢复的状态。常见命令形式类似:

neo4j-admin database restore \
  --from=/var/backups/neo4j/neo4j \
  --database=neo4j \
  --overwrite-destination=true

具体参数应以目标版本的帮助输出为准。

--overwrite-destination=true 的风险是覆盖目标数据库。执行前应确认:

  • 当前目标数据库确实是恢复目标;
  • 已经保存目标环境现有数据;
  • Neo4j 服务已停止或满足该命令的前置条件;
  • 配置、插件、证书和文件权限已准备好;
  • 恢复后数据库名与应用连接配置一致。

恢复命令通常只恢复数据库数据,不会自动恢复完整运行环境。还应单独管理:

  • neo4j.conf 等配置;
  • TLS 证书和密钥;
  • 插件和自定义过程;
  • 用户、角色和权限的恢复边界;
  • 外部密钥、对象存储和监控配置;
  • 应用侧 schema 迁移记录。

恢复完成后,不应立即让全部业务流量进入。应按以下顺序验证:

SHOW DATABASES;
SHOW INDEXES;
SHOW CONSTRAINTS;

然后执行业务级检查:

MATCH (p:Person)
RETURN count(p) AS persons;
MATCH ()-[r:FOLLOWS]->()
RETURN count(r) AS follows;

检查重点包括:

  1. 数据库是否成功启动并处于可用状态;
  2. 节点和关系数量是否在预期范围;
  3. 约束是否存在且在线;
  4. 索引是否在线;
  5. 关键查询计划是否仍使用预期索引;
  6. 应用是否能通过认证并执行读写;
  7. 集群恢复时各副本是否重新加入并完成同步。

9.5 备份策略的可验证性

“每天备份一次”不是完整策略,还需要定义:

  • 恢复点目标,即最多允许丢失多长时间的数据;
  • 恢复时间目标,即多久必须恢复服务;
  • 备份保留周期;
  • 备份是否跨主机或跨区域保存;
  • 加密和访问控制;
  • 备份失败告警;
  • 恢复演练频率;
  • 恢复后如何重新加入集群。

备份成功但从未恢复验证,不能证明可恢复。最可靠的验证是定期在隔离环境恢复一份备份,启动数据库,检查数据、约束、索引和关键查询。


十、运行时诊断:从慢查询到故障定位

10.1 查看正在运行的事务

在具备权限的情况下,可以使用:

SHOW TRANSACTIONS;

输出通常可以帮助识别:

  • 事务 ID;
  • 所属用户;
  • 当前查询;
  • 已运行时间;
  • 等待锁或其他资源的状态;
  • 所在服务器。

确认查询确实需要终止后,可以使用:

TERMINATE TRANSACTION 'transaction-id';

事务 ID 必须替换成实际值。终止长事务的风险包括:

  • 应用收到事务失败;
  • 已执行但未提交的变更被回滚;
  • 客户端自动重试导致重复负载;
  • 终止后锁释放和资源回收仍需要时间。

因此,终止事务是止损手段,不是查询优化手段。事后仍应通过 PROFILE、查询日志和应用调用栈查明原因。

10.2 慢查询排查顺序

一个可重复的排查过程如下。

1. 固定数据库和版本

同名查询在不同数据库、不同 Neo4j 版本或不同索引状态下,计划可能不同。先记录:

  • Neo4j 版本;
  • 数据库名;
  • 集群角色;
  • 客户端驱动版本;
  • 是否使用参数;
  • 采样参数值。

2. 使用 EXPLAIN 确认计划形状

重点看:

  • 是否从索引查找开始;
  • 是否从全标签扫描开始;
  • 关系类型和方向是否明确;
  • 可变长度扩展范围是否过大;
  • 是否存在早期 Filter
  • 是否有 SortDistinctEager

3. 使用代表性参数执行 PROFILE

至少选择:

  • 普通节点;
  • 高度连接节点;
  • 没有结果的节点;
  • 返回结果很多的节点。

只用一个低度节点测试,容易掩盖超级节点问题。

4. 比较估计行数和实际行数

找到第一次明显膨胀的位置。优化通常应从那里开始,而不是盲目添加索引。

5. 处理资源和并发问题

如果计划本身合理,但线上仍慢,应继续检查:

  • 是否存在锁等待;
  • 是否多个大查询同时运行;
  • 是否客户端结果消费过慢;
  • 是否发生集群路由重试;
  • 是否网络延迟占主要时间;
  • 是否磁盘、堆内存或页面缓存不足。

十一、常见误解与反例

误解一:有索引就一定快

反例:

MATCH (p:Person {id: $id})-[:FOLLOWS*1..5]->(x)
RETURN x;

即使 Person.id 有唯一索引,如果该用户拥有大量关注者,五层路径仍可能产生海量候选。索引只优化了第一步,不能消除后续组合爆炸。

误解二:给所有属性建立索引

索引有存储、写入维护和后台构建成本。一个只返回大部分节点的低选择性谓词,使用索引未必比扫描更好。应根据实际查询模式、选择性和执行计划创建索引,而不是为每个属性机械建索引。

误解三:DISTINCT 可以修复错误的遍历设计

DISTINCT 可以消除重复结果,但不能避免重复路径已经被生成。若扩展先产生一亿行,再用 DISTINCT 压缩到一千行,主要成本已经发生。

误解四:集群中的读副本会自动并行执行查询

单条查询通常由一个服务器执行。读副本可以分担不同请求,但不会自动把一个可变长度遍历拆给多个副本。

误解五:事务函数重试不会有副作用

数据库事务可以回滚,但外部系统副作用不能随数据库回滚。一个可重试事务函数中直接发送支付请求,可能出现支付已成功而数据库事务重试的情况。必须设计幂等请求或可靠的副作用协调方案。

误解六:备份文件存在就说明恢复可用

备份可能因链不完整、版本不兼容、权限错误、损坏或缺少配置而无法恢复。只有实际恢复并执行业务验证,才能证明备份策略有效。


十二、把查询、模型和运维放在同一条链路上

一个可维护的 Neo4j 系统通常按以下因果链设计:

业务访问模式
  -> 节点、关系和方向建模
  -> 唯一约束与必要索引
  -> 受控的 Cypher 起点和遍历边界
  -> EXPLAIN/PROFILE 验证计划
  -> 驱动事务与重试语义
  -> 集群路由与因果读取
  -> 备份、恢复和演练

其中任何一环都可能破坏整体效果:

  • 模型没有稳定业务键,MERGE 无法可靠幂等;
  • 起点不受限,索引也无法阻止大范围遍历;
  • 查询返回路径而非终点,结果规模被无意放大;
  • 只看最终行数,不看中间算子行数,无法发现计划膨胀;
  • 集群读写不区分,写后读取可能出现因果延迟;
  • 事务自动重试没有幂等设计,故障恢复反而制造重复副作用;
  • 只保存备份文件、不做恢复验证,无法证明系统具备灾难恢复能力。

Neo4j 查询优化的核心不是孤立地选择某个索引或某个集群参数,而是控制候选集如何产生、如何扩展、何时过滤、怎样提交,以及发生故障后如何恢复。只要能沿着执行计划逐步解释每一行数据从哪里来、为什么变多、在哪一步被过滤,并能在集群和备份层验证状态与恢复路径,图查询的性能问题和运维风险就能从“经验猜测”转化为可测量、可复现的问题。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。