跳到主要内容

创建日期:2026-10-15 | 最近更新:2026-10-15 版本基线:Neo4j 5.26.4 community。本系列用本机 Docker 实例实测(docker exec neo4j-lab cypher-shell ...),本节所有执行计划都是真实的 PROFILE / EXPLAIN 输出,脚本与日志留档在 source/neo4j-lab/。

Neo4j 101 索引与执行计划:五种索引与读懂 EXPLAIN / PROFILE

一句话:图的遍历很快,但入口经常不快——一条 MATCH (p:Product {skuId:…}) 是走索引直取一行,还是翻遍所有 Product,全看你建没建索引。这篇讲五种索引各管什么谓词,以及怎么用 EXPLAIN / PROFILE 把执行计划读成人话。

本篇是 Neo4j 101 系列 第 4 篇。上一篇写了各种查询,这一篇回答"它们跑起来到底做了什么"。

1. 没索引会怎样​

先看一个真实的前后对比。同一条查询,先把索引摘掉再放回来:

PROFILE MATCH (p:Product) WHERE p.name = 'iPhone 15 Pro 256G 黑色' RETURN p.skuId AS SKU;

没有索引时(DROP INDEX product_name):

+------------------+----+-----------+----------------+------+---------+
| Operator | Id | Details | Estimated Rows | Rows | DB Hits |
+------------------+----+-----------+----------------+------+---------+
| +ProduceResults | 0 | SKU | 0 | 1 | 0 |
| +Projection | 1 | p.skuId | 0 | 1 | 1 |
| +Filter | 2 | ... | 0 | 1 | 1 |
| +NodeByLabelScan | 3 | p:Product | 10 | 6 | 7 |
+------------------+----+-----------+----------------+------+---------+

有索引时:

+-----------------+----+---------------------------------------------------------+----------------+------+---------+
| Operator | Id | Details | Estimated Rows | Rows | DB Hits |
+-----------------+----+---------------------------------------------------------+----------------+------+---------+
| +ProduceResults | 0 | SKU | 0 | 1 | 0 |
| +Projection | 1 | p.skuId | 0 | 1 | 1 |
| +NodeIndexSeek | 2 | RANGE INDEX p:Product(name) WHERE name = $autostring_0 | 0 | 1 | 2 |
+-----------------+----+---------------------------------------------------------+----------------+------+---------+

差别一目了然:

算子扫了/找了几行DB Hits
无索引NodeByLabelScan扫遍全部 6 个 Product7
有索引NodeIndexSeek直取 1 行2

这里数据只有 6 行,看不出来。注意 Estimated Rows 那一列:计划器估算是 10 行(它的统计信息认为 Product 有 10 个), 实际是 6 行——数据量上万倍之后,这个"扫全表"就是几百万次 DB Hits。

一个必须纠正的高频说法:很多人说"没索引就是 AllNodesScan(全节点扫描)"。不准确。 实测:MATCH (n) RETURN n 是 AllNodesScan,但 MATCH (p:Product) RETURN p 是 NodeByLabelScan—— 带标签的会退化成"按标签扫",只有完全不带标签的才是全节点扫。 两者都能被"加索引"治好,但报出来的算子名不一样,看计划时别认错。

2. 五种索引各管什么​

索引类型建法管哪些谓词本机实测
RANGECREATE INDEX … ON (n.prop)=、>、<、STARTS WITH✅ 13 个索引里 9 个是它
TEXTCREATE TEXT INDEX … ON (n.prop)CONTAINS、ENDS WITH✅ 建出来了
POINTCREATE POINT INDEX … ON (n.prop)空间谓词(距离、包围盒)需要坐标数据才有意义
LOOKUP数据库默认就建好按标签或关系类型找起点✅ 每个库里默认两个
FULLTEXTCREATE FULLTEXT INDEX … ON EACH [n.a, n.b]分词、模糊、排序打分✅ 支持中文(cjk 分析器)

TEXT 和 RANGE 的分工容易被忽略:5.x 之前的 CONTAINS 走 RANGE 索引基本等于全扫; 现在 TEXT 索引用 trigram 实现,CONTAINS / ENDS WITH 才真正快起来。 看到 WHERE p.name CONTAINS '香水' 却在 name 上建了 RANGE——那是白建。

一张决策图:

3. 建 / 看 / 删​

5.x 的语法是 CREATE INDEX … FOR … ON …:

CREATE INDEX product_name IF NOT EXISTS FOR (p:Product) ON (p.name);
CREATE TEXT INDEX product_desc IF NOT EXISTS FOR (p:Product) ON (p.description);
CREATE FULLTEXT INDEX product_ft IF NOT EXISTS FOR (p:Product) ON EACH [p.name, p.tags];

老教程里的 4.x 写法已经不能用了,实测报错原文:

CREATE INDEX ON :Product(price);
Invalid create index syntax, use `CREATE INDEX FOR ...` instead. (line 1, column 14 (offset: 13))

