未来を形作るテクノロジーの深掘り記事。

SQLiteの先へ:組み込み型グラフデータベース

グラフデータベースが組み込み型へ向かう理由、Rustが果たす役割、リレーショナルより適したケースを解説します。

錆びた機械仕掛けの殻に収められた、光るノードがつながった小さな立方体

SQLiteはどこにでもあります。スマートフォンにもブラウザにもスマートテレビにも、たぶん車の中にも入っています。別のサーバーを動かさずにアプリケーションへフルのSQLデータベースを提供するという根本的な問題を解決し、その出来があまりに良かったので、「組み込みデータベース」と「SQLite」はほぼ同義語になりました。ただ、SQLiteのリレーショナルモデルが常に最適とは限りません。データの本質がリレーションシップにある場合、たとえばソーシャルなつながり、依存関係グラフ、知識ネットワーク、経路問題では、外部キーを使って表に押し込むと、クエリがひどく複雑になるか、絶望的に遅くなります。

SQLiteがリレーショナルデータに対して果たしたことを、グラフデータに対して実現しようとする新しい組み込み型グラフデータベースが登場しつつあります。高速で依存関係がなく、プロセス内で動作し、つながったデータに適したクエリ言語を話すデータベースです。その中にはRustで書かれたものがいくつもあり、この課題にRustが非常によく合うことがわかってきました。ここでは、なぜグラフデータベースが組み込み型になりつつあるのか、Rustのエコシステムが何をもたらすのか、そして実際にどんなときにグラフデータベースを検討すべきかを見ていきましょう。

リレーショナルモデルの死角

リレーショナルデータベースは、ほとんどのデータパターンをうまく扱えます。1対多なら外部キー、多対多なら中間テーブル。単純な検索、集計、フィルタリングもSQLが得意とするところです。問題が起きるのは、クエリが経路、深さ、接続性を扱う必要がある場合です。

依存関係リゾルバーを考えてみましょう。パッケージがあり、それぞれ他のパッケージに依存し、それぞれにバージョン制約があります。次のような問いに答える必要があります。「パッケージXをインストールしたら、推移的な依存関係ツリー全体はどうなるか?循環依存はないか?どの深さでもバージョン要件が衝突していないか?」 SQLでこれを行うには再帰的CTE(共通テーブル式)が必要ですが、冗長で最適化しにくく、グラフが深くなるほど指数関数的に遅くなります。

-- Finding all transitive dependencies in SQL
-- This works, but gets ugly fast
WITH RECURSIVE deps AS (
-- Base case: direct dependencies
SELECT dependency_id, 1 as depth
FROM package_dependencies
WHERE package_id = 'my-package'
UNION ALL
-- Recursive case: dependencies of dependencies
SELECT pd.dependency_id, d.depth + 1
FROM package_dependencies pd
JOIN deps d ON pd.package_id = d.dependency_id
WHERE d.depth < 50  -- safety limit to prevent infinite loops
)
SELECT DISTINCT dependency_id, MIN(depth) as min_depth
FROM deps
GROUP BY dependency_id
ORDER BY min_depth;
-- Now try detecting circular dependencies.
-- Or finding the shortest path between two packages.
-- Or querying all packages within 3 hops that match a version constraint.
-- Each query gets progressively more painful.

グラフデータベースでは、これはネイティブなクエリパターンです。データモデルと戦うのではなく、データモデルに沿って作業できます。エッジをたどり、パスを追い、サイクルを検出する。これらは後付けの再帰ではなく、第一級の操作です。

なぜ組み込み可能であることが重要なのか

Neo4jは10年以上にわたって支配的なグラフデータベースであり、本当に優れています。しかし、これはサーバーです。別のプロセスとして動かし、ネットワークプロトコル経由で接続し、JVMのメモリを管理し、運用上のオーバーヘッドを引き受けなければなりません。専任の運用チームがいる本番アプリケーションなら問題ありません。しかしCLIツール、デスクトップアプリ、ビルドシステム、組み込みデバイスには、どう考えても過剰です。

ここでもSQLiteの発想が当てはまります。多くのユースケースは、サーバーのオーバーヘッドなしでグラフクエリ機能を必要としています。コールグラフを構築するコード解析ツール。エンティティ間の関係を保存するゲームエンジン。双方向リンクを持つローカルファーストのメモアプリ。ネットワークトポロジーの分析ツール。どれもグラフクエリを必要としますが、利用者にデータベースサーバーのインストールと設定を求めたいとは思っていません。

組み込み型グラフデータベースはプロセス内で動作し、データをローカルファイルに保存し、ネットワークプロトコルではなくライブラリAPIを公開します。アプリケーションはSQLiteとリンクするのと同じように、それらにリンクします。サーバーもポートも認証も、デプロイの複雑さもありません。

Rustがグラフデータベースにもたらすもの

