neo4j-query-tuning-skill について
Neo4jのCypherクエリを診断するためのチューニングガイドです。実行計画、インデックス、ランタイム、監視コマンドを解説し、修正の適用と効果測定の手順も示します。
主な機能
- 01EXPLAINとPROFILEの計画を解釈
- 02スキャンや直積を特定
- 03適切なインデックスを確認・作成
- 04Cypherランタイムを比較
- 05クエリの監視と終了
活用シーン
- 01予想外に遅いCypherクエリの調査
- 02計画指標からボトルネックを特定
- 03不足インデックスや結合条件を修正
- 04長時間実行中のクエリを監視・処理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ neo4j-contrib/ neo4j-skills --skill neo4j-query-tuning-skillインストール後、Claude Code で/neo4j-query-tuning-skillを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
README.mdreferences/plan-operators.mdstats-and-monitoring.md詳細
使用する場面
- クエリの実行に予想以上に時間がかかり、根本原因の分析が必要な場合
- EXPLAIN/PROFILE の出力があり、その解釈が必要な場合
- 欠落しているインデックスや未使用のインデックスを特定する場合
- スロット型/パイプライン型/並列ランタイムのどれを使うか判断する場合
- ライブクエリを監視する場合:SHOW QUERIES、SHOW TRANSACTIONS
- カーディナリティの推定が誤っている場合(プランの再計画が必要)
使用しない場面
- Cypher を一から作成する場合 →
neo4j-cypher-skill - GDS アルゴリズムのパフォーマンス →
neo4j-gds-skill - スキーマ設計/データモデリング →
neo4j-modeling-skill
EXPLAIN と PROFILE の比較
| EXPLAIN | PROFILE | |
|---|---|---|
| クエリを実行しますか? | いいえ | はい |
| データを返しますか? | いいえ | はい |
rows(実測値)を表示しますか? | いいえ | はい |
dbHits(実測値)を表示しますか? | いいえ | はい |
estimatedRows を表示しますか? | はい | はい |
| コスト | ゼロ | クエリ全体のコスト |
PROFILE は 2 回実行してください。1 回目でページキャッシュがウォームアップされ、2 回目に代表的なメトリクスが得られます。
EXPLAIN MATCH (p:Person {email: $email}) RETURN p.name
PROFILE MATCH (p:Person {email: $email}) RETURN p.name
クエリ API を使う方法(ドライバー不要):
curl -X POST https://<host>/db/<db>/query/v2 \
-u <user>:<pass> -H "Content-Type: application/json" \
-d '{"statement": "EXPLAIN MATCH (p:Person {email: $email}) RETURN p.name", "parameters": {"email": "[email protected]"}}'
主要なプランメトリクス
| メトリクス | 良好 | 調査が必要な場合 |
|---|---|---|
dbHits | 低い。インデックス追加後に減少する | rows に対して高い |
rows | プランの早い段階で減少する | 最終オペレーターまで大きい |
estimatedRows | rows に近い | 実測値との乖離が 10 倍を超える |
pageCacheHitRatio | >0.99 | <0.90(ディスク I/O がボトルネック) |
pageCacheHits | 多い | — |
pageCacheMisses | ほぼ 0 | 増加している(ページキャッシュが小さすぎる) |
プランは 下から上へ読みます。最下部のリーフオペレーターがデータ取得を開始します。
オペレーターリファレンス
| オペレーター | 良否 | 意味 | 対処法 |
|---|---|---|---|
NodeIndexSeek | ✓ | RANGE/LOOKUP インデックスによる完全一致 | — |
NodeUniqueIndexSeek | ✓ | 一意性制約のインデックスヒット | — |
NodeIndexContainsScan | ✓ | TEXT インデックスの CONTAINS/STARTS WITH | — |
NodeIndexScan | ~ | インデックス全体のスキャン(述語なし) | WHERE 述語または複合インデックスを追加する |
NodeByLabelScan | ✗ | ラベルが付いたすべてのノードをスキャンする | 検索対象のプロパティに RANGE インデックスを追加する |
AllNodesScan | ✗✗ | ノードストア全体をスキャンする | MATCH にラベルとインデックスを追加する |
Expand(All) | ~ | ノードからリレーションシップをたどる | 通常の動作。LIMIT または WHERE で制限する |
Expand(Into) | ~ | マッチした 2 つのノード間のリレーションシップを検索する | 接続先が既知のノード間結合では通常の動作 |
Filter | ~ | スキャン後に述語を適用する | インデックスを使って述語を WHERE に移動する |
CartesianProduct | ✗ | 2 つの MATCH 間に結合述語がない | WHERE に結合条件を追加するか、MATCH の間に WITH を使う |
NodeHashJoin | ~ | ノード ID によるハッシュ結合 | 通常の動作。プランナーがハッシュ結合を選択している |
ValueHashJoin | ~ | 値によるハッシュ結合 | 通常の動作。入力が大きい場合はメモリに注意する |
EagerAggregation | ~ | 全件集約(ORDER BY、count(*)) | 集約では通常の動作 |
Aggregation | ✓ | ストリーミング集約 | — |
Eager | ✗ | 読み取り/書き込みの競合があり、すべての行を実体化する | 以下の Eager 対処方法を参照 |
Sort | ~ | 全件ソート — O(n log n) | Sort の前に LIMIT を追加する。LIMIT をより早い段階に移動する |
Top | ✓ | Sort と Limit を結合 — O(n log k) | Sort と Limit の組み合わせより優先する |
Limit | ✓ | 行を早い段階で切り詰める | できるだけ早い段階に移動する |
Skip | ~ | オフセットページネーション | 大規模グラフではキーセットページネーションを使う |
ProduceResults | — | 最終出力オペレーター | ツリーのルート |
UndirectedRelationshipByIdSeekPipe | ~ | リレーションシップ ID による検索 | id(r) を避け、elementId(r) を使う |
オペレーターの完全なリファレンス → references/plan-operators.md
診断ワークフロー(エージェント用手順書)
ステップ 1 — ベースラインプラン
EXPLAIN <query>
出力を確認し、AllNodesScan、NodeByLabelScan、CartesianProduct、Eager を探します。
ステップ 2 — インデックスを確認
SHOW INDEXES YIELD name, type, labelsOrTypes, properties, state
WHERE state = 'ONLINE'
問題のオペレーターに関係するラベル/プロパティにインデックスがあるか確認します。
ステップ 3 — 不足しているインデックスを作成
// RANGE index for equality/range predicates:
CREATE INDEX person_email IF NOT EXISTS FOR (n:Person) ON (n.email)
// TEXT index for CONTAINS/ENDS WITH:
CREATE TEXT INDEX person_bio IF NOT EXISTS FOR (n:Person) ON (n.bio)
// Composite for multi-property lookup:
CREATE INDEX order_status_date IF NOT EXISTS FOR (n:Order) ON (n.status, n.createdAt)
計測する前に、state = 'ONLINE' になるまで待ちます。
ステップ 4 — 修正後にプロファイルを取得
PROFILE <query>
修正前後の dbHits と経過時間(ミリ秒)を比較します。目標は、スキャンオペレーターが NodeIndexSeek に置き換わることです。
ステップ 5 — 古い統計情報(estimatedRows が大きく乖離している場合)
CALL db.prepareForReplanning()
// or resample a specific index:
CALL db.resampleIndex("person_email")
// or resample all outdated:
CALL db.resampleOutdatedIndexes()
設定:dbms.cypher.statistics_divergence_threshold(デフォルトは 0.75 — 統計情報が 75% を超えて変化すると、プランの有効期限が切れます)。
よくあるプランの問題を解決する
インデックスがない → NodeByLabelScan / AllNodesScan
// Force index hint when planner ignores it:
MATCH (p:Person {email: $email})
USING INDEX p:Person(email)
RETURN p.name
// Force label scan (sometimes faster for high selectivity):
MATCH (p:Person {email: $email})
USING SCAN p:Person
RETURN p.name
アンカーが不適切 — プランナーが誤った開始ノードを選択する
MATCH の順序を入れ替えるか、ヒントを使用します:
// Force join at specific node:
MATCH (a:Author)-[:WROTE]->(b:Book)-[:IN_CATEGORY]->(c:Category {name: $cat})
USING JOIN ON b
RETURN a.name, b.title
CartesianProduct — 接続されていない 2 つの MATCH
// Bad (Cartesian product):
MATCH (a:Author {id: $aid})
MATCH (b:Book {id: $bid})
RETURN a.name, b.title
// Good (explicit join or WITH):
MATCH (a:Author {id: $aid})-[:WROTE]->(b:Book {id: $bid})
RETURN a.name, b.title
// Or: WITH between them to reset planning context
Eager — 読み取り/書き込みの競合
3 つの方法(最も簡単なものを選択してください):
- MATCH ノードに具体的なラベルを追加することで、プランナーが読み取り/書き込みセットを区別できるようにします
- 収集してから書き込む:
WITH collect(n) AS nodes UNWIND nodes AS n SET n.x = 1 - CALL IN TRANSACTIONS:各バッチを独自のトランザクションに分離します
CYPHER 25
MATCH (p:Person) WHERE p.score > 100
CALL (p) { SET p.tier = 'gold' } IN TRANSACTIONS OF 1000 ROWS
コストの高い CONTAINS / ENDS WITH
// Needs TEXT index (RANGE does NOT support these):
CREATE TEXT INDEX person_bio IF NOT EXISTS FOR (n:Person) ON (n.bio)
MATCH (p:Person) WHERE p.bio CONTAINS $keyword RETURN p.name
過剰なトラバーサル — LIMIT を早い段階で適用する
// Bad: LIMIT after expensive join
MATCH (a:Author)-[:WROTE]->(b:Book)-[:REVIEWED_BY]->(r:Review)
RETURN a.name, b.title, r.text LIMIT 10
// Good: anchor limit before fan-out
MATCH (a:Author)-[:WROTE]->(b:Book)
WITH a, b LIMIT 10
MATCH (b)-[:REVIEWED_BY]->(r:Review)
RETURN a.name, b.title, r.text
Cypher ランタイムの選択
| ランタイム | 選択方法 | 最適な用途 | 避けるべき場合 |
|---|---|---|---|
pipelined | CYPHER runtime=pipelined | デフォルトの OLTP。ストリーミング、低メモリ使用量 | 未サポートの演算子は slotted にフォールバックします |
slotted | CYPHER runtime=slotted | 動作の安定性が保証される。デバッグ | パフォーマンスが重要な OLTP |
parallel | CYPHER 25 runtime=parallel | 大規模な分析スキャン。集計 | OLTP、書き込み、短いクエリ、Aura Free |
ほとんどのクエリでは Pipelined がデフォルトです。Parallel を使用するには dbms.cypher.parallel.worker_limit の設定が必要です。Enterprise および Aura Pro 2025+ で利用できます。
// Force parallel for large aggregation:
CYPHER 25 runtime=parallel
MATCH (n:Transaction) WHERE n.amount > 1000
RETURN n.currency, count(*), sum(n.amount)
クエリの監視コマンド
// Live queries + resource usage:
SHOW QUERIES YIELD query, queryId, elapsedTimeMillis, allocatedBytes, status, username
// Running transactions:
SHOW TRANSACTIONS YIELD transactionId, currentQuery, currentQueryProgress, elapsedTime, status, username, cpuTime, activeLockCount // currentQueryProgress added [2026.03]
// Kill a specific transaction:
TERMINATE TRANSACTION $transactionId
// Kill a query:
TERMINATE QUERY $queryId
// Graph count stats (node/rel counts by label/type — feed into planner):
CALL db.stats.retrieve('GRAPH COUNTS') YIELD section, data RETURN section, data
// Token stats (label/property/rel-type IDs):
CALL db.stats.retrieve('TOKENS') YIELD section, data RETURN section, data
監視に関する完全なリファレンス → references/stats-and-monitoring.md
チェックリスト
- まず
EXPLAINを実行する — 実行コストをかけずにプランの問題を特定できます -
AllNodesScan/NodeByLabelScanを確認する — インデックスがありません -
CartesianProductを確認する — 結合述語がありません -
Eagerを確認する — 読み取り/書き込みの競合があります -
SHOW INDEXES— 関連するインデックスが存在し、state = 'ONLINE'であることを確認します - 不足しているインデックスを作成し、ONLINE になるまで待ちます
-
PROFILEを 2 回実行する — 1 回目でキャッシュをウォームアップし、2 回目で代表的な結果を得ます - 修正前後の
dbHitsを比較します -
estimatedRowsが大きく乖離している場合 →CALL db.prepareForReplanning() - ファンアウトの大きい処理の前に
LIMIT/WITH n LIMIT kを適用します - CONTAINS/ENDS WITH には RANGE ではなく TEXT インデックスを使用します
- 大規模な分析クエリでは
runtime=parallelを検討します - 長時間実行中のクエリは
TERMINATE TRANSACTIONで終了します