PostgreSQLをグラフデータベースとして活用:Apache AGEを6ヶ月運用して分かったこと

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

Apache AGEを導入して6ヶ月:純粋なリレーショナルクエリを捨てた理由

6ヶ月前、私たちの物流プラットフォームは限界に達していました。サプライヤー、倉庫、配送ルート間の多層的な依存関係を追跡するためのレコメンデーションエンジンを構築していましたが、PostgreSQLは構造化データの扱いには長けているものの、深い階層のリレーションシップのマッピングには苦戦していました。再帰的CTE(共通テーブル式)は200行に及ぶ巨大なモンスターと化し、実行に5秒かかり、デバッグにはそれ以上の時間がかかる有様でした。

Neo4jの導入も検討しましたが、別のデータベースクラスターを管理することは運用上の罠(メンテナンス・トラップ)に感じられました。そんな時、Apache AGE(A Graph Extension)に出会いました。これは、openCypherを使用してPostgreSQLにグラフ機能を直接組み込む拡張機能です。本番環境で半年間運用した結果、絡まり合ったスパゲッティ状態のデータアーキテクチャが、効率化されたシステムへと変貌を遂げるのを目の当たりにしました。

5分で完了するセットアップ

AGEを試すには、Dockerを使用するのが最も簡単です。既存のインスタンス上でソースからコンパイルすることも可能ですが、初期ビルド時にバージョンの不一致で苦労することが多いため、Dockerをお勧めします。

docker run --name age-postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d apache/age

コンテナが起動したら、psql経由で接続し、初期化スクリプトを実行します。これらのコマンドにより環境が整い、必要な検索パスが設定されます。

-- AGE拡張をロード
CREATE EXTENSION age;

-- ageスキーマを含むようにパスを設定
LOAD 'age';
SET search_path = ag_catalog, "$user", public;

AGEでは、データは「グラフ」の中に保存され、これらは隔離された名前空間のように機能します。実際に動作を確認するために、小さなネットワークを構築してみましょう。

SELECT create_graph('social_network');

-- Personノードを作成
SELECT * FROM cypher('social_network', $$
  CREATE (n:Person {name: 'Alice', age: 30})
$$) as (v agtype);

-- 別のPersonとリレーションシップを作成
SELECT * FROM cypher('social_network', $$
  MATCH (a:Person {name: 'Alice'})
  CREATE (b:Person {name: 'Bob', age: 25}),
         (a)-[:FOLLOWS]->(b)
$$) as (v agtype);

Apache AGEがいかにギャップを埋めるか

AGEの特別な点はそのストレージエンジンにあります。単にグラフを模倣するのではなく、グラフのラベルをPostgresのテーブルに、エッジを最適化された構造にマッピングします。openCypherクエリを実行すると、AGEはそれをネイティブなPostgresの実行計画へと変換します。

openCypherの利点

Neo4jを触ったことがあれば、その構文はすでにご存知でしょう。これはリレーションシップを多用するデータのために作られています。以下の「友達の友達」を探すクエリの可読性を比較してみてください。

標準SQL(難しい方法):

WITH RECURSIVE friends AS (
    SELECT friend_id FROM user_relations WHERE user_id = 1
    UNION
    SELECT r.friend_id FROM user_relations r
    INNER JOIN friends f ON f.friend_id = r.user_id
)
SELECT * FROM friends;

Apache AGE(スマートな方法):

SELECT * FROM cypher('social_network', $$
  MATCH (u:Person {name: 'Alice'})-[:FOLLOWS*2]->(fof)
  RETURN fof.name
$$) as (fof_name agtype);

Cypherバージョンは「意図」に基づいています。パターンを記述するだけで、AGEがトラバーサル(探索)ロジックを処理します。私たちの本番環境では、この移行によりデータアクセス層のコードが40%削減され、クエリの可読性が一晩で劇的に向上しました。

ハイブリッドクエリの威力

チームにとっての「アハ体験(なるほど!と思える瞬間)」は、標準のリレーショナルテーブルとグラフデータを結合できると気づいた時でした。これはスタンドアロンのグラフデータベースに対する大きなアドバンテージです。重いトランザクションデータは厳格なテーブルに保持しつつ、複雑なネットワークロジックをグラフエンジンにオフロードできるのです。

例えば、標準のordersテーブルとcustomer_connectionsグラフを結合して、影響力のある購入者を特定する場合を考えてみましょう。

SELECT 
    u.customer_name, 
    o.total_amount
FROM orders o
JOIN (
    SELECT * FROM cypher('social_network', $$
        MATCH (c:Customer)-[:REFERRED]->(other)
        RETURN c.name as customer_name, count(other) as referral_count
    $$) as (customer_name agtype, referral_count agtype)
) AS u ON o.customer_name = u.customer_name::text
WHERE u.referral_count::int > 5;

現場で学んだ教訓

本番ワークロードをAGEに移行することで、ドキュメントには書かれていないいくつかのことを学びました。注意すべき点は以下の通りです。

1. インデックス作成は必須

グラフ探索は魔法ではありません。AGEはプロパティをagtypeという JSONBに似た形式で保存します。emailskuでノードをマッチングさせる場合は、基盤となるラベルテーブルにGINインデックスを作成してください。これがないと、クエリは低速なフルテーブルスキャンにフォールバックしてしまいます。

2. agtypeのキャストをマスターする

結果はagtypeとして返されます。アプリケーションのドライバ(Node.jsやPython)は、これらを文字列やオブジェクトとして認識する場合があります。アプリケーションが正しいデータ型を受け取れるよう、SQLラッパー内で(result).property::int::textのように明示的なキャストを行う習慣をつけましょう。

3. メモリのチューニング

深い探索はRAMを消費します。デフォルトの4MBから256MBにwork_memを増やすことが、4レベル以上の深いMATCH操作には不可欠であることが分かりました。バッファキャッシュのヒット率が低下すると、探索速度は極端に落ちます。

4. バージョンの確認

AGEはホストとなるPostgreSQLのバージョンに敏感です。現在はPostgreSQL 11から16をサポートしていますが、特定のAGEリリースは特定のPGバージョンをターゲットにしています。私たちは開始するためにレガシーなPG 10クラスターをアップグレードする必要がありました。導入前に互換性マトリックスを確認してください。

結論

Apache AGEは、両方の世界の良いとこ取りを提供してくれます。PostgreSQLのACIDコンプライアンスと、グラフデータベースの柔軟なモデリングを、マルチデータベース構成のオーバーヘッドなしで享受できます。もしSQLのJOINが手に負えなくなってきたら、CTEを書くのをやめてCypherを書き始めるべき時かもしれません。

Share: