Neo4jとCypher入門:複雑なリレーションシップのモデリングとクエリ設計

Database tutorial - IT technology blog
Database tutorial - IT technology blog

課題:複数テーブルの「JOIN地獄」

大半のバックエンドシステムは、整然としたリレーショナルテーブルからスタートします。ユーザー情報は1つのテーブルに、ロールは別のテーブルに、組織階層はネストされた外部キーマッピングで管理されます。この設計は、プロダクトチームがソーシャルフィード、リアルタイムレコメンデーションエンジン、再帰的な権限ツリー、あるいは不正検知リングの検出などを要求するまでは問題なく機能します。

しかし、3〜5ホップを超える動的なリレーションシップの探索を行うと、従来のリレーショナルスキーマはたちまちパフォーマンスの限界に達します。

複数のJOIN句を連結したり、1,000万行規模のテーブルに対して再帰的な共通テーブル式(CTE)を実行したりすると、データベースエンジンは負荷の高いインデックススキャンやメモリ上でのコストのかかる直積結合(Cartesian build)を強いられます。例えば「Aliceの直属の部下が監督するプロジェクトに関わっている共通の知り合いは誰か?」といったクエリは、本番環境の負荷下で実行レイテンシが40msから8秒以上に跳ね上がることがあります。

MongoDBのようなドキュメント指向データベースも同様の壁に直面します。ネストされたグラフデータを非正規化すると更新時の不整合が生じやすくなり、アプリケーション層でクエリを繋ぎ合わせるとネットワークの往復回数(ラウンドトリップ)が増加してしまいます。Neo4jは、接続関係をディスク上およびメモリ上に物理的に保存する構造そのものを根本から変えることで、このボトルネックを解消します。

基本概念:グラフモデリングとインデックスフリー隣接性

Neo4jは、ネイティブなラベル付きプロパティグラフデータベースです。実行時に外部キーインデックスを検索してリレーションシップを解決するのではなく、インデックスフリー隣接性(Index-Free Adjacency)を採用しています。各ノードは、隣接するリレーションシップや隣接ノードへの物理メモリポインタを直接保持します。リレーションシップの走査は、$O(\log N)$のB-Treeインデックス探索ではなく、$O(1)$の直接的なポインタジャンプで行われます。

グラフを構成する基本要素

  • ノード(Nodes):ドメイン内の核となるエンティティ(例:Person、Company、Project)。
  • ラベル(Labels):ノードをグループ化し、インデックス検索の対象を絞り込むためのタグ(例::User、:Admin)。
  • リレーションシップ(Relationships):ノード間に存在する方向性を持った型付きのエッジ(例:-[:MANAGES]->、-[:WORKS_ON]->)。すべてのリレーションシップには開始ノード、終了ノード、そして必ず1つのタイプが存在します。
  • プロパティ(Properties):ノードおよびリレーションシップの両方に付与できるキー・バリュー形式の属性(例:since: 2023、hoursPerWeek: 35)。

Cypher:直感的なパターンマッチング言語

Neo4jのクエリには、直感的なアスキーアートをベースにした宣言型言語「Cypher」を使用します。丸括弧はノード(p:Person)を表し、矢印と角括弧はリレーションシップ-[:WORKS_ON]->を表現します。クエリの見た目がホワイトボードに描くドメインの構成図と酷似しているため、直感的に記述できます。

ハンズオン実践:環境構築、モデリング、クエリ実行

1. DockerによるNeo4jのデプロイ

ローカルインスタンスを立ち上げるには、Dockerを利用するのが最も迅速です。ターミナルで以下のコマンドを実行し、Neo4j Community Editionを起動します。

docker run -d \
  --name neo4j-local \
  -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/StrongPassword123 \
  -e NEO4J_PLUGINS='["apoc"]' \
  -v neo4j_data:/data \
  neo4j:5.20.0

ポート7474はインタラクティブなWeb UIであるNeo4j Browserを提供します。ポート7687は、Node.js、Python、Go、Javaなどのバックエンドドライバが使用するバイナリプロトコル(Boltプロトコル)を処理します。ブラウザでhttp://localhost:7474にアクセスし、設定した認証情報でログインしてください。

2. ノードとリレーションシップの作成

チームメンバー、進行中のプロジェクト、保有スキルで構成されるエンジニアリング部門をモデリングしてみましょう。Neo4j Browserで以下のスニペットを直接実行します。

