Gremlin で Amazon Neptune をクエリする

Gremlin で Amazon Neptune をクエリする

Gremlin によるグラフトラバーサルの例と、Neptune の読み取り・更新クエリにおけるトランザクション分離レベルの違い。

Takahiro Iwasa
11 min read

Amazon Neptune は、Gremlin と SPARQL に対応したマネージドグラフデータベースです。以下の例では、Gremlin によるトラバーサルに焦点を当てます。

グラフデータベースとは

グラフデータベースは、エンティティ間の関係の格納とクエリに最適化されています。リレーショナルデータベースや NoSQL データベースとは異なり、グラフデータベースは関係が複雑でパフォーマンスが重要となるシナリオで真価を発揮します。

グラフデータベースにおいて:

  • 頂点(Vertices) はエンティティを表します。
  • エッジ(Edges) は頂点間の関係を定義します。
  • 頂点とエッジのどちらもプロパティ(キーと値のペア)を持つことができ、これはプロパティグラフと呼ばれます。

Graph Representation

Neptune におけるトランザクション

Neptune は、読み取り専用クエリと更新クエリを異なるトランザクション分離レベルで管理します。詳細は公式ドキュメントを参照してください。

読み取り専用クエリでは、Neptune は MultiVersion Concurrency Control(MVCC) の中でスナップショット分離を使用します。トランザクション開始時点のスナップショットを参照するため、ダーティリード非再現リードファントムリードのいずれも発生しません。

更新クエリでは、Neptune はダーティリードを防ぐために READ COMMITTED 分離レベルを使用します。さらに、Neptune は読み取り対象のレコードセットをロックすることで、非再現リードとファントムリードの両方を回避します。

トラバーサルの例

グラフデータの準備

この例では、頂点に age プロパティを持ち、エッジに weight プロパティを定義したグラフを使用します。

上記のサンプルデータは、以下のコマンドでロードできます。%%gremlin は、Neptune Workbench での利用を想定した Jupyter Notebook のマジックコマンドです。

%%gremlin
// Drop existing data
g.V().drop()
%%gremlin
// Add Vertices
g.addV('person').property(id, 'A').property('age', 30)
.addV('person').property(id, 'B').property('age', 25)
.addV('person').property(id, 'C').property('age', 35)
.addV('person').property(id, 'D').property('age', 20)
.addV('person').property(id, 'E').property('age', 18)
.addV('person').property(id, 'F').property('age', 25)
.addV('person').property(id, 'G').property('age', 10)
.addV('person').property(id, 'H').property('age', 15)
%%gremlin
// Add Edges
g.V('A').addE('know').to(g.V('B')).property('weight', 1.0)
.V('A').addE('know').to(g.V('C')).property('weight', 0.9)
.V('A').addE('know').to(g.V('H')).property('weight', 0.5)
.V('B').addE('know').to(g.V('D')).property('weight', 0.8)
.V('B').addE('know').to(g.V('E')).property('weight', 0.4)
.V('C').addE('know').to(g.V('F')).property('weight', 0.5)
.V('C').addE('know').to(g.V('G')).property('weight', 0.6)
.V('D').addE('know').to(g.V('E')).property('weight', 0.7)
.V('H').addE('know').to(g.V('E')).property('weight', 1.0)
.V('H').addE('know').to(g.V('G')).property('weight', 1.0)

例 1: すべての頂点を取得する

%%gremlin
// Extract Vertices
g.V()
.project('id', 'properties') // Projection
.by(id()).by(valueMap()) // valueMap returns properties of vertices.

結果:

RowData
1{'id': 'A', 'properties': {'age': [30]}}
2{'id': 'B', 'properties': {'age': [25]}}
3{'id': 'C', 'properties': {'age': [35]}}
4{'id': 'D', 'properties': {'age': [20]}}
5{'id': 'E', 'properties': {'age': [18]}}
6{'id': 'F', 'properties': {'age': [25]}}
7{'id': 'G', 'properties': {'age': [10]}}
8{'id': 'H', 'properties': {'age': [15]}}

