创建日期: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 个 Product | 7 |
| 有索引 | 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. 五种索引各管什么
| 索引类型 | 建法 | 管哪些谓词 | 本机实测 |
|---|---|---|---|
| RANGE | CREATE INDEX … ON (n.prop) | =、>、<、STARTS WITH | ✅ 13 个索引里 9 个是它 |
| TEXT | CREATE TEXT INDEX … ON (n.prop) | CONTAINS、ENDS WITH | ✅ 建出来了 |
| POINT | CREATE POINT INDEX … ON (n.prop) | 空间谓词(距离、包围盒) | 需要坐标数据才有意义 |
| LOOKUP | 数据库默认就建好 | 按标签或关系类型找起点 | ✅ 每个库里默认两个 |
| FULLTEXT | CREATE 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. 三个「这个查询要炸」的信号
- 计划里出现
CartesianProduct—— 通常意味着两个模式该连的关系没连上。 Estimated Rows突然跳两个数量级 —— 上游只给几行,下游要处理几百万行。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 最大的算子 | 补索引 / 改写模式 |
看到 NodeByLabelScan | SHOW INDEXES 看 populationPercent | 建索引;没到 100 就等 |
看到 AllNodesScan | 模式里是不是 (n) 没写标签 | 补标签 |
看到 CartesianProduct | 两个模式是不是互不相连 | 用关系把它们连起来 |
| 索引建了没用上 | Details 里的表达式 | 属性上是不是套了函数 |
Estimated 和 Rows 差很多 | 统计信息过期 | 一般无需干预,久了会自平衡 |
自测
NodeByLabelScan和AllNodesScan分别什么时候出现?CONTAINS该建哪种索引?为什么在name上建 RANGE 帮不了它?populationPercent不是 100 意味着什么?- 复合索引
(userId, status)能加速哪些查询?哪种加速不了? EXPLAIN和PROFILE最大的区别是什么?本系列为什么必须加--format verbose?- 计划里出现
CartesianProduct说明什么?为什么Apply不是它? - 为什么
toLower(p.name) = '…'用不上name上的索引?怎么解决?
关联
- 上一篇:查询与路径
- 下一篇:聚合与边界
- 关系型那头的对照:MySQL 索引与 EXPLAIN(同一套"最左前缀"直觉)
- 系列入口:Neo4j 101
参考
- 实测环境: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(分析器与查询过程)