// エンジニアノードの作成
CREATE (alex:Person {id: 'u1', name: 'Alex', role: 'Staff Engineer'})
CREATE (bob:Person {id: 'u2', name: 'Bob', role: 'Backend Developer'})
CREATE (clara:Person {id: 'u3', name: 'Clara', role: 'DevOps Lead'})
CREATE (david:Person {id: 'u4', name: 'David', role: 'Frontend Developer'})

// プロジェクトおよびスキルノードの作成
CREATE (projA:Project {id: 'p1', name: 'Payment Gateway', status: 'Active'})
CREATE (projB:Project {id: 'p2', name: 'Cloud Migration', status: 'Active'})
CREATE (k8s:Skill {name: 'Kubernetes'})
CREATE (go:Skill {name: 'Golang'})
CREATE (react:Skill {name: 'React'})

// ノード間をリレーションシップで接続
CREATE (alex)-[:MANAGES]->(bob)
CREATE (alex)-[:MANAGES]->(clara)
CREATE (bob)-[:WORKS_ON {hoursPerWeek: 30}]->(projA)
CREATE (clara)-[:WORKS_ON {hoursPerWeek: 20}]->(projB)
CREATE (david)-[:WORKS_ON {hoursPerWeek: 40}]->(projA)
CREATE (bob)-[:KNOWS]->(david)
CREATE (clara)-[:HAS_SKILL {proficiency: 'Expert'}]->(k8s)
CREATE (bob)-[:HAS_SKILL {proficiency: 'Intermediate'}]->(go)
CREATE (projA)-[:REQUIRES]->(go)
CREATE (projB)-[:REQUIRES]->(k8s);

3. グラフパターンのクエリ

データが投入できたので、SQLであれば多数のJOINが必要となるトラバーサルパターンを実行してみましょう。

同一プロジェクトに関わる同僚の検索

MATCH (p1:Person)-[:WORKS_ON]->(proj:Project)<-[:WORKS_ON]-(p2:Person)
WHERE p1.name = 'Bob' AND p1 <> p2
RETURN proj.name AS Project, p2.name AS Colleague, p2.role AS Role;

2ホップ先にいる特定スキルを持つチームメンバーの検索

Payment GatewayチームがKubernetesの専門知識を必要としていると仮定します。Alexが管理しているメンバーの中から、そのスキルを持つ人材を検索できます。

MATCH (mgr:Person {name: 'Alex'})-[:MANAGES]->(member:Person)-[:HAS_SKILL]->(s:Skill {name: 'Kubernetes'})
RETURN member.name AS RecommendedEngineer, member.role AS Position;

共同作業者間の最短パスの計算

2人の開発者間における分離度やコミュニケーションパスを確認するには、次のように記述します。

MATCH path = shortestPath((david:Person {name: 'David'})-[*..5]-(clara:Person {name: 'Clara'}))
RETURN path;

4. 実務で役立つプロダクション運用のヒント

  • 探索の起点にインデックスを貼る:グラフトラバーサル自体はダイレクトポインタを使用しますが、起点となるルートノードの特定には依然としてインデックスが必要です。IDフィールドには常に一意性制約を付与してください:CREATE CONSTRAINT FOR (p:Person) REQUIRE p.id IS UNIQUE;
  • べき等な取り込みのためにMERGEを活用する:ETLパイプラインでは、MERGEを使用することで既存レコードの検索または新規作成をオンデマンドで行い、ノードの重複や参照の不整合を防ぎます。
  • 共有属性を独立したノードに昇格させる:頻繁にフィルタリングや集計を行う共通の属性(CountryやTeamなど)が多数のレコードで共有されている場合は、単純な文字列プロパティのままにせず、個別のノードとしてモデル化してください。
  • ページキャッシュのサイズを適切に設定する:ミリ秒未満の高速ホップを維持するために、server.memory.pagecache.size(例:8GBや16GBなど)を適切に構成し、グラフのリレーションシップストアをRAM上に常駐させてください。

コネクテッドデータに適したツールの選定

リレーショナルデータベースは、トランザクション台帳の記録や表形式のレポーティングに極めて優れています。また、ドキュメントデータベースは独立したドキュメント形式のペイロードを扱うのに最適です。しかし、相互に深く結びついたエンティティが中心となるドメインでは、それらの関係性をフラットなテーブルに無理やり押し込めることで、壊れやすく低速なクエリを生み出す原因となります。

プライマリデータベースと並行してNeo4jを導入することで、認可ツリー、アイデンティティグラフ、レコメンデーションエンジンなどの処理を専用のエンジンにオフロードできます。Cypherを通じてリレーションシップを第一級プリミティブとして扱うことで、データ規模が拡大してもクエリコードを簡潔に保ち、安定した応答速度を維持できます。

Share: