Gremlin で Amazon Neptune をクエリする

Gremlin で Amazon Neptune をクエリする

Amazon Neptune にプロパティグラフを登録し、Gremlin のトラバーサルで関係をクエリする方法を解説します。

Takahiro Iwasa
10 min read

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

グラフデータベースとは

グラフデータベースは、エンティティ間の関係の格納とクエリに最適化されています。リレーショナルモデルでは複雑な結合が必要になるような、相互に接続されたデータのトラバーサルに適しています。

グラフデータベースでは、

  • 頂点(Vertices) はエンティティを表します。
  • エッジ(Edges) は頂点間の関係を定義します。
  • 頂点とエッジのどちらにもプロパティ(キーと値のペア)を設定できます。このモデルをプロパティグラフと呼びます。

Graph Representation

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

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

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

更新クエリでは、Neptune は READ COMMITTED 分離レベルでダーティリードを防止します。さらに、読み取り対象のレコード範囲をロックし、非再現リードとファントムリードを防ぎます。

トラバーサルの例

グラフデータの準備

この例では、頂点に 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 によるフィルタリング

'A' からの経路にある各エッジの 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 のパフォーマンスが低くなることがある

リレーショナルモデルでは、関係の階層が深くなるほどクエリが複雑になります。相互に接続されたデータを行と列で表現してたどるには、再帰クエリや複数回の結合が必要になる場合があります。

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

ダーティリード

Tx1 が行を更新し、コミット前の値を Tx2 が読み取った後、Tx1 が失敗またはロールバックすると、Tx2 はコミットされなかったデータを読み取ったことになります

非再現リード

Tx1 が行を読み取った後、Tx2 がその行を更新または削除してコミットし、Tx1 が再度読み取ると、最初とは異なるコミット済みの値が返されます

ファントムリード

Tx1 が行の集合を読み取った後、Tx2 が条件に一致する行を挿入または削除してコミットし、Tx1 が同じクエリを再実行すると、最初とは異なる行の集合が返されます

分離レベル

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

まとめ

この例では、小さなプロパティグラフを Neptune にロードし、Gremlin で複数ホップの関係とエッジの累積 weight をクエリしました。

例 2 の repeat(outE().inV()).times(2).emit() は頂点 A から 2 ホップ以内をたどり、例 3 の withSack(1.0f) は各経路の累積 weight を保持します。

リレーショナルデータベースのツリーモデルには、それぞれ異なるトレードオフがあります。隣接リストは更新が単純ですが、離れたノードのクエリが複雑になります。Closure Table は検索を効率化できる一方、追加のストレージと書き込み処理が必要です。

関係そのものが主なクエリ対象である場合、グラフモデルを使うと、リレーショナルモデルで結合を繰り返すよりもトラバーサルを直接表現できます。

About the author

Takahiro Iwasa

Takahiro Iwasa

Software Developer

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