Rustは、新しいデータベースプロジェクトの選択言語としてかなり多く選ばれるようになりました。その理由は、よく言われる「ガベージコレクションなしのメモリ安全性」という売り文句だけにとどまりません。

  • 予測可能なレイテンシ。 グラフ走査はレイテンシに敏感です。走査の各ホップはメモリアクセスであり、深い走査では何百万回にもなります。10ホップの走査の途中でGCが止まれば、テールレイテンシが台無しになります。Rustの所有権モデルはGCの停止なしに決定的なメモリ管理を提供し、一貫したクエリ性能に欠かせません。
  • 安全な並行処理。 グラフデータベースは並列走査の恩恵を大きく受けます。複数の経路を同時に探索すれば、100msのクエリが10msになることもあります。Rustの型システムはデータ競合をコンパイル時に防ぐので、グラフデータを壊す心配なく積極的に並列化できます。
  • 小さなバイナリ、ランタイム不要。 組み込みデータベースでは、デプロイのサイズが重要です。Rustのグラフデータベースは、ランタイム依存のない単一のネイティブライブラリにコンパイルされます。200MBのランタイムを必要とするJVMベースの製品や、頼んでもいないガベージコレクタを同梱するGo製品と比べてみてください。
  • C FFI。 RustはC互換のAPIを公開できるため、データベースはほぼあらゆる言語から利用できます。Rustで書き、Python、JavaScript、Go、Swiftなど、Cを話せるあらゆる言語から呼び出せます。

プロパティグラフモデル

組み込み型グラフデータベースの多くはプロパティグラフモデルを採用しています。グラフデータベースを使ったことがなければ、理解しておく価値があります。このモデルには3つの基本要素があります。

  • ノード ―― ラベルとキーバリューのプロパティを持つエンティティです。テーブルの行に近いものですが、固定スキーマはありません。
  • エッジ ―― ノード同士を結ぶ有向の接続で、こちらもラベルとプロパティを持ちます。ラベルは関係の種類を表します(DEPENDS_ON、AUTHORED_BY、LINKS_TOなど)。
  • 走査 ―― エッジをノードからノードへとたどるクエリです。プロパティで絞り込んだり、結果を集計したり、経路を探したりできます。
// Conceptual API for an embeddable graph database
let db = GraphDB::open("my_graph.db")?;
// Create nodes
let alice = db.create_node("Person", props! {
"name" => "Alice",
"role" => "engineer"
})?;
let project = db.create_node("Project", props! {
"name" => "Auth Service",
"language" => "Rust"
})?;
// Create edges
db.create_edge(alice, project, "WORKS_ON", props! {
"since" => "2025-01"
})?;
// Traverse: find all projects worked on by Alice's teammates
let results = db.traverse()
.from(alice)
.follow("WORKS_ON")        // Alice -> projects
.reverse("WORKS_ON")       // projects <- other people
.follow("WORKS_ON")        // other people -> their projects
.filter(|node| node.label() == "Project")
.collect()?;

プロパティグラフモデルは、急速に変化するデータに対してリレーショナルスキーマより柔軟です。スキーマを事前に定義する必要はなく、新しい関係の種類を追加してもマイグレーションは不要です。新しいラベルでエッジを作るだけです。このスキーマレスな柔軟性は諸刃の剣で、明確に定義されたスキーマが提供する安全性は失われます。それでも、グラフ構造が進化していくアプリケーション(ナレッジベース、ソーシャルネットワーク、依存関係の追跡など)にとっては、現実的なトレードオフです。

実際にグラフデータベースを使うべきとき

グラフデータベースは、不要な場面で推奨されることもあれば、本当に役立つ場面で見過ごされることもあります。ここでは、どこで力を発揮し、どこでは発揮しないかについての正直な評価を書きます。

向いているケース: 依存関係の解決、アクセス制御(どのグループ所属を経由して誰が何にアクセスできるか)、不正検知(エンティティ間のつながりを見つける)、レコメンドエンジン(Xを好んだユーザーはYも好んだ)、ネットワークトポロジー分析、ナレッジグラフ、コード解析ツール。共通点は、クエリが自然に経路と接続性を表現していることです。

向いていないケース: 単純なCRUDアプリケーション、時系列データ、分析・集計ワークロード、そして主に「条件Xに一致するレコードを取得する」クエリ。SELECT * FROM users WHERE country = 'US' ORDER BY created_at のようなクエリなら、リレーショナルデータベースが適切なツールです。面白そうだからという理由でグラフデータベースを使わないでください。クエリが本当にグラフの形をしているから使うのです。

私が使っている試金石はこれです。データモデルをホワイトボードに描いてみてください。主に行に並んだ箱(属性を持つエンティティ)なら、リレーショナルデータベースを使いましょう。箱が矢印で結ばれていて、矢印が箱と同じくらい重要なら、グラフデータベースを検討する価値があります。

ストレージエンジンとトレードオフ

