はじめに
皆さんは DynamoDB を利用していますか?
私は実務での利用経験はないのですが個人開発をする場合、無料枠があるおかげで RDS よりもファーストチョイスになっています。
そのなかで GSI や LSIを 使ってはいたのですが、実際の仕組みをあまり理解せずに使用しており、それぞれの制限については理解しているものの、内部的な動作については全く分かっていませんでした。
この前たまたま『データ指向アプリケーションデザイン』読んだ際にデータベース全般の概念として ローカルインデックス と グローバルインデックス の説明がありました。それを読んで DynamoDB の LSI/GSI の違いが一気に腑に落ちたので、備忘録も兼ねて本記事に自分なりの解釈で解説を書こうと思った次第です。
DynamoDB ではローカルインデックスとグローバルインデックスはそれぞれ LSI(Local Secondary Index) と GSI(Global Secondary Index) として提供しています。
本記事では、DynamoDBの細かな仕様の前に、より抽象的な「データベースにおけるローカル/グローバルインデックスの概念」として図解してみたいと思います。
もし内容に誤り等があれば、コメントでご指摘いただけると幸いです。
前提:パーティション分割されたデータベースのデータ配置
ローカルインデックスとグローバルインデックスの違いを理解するには、まず「パーティション」の概念を押さえる必要があります。
データベースの運用では、データ量が増えてくると1つのデータを読み込むだけでも大容量のファイルを読み込むことになり、パフォーマンスに影響をおよぼします。そのため、データを複数のファイルに分散して保存します。この分割された各ファイルのことを パーティション と呼びます。
どのパーティションにデータを配置するかは、特定のキー(パーティションキー)のハッシュ値によって決まります。
以下は、注文データをユーザー ID でパーティション分割した場合のイメージです。
┌────────────────────────────────────────────────────────────────────┐
│ 注文データ │
│ │
│ hash(UserId) の値域に応じて3つのパーティションに分散 │
└────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ Partition 1 │ │ Partition 2 │ │ Partition 3 │
│ (hash 0x00-0x55) │ │ (hash 0x56-0xAA) │ │ (hash 0xAB-0xFF) │
│ │ │ │ │ │
│ UserId=U001 │ │ UserId=U003 │ │ UserId=U005 │
│ → 行1, 行2 │ │ → 行5, 行6 │ │ → 行9, 行10 │
│ UserId=U002 │ │ UserId=U004 │ │ │
│ → 行3, 行4 │ │ → 行7, 行8 │ │ │
└────────────────────┘ └────────────────────┘ └────────────────────┘
行1: {UserId=U001, Date=2025-01-10, Amount=¥800}
行2: {UserId=U001, Date=2025-03-05, Amount=¥300}
行3: {UserId=U002, Date=2025-02-14, Amount=¥450}
行4: {UserId=U002, Date=2025-04-22, Amount=¥120}
行5: {UserId=U003, Date=2025-02-01, Amount=¥200}
-
同じパーティションキーを持つデータは必ず同一パーティションに格納される
UserId=U001のデータは常に Partition 1 にある
どのパーティションにデータを配置するかは、パーティションキーのハッシュ値によって決まります。そのため、パーティションキーがわかればすべてのデータを読み取る必要がなく、小さいサイズに分割された1つのパーティションへのアクセスで完結します。
ローカルインデックスの内部構造
ローカルインデックスとは
ローカルインデックスとは、各パーティション内に閉じた形で構築されるインデックスです。
パーティション分割されたデータベースでは、パーティションキー(例: UserId)のハッシュ値によってデータの配置先が決まります。パーティションキー以外の属性で効率よく検索やソートをしたい場合に登場するのがローカルインデックスです。
ローカルインデックスは、パーティションキー以外の属性に対して、各パーティションの内部にインデックスを作成するものです。
インデックスのスコープが各パーティション内に限定されています。あるパーティションのローカルインデックスは、そのパーティションに属するデータだけを対象とします。他のパーティションのデータについては一切関知しません。
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Partition 1 │ │ Partition 2 │ │ Partition 3 │
│ (U001, U002) │ │ (U003, U004) │ │ (U005) │
│ │ │ │ │ │
│ ┌──────────────────┐│ │ ┌──────────────────┐│ │ ┌──────────────────┐│
│ │ テーブルデータ ││ │ │ テーブルデータ ││ │ │ テーブルデータ ││
│ │ U001, 01-10, ¥800││ │ │ U003, 02-01, ¥200││ │ │ U005, 01-10, ¥150││
│ │ U001, 03-05, ¥300││ │ │ U003, 04-15, ¥350││ │ │ U005, 05-20, ¥500││
│ │ U002, 02-14, ¥450││ │ │ U004, 01-10, ¥600││ │ └──────────────────┘│
│ │ U002, 04-22, ¥120││ │ │ U004, 03-30, ¥900││ │ │
│ └──────────────────┘│ │ └──────────────────┘│ │ ┌──────────────────┐│
│ │ │ │ │ │ Index(Date) ││
│ ┌──────────────────┐│ │ ┌──────────────────┐│ │ │ 01-10 → U005 ││
│ │ Index(Date) ││ │ │ Index(Date) ││ │ │ 05-20 → U005 ││
│ │ 01-10 → U001 ││ │ │ 01-10 → U004 ││ │ └──────────────────┘│
│ │ 02-14 → U002 ││ │ │ 02-01 → U003 ││ │ │
│ │ 03-05 → U001 ││ │ │ 03-30 → U004 ││ │ ※ 自パーティション │
│ │ 04-22 → U002 ││ │ │ 04-15 → U003 ││ │ のデータのみ │
│ └──────────────────┘│ │ └──────────────────┘│ └─────────────────────┘
│ │ │ │
│ ※ 自パーティション │ │ ※ 自パーティション │
│ のデータのみ │ │ のデータのみ │
└─────────────────────┘ └─────────────────────┘
DynamoDB ではこの仕組みが LSI(Local Secondary Index) として提供されています。LSI はテーブルと同じパーティションキーを共有し、別の属性をソートキーとして指定するという DynamoDB 特有のモデルになっていますが、「パーティション内に閉じたインデックス」という本質は同じです。
テーブルデータとインデックスデータが同じパーティション内に同居しているため、書き込み時のインデックス更新が同一ノード内で完結します。他のノードへのネットワーク通信が発生しないため、書き込みのオーバーヘッドが小さく抑えられます。
クエリの動作
「UserId=U001 の注文を Date 順で取得したい」というクエリの場合、パーティションキーが指定されているため、アクセス先は1つのパーティションに絞り込めます。
クエリ: UserId=U001 の注文を Date 順で取得
hash(U001) → Partition 1 を特定
│
▼
┌─────────────────────┐
│ Partition 1 │
│ │
│ Index(Date) │ Partition 2, 3 には
│ 01-10 → U001 ✔ │ アクセス不要
│ 02-14 → U002 ✘ │
│ 03-05 → U001 ✔ │
│ 04-22 → U002 ✘ │
└─────────────────────┘
│
▼
結果: 01-10 ¥800, 03-05 ¥300
グローバルインデックスの内部構造
グローバルインデックスの必要性
ローカルインデックスでは、「Date=2025-01-10 の注文をすべて取得したい」のようなパーティションキーを指定しないクエリの場合、全てのパーティションを走査する必要があります。
クエリ: Date=2025-01-10 の注文をすべて取得
どのパーティションに該当データがあるか不明
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│Partition 1│ │Partition 2│ │Partition 3│
│ │ │ │ │ │
│ Index │ │ Index │ │ Index │
│ 01-10 ✔ │ │ 01-10 ✔ │ │ 01-10 ✔ │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
▼ ▼ ▼
U001,¥800 U004,¥600 U005,¥150
│ │ │
└──────────────┼──────────────┘
▼
結果をマージして返却
このため、パーティションが多くなるにつれこのようなクエリは非効率的になってきます。
これを解決するのが、グローバルインデックスです。
グローバルインデックスとは
グローバルインデックスはすべてのパーティションを横断的に、ある属性値から「そのデータがどのパーティションに存在するか」を引けるようにしたインデックスです。
グローバルインデックス(Date)
テーブル本体
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Partition 1 │ │ Partition 2 │ │ Partition 3 │
│ (U001, U002) │ │ (U003, U004) │ │ (U005) │
│ │ │ │ │ │
│ 行1: U001,01-10 │ │ 行5: U003,02-01 │ │ 行9: U005,01-10 │
│ 行2: U001,03-05 │ │ 行6: U003,04-15 │ │ 行10: U005,05-20 │
│ 行3: U002,02-14 │ │ 行7: U004,01-10 │ │ │
│ 行4: U002,04-22 │ │ 行8: U004,03-30 │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
テーブル本体とは独立して存在
┌──────────────────────────────────────────────┐
│ │
│ 01-10 → Partition 1 / 行1 │
│ Partition 2 / 行7 │
│ Partition 3 / 行9 │
│ 02-01 → Partition 2 / 行5 │
│ 02-14 → Partition 1 / 行3 │
│ 03-05 → Partition 1 / 行2 │
│ 03-30 → Partition 2 / 行8 │
│ 04-15 → Partition 2 / 行6 │
│ 04-22 → Partition 1 / 行4 │
│ 05-20 → Partition 3 / 行10 │
│ │
└──────────────────────────────────────────────┘
Date=2025-02-01を調べたい時に、グローバルインデックスがあればPartition 2だけを読み取ればいいことがわかります。
ローカルインデックスではすべてのパーティションにアクセスする必要がありましたが、こちらではグローバルインデックスとPartition 2への読み取りだけで完結します。
DynamoDBのGSIの実際の実装では、インデックスを引いてから本体のパーティションに読み取りに行くのではなく、GSI作成時に必要なデータをGSI側に「プロジェクション(コピー)」して保持する仕組みになっています。これにより、本体へのアクセスすら不要になり、より高速な読み取りを実現しています。
DynamoDB ならではの注意点 ── 整合性モデル
最後に、DynamoDB 固有の注意点として整合性モデルについて触れておきます。
LSI:強い整合性の読み取りが可能
LSI はテーブルデータと同一ノード内で完結しているため、ConsistentRead=true を指定することで 強い整合性のある読み取り(Strongly Consistent Read) が可能です。
[書き込み] [読み取り]
│ │
▼ ▼
┌──────────────────────────────────────┐
│ Partition 1 │
│ │
│ テーブルデータ ←── 書き込み完了 │
│ LSI データ ←── 同時に更新済み │ ← 同じノード内なので
│ │ 強い整合性を保証できる
└──────────────────────────────────────┘
GSI:結果整合性のみ
GSI はテーブルとは物理的に別のパーティション群にデータが格納されているため非同期でデータが伝搬します。クエリで返るデータは 結果整合性(Eventually Consistent) となり、ConsistentRead=true を指定することはできません。
[書き込み] [読み取り]
│ │
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ テーブル本体 │ │ GSI │
│ Partition 1 │ 非同期 │ GSI Partition A │
│ │ ───────→ │ │
│ ✔ 書き込み完了 │ 伝搬中 │ ✖ まだ反映されて │
│ │ │ いない可能性 │
└────────────────────┘ └────────────────────┘
通常は1秒以下で反映されるが、保証はない
実務上の影響
この整合性の違いが問題になる典型的なケースを挙げます。
GSI:書き込み直後の検索
ユーザーが注文を作成した直後に、GSI を使った一覧画面を表示すると、まだ新しい注文が表示されない場合があります。
通常は数ミリ秒で反映されるのですが、インフラに障害があった場合等は時間がかかる場合があります。
LSI:10GB制限
LSI を使う場合、同一パーティションキーに属するすべてのデータ(テーブルデータ+LSI データ)の合計サイズが 10GB を超えることができません。
DynamoDB では 1 つのパーティションの上限が 10GB です。通常はパーティションの自動分割によってこの制限を意識する必要はありませんが、LSI がある場合は同じパーティションキーのデータを別パーティションに分離できないため、この上限がそのまま制約として現れます。この制限を超えると書き込みが拒否されるため、1 つのパーティションキーに大量のデータが集中する設計では注意が必要です。