限界点:「JOIN地獄」からの脱脱
長年、PostgreSQLを頼りにしてきました。非常に強力なデータベースですが、限界もあります。6ヶ月前、ソーシャルコマースアプリをスケールさせていた際、その壁に突き当たりました。850万件のフォロー関係と、約200万件の購入イベントを追跡していましたが、データは単にリンクされているだけでなく、興味や行動が複雑に重なり合う密なウェブ(網)のようになっていました。
従来のSQL構成では、単純な「おすすめ商品」クエリに6つのネストされたJOINが必要でした。レスポンスタイムは4.8秒まで低下。14日間のスプリントを費やして外部キーのインデックス作成やクエリの書き直しを行いましたが、パフォーマンスの向上は微々たるものでした。ボトルネックは構造的な問題だったのです。Neo4jに移行したのは、グラフがリレーションシップを物理的なポインタとして保存するからです。実行時に計算されるのではなく、すでに存在しているのです。
テーブルを忘れ、パターンで考える
最も難しかったのは、スプレッドシートのような思考を捨てることでした。Neo4jでは、ノード(「モノ」)、プロパティ(データ)、リレーションシップ(「コネクタ」)を扱います。すべてのリレーションシップには方向とタイプがあります。このデータを辿る感覚は、ホワイトボードに絵を描くのに似ています。表形式ではなく、視覚的なのです。
デプロイ:実戦でのNeo4j
本番環境では、Dockerが最もスムーズな道です。クリーンな隔離環境を提供し、環境の差異をなくしてくれます。ローカルでのプロトタイピングにはNeo4j Desktopが便利ですが、中規模のステージング環境ではDocker経由のCommunity Editionが完璧に機能しています。
以下は、ステージングクラスターで使用している docker-compose.yml です:
services:
neo4j:
image: neo4j:5.12-community
container_name: neo4j_production
ports:
- "7474:7474" # HTTP
- "7687:7687" # Bolt (バイナリプロトコル)
volumes:
- ./data:/data
- ./logs:/logs
- ./import:/import
- ./plugins:/plugins
environment:
- NEO4J_AUTH=neo4j/your_strong_password
- NEO4J_PLUGINS=["apoc"]
- NEO4J_dbms_memory_heap_initial_size=1G
- NEO4J_dbms_memory_heap_max_size=2G
APOC (Awesome Procedures on Cypher) プラグインを忘れないでください。Neo4jにおけるスイスアーミーナイフのような存在です。複雑なデータの再構築や、標準のCypherではカバーできない高度なグラフアルゴリズムが必要になった際、最終的にこれが必要になります。
ロジック:Cypherによるモデリング
Cypherは、アスキーアートを使ってパターンを定義します。誰が誰をフォローしているかを確認するには、(u:User)-[:FOLLOWS]->(f:User) と記述します。6ヶ月使ってみて、SQLのサブクエリよりも遥かに読みやすいと感じています。単なる結合ロジックではなく、「意図」を表現しているからです。
データの整合性の強制
NoSQLだからといって、データが乱雑でいい理由にはなりません。私の最初のステップは、一意性制約(Uniqueness Constraints)を設定することでした。これがないと、グラフはやがて重複したノードで埋め尽くされ、機能不全に陥ります。
// ユーザーメールの重複を防止
CREATE CONSTRAINT user_email_unique IF NOT EXISTS
FOR (u:User) REQUIRE u.email IS UNIQUE;
// 商品カタログの検索を高速化
CREATE INDEX product_name_index IF NOT EXISTS
FOR (p:Product) ON (p.name);
リレーションシップ・プロパティの力
SQLでは、注文は結合テーブルの1行です。Neo4jでは、それは直接的なリンクです。私たちは timestamp と price_paid を PURCHASED エッジに直接保存しています。これにより、リレーションシップ自体が豊かなデータソースになります。
// 新規購入を記録
MATCH (u:User {id: 'user_123'})
MATCH (p:Product {id: 'prod_999'})
MERGE (u)-[r:PURCHASED {date: datetime(), amount: 49.99}]->(p)
RETURN r;
MERGE キーワードは不可欠です。これは「アップサート(upsert)」として機能します。既存のパスに一致させるか、存在しない場合は新しいパスを作成します。これにより、高頻度のデータ取り込み時における予期せぬデータの重複を防ぐことができます。
運用:グラフの健全性を監視する
グラフを構築するのは簡単な部分です。重い本番負荷の中で高速性を維持するには、絶え間ない警戒が必要です。ここでは、私がインスタンスをどのように監視しているかを紹介します。
ロジックの可視化
Neo4j Browser(localhost:7474)は、私の日々のコマンドセンターです。レコメンデーションエンジンが無関係な商品を返し始めたら、パスを可視化します。ノードがどのようにつながっているかを見ることで、フラットなSQLの結果セットでは隠れてしまうようなロジックの欠陥が浮き彫りになることがよくあります。
スロークエリの追跡
レイテンシが急上昇したときは、PROFILE プレフィックスを使用します。実行計画を分解し、すべての「db hit」をカウントします。通常、原因は「デカルト積(Cartesian product)」です。これは、適切なアンカーのない汎用的な MATCH パターンによって引き起こされる暴走クエリです。
PROFILE
MATCH (u:User)-[:FOLLOWS]->(friend)-[:PURCHASED]->(p:Product)
WHERE u.id = 'user_123'
RETURN p.name, count(*) AS recommendations
ORDER BY recommendations DESC
LIMIT 5;
メモリチューニング
Neo4jはメモリを大量に消費します。ページキャッシュ(Page Cache)が命です。私は :sysinfo コマンドを使用してキャッシュヒット率を追跡しています。95%以上を目指していますが、もし80%まで低下すると、データベースがディスクへのアクセスを開始し、パフォーマンスが急落します。その時が dbms.memory.pagecache.size を増やすタイミングです。
6ヶ月後の結論
グラフデータベースへの切り替えは流行に乗ったわけではありません。技術的な必然性でした。おすすめ商品のクエリ時間は、SQLでの4.8秒からNeo4jではわずか180ミリ秒に短縮されました。もしあなたのデータが、ソーシャルネットワークや不正検知のように接続の網目であるなら、JOINと戦うのはやめましょう。思考の転換は必要ですが、一度パターンで考え始めれば、もう元には戻れません。