内部では、組み込み型グラフデータベースは興味深いストレージエンジンの判断に直面します。代表的なアプローチは以下のとおりです。

  • 隣接リストストレージ。 各ノードが出ていくエッジのリストを保持します。ローカルな走査(ノードの隣接ノードの検索)は速いものの、グローバルなクエリ(ラベルXのすべてのエッジの検索)は遅くなります。ローカルな走査が最も頻繁な操作なので、多くの組み込み型グラフデータベースがこれを採用しています。
  • エッジリストストレージ。 エッジは別のソート済み構造に保存され、起点、終点、またはラベルでインデックスされます。グローバルなクエリや一括操作には向いていますが、ローカルな走査には間接参照が増えます。
  • ハイブリッド方式。 走査には隣接リストを使い、フィルタ付きクエリのためにエッジラベルやノードプロパティにセカンダリインデックスを維持するデータベースもあります。最も柔軟ですが、ストレージを多く使い、書き込みが高コストになります。

Rustで書かれた多くのグラフデータベースは、RocksDBやsledのような既存の組み込みキーバリューストアの上に構築されています。これは実用的な選択です。実績のある永続化、クラッシュリカバリ、コンパクションが手に入ります。グラフ層はノードとエッジをキーバリュー操作へと対応させます。欠点は、性能の上限がキーバリューストアの特性に縛られることと、グラフ固有の最適化(ノードのエッジをディスク上で連続配置してキャッシュ効率の良い走査を実現するなど)が実装しにくいことです。

クエリ言語:決着のついていない問題

SQLはリレーショナルデータベースにおける共通言語です。グラフデータベースには同等の合意がありません。Cypher(Neo4j)、Gremlin(Apache TinkerPop)、SPARQL(RDFグラフ向け)、そして新しいGQL標準が、いずれも注目を競っています。

多くの組み込み型グラフデータベースは、クエリ言語ではなく、ホスト言語でのビルダーパターンAPIを提供することでこの問題を回避しています。メソッドチェーンで走査を組み立てます。これは型付き言語では使いやすく、クエリ言語を解析・最適化する複雑さを避けられます。トレードオフは、クエリがデータベース間で移植できないことです。ある組み込み型グラフデータベースから別のものへ移行するには、クエリコードを書き直す必要があります。

GQL(Graph Query Language)は、グラフクエリの統一を目指すISO標準です。Cypherから多くを借用しており、徐々に普及しつつあります。今日組み込み型グラフデータベースを選ぶなら、GQLサポートがロードマップにあるかを確認する価値があります。標準化されたクエリ言語は、独自仕様の方が最初は洗練されていても、長期的には勝つ傾向があります。

実践上の考慮点

プロジェクトで組み込み型グラフデータベースを評価するなら、実際に重要なのは次の点です。

  1. 自分のワークロードで計測する。 グラフデータベースのベンチマークは誤解を招きやすいことで有名です。浅く広い走査(ソーシャルネットワークの友達の友達)に強いデータベースが、深く狭い走査(依存関係チェーンの解決)では苦戦することもあります。実際のクエリパターンでプロトタイプを作りましょう。
  2. クラッシュ時の安全性を確認する。 組み込みデータベースは耐久性の保証次第で価値が決まります。先行書き込みログを使っているか、クラッシュセーフか、電源障害からデータを失わずに復旧できるかを確認しましょう。「予期しないシャットダウンで破損する」は、本番利用では受け入れられない答えです。
  3. メモリモデルを確認する。 グラフ全体をメモリマップするデータベースもあり、グラフが利用可能なRAMを超えるまでは非常に快適です。ディスクを基本にしてキャッシュを使う方式のものもあります。選んだデータベースがどのモデルを採用しているか、自分のグラフが収まるかを把握しておきましょう。
  4. バインディングの品質を見る。 データベースがRustで書かれていても、Pythonから使うなら、Rust内部よりPythonバインディングの方が重要です。バインディングがメンテナンスされ、ドキュメントがあり、性能も出るか、それとも後付けか確認しましょう。
  5. マイグレーションを考える。 スキーマレスは変更がないことを意味しません。グラフモデルが進化したとき、既存データをどう扱いますか?マイグレーションスクリプトやバージョン管理されたスキーマをサポートするデータベースもあれば、完全に利用者任せのものもあります。

組み込み型グラフデータベースの領域は、リレーショナルの世界と比べるとまだ若いです。SQLiteは20年以上磨かれてきました。多くの組み込み型グラフデータベースは、作られてから5年未満です。つまり、粗さが残り、コミュニティの情報は少なく、リスクも高めです。ただ、サーバーのオーバーヘッドなしにグラフクエリを使えるという中核の価値は確かです。適切なユースケースであれば、組み込み型グラフデータベースは何百行もの再帰SQLを数行の走査コードに置き換え、しかもより速く動かし、データモデルを解決したい問題に本当に一致させられます。