Gremlin で Amazon Neptune をクエリする
Gremlin によるグラフトラバーサルの例と、Neptune の読み取り・更新クエリにおけるトランザクション分離レベルの違い。
Amazon Neptune は、Gremlin と SPARQL に対応したマネージドグラフデータベースです。以下の例では、Gremlin によるトラバーサルに焦点を当てます。
グラフデータベースとは
グラフデータベースは、エンティティ間の関係の格納とクエリに最適化されています。リレーショナルデータベースや NoSQL データベースとは異なり、グラフデータベースは関係が複雑でパフォーマンスが重要となるシナリオで真価を発揮します。
グラフデータベースにおいて:
- 頂点(Vertices) はエンティティを表します。
- エッジ(Edges) は頂点間の関係を定義します。
- 頂点とエッジのどちらもプロパティ(キーと値のペア)を持つことができ、これはプロパティグラフと呼ばれます。

Neptune におけるトランザクション
Neptune は、読み取り専用クエリと更新クエリを異なるトランザクション分離レベルで管理します。詳細は公式ドキュメントを参照してください。
読み取り専用クエリでは、Neptune は MultiVersion Concurrency Control(MVCC) の中でスナップショット分離を使用します。トランザクション開始時点のスナップショットを参照するため、ダーティリード、非再現リード、ファントムリードのいずれも発生しません。
更新クエリでは、Neptune はダーティリードを防ぐために READ COMMITTED 分離レベルを使用します。さらに、Neptune は読み取り対象のレコードセットをロックすることで、非再現リードとファントムリードの両方を回避します。
トラバーサルの例
グラフデータの準備
この例では、頂点に age プロパティを持ち、エッジに weight プロパティを定義したグラフを使用します。

上記のサンプルデータは、以下のコマンドでロードできます。%%gremlin は、Neptune Workbench での利用を想定した Jupyter Notebook のマジックコマンドです。
%%gremlin// Drop existing datag.V().drop()%%gremlin// Add Verticesg.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 Edgesg.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 Verticesg.V() .project('id', 'properties') // Projection .by(id()).by(valueMap()) // valueMap returns properties of vertices.結果:
| Row | Data |
|---|---|
| 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'))結果:
| Row | Data |
|---|---|
| 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.5g.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())結果:
| Row | Data |
|---|---|
| 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 3g.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出力例:
| Row | Data |
|---|---|
| 1 | path[{<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}] |
| 2 | path[{<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 は前回とは異なる行を読み取ってしまいます。

分離レベル
| Level | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Not possible | Possible | Possible |
| REPEATABLE READ | Not possible | Not possible | Possible |
| SERIALIZABLE | Not possible | Not possible | Not 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 セマンティクスを用いることで、そのトレードオフなしに並行性の問題を処理しています。関係性そのものがクエリの対象であるデータにとっては、この組み合わせこそが、リレーショナルスキーマよりもグラフスキーマを選ぶ価値がある理由です。
Related posts
Cognito User PoolsとOIDCでSlackサインインを実装する
Cognito user poolをOIDC経由でSlackと連携させ、"Sign in with Slack"をAmplifyでNext.jsアプリに組み込みます。
Lambda Web AdapterでFastAPIをAWS Lambdaにデプロイする
Lambda Web Adapterを使うと、FastAPIで書いたAPIバックエンドをコンテナのまま単一のLambda関数にデプロイできます。
API Gateway WebSocket:モック統合の実装
バックエンドのLambdaを一切使わず、モック統合のみでAPI Gateway WebSocket APIを構築し、あらかじめ用意されたレスポンスを返します。
CloudFront署名付きURL経由でS3にアップロードする
CloudFrontの署名付きURLを使えば、独自ドメイン経由でS3にアップロードできます。S3の直接の署名付きURLが使えない場合に有用です。
AWS EventBridge Scheduler:スケジュールに沿ってEC2を起動・停止する
Lambdaを介さずEventBridge SchedulerがEC2 APIを直接呼び出すことで、cronスケジュールに従ってEC2インスタンスを起動・停止します。