例 2: つながりをたどる

'A' から 2 段階以内でつながっており、かつ 25 歳より上のすべての人物を取得します。

%%gremlin
// Extract persons (entities) which are older than 25 years old and connected from A up to 2nd.
g.V('A')
.repeat(outE().inV()).times(2).emit() // Repeat traversal of adjacent vertices twice
.has('age', gte(25)) // Greater than or equal 25 years old
.project('id', 'age')
.by(id()).by(values('age'))

結果:

RowData
1{'id': 'B', 'age': 25}
2{'id': 'C', 'age': 35}
3{'id': 'F', 'age': 25}

例 3: weight によるフィルタリング

乗算した weight が 0.5 を超える人物を見つけます。

%%gremlin
// Start traversal at A which extracts vertices that have a multiplied weight value greater than 0.5
g.withSack(1.0f).V('A') // Sack can be used to store temporary data
// Multiply a weight value of an out-directed edge by a sack value, and traverse all in-directed vertices
.repeat(outE().sack(mult).by('weight').inV().simplePath()).emit()
.where(sack().is(gt(0.5))) // A sack value greater than 0.5
.dedup() // deduplication
.project('id', 'weight')
.by(id).by(sack())

結果:

RowData
1{'id': 'B', 'weight': 1.0}
2{'id': 'C', 'weight': 0.9}
3{'id': 'D', 'weight': 0.8}
4{'id': 'G', 'weight': 0.54}
5{'id': 'E', 'weight': 0.5599…}

グラフの可視化

Neptune Workbench には、グラフをインタラクティブに可視化するツールが用意されています。詳細は公式ドキュメントを参照してください。

グラフの可視化には、表示オプションを追加した同様のトラバーサルを使用します。

%%gremlin -d T.id -de weight
// -d specifies the vertex property to display
// -de specifies the edge property to display
// Execute traversal from example 3
g.withSack(1.0f).V('A') // Sack is used to store temporary data
.repeat(outE().sack(mult).by('weight').inV().simplePath()).emit() // Traverse with edge weight
.where(sack().is(gt(0.5))) // Filter paths where the sack value > 0.5
.dedup() // Remove duplicate paths
.path() // Extract path data
.by(elementMap()) // Display properties of vertices and edges

出力例:

RowData
1path[{<T.id: 1>: 'A', <T.label: 4>: 'person', 'age': 30}, {<T.id: 1>: '8ebe47fa-901b-c6d3-a11f-0a9bf0ce8aa2', <T.label: 4>: 'know', <Direction.IN: 2>: {<T.id: 1>: 'B', <T.label: 4>: 'person'}, <Direction.OUT: 3>: {<T.id: 1>: 'A', <T.label: 4>: 'person'}, 'weight': 1.0}, {<T.id: 1>: 'B', <T.label: 4>: 'person', 'age': 25}]
2path[{<T.id: 1>: 'A', <T.label: 4>: 'person', 'age': 30}, {<T.id: 1>: '7abe47fa-901c-c394-4bed-6dce7defa3f9', <T.label: 4>: 'know', <Direction.IN: 2>: {<T.id: 1>: 'C', <T.label: 4>: 'person'}, <Direction.OUT: 3>: {<T.id: 1>: 'A', <T.label: 4>: 'person'}, 'weight': 0.9}, {<T.id: 1>: 'C', <T.label: 4>: 'person', 'age': 35}]

実行後、Neptune Workbench の Graph タブに移動すると結果を可視化できます。このインターフェースはドラッグ、ズームイン、ズームアウトの操作に対応しており、直感的にグラフを探索できます。

付録 1: リレーショナルデータベース(RDB)におけるツリー構造

ツリー構造は、様々なアプローチでリレーショナルデータベース(RDB)上にモデル化できます。シナリオによっては、グラフデータベースが必ずしも必要とは限りません。