SHOW INDEXES 怎么看——这是本机 13 个索引的真实输出(删掉 owningConstraint 列以省宽度):

+---------------------+------------+---------------+----------------------+-------------------+--------------------+
| name | type | labelsOrTypes | properties | populationPercent | indexProvider |
+---------------------+------------+---------------+----------------------+-------------------+--------------------+
| "product_ft" | "FULLTEXT" | ["Product"] | ["name", "tags"] | 100.0 | "fulltext-1.0" |
| "index_343aff4e" | "LOOKUP" | NULL | NULL | 100.0 | "token-lookup-1.0" |
| "index_f7700477" | "LOOKUP" | NULL | NULL | 100.0 | "token-lookup-1.0" |
| "brand_name" | "RANGE" | ["Brand"] | ["name"] | 100.0 | "range-1.0" |
| "category_nm" | "RANGE" | ["Category"] | ["name"] | 100.0 | "range-1.0" |
| "order_id" | "RANGE" | ["Order"] | ["orderId"] | 100.0 | "range-1.0" |
| "order_user_status" | "RANGE" | ["Order"] | ["userId", "status"] | 100.0 | "range-1.0" |
| "product_name" | "RANGE" | ["Product"] | ["name"] | 100.0 | "range-1.0" |
| "product_sku" | "RANGE" | ["Product"] | ["skuId"] | 100.0 | "range-1.0" |
| "promo_id" | "RANGE" | ["Promotion"] | ["promoId"] | 100.0 | "range-1.0" |
| "store_id" | "RANGE" | ["Store"] | ["storeId"] | 100.0 | "range-1.0" |
| "user_id" | "RANGE" | ["User"] | ["userId"] | 100.0 | "range-1.0" |
| "product_desc" | "TEXT" | ["Product"] | ["description"] | 100.0 | "text-2.0" |
+---------------------+------------+---------------+----------------------+-------------------+--------------------+

几个要点:

  • populationPercent = 100.0 才是"建好了、可以用了"。刚建完索引时它可能小于 100——索引是后台异步填充的,没填满时用不上。这是从"建索引"到"索引生效"之间那道容易被忽略的沟。
  • indexProvider 是底层实现的名字,RANGE 是 range-1.0、TEXT 是 text-2.0、FULLTEXT 是 fulltext-1.0。
  • owningConstraint 非空 = 这个索引是约束自带的(前面 7 条唯一性约束各带一个索引)。

4. 复合索引与最左前缀​

和 MySQL 一个道理。本机建了个 order_user_status ON (o.userId, o.status),实测:

// 用得上:条件里有最左列 userId
EXPLAIN MATCH (o:Order) WHERE o.userId='u1' AND o.status='paid' RETURN o.orderId;
| +NodeIndexSeek | 2 | RANGE INDEX o:Order(userId, status) WHERE userId = $autostring_0 AND status = $autostring_1 |
// 用不上:只给了第二列 status
EXPLAIN MATCH (o:Order) WHERE o.status='paid' RETURN o.orderId;
| +NodeByLabelScan | 3 | o:Order |

**只给 status 就退回全标签扫描。**建复合索引前先想清楚"最常单查哪一列",把它放最左。

5. 全文索引与中文​

全文索引和前面几种不是一回事:它给文本分词、建倒排,查询用专门的过程,返回相关性得分。

CREATE FULLTEXT INDEX product_ft IF NOT EXISTS
FOR (p:Product) ON EACH [p.name, p.tags]
OPTIONS { indexConfig: { `fulltext.analyzer`: 'cjk' } };

CALL db.index.fulltext.queryNodes('product_ft', '旗舰') YIELD node, score
RETURN node.skuId AS SKU, node.name AS 商品, round(score, 3) AS 得分
ORDER BY 得分 DESC;

真实输出:

+---------------------------------------------+
| SKU | 商品 | 得分 |
+---------------------------------------------+
| "SKU-001" | "iPhone 15 Pro 256G 黑色" | 0.483 |
| "SKU-004" | "小米 14 256G 白色" | 0.483 |
+---------------------------------------------+

命中的是 tags 里带 '旗舰' 的那两个商品——注意它匹配的不是 name,是 tags 数组,全文索引对数组一样有效。

**中文必须显式指定分析器。**默认的 standard 分析器按"非字母边界"切词, 中文没有空格,会被切成一堆无意义的碎片。本机 db.index.fulltext.listAvailableAnalyzers() 返回 45 个分析器,中文相关的是 cjk。改成 cjk 之后,上面这句才真正按"词"匹配。

顺带一提:'旗舰' 是一个词所以能命中。如果你查的是个子串(比如 '果 15'), 分词之后对不上就是查不到——全文索引是词级匹配,不是子串匹配。要子串用 TEXT 索引。

6. EXPLAIN vs PROFILE​

是否执行有 Rows / DB Hits 吗什么时候用
EXPLAIN❌ 只规划❌(显示 ?)写完查询先看一眼"会不会走笛卡尔积"
PROFILE✅ 真跑✅已经慢了,要找出慢在哪一步

本系列贴计划时的唯一正确姿势:cypher-shell 非交互模式下,PROFILE 默认只打印汇总行,不打印算子树。 必须加 --format verbose:

docker exec -i neo4j-lab cypher-shell -u neo4j -p password --format verbose "PROFILE MATCH ..."

这个坑很隐蔽——不加的话你会以为"计划怎么没输出",其实是被格式选项吞了。

7. 六个必认识的算子​

算子含义看到它意味着
AllNodesScan扫所有节点无标签的模式 (n);数据量大就是灾难
NodeByLabelScan按标签扫有标签但没索引(或索引没生效)
NodeIndexSeek / NodeUniqueIndexSeek走索引直取好。后者是唯一约束撑起来的,最快
Expand(All)沿边走一跳正常,这是图的本职工作
VarLengthExpand(All)变长路径展开看 Details 里有没有上界
CartesianProduct两两组合危险信号,通常是把互不相连的模式写在一起了

还有两个"计划比你聪明"的例子:

EXPLAIN MATCH (n) RETURN count(n) AS 节点总数;
| +NodeCountFromCountStore | 1 | count( () ) AS 节点总数 |

**它不扫描,直接读计数存储。**所以 MATCH (n) RETURN count(n) 在任何规模上都是常数时间——这条在关系型里要 COUNT(*) 扫全表。

EXPLAIN MATCH (b:Brand) MATCH (c:Category) WHERE b.name = c.name RETURN b.name AS 名字;
| +Apply | 2 | |
| |--+NodeUniqueIndexSeek| 3 | UNIQUE b:Brand(name) WHERE name = c.name, cache[...]|
| +NodeByLabelScan | 4 | c:Category |

两个看似独立的模式,没有变成 CartesianProduct——优化器把它重写成 Apply(对 Category 的每一行,去 Brand 的索引里查一次)。 这就是"看起来像笛卡尔积、其实不是"的例子,也是为什么看计划比看 SQL 猜要靠谱。

8. 三个「这个查询要炸」的信号​

  1. 计划里出现 CartesianProduct —— 通常意味着两个模式该连的关系没连上。
  2. Estimated Rows 突然跳两个数量级 —— 上游只给几行,下游要处理几百万行。
  3. VarLengthExpand 没有上界 —— Details 里写的是 * 而不是 *..3:
EXPLAIN MATCH (a:Category {name:'手机'})-[:SUBCATEGORY_OF*]->(b:Category) RETURN a.name, b.name;
| +VarLengthExpand(All) | 3 | (a)-[anon_0:SUBCATEGORY_OF*]->(b) |

对比有上界的版本:

| +VarLengthExpand(All) | 3 | (a)-[anon_0:SUBCATEGORY_OF*..3]->(b) |

*..3 和 * 在生产上是"能跑"和"可能跑到 OOM"的区别。

还有个最隐蔽的:在属性上套函数。

EXPLAIN MATCH (p:Product) WHERE toLower(p.name) = 'iphone 15 128g 蓝色' RETURN p.skuId AS SKU;
| +Filter | 2 | toLower(p.name) = $autostring_0 |
| +NodeByLabelScan | 3 | p:Product |

name 上明明有索引,但因为 toLower(p.name) 不是对"属性本身"求值,索引没法用,退化成 NodeByLabelScan。 解决办法:改成范围/等值查询,或者把规范化后的值预先存成一个属性(p.nameLower)再给它建索引。

9. 一页速查​

症状看什么怎么治
查询慢PROFILE 找 DB Hits 最大的算子补索引 / 改写模式
看到 NodeByLabelScanSHOW INDEXES 看 populationPercent建索引;没到 100 就等
看到 AllNodesScan模式里是不是 (n) 没写标签补标签
看到 CartesianProduct两个模式是不是互不相连用关系把它们连起来
索引建了没用上Details 里的表达式属性上是不是套了函数
Estimated 和 Rows 差很多统计信息过期一般无需干预,久了会自平衡

自测​

  1. NodeByLabelScan 和 AllNodesScan 分别什么时候出现?
  2. CONTAINS 该建哪种索引?为什么在 name 上建 RANGE 帮不了它?
  3. populationPercent 不是 100 意味着什么?
  4. 复合索引 (userId, status) 能加速哪些查询?哪种加速不了?
  5. EXPLAIN 和 PROFILE 最大的区别是什么?本系列为什么必须加 --format verbose?
  6. 计划里出现 CartesianProduct 说明什么?为什么 Apply 不是它?
  7. 为什么 toLower(p.name) = '…' 用不上 name 上的索引?怎么解决?

关联​

参考​

  • 实测环境:Neo4j 5.26.4 community(neo4j:5.26.4-community)
  • 脚本与真实输出:source/neo4j-lab/(cypher/04-explain.cypher、out/04-explain.log、out/03-queries.log)
  • Neo4j 官方文档:Cypher Manual — Indexes(五种索引的定义与可用谓词)、Full-text indexes(分析器与查询过程)