ただし、“SQL Antipatterns” で論じられているように、Naive Tree モデルは多くのケースにおいて最適とは言えないことに注意が必要です。

  • Naive Tree
    • 表現: t1.id = t2.parent_id
    • 利点
      • 実装がシンプル(隣接リスト)
    • 欠点
      • 隣接していないノードの抽出が難しい
      • SQL が複雑になる
      • パフォーマンスが低い
  • Path Enumeration
    • 表現: path LIKE '1/2%'
    • 利点
      • 隣接していないノードの抽出が簡単になる
    • 欠点
      • INSERT/UPDATE/DELETE 操作が複雑になる
      • カラムの最大長による制限がある
  • Nested Sets
    • 表現: Left > 1 AND Right < 6
    • 利点
      • 隣接していないノードのクエリに効率的
    • 欠点
      • INSERT/UPDATE 操作が複雑になる
      • カラムの最大長による制限がある
      • 構造が直感的でない
  • Closure Table
    • 表現: ツリー用の別テーブル
    • 利点
      • すべてのノードのクエリに効率的
      • INSERT/UPDATE/DELETE を簡単に扱える
    • 欠点
      • データサイズが大きく増加する可能性がある
      • INSERT/UPDATE/DELETE のパフォーマンスが低くなることがある

RDB にデータを格納する場合、関係性のクエリは次第に複雑になっていく可能性があります。SQL クエリはしばしば複雑さを増していき、行と列の構造の中で相互に接続されたデータを表現するのは直感的ではありません。

付録 2: トランザクション

ダーティリード

Tx1 が行を更新し、Tx2 がその行を読み取った後、Tx1 が失敗またはロールバックすると、Tx2 は反映されていないデータを読み取ってしまいます

非再現リード

Tx1 が行を読み取った後、Tx2 がその行を更新または削除してコミットし、Tx1 が再度その行を読み取ると、Tx1 は前回とは異なるコミット済みデータを読み取ってしまいます

ファントムリード

Tx1 がレコードを読み取った後、Tx2 が一部の行を挿入または削除してコミットし、Tx1 が再度行を読み取ると、Tx1 は前回とは異なる行を読み取ってしまいます

分離レベル

LevelDirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTEDPossiblePossiblePossible
READ COMMITTEDNot possiblePossiblePossible
REPEATABLE READNot possibleNot possiblePossible
SERIALIZABLENot possibleNot possibleNot possible

まとめ

小さなグラフを Neptune にロードし、Gremlin のトラバーサルでクエリしてみたことで、グラフデータベースにおけるマルチホップの関係クエリとトランザクション分離の仕組みがよくわかりました。例 2 の repeat(outE().inV()).times(2).emit() は、頂点 A から 2 ホップ分をたった一つの式で歩き、例 3 の withSack(1.0f) は、トラバーサルが進むにつれて各パスに沿った累積の weight 値を運びます。どちらも、SQL であれば再帰 CTE や複数の自己結合の連鎖が必要になるところを、平易な英語に近い形で表現できています。この差は、付録の RDB ツリーモデリングの比較でさらに広がります。隣接リストは 1 階層を超えるとクエリのコストが急激に高くなり、closure table のような代替手法は、書き込みの複雑さとストレージの増大を引き換えに読み取り性能を得ることで対処します。一方、Neptune では読み取りにはスナップショット分離を、書き込みにはロックと READ COMMITTED セマンティクスを用いることで、そのトレードオフなしに並行性の問題を処理しています。関係性そのものがクエリの対象であるデータにとっては、この組み合わせこそが、リレーショナルスキーマよりもグラフスキーマを選ぶ価値がある理由です。

About the author

Takahiro Iwasa

Takahiro Iwasa

Software Developer

This blog shares technical notes from hands-on projects—architecture, implementation, and AWS service integrations.