DynamoDBを本番運用するエンジニアであれば、インデックス設計に頭を悩ませた経験が一度はあるのではないでしょうか。
特に、GSI(グローバルセカンダリインデックス)の追加やLSI(ローカルセカンダリインデックス)の選択を誤ると、クエリの遅延やスループットの無駄遣いだけでなく、予期せぬコスト増を招くことになります。
そもそもDynamoDBは、スキャン操作やフィルター式に頼る設計が致命的になりやすいデータベースです。
では、どうすれば検索性を高めつつ、システムを安定稼働させられるのか。
その答えは、アクセスパターンを先に定義し、インデックスをそのパターンに合わせて最適化するという、古典的かつ本質的な原則にあります。
まずはインデックスの種類と特性を正確に把握しておく必要があります。
以下の表に主要な違いを整理しました。
| インデックス種別 | 特徴 | 制限事項 | 推奨ユースケース |
|---|---|---|---|
| LSI | 同一パーティションキー内で別のソートキーを提供 | テーブル作成時のみ定義可能。最大5個 | 時系列データやバージョン管理など、同一パーティション内での多様な並び替え |
| GSI | 異なるパーティションキーを新たに設定可能 | 非同期更新のため結果整合性。書き込み容量が追加消費 | 異なる属性からの検索(例:ユーザーIDとステータス)やクロスパーティションクエリの回避 |
この違いを無視して、後からLSIを追加しようとしてテーブルを再作成する羽目になるケースを何度見てきたことか。
ここが最初の分水嶺です。
次に実践的な設計術として、オーバーロードインデックスの活用を推奨します。
これはGSIのパーティションキーとソートキーに汎用的な属性名(例:GSI1PK、GSI1SK)を割り当て、異なるエンティティ種別で使い回す手法です。
例えば、注文履歴と顧客情報を同じGSIで検索可能にする設計です。
これにより、インデックスの総数増加を抑えつつ、柔軟な検索パスを確保できます。
その際、射影属性はALLではなく、必要最低限の属性に絞ることを忘れないでください。
更新時のコストと時間が大きく変わります。
設計時に必ず確認すべきポイントは以下の通りです。
- クエリで必ず使用する属性はパーティションキーに含める。フィルター式は最終手段と考える
- ソートキーには順序付けが必要な属性(日付や数値)を設定し、範囲検索を活用する
- GSIの書き込みキャパシティユニットはベーステーブルとは別に管理する必要があるため、事前に負荷試算を行う
インデックスは多ければ良いというものではありません。
必要数のGSIは多くとも5つ程度に抑えるのが現実的です。
それ以上になる場合は、そもそもテーブル分割やアクセスパターンの再設計を検討すべきでしょう。
最後に、安定稼働の鍵は監視とフィードバックにあります。
CloudWatchメトリクスでスロットリングやレイテンシを常に観測し、アクセスパターンの変化に合わせてインデックスを追加・削除する運用サイクルを確立すること。
これが設計を静的なものにせず、成長するシステムに対応するための現実解です。
インデックスを制御できれば、DynamoDBは驚くほど強力な武器になります。
ぜひ、今日から実践してみてください。
- なぜDynamoDBのインデックス設計はバックエンドエンジニアの頭痛の種なのか
- インデックスを理解する前に押さえるべきDynamoDBのパーティションとソートキーの原則
- ローカルセカンダリインデックス(LSI)の特性と、ここぞという活用シーン
- グローバルセカンダリインデックス(GSI)でクロスパーティション検索を劇的に高速化する
- アクセスパターン駆動設計でインデックスを決める実践的な5ステップ
- オーバーロードインデックスでGSIの総数を抑えつつ柔軟な検索パスを実現する
- 射影属性の選定がパフォーマンスとコストを分ける-ALL・KEYS_ONLY・INCLUDEの使い分け
- インデックス追加・削除を安全に行う運用ルールとCloudWatchによる監視ポイント
- まとめ:インデックス設計はアクセスパターンが全てであり、運用で育てるもの
なぜDynamoDBのインデックス設計はバックエンドエンジニアの頭痛の種なのか

リレーショナルデータベースに慣れたエンジニアが、最初にDynamoDBで壁にぶつかるのがインデックス設計です。
RDBでは後から好きなカラムにインデックスを張れますが、DynamoDBではアクセスパターンが先に決まっていなければ、まともなクエリすら書けないという根本的な制約があります。
この逆転の発想に適応できないまま、スキャンやフィルターでごまかす設計が量産される。
その結果、レイテンシの悪化、プロビジョンドキャパシティの無駄遣い、そして想定外のコスト請求という三重苦を招くのです。
では、なぜこれほどまでにインデックス設計が難しいのでしょうか。
第一に、DynamoDBのプライマリキーはパーティションキー+ソートキーという複合構造であり、この組み合わせでデータの物理的な配置場所が決まる点です。
パーティションキーが不均一な値(例えばユーザー属性のgenderなど)だと、特定のパーティションに負荷が集中し、ホットパーティション問題が発生します。
第二に、GSI(グローバルセカンダリインデックス)は非同期更新のため結果整合性であり、書き込み直後の読み取りでズレが生じる可能性を常に意識しなければならないこと。
第三に、インデックスの追加自体がテーブル全体の再構築を伴う大規模操作になる場合があり、本番環境での適用タイミングに神経を使うこと。
これらが重なって、インデックス設計は「最初が勝負」と言われる所以です。
スキャンとフィルターに頼る設計が招くレイテンシとコストの悪夢
多くの初心者エンジニアが陥る過ちは、Scan操作を万能検索のように使ってしまうことです。
DynamoDBのScanは、テーブル全体の全アイテムを読み取り、その後にFilterExpressionで絞り込みます。
一見便利に見えますが、この動作の代償は非常に大きい。
例えば、100万アイテムのテーブルで1件だけ該当するデータを探す場合でも、Scanは全100万アイテムを読み取った上でフィルターを適用します。
そのため、読み取りキャパシティユニット(RCU)を大量に消費し、応答時間は秒単位に膨れ上がります。
さらに悪いことに、FilterExpressionは読み取り後の絞り込みであるため、返却されるアイテム数が少なくても消費RCUは全スキャン分という点です。
この性質を理解せずに「フィルターで絞れば大丈夫」と設計すると、アクセス増加に比例してコストが線形以上に跳ね上がります。
以下の表でScanとQueryの違いを明確にしておきましょう。
| 操作種別 | 読み取り対象 | フィルター適用タイミング | 消費キャパシティ | レイテンシ傾向 |
|---|---|---|---|---|
| Scan | 全アイテム | 読み取り後 | 全アイテム数分 | データ量に比例 |
| Query | 指定パーティション内 | 読み取り時(インデックス利用) | 対象アイテムのみ | ほぼ一定(キー指定時) |
この表から明らかなように、Queryが使えるなら絶対にScanは使うべきではありません。
では、なぜScanが使われるのか。
それは、検索条件にパーティションキーが含まれていないからです。
例えば「ステータスが’完了’の全注文を取得したい」といった要件では、ステータスをパーティションキーに持つインデックスがなければQueryできません。
そのような場合に、エンジニアは「とりあえずScanしてフィルター」という安易な解決策を選びがちです。
ここで実際のコード例を見てみましょう。
AWS SDK for Python(boto3)での悪い例と良い例です。
# 悪い例:Scan + FilterExpression
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Orders')
response = table.scan(
FilterExpression='order_status = :status',
ExpressionAttributeValues={':status': 'completed'}
)
# この時点で全件読み取り済み。データ量が100万件ならRCUを大量消費。
# 良い例:GSIを利用したQuery
response = table.query(
IndexName='StatusIndex',
KeyConditionExpression='order_status = :status',
ExpressionAttributeValues={':status': 'completed'}
)
# インデックス上の指定パーティションのみ読み取るため、対象件数分のRCUで完了。
この差は、本番運用でコストにして数倍から数十倍の開きになります。
しかもScanは並列化しても全件読み取りの総量は変わらないため、スループットを上げようとするとさらにRCUを追加で購入する羽目に。
つまり、インデックス設計を怠ると、システムのスケールアップがコスト増に直結するという悪循環に陥るのです。
さらに見逃せないのが、Scanが原因で発生するタイムアウト障害です。
API Gatewayの統合タイムアウト(デフォルト29秒)を超えるScanが頻発すると、クライアント側でリトライが発生し、そのリトライがさらにScanを呼び出すというスロットリング地獄を引き起こします。
こうした障害は、インフラ監視だけでは気づきにくく、原因究明に長時間を要するケースが後を絶ちません。
結論として、スキャンとフィルターは「絶対に避ける」ではなく「最後の手段としてのみ許容する」という立ち位置です。
そのためには、最初のアクセスパターン定義で、すべての検索条件をカバーするインデックスを漏れなく設計することが求められます。
次の見出しでは、そのための基礎となるパーティションキーとソートキーの原則を、具体例を交えて掘り下げていきます。
インデックスを理解する前に押さえるべきDynamoDBのパーティションとソートキーの原則

DynamoDBのインデックス設計に入る前に、その基盤となるプライマリキーの構造を正確に理解しておく必要があります。
プライマリキーはパーティションキー(必須)とソートキー(オプション)で構成され、この組み合わせが各アイテムの物理的な格納場所を一意に決定します。
パーティションキーは、データを分散配置するためのハッシュ値の入力元であり、ソートキーは同一パーティション内での順序付けと範囲検索を可能にする属性です。
ここで重要なのは、パーティションキーの選択がパフォーマンスとコストのほぼ全てを左右するという事実です。
適切なパーティションキーは、リクエストを複数のパーティションに均等に分散させ、結果としてスループットを最大化します。
逆に不適切なキーは、特定のパーティションに負荷が集中するホットパーティションを生み、スロットリングやレイテンシ増大の直接原因となります。
ソートキーは、同じパーティションキーを持つアイテム群の中での検索や並び替えを効率化するための強力な武器です。
例えば、customer_idをパーティションキー、order_dateをソートキーにすれば、特定顧客の注文を日付範囲で瞬時に取得できます。
しかし、ソートキーだけではデータの分散性には寄与しないため、パーティションキーのカーディナリティ(値の多様性)こそが最重要指標であることを忘れてはなりません。
パーティションキー設計の失敗例と成功例をコードで比較する
では、実際のコード例を通して、パーティションキー設計の良し悪しがどのような影響を及ぼすかを具体的に見ていきましょう。
ここでは、オンラインストアの注文テーブルを想定します。
まず、失敗例からです。
初心者がやりがちなのが、パーティションキーにorder_status(注文ステータス)を選択することです。
ステータスは「pending」「shipped」「delivered」「cancelled」など高々数種類しかありません。
この設計では、データ量が増えるとdeliveredステータスのパーティションに膨大なアイテムが集中し、書き込み・読み込みともにそのパーティションの上限(最大3000 RCUまたは1000 WCU)に簡単に達します。
以下のPythonコードは、この悪い設計でクエリを実行しようとする様子です。
# 失敗例:カーディナリティの低いパーティションキー
import boto3
from boto3.dynamodb.conditions import Key
table = boto3.resource('dynamodb').Table('Orders')
# 'delivered' という1つの値に数百万件が集中する
response = table.query(
KeyConditionExpression=Key('order_status').eq('delivered')
)
# このクエリは単一パーティションを読み取るが、そのパーティションのサイズが大きすぎて
# 読み込み制限を超えやすく、スロットリングが頻発する。
この設計の問題点は、パーティションキーの一意な値の数(カーディナリティ)が極端に少ないことです。
結果として、DynamoDBのスケーリング能力を完全に殺いでしまいます。
次に、成功例を見てみましょう。
適切な設計では、パーティションキーに高いカーディナリティを持つ属性、例えばorder_id(注文ID)を選びます。
注文IDは各注文ごとにユニークなUUIDやタイムスタンプベースの値を取るため、数百万件のデータがあってもパーティションは均等に分散します。
# 成功例:高カーディナリティのパーティションキー
table = boto3.resource('dynamodb').Table('Orders')
# order_id はランダムなUUID(例:'a1b2c3d4-...')で、値の種類はレコード数と同程度
response = table.query(
KeyConditionExpression=Key('order_id').eq('a1b2c3d4-1234-5678-90ab-cdef12345678')
)
# 各クエリは単一の小さなパーティションにアクセスするだけなので、レイテンシは安定し、
# スロットリングのリスクが劇的に低下する。
しかし、order_idだけでは「特定顧客の全注文を取得する」といったアクセスパターンに対応できません。
その場合は、ソートキーを組み合わせて複合プライマリキーとするか、あるいはGSIを別途作成します。
例えば、customer_idをパーティションキー、order_dateをソートキーとするベーステーブルと、order_idをパーティションキーとするGSIを併用するのが一般的なベストプラクティスです。
以下の表で、パーティションキーの選択基準を整理します。
| 選択したパーティションキー | カーディナリティ | データ分散性 | ホットパーティションリスク | 推奨度 |
|---|---|---|---|---|
| order_status(3〜5種) | 極低 | 著しく不均一 | 非常に高い | 非推奨 |
| customer_id(数万人) | 中〜高 | 比較的良好 | 低い(巨大顧客は要注意) | 条件付き |
| order_id(全件ユニーク) | 最高 | 完全に均等 | ほぼゼロ | 推奨 |
| 複合キー(category#timestamp) | 中〜高 | 設計次第 | 設計次第 | 要検討 |
この表から明らかなように、パーティションキーは一意性が高く、将来的なデータ増加に対しても値の種類が十分に拡大する属性を選ぶべきです。
もし業務上都合が悪い場合は、サロゲートキー(人工的なID)を導入し、必要な検索パスはGSIで補完するというアプローチを取ります。
もう一つ、よくある失敗例として、タイムスタンプをそのままパーティションキーに使うケースがあります。
例えばcreated_atをパーティションキーにすると、現在の日時に近いパーティションに書き込みが集中し、まさにホットパーティションを自ら作り出します。
この対策としては、タイムスタンプの先頭にランダムなサフィックスを付与するシャーディング手法が有効です。
例えば2026-08-22#${random_suffix}のようにして、書き込みを複数のパーティションに分散させます。
ただし、この場合、特定日時の範囲検索が複雑になるため、ソートキーに元のタイムスタンプを残すなどの工夫が必要です。
まとめると、パーティションキー設計の黄金則は以下の通りです。
- カーディナリティが最大になる属性を選ぶ(理想的には全アイテムでユニーク)
- 書き込みの偏りを予測し、必要ならサフィックスで分散させる
- ソートキーは範囲検索や順序付けが必要な属性に割り当てる
- アクセスパターンが複数ある場合は、GSIを追加することを恐れない
この原則を守るだけで、後のインデックス追加や運用監視が格段に楽になります。
逆に、この段階で妥協すると、後でインデックスをいくら追加しても根本的なパフォーマンス課題は解決しません。
次章では、LSIとGSIの具体的な使い分けに入る前に、もう一つの重要な概念である「ソートキーの活用術」について掘り下げていきます。
ローカルセカンダリインデックス(LSI)の特性と、ここぞという活用シーン

LSIは、DynamoDBにおけるセカンダリインデックスの一種で、ベーステーブルと同じパーティションキーを持ちつつ、異なるソートキーを提供するインデックスです。
つまり、LSIはテーブルのパーティションキーをそのまま引き継ぐため、データの物理的な分散先は変わりません。
その代わりに、同一パーティション内で別の属性をソートキーとして使ったクエリが可能になります。
これにより、例えば「顧客ID(パーティションキー)+注文日(ソートキー)」というベースのプライマリキーに加えて、「顧客ID+注文金額」のLSIを作成すれば、特定顧客の注文を金額順に取得したり、金額の範囲検索を効率的に行えるようになります。
LSIの最大の強みは、強い整合性が保証される点です。
GSIが結果整合性であるのに対し、LSIはベーステーブルと同じトランザクション内で更新されるため、書き込み直後の読み取りでも常に最新のデータを取得できます。
この特性から、リアルタイム性が厳しく求められるバックオフィスシステムや、金融系の処理履歴検索などで重宝されます。
では、具体的にどのようなシーンでLSIを活用すべきでしょうか。
典型的なユースケースは時系列データの多軸ソートです。
IoTデバイスの計測ログをデバイスID(パーティションキー)と計測時刻(ソートキー)で保存しつつ、別のLSIで計測値の大きさ順に照会する、といったケースです。
また、バージョン管理されたドキュメントにおいて、同一ドキュメントID内でバージョン番号(ソートキー)と更新日(LSIのソートキー)の両方で検索したい場合も有効です。
ただし、LSIはテーブルあたり最大5つまでという制限があり、さらに各LSIはベーステーブルと同じパーティションキーに縛られるため、異なるパーティションキーでの検索はどうしてもLSIでは実現できません。
その点を理解した上で、LSIを選択するかGSIを選択するかの判断基準を持つことが重要です。
LSIはテーブル作成時にしか定義できないという制約をどう乗り越えるか
LSIの最大の制約、そして多くのエンジニアを悩ませるのがテーブル作成時にしか定義できないという点です。
一度テーブルを作成した後では、LSIの追加・削除・変更は一切できません。
これはRDBのALTER TABLEに慣れた者からすると非常に大きなハードルです。
では、この制約をどう乗り越えるのか。
実践的なアプローチをいくつか紹介します。
第一に、LSIの代わりにGSIを使用するという選択肢です。
GSIは作成後に追加・削除が可能であり、パーティションキーも自由に設定できます。
ただし、GSIは結果整合性であり、書き込み容量が追加で消費される点をトレードオフとして受け入れる必要があります。
もし強い整合性が絶対条件でないなら、GSIに切り替えるのが最も現実的な解決策です。
第二に、テーブルの再作成とデータ移行という古典的な手法です。
これは、新しいテーブルを希望するLSI定義で作成し、旧テーブルからデータをコピーする方法です。
具体的な手順としては、以下のステップを踏みます。
- 旧テーブルのアイテムをScanまたはExportで取得する
- 新テーブルを作成し、LSIを定義する
- 取得したデータを新テーブルに一括書き込みする(バッチ書き込みやAWS Data Pipelineを利用)
- アプリケーションの接続先を新テーブルに切り替える
- 旧テーブルを削除する
このプロセスではダウンタイムが発生するため、深夜のメンテナンス時間帯や、ブルーグリーンデプロイメントのように新旧両方のテーブルを一時的に並列稼働させる戦略が有効です。
AWSのDynamoDB BackupとRestore機能を使えば、テーブル定義のみを変更して復元することも可能です(ただし、復元時にLSI定義を変更できます)
この方法は大規模テーブルでも比較的短時間で完了するため、実務でよく用いられます。
第三に、LSIを使わずにソートキー設計自体を工夫するアプローチです。
これは、ベーステーブルのソートキーに複数の属性を連結した合成キーを用いる手法です。
例えば、order_date#amountのようにフォーマットを決めておけば、begins_with関数を使った前方一致検索で擬似的な複数軸ソートを実現できます。
ただし、この方法は柔軟性に欠け、範囲検索のパターンがあらかじめ限定される場合にしか有効ではありません。
以下の表に、LSIの制約を乗り越える各手法のメリットとデメリットを整理しました。
| 対策手法 | メリット | デメリット | 適用ケース |
|---|---|---|---|
| GSIへの切り替え | 後から追加可能、パーティションキー自由 | 結果整合性、追加書き込み容量 | 強い整合性が不要な検索 |
| テーブル再作成+移行 | LSIの恩恵をそのまま享受 | ダウンタイム発生、運用コスト | 大規模な設計変更時やバージョンアップ時 |
| 合成ソートキーの活用 | 追加インデックス不要 | クエリパターンが制限される | 検索パターンが少数に絞られるケース |
実際のコードでLSIを定義する例を示します。
AWS SDK for Python(boto3)のcreate_tableでLSIを指定する方法です。
import boto3
dynamodb = boto3.client('dynamodb')
response = dynamodb.create_table(
TableName='Orders',
AttributeDefinitions=[
{'AttributeName': 'customer_id', 'AttributeType': 'S'},
{'AttributeName': 'order_date', 'AttributeType': 'S'},
{'AttributeName': 'order_amount', 'AttributeType': 'N'} # LSIのソートキー用
],
KeySchema=[
{'AttributeName': 'customer_id', 'KeyType': 'HASH'},
{'AttributeName': 'order_date', 'KeyType': 'RANGE'}
],
LocalSecondaryIndexes=[
{
'IndexName': 'AmountIndex',
'KeySchema': [
{'AttributeName': 'customer_id', 'KeyType': 'HASH'},
{'AttributeName': 'order_amount', 'KeyType': 'RANGE'}
],
'Projection': {'ProjectionType': 'ALL'}
}
],
BillingMode='PAY_PER_REQUEST'
)
この例では、AmountIndexというLSIを定義し、order_amountをソートキーにしています。
このLSIを使ったクエリは以下の通りです。
table = boto3.resource('dynamodb').Table('Orders')
response = table.query(
IndexName='AmountIndex',
KeyConditionExpression='customer_id = :cid AND order_amount BETWEEN :low AND :high',
ExpressionAttributeValues={
':cid': 'C12345',
':low': 1000,
':high': 5000
}
)
このように、LSIは非常に強力ですが、制約も大きいため、設計フェーズで将来の検索パターンを十分に予測し、必要なLSIを漏れなく定義しておくことが成功の鍵です。
もし後から必要になった場合は、上記の乗り越え方を状況に応じて選択し、ダウンタイムやコストを最小化する計画を立ててください。
特に、テーブル再作成は大掛かりな作業になるため、本番適用前に必ずステージング環境で手順を検証することを推奨します。
グローバルセカンダリインデックス(GSI)でクロスパーティション検索を劇的に高速化する

LSIが同一パーティション内でのソート軸追加に特化していたのに対し、GSI(グローバルセカンダリインデックス)はベーステーブルとは完全に異なるパーティションキーを設定できるという自由度の高さが最大の特徴です。
これにより、例えば注文テーブルにおいて、ベースのパーティションキーがcustomer_idであっても、GSIとしてorder_statusをパーティションキーに持つインデックスを作成すれば、ステータスベースの高速な一括検索が実現します。
GSIは内部的に独立したテーブルと見なせる構造を持ち、ベーステーブルへの書き込みが発生するたびに、そのインデックスにも非同期でデータが反映されます。
この仕組みにより、アプリケーションはScanを使わずに、あらゆるアクセスパターンをQueryで処理できるようになるため、レイテンシを桁違いに改善できるのです。
実際のユースケースを挙げましょう。
ECサイトで「現在のステータスが’shipped’の全注文をリアルタイムに一覧表示する」という管理者向け機能を考えます。
このとき、ベーステーブルのパーティションキーがorder_idであれば、Scanなしでは全注文を舐めるしかありません。
しかし、GSIにstatusをパーティションキー、shipped_dateをソートキーとして定義しておけば、status = 'shipped'という条件だけで該当アイテム群を瞬時に取得でき、しかも出荷日順にソートされた結果が得られます。
このようにGSIは、クロスパーティション検索をQuery相当の効率に変える魔法のような存在です。
ただし、その魔法には代償が伴います。
ここで重要なのが、GSIが結果整合性で動作する点と、書き込み容量が追加で消費されるという二つのトレードオフです。
次の見出しで、このトレードオフをどう評価し、どう制御するかを掘り下げます。
GSIの結果整合性と書き込み容量増加というトレードオフを正しく評価する
GSIを導入する際、エンジニアが必ず直面するのが「結果整合性と追加コストの壁」です。
まず結果整合性について説明します。
GSIはベーステーブルへの書き込みが完了した後、非同期でインデックスに反映されます。
そのため、書き込み直後にGSI経由で読み取りを行うと、最新のデータがまだ反映されていない可能性があります。
この遅延は通常は数ミリ秒から数百ミリ秒程度ですが、システム負荷が高い場合やバースト書き込み時には数秒に達することも珍しくありません。
この性質を無視して、例えば「注文ステータスを完了に更新した直後に、GSIでその注文を取得して次の処理を行う」といった設計をすると、データが取得できずに処理が失敗するリスクがあります。
では、どう対処するか。
実践的な解決策は以下の通りです。
- 結果整合性が許容される読み取りと強い整合性が必須の読み取りを分離する。後者ではベーステーブルのプライマリキーを直接使うか、あるいはアプリケーション側でリトライと遅延を組み込む
- DynamoDBの
ConsistentReadパラメータはGSIではサポートされていないため、代わりに条件付き書き込みやトランザクションAPIを活用して整合性を担保する設計を検討する - 重要なビジネスフローでは、GSIではなくベーステーブルのQueryを優先し、GSIは表示用やバッチ処理用に限定する
次に、書き込み容量の増加です。
GSIを持つテーブルでは、ベーステーブルへの1回の書き込み(Put, Update, Delete)に対して、定義されたGSIの数だけ追加の書き込み処理が発生します。
つまり、GSIが2つあれば、1件の書き込みが実質的に3回の書き込み操作(ベース+2つのインデックス)を引き起こします。
この追加書き込みは、プロビジョンドモードではWCUを余分に消費し、オンデマンドモードではリクエスト単価がその分増加します。
特に、頻繁に更新される属性をGSIのパーティションキーやソートキーに含めている場合、その属性が変わるたびにインデックス上のアイテムの再配置が発生し、さらに多くのWCUを消費します。
このトレードオフを正しく評価するには、読み取り頻度と書き込み頻度の比率を正確に把握することが第一歩です。
読み取りが圧倒的に多く、書き込みが稀なシステムでは、GSIの追加コストは十分にペイします。
逆に、書き込みが非常に多いログ収集系では、GSIを乱用するとコストが爆発するため、設計を慎重に進める必要があります。
以下の表は、GSI導入の判断基準をまとめたものです。
| 評価軸 | 導入推奨ケース | 導入注意ケース | 代替案 |
|---|---|---|---|
| 読み取り対書き込み比率 | 読み取りが10倍以上 | 書き込みが読み取りを上回る | バッチ処理での事前集計 |
| 必要な整合性レベル | 結果整合性で十分(表示用途) | 書き込み直後の読み取りが必須 | ベーステーブル直接Query + キャッシュ |
| インデックス属性の更新頻度 | 低頻度(一度設定したら変わらない) | 高頻度で属性値が変わる | 属性を射影から除外する |
| クエリパターンの多様性 | 複数の異なるパーティションキーが必要 | 1〜2種類のパターンのみ | LSIまたは合成ソートキーで代替 |
また、実際のコスト試算では、GSIのWCU消費量をモニタリングすることが欠かせません。
CloudWatchのConsumedWriteCapacityUnitsメトリクスをインデックス単位で確認し、予算オーバーを防ぐアラートを設定してください。
AWSのコストエクスプローラーと組み合わせて、GSIが全体のコストに占める割合を定期的にレビューする習慣も有効です。
コード例として、GSIを作成するboto3のコードを示します。
先のLSIの例と対比しながら見てください。
import boto3
dynamodb = boto3.client('dynamodb')
response = dynamodb.create_table(
TableName='Orders',
AttributeDefinitions=[
{'AttributeName': 'order_id', 'AttributeType': 'S'}, # ベースのパーティションキー
{'AttributeName': 'customer_id', 'AttributeType': 'S'}, # GSIのパーティションキー
{'AttributeName': 'order_status', 'AttributeType': 'S'}, # GSIのソートキー(例)
],
KeySchema=[
{'AttributeName': 'order_id', 'KeyType': 'HASH'}, # ベーステーブルのPK
],
GlobalSecondaryIndexes=[
{
'IndexName': 'CustomerStatusIndex',
'KeySchema': [
{'AttributeName': 'customer_id', 'KeyType': 'HASH'},
{'AttributeName': 'order_status', 'KeyType': 'RANGE'}
],
'Projection': {'ProjectionType': 'KEYS_ONLY'}, # 射影を最小限に
'ProvisionedThroughput': {
'ReadCapacityUnits': 10,
'WriteCapacityUnits': 5
}
}
],
BillingMode='PROVISIONED'
)
そして、このGSIを使ったクエリ例です。
table = boto3.resource('dynamodb').Table('Orders')
response = table.query(
IndexName='CustomerStatusIndex',
KeyConditionExpression='customer_id = :cid AND order_status = :status',
ExpressionAttributeValues={
':cid': 'C12345',
':status': 'shipped'
}
)
# このクエリはGSI上の特定パーティション(customer_id)を対象とし、
# ベーステーブルの全パーティションをスキャンしないため、高速かつ経済的。
最後に、GSIのトレードオフを評価する実務的なチェックポイントをまとめます。
- 結果整合性が許容されるかをビジネス要件と照合する。許容できないなら、ベーステーブルのキー設計を見直す
- 書き込み増加によるコスト上昇を、読み取り効率化による節約効果と比較し、正味のメリットを算出する
- 射影属性を必要最小限に絞り(ALLではなくKEYS_ONLYやINCLUDE)、インデックスサイズと更新コストを削減する
- Auto Scalingやオンデマンドモードを活用し、トラフィック変動に柔軟に対応できるようにする
GSIは間違いなく強力な武器ですが、設計段階でトレードオフを定量的に評価し、運用後も継続的に監視することで、その真価を発揮します。
次の章では、アクセスパターン駆動でインデックスを体系的に設計する実践ステップを紹介します。
アクセスパターン駆動設計でインデックスを決める実践的な5ステップ

ここまでLSIとGSIの特性やトレードオフを詳しく見てきましたが、結局のところ「どのインデックスを、どこに、いくつ作るか」は、システムが実際にどのようなクエリを受け付ける必要があるかに完全に依存します。
この考え方をアクセスパターン駆動設計と呼びます。
RDBのように正規化と汎用性を優先するのではなく、DynamoDBではあらかじめ既知の全アクセスパターンを列挙し、それらを効率的に捌くためのキー構造とインデックス構成を逆算するのがセオリーです。
私がこれまで複数のプロジェクトで実践してきた経験から、効果的な設計フローは以下の5ステップに集約されます。
- ステップ1:全クエリバリエーションを洗い出し、必須条件をパーティションキーにマッピングする
- ステップ2:各クエリに必要なソート条件を特定し、ソートキーまたはLSIの候補とする
- ステップ3:パーティションキーとは異なる軸での検索要件をGSIの候補として抽出する
- ステップ4:各インデックスの射影属性を選定し、読み取り効率と書き込みコストをバランスさせる
- ステップ5:プロビジョニングモードとAuto Scalingの設定値を負荷見積もりから算出する
このフローに従えば、後から「インデックスが足りない」と気付く事態を大幅に減らせます。
それでは、最初のステップを詳細に見ていきましょう。
クエリのバリエーションを洗い出し、必須条件をパーティションキーにマッピングする
設計の最初にして最も重要な作業は、システムが応答すべきすべてのクエリパターンを漏れなくリストアップすることです。
ここでいうクエリとは、アプリケーションのユースケースごとに「どの属性で絞り込み、どの属性でソートし、どの属性を返すか」という具体的な操作を指します。
例えば、ECサイトであれば「顧客IDで全注文を日付降順に取得」「ステータスが’pending’の注文を取得」「注文IDで単一アイテムを取得」などが該当します。
この洗い出しには、フロントエンドの画面仕様やAPIのエンドポイント定義、あるいはバッチ処理のジョブ定義を徹底的にレビューする必要があります。
見落としがあると、そのクエリだけがScanに頼らざるを得なくなり、パフォーマンス障害の原因になります。
次に、各クエリから必須条件(WHERE句に相当する絞り込み条件)を抽出し、その中でカーディナリティが最も高く、かつ均等に分布する属性をパーティションキーの候補とします。
このとき、単一属性で十分な場合もあれば、複数属性を連結した合成パーティションキー(例:category#timestamp)が有効な場合もあります。
重要なのは、パーティションキーはQueryの必須条件として必ず含まれていなければならないという制約です。
つまり、どのクエリも少なくとも1つのパーティションキー(ベースまたはGSI)を指定して実行されるように設計します。
実際のマッピング作業では、以下のような表を作成すると整理しやすいです。
ここでは架空の物流システムを例に挙げます。
| クエリID | ユースケース | 絞り込み必須属性(候補) | ソート/範囲属性 | 想定頻度 | 割り当てるキー種別 |
|---|---|---|---|---|---|
| Q1 | 特定配送員の本日担当荷物一覧 | delivery_person_id + date |
scheduled_time |
高頻度 | ベーステーブルのPK(delivery_person_id#dateをパーティションキー、scheduled_timeをソートキー) |
| Q2 | 配送ステータス別の荷物カウント(管理ダッシュボード) | status |
なし(集計のみ) | 中頻度 | GSI(statusをパーティションキー、射影はKEYS_ONLY) |
| Q3 | 荷物IDによる詳細参照 | parcel_id |
なし | 高頻度 | GSI(parcel_idをパーティションキー)またはベーステーブル自体をparcel_idで設計し直す |
この表では、Q1が最も多いアクセスと想定されるため、ベーステーブルのプライマリキーをdelivery_person_id#date(合成パーティションキー)とscheduled_time(ソートキー)に設定します。
Q2はstatusだけのクエリですが、statusのカーディナリティは低い(5種程度)ため、GSIにしてもホットパーティションになり得ます。
その場合は、ステータスに加えて日付でシャーディングするなど、さらに工夫が必要です(例:status#yyyy-mm-dd)
Q3はparcel_idがユニークなので、GSIまたはベースキーをparcel_idに変更するか、どちらかを選択します。
ここでのポイントは、すべてのクエリにパーティションキーを割り当てるという原則です。
どれか一つでもパーティションキーなしのクエリがあれば、それはScanを強いることになります。
もしどうしてもパーティションキーを決められないクエリがあるなら、その時点で設計に穴があると認識してください。
実際のコードでは、アクセスパターンを定義する設定ファイル(JSONなど)を用意し、チームで共有するのが実践的です。
以下はその一例です。
{
"accessPatterns": [
{
"patternId": "Q1",
"description": "配送員別・日別担当荷物一覧",
"partitionKey": "delivery_person_id#date",
"sortKey": "scheduled_time",
"indexType": "BASE",
"projection": "ALL",
"estimatedFrequency": 1000
},
{
"patternId": "Q2",
"description": "ステータス別カウント",
"partitionKey": "status#date_shard",
"sortKey": null,
"indexType": "GSI",
"indexName": "StatusCountIndex",
"projection": "KEYS_ONLY",
"estimatedFrequency": 50
}
]
}
このJSONを設計ドキュメントとして管理し、コード生成やテストケースの作成に活用すれば、設計と実装の乖離を防げます。
最後に、マッピング作業で絶対に外せないのは将来の拡張性の見通しです。
現時点では使われていないが、半年後に追加予定の検索条件があれば、それも候補に入れておくべきです。
LSIは後から追加できないため、特に慎重に扱います。
もし不確かな要件がある場合は、GSIを優先的に検討し、必要になったら後から追加するという柔軟な戦略を取りましょう。
このように、最初のステップを丁寧にこなすことで、残りの4ステップは自ずと道筋が立ちます。
オーバーロードインデックスでGSIの総数を抑えつつ柔軟な検索パスを実現する

ここまでの議論で、アクセスパターンごとにGSIを追加することがいかに有効かはお分かりいただけたと思います。
しかし、GSIを増やせば増やすほど、書き込みコストは線形に増加し、テーブル全体の管理複雑性も高まります。
実際、AWSの公式ベストプラクティスでもGSIはテーブルあたり多くとも5つ程度に抑えることが推奨されています。
では、アクセスパターンが10種類以上あるシステムはどうすればよいのか。
その答えの一つがオーバーロードインデックスです。
オーバーロードインデックスとは、GSIのパーティションキーとソートキーに汎用的な属性名(例えばGSI1PK、GSI1SK)を割り当て、異なるエンティティ種別や異なる検索軸を同じインデックスで使い回す設計手法です。
例えば、GSI1PKには「顧客ID」を格納することもあれば、「注文ステータス」を格納することもあります。
そしてGSI1SKには「注文日」や「金額」など、その検索軸に応じたソート属性を入れます。
このようにすることで、1つのGSIで複数の異なるクエリパターンを吸収できるため、インデックスの総数を大幅に削減できるのです。
この手法のメリットはコスト面だけにとどまりません。
インデックス数が少なければ、テーブル定義がシンプルになり、運用監視の対象も絞られます。
また、新しいアクセスパターンが後から追加されても、既存のオーバーロードインデックスに新しい種類のデータを流し込むだけで対応できるケースが多く、テーブル再作成のリスクを回避できます。
ただし、その代償としてインデックス内のデータ構造が抽象化されるため、クエリ時にどのエンティティ種別を対象とするかを明確に区別する仕組みが必須になります。
その仕組みの中核を担うのが、次に解説する「型属性」です。
オーバーロード時にエンティティ種別を識別するための型属性の設計ノウハウ
オーバーロードインデックスを運用する上で最も重要なのが、各アイテムがどのエンティティ種別(Entity Type)に属するかを識別できる属性、すなわち型属性(通常はentity_typeやitem_typeといった名前)の設計です。
この属性がないと、同じGSIに混在するユーザーデータと注文データを区別できず、クエリ結果に異種エンティティが混ざってアプリケーションで誤動作を引き起こします。
型属性の設計には、以下の3つのプラクティスを押さえておく必要があります。
- 型属性は必ず全てのアイテムに含め、かつ値は一貫した命名規則(例:
USER、ORDER、PRODUCT)で定義する。これにより、フィルター式で簡単に絞り込めるようになります - 型属性をGSIのソートキーの先頭に含めるか、または別途フィルター属性として射影する。ソートキーの先頭に含めれば、
begins_withで特定種別だけを効率的に抽出できますが、ソートキーの自由度が制限されます。多くの場合は、射影に含めてFilterExpressionで絞る方式が柔軟性が高く推奨されます - 型属性の値は、GSIのパーティションキーの値と組み合わせて一意性を担保する。例えば、
GSI1PKに「CUSTOMER#123」、entity_typeに「ORDER」とすることで、「顧客123の注文」という意味づけが明確になります
具体的な設計例を見てみましょう。
下記の表は、同じGSI1というオーバーロードインデックスを、3つの異なるエンティティ種別で使い回すマッピングを示しています。
| エンティティ種別 | GSI1PK(パーティションキー)の値例 | GSI1SK(ソートキー)の値例 | entity_type属性値 |
|---|---|---|---|
| ユーザー | USER#user123 |
created_at(タイムスタンプ) |
USER |
| 注文 | CUSTOMER#cust456 |
order_date#order_id |
ORDER |
| 商品レビュー | PRODUCT#prod789 |
rating(数値) |
REVIEW |
この設計では、GSI1PKが「どの親エンティティに関連するか」を表し、GSI1SKがその親内での順序や範囲を表現しています。
そしてentity_typeで、そのアイテムが具体的に何であるかを判別します。
実際のクエリコードでは、以下のようにentity_typeをフィルター条件に加えます。
ここではPython(boto3)の例を示します。
import boto3
from boto3.dynamodb.conditions import Key
table = boto3.resource('dynamodb').Table('MyAppTable')
# 顧客cust456の全注文を、注文日降順で取得したい
response = table.query(
IndexName='GSI1',
KeyConditionExpression=Key('GSI1PK').eq('CUSTOMER#cust456'),
FilterExpression='entity_type = :etype',
ExpressionAttributeValues={
':etype': 'ORDER'
},
ScanIndexForward=False # 降順(ソートキーで)
)
# 注意:FilterExpressionはクエリ結果に対して適用されるが、
# 読み取り対象はGSI1PKで絞られた全アイテム(異種混在)のため、
# 無駄な読み取りが発生する点に留意する。
ここでの注意点は、FilterExpressionは読み取り後の絞り込みであるため、GSI1PKに異種エンティティが大量に混在していると、その分だけ余計なRCUを消費することです。
そのため、GSI1PKの設計では、なるべく同じ種別のアイテムが同じパーティションに集まるようにプレフィックスを工夫することが推奨されます。
例えばUSER#とCUSTOMER#を分けるのではなく、ENTITY#USER#idとENTITY#ORDER#custIdのように、先頭にエンティティ種別を入れることで、パーティション自体を種別で分離する方法もあります。
この場合、クエリ時にGSI1PKにENTITY#ORDER#cust456を指定すれば、FilterExpressionなしで注文のみを取得できます。
ただし、その場合は異なる種別間での横断検索ができなくなるため、トレードオフを考慮してください。
もう一つの実践的ノウハウとして、射影属性にentity_typeを必ず含めることが挙げられます。
もし射影から外してしまうと、インデックス上でentity_typeの値が参照できず、FilterExpressionが使えなくなります。
そのため、ProjectionTypeがKEYS_ONLYの場合は特に注意が必要です。
INCLUDEでentity_typeだけを追加射影するのが安全です。
最後に、オーバーロードインデックスを導入する際の判断基準を整理します。
- アクセスパターンが10以上あり、それぞれの頻度が中程度以下であれば導入効果が大きい
- エンティティ間で検索条件の構造が類似している(すべて「親ID+日付」など)場合に実装が容易
- 逆に、各エンティティでソートキーのデータ型がバラバラ(文字列・数値・バイナリ)だと、GSIのソートキー型を統一できずオーバーロードは困難。その場合は型を文字列に正規化するなどの工夫が必要
オーバーロードインデックスは、DynamoDBの柔軟性を最大限に引き出す高度なテクニックです。
型属性の設計を丁寧に行い、クエリ時のコスト増を許容できる範囲に抑えることで、GSIの総数を最小化しながら多彩な検索要件を満たすという理想的なバランスが実現できます。
次の見出しでは、射影属性の選定という、これまたコストとパフォーマンスに直結するテーマを掘り下げます。
射影属性の選定がパフォーマンスとコストを分ける-ALL・KEYS_ONLY・INCLUDEの使い分け

インデックスのキー構造を決めたら、次に検討すべきは射影(Projection)、すなわちインデックスにベーステーブルのどの属性をコピーして保持するかという設定です。
この選択は、読み取りの効率性と書き込みのコストに直接影響を与えるため、軽視できない重要な設計要素です。
DynamoDBのセカンダリインデックス(LSIおよびGSI)では、射影タイプとして以下の3つが提供されています。
- ALL:ベーステーブルの全属性をインデックスにコピーします。インデックスだけで完結したクエリが可能になり、テーブルへの追加読み取りが不要になる点が最大のメリットです。しかし、インデックスのストレージサイズが肥大化し、書き込み時の更新コストも最大になります
- KEYS_ONLY:インデックスのパーティションキーとソートキー、そしてベーステーブルのプライマリキーのみをコピーします。サイズが最小で、書き込みコストも最も抑えられますが、インデックスだけでは不足属性が多いため、追加の
GetItem呼び出しが発生しやすくなります - INCLUDE:KEYS_ONLYに加えて、あらかじめ指定した任意の属性を追加でコピーできます。ストレージサイズと書き込みコストをALLとKEYS_ONLYの中間に調整できる、柔軟性の高い選択肢です
これらを適切に使い分けるには、各クエリがインデックスだけで完結するか(Covered Query)、それともベーステーブルへの追加アクセスが必要かを判断する必要があります。
Covered Queryが実現できれば、読み取りレイテンシは最小化され、かつ消費RCUも削減できます。
ただし、その代償として書き込み時のインデックス更新負荷が増すため、読み取り頻度と書き込み頻度のバランスが再び重要な判断基準となります。
以下の表に、各射影タイプの特性と推奨シーンを整理しました。
| 射影タイプ | コピーされる属性 | ストレージサイズ | 書き込みコスト | 読み取り効率 | 推奨ユースケース |
|---|---|---|---|---|---|
| ALL | 全属性 | 最大 | 最大 | 最高(Covered Query確実) | 読み取りが圧倒的に多く、書き込みが稀な参照系テーブル |
| KEYS_ONLY | キー属性のみ | 最小 | 最小 | 最低(ほぼ常に追加GetItem必要) | インデックスは絞り込み専用で、属性取得は別途行うバッチ処理 |
| INCLUDE | キー+指定属性 | 中程度 | 中程度 | 中〜高(指定属性次第) | ほとんどの実運用系システムで最も推奨されるバランス型 |
実務では、INCLUDEをデフォルトとし、どうしても読み取り速度がクリティカルな場合のみALLを検討するというスタンスが現実的です。
KEYS_ONLYは、インデックスを単なる「存在確認」や「カウント」にしか使わない特殊ケースに限定しましょう。
不要な属性を射影から外すことで得られる書き込み最適化の効果
ここで、射影を絞り込むことによる書き込み最適化のメカニズムを詳しく解説します。
DynamoDBのインデックスは、ベーステーブルのアイテムが書き込まれるたびに、射影に含まれる属性の値が変更されているか否かに関わらず、インデックス上の該当アイテム全体が再書き込みされます。
つまり、ALLを選んだ場合、たった1つの属性(例えばlast_accessed_at)が更新されるだけで、インデックス上の全属性が書き換えられるのです。
この動作は、特に幅広なテーブル(属性数が数十〜百以上)で顕著なコスト増を招きます。
具体的な数値で考えてみましょう。
1アイテムが50属性を持ち、そのうちインデックスのキーに関係するのは2属性だけだとします。
このとき、ALLを選択すると、書き込み1件あたりインデックス側で50属性分の書き込み処理が発生します。
一方、KEYS_ONLYなら2属性分、INCLUDEで必要な3属性を追加しても合計5属性分で済みます。
この差は、書き込み頻度が1秒間に100件あれば、1時間で約18万件の属性書き込み差となり、プロビジョンドWCUの節約だけでなく、オンデマンドモードでのリクエスト単価削減にも直結します。
さらに、書き込み最適化はコストだけでなくスロットリング耐性も向上させます。
インデックスへの書き込みはベーステーブルとWCUを共有しないため、射影が大きいほどインデックス側のWCU消費が増え、結果としてGSIのスロットリングが発生しやすくなります。
スロットリングが起きると書き込み遅延が生じ、それが連鎖的にアプリケーションのタイムアウトやリトライ過多を招くため、システム安定性の観点からも射影の絞り込みは極めて有効です。
では、実際にどの属性を射影に含めるべきか。
基準はシンプルです。
そのインデックスを使ったクエリで、アプリケーションが必ず必要とする属性だけを選ぶことです。
例えば、注文ステータス別の一覧表示に使うGSIであれば、「注文ID」「顧客名」「ステータス」「更新日時」の4属性だけあれば十分で、shipping_addressやpayment_methodのような詳細情報は射影から外します。
足りない属性は、インデックスから取得したプライマリキーを使ってベーステーブルをGetItemで追加読み取りすればよいのです。
この「インデックスで絞り込み+テーブルで補完」という2段階読み取りは、一見非効率に思えますが、実際には射影を大幅に削減した方が総合的なコストとパフォーマンスで優位になるケースがほとんどです。
コード例で射影設定の違いを示します。
以下はAWS SDK for Python(boto3)でGSIを作成する際の射影指定です。
import boto3
dynamodb = boto3.client('dynamodb')
# INCLUDEを使用し、必要な属性のみを射影する例
response = dynamodb.create_table(
TableName='Orders',
AttributeDefinitions=[
{'AttributeName': 'order_id', 'AttributeType': 'S'},
{'AttributeName': 'customer_id', 'AttributeType': 'S'},
{'AttributeName': 'status', 'AttributeType': 'S'},
{'AttributeName': 'updated_at', 'AttributeType': 'S'}
],
KeySchema=[
{'AttributeName': 'order_id', 'KeyType': 'HASH'}
],
GlobalSecondaryIndexes=[
{
'IndexName': 'StatusIndex',
'KeySchema': [
{'AttributeName': 'status', 'KeyType': 'HASH'},
{'AttributeName': 'updated_at', 'KeyType': 'RANGE'}
],
'Projection': {
'ProjectionType': 'INCLUDE',
'NonKeyAttributes': ['customer_id', 'order_id'] # キー以外に必要な属性だけ指定
},
'ProvisionedThroughput': {
'ReadCapacityUnits': 10,
'WriteCapacityUnits': 5
}
}
],
BillingMode='PROVISIONED'
)
この例では、statusとupdated_atがキー属性として必ず射影され、それに加えてcustomer_idとorder_idを追加で射影しています。
shipping_addressやpayment_methodは射影に含めていないため、このインデックスの書き込み更新コストは最小限に抑えられます。
もう一つの実践的テクニックとして、射影に含める属性は変更頻度が低いものに絞るというルールがあります。
例えばcustomer_nameは滅多に変わらないため射影に含めても更新コストへの影響は小さいですが、last_login_atのように頻繁に変わる属性は射影から外すべきです。
なぜなら、頻繁に変わる属性が射影に含まれていると、その属性が更新されるたびにインデックス全体が再書き込みされるからです。
最後に、射影設計のチェックポイントをまとめます。
- 各インデックスのクエリで実際に使う属性をリストアップし、それ以外は射影から外す
- ALLは「読み取り最優先・書き込みほぼなし」の特殊ケースのみに限定する
- INCLUDEで追加する属性は、変更頻度が低く、かつ読み取り頻度が非常に高いものに絞る
- インデックス更新によるWCU消費量をCloudWatchで監視し、射影の見直しを定期的に行う
射影は、インデックス設計の中で「最後の調整弁」とも言える要素です。
キー構造が決まった後でも、ここを適切にチューニングすることで、コストを数割削減できることが珍しくありません。
ぜひ、自分のテーブルで現在の射影設定を見直してみてください。
思わぬ無駄が発見できるはずです。
インデックス追加・削除を安全に行う運用ルールとCloudWatchによる監視ポイント

インデックス設計が完了し、テーブルが本番稼働を始めても、仕事は終わりません。
アクセスパターンは時間とともに変化し、新たな要件が発生すればインデックスの追加が必要になります。
また、使われなくなったインデックスは削除してコストを最適化すべきです。
しかし、DynamoDBのインデックス操作は、特にGSIであってもオンライン操作ではあるものの、バックグラウンドで大量のデータコピーが発生するため、本番環境での追加・削除には細心の注意が求められます。
ここでは、安全な運用ルールと、それを支えるCloudWatch監視の実践ポイントを解説します。
まず、インデックス追加時の基本ルールは以下の通りです。
- 追加は必ずメンテナンスウィンドウを設定し、トラフィックが低い時間帯に実行する。GSIの追加中はベーステーブルの読み書きに影響はありませんが、インデックス構築中に書き込みが集中するとバックグラウンドの追いつき遅延が拡大し、結果整合性の遅延が数分に及ぶことがあります
- 追加前に、そのインデックスが本当に必要かどうかをクエリログやアクセスパターンの実測値で再検証する。開発環境の想定だけでなく、本番の実際のクエリ分布を確認することが肝要です
- GSIの追加は、
UpdateTableAPIで非同期に実行されますが、進行状況はCloudWatchのGlobalSecondaryIndexPopulationメトリクスで追跡できます。この値が100%になるまでは、インデックスが完全に利用可能になったとは見なさないでください - 削除する場合は、事前にアプリケーションコードからそのインデックスを参照している箇所をすべて無効化し、一定期間(例えば1週間)様子を見てから削除する。これにより、万が一参照が残っていた場合の障害を未然に防げます
これらのルールを徹底するためには、CI/CDパイプラインにインデックス変更の承認プロセスを組み込むことが有効です。
特に、テーブル作成時にしか定義できないLSIの変更は、テーブル再作成が必要な重大変更として扱い、別途データ移行計画を立てるべきでしょう。
次に、CloudWatchによる監視ポイントです。
インデックス関連で特に注視すべきメトリクスは以下のとおりです。
ConsumedReadCapacityUnitsおよびConsumedWriteCapacityUnits(インデックス別):インデックスごとの消費キャパシティを監視し、プロビジョニングされた容量に対する割合が80%を超えていないかをチェックしますGlobalSecondaryIndexPopulation(GSI追加時):インデックスの構築進捗をパーセントで表示します。これが100%になるまでは、そのGSIへのクエリは遅延やタイムアウトのリスクが高いと認識してくださいOnlineIndexPercentageProgress(LSIは対象外ですが、GSI追加中に有用)SuccessfulRequestLatency(インデックス別):インデックスを使ったクエリのレイテンシが、ベーステーブルのクエリと比較して異常に高くないかを監視します。結果整合性の遅延が拡大すると、ここに現れますThrottledRequests(インデックス別):スロットリング発生数。これがゼロでない状態が続く場合は、インデックスのプロビジョニングが不足しているか、射影が大きすぎる可能性があります
これらのメトリクスを総合的に評価するために、カスタムダッシュボードを作成し、インデックスごとに読み取り/書き込みの使用率とレイテンシを一覧表示することをお勧めします。
スロットリング発生時のアラート設計と自動スケーリングとの連携策
スロットリングはDynamoDB運用における最も一般的な障害要因の一つです。
インデックスがスロットリングされると、そのインデックスを使用する全クエリが遅延または失敗し、システム全体の信頼性を損ないます。
そこで、事前にアラートを設計し、自動スケーリングと連携させることで、スロットリングを未然に防ぐ、あるいは早期に回復する仕組みを構築しましょう。
アラート設計の基本は、プロビジョニングされた容量の使用率が閾値を超えたら即座に通知することです。
具体的には、以下のCloudWatchアラームをインデックスごとに設定します。
- アラーム1:
ConsumedWriteCapacityUnits/ProvisionedWriteCapacityUnitsが 80% を超えたら「WARNING」、95% を超えたら「CRITICAL」とする - アラーム2:
ThrottledRequestsが1分間で 1件以上 発生したら即座に「CRITICAL」とする(ゼロトレランスが理想) - アラーム3:
SuccessfulRequestLatencyが p95で100ミリ秒 を超えたら「WARNING」、500ミリ秒 を超えたら「CRITICAL」とする
これらのアラームは、SNSトピックに通知してメールやSlackに飛ばすとともに、AWS Auto Scaling(Application Auto Scaling)のターゲット追跡スケーリングポリシーと連携させることが重要です。
DynamoDBのAuto Scalingは、テーブルおよびGSIごとに読み取り/書き込みキャパシティユニットを自動調整します。
ターゲット使用率(デフォルトは70%)を設定しておけば、使用率が上昇した際にプロビジョニング容量が自動的に増加します。
ただし、このスケーリングには数分のラグがあるため、急激なトラフィック増加には対応できない点に注意が必要です。
そのため、オンデマンドモードを選択するか、あるいはプロビジョンドモードでも最低限のバッファ(例えばピークトラフィックの1.5倍)を常に確保しておくべきです。
コード例として、CloudWatchアラームとAuto ScalingポリシーをAWS SDK for Python(boto3)で設定する方法を示します。
import boto3
cloudwatch = boto3.client('cloudwatch')
app_autoscaling = boto3.client('application-autoscaling')
# 1. スロットリング発生時のアラーム(CRITICAL)を作成
cloudwatch.put_metric_alarm(
AlarmName='GSI-StatusIndex-Throttled',
ComparisonOperator='GreaterThanThreshold',
EvaluationPeriods=1,
MetricName='ThrottledRequests',
Namespace='AWS/DynamoDB',
Period=60,
Statistic='Sum',
Threshold=0.5, # 1分間で1件以上を検知するため0.5以上でアラート(実際は整数)
ActionsEnabled=True,
AlarmActions=['arn:aws:sns:us-east-1:123456789012:MySNSTopic'],
AlarmDescription='GSI StatusIndex でスロットリングが発生しました',
Dimensions=[
{'Name': 'TableName', 'Value': 'Orders'},
{'Name': 'GlobalSecondaryIndexName', 'Value': 'StatusIndex'}
],
Unit='Count'
)
# 2. Auto Scaling ターゲット追跡ポリシーをGSIに適用(書き込みキャパシティ)
app_autoscaling.register_scalable_target(
ServiceNamespace='dynamodb',
ResourceId='table/Orders/index/StatusIndex',
ScalableDimension='dynamodb:index:WriteCapacityUnits',
MinCapacity=5,
MaxCapacity=50
)
app_autoscaling.put_scaling_policy(
PolicyName='GSI-StatusIndex-Write-Scaling',
ServiceNamespace='dynamodb',
ResourceId='table/Orders/index/StatusIndex',
ScalableDimension='dynamodb:index:WriteCapacityUnits',
PolicyType='TargetTrackingScaling',
TargetTrackingScalingPolicyConfiguration={
'TargetValue': 70.0, # 使用率70%を維持
'PredefinedMetricSpecification': {
'PredefinedMetricType': 'DynamoDBWriteCapacityUtilization'
},
'ScaleOutCooldown': 60,
'ScaleInCooldown': 120
}
)
この設定により、GSIの書き込み使用率が70%を超えると自動的にWCUが増加し、スロットリングを回避します。
ただし、スケールアウトのクールダウン(60秒)とスケールインのクールダウン(120秒)を適切に調整し、頻繁な変動で無駄なスケーリングが発生しないようにチューニングすることが実務のポイントです。
さらに、より高度な運用として、スロットリングが発生した場合にアプリケーション側でリトライバックオフ(指数関数的バックオフ+ジッタ)を実装しておくことも必須です。
AWS SDKはデフォルトでリトライ機構を持ちますが、カスタムリトライポリシーを追加することで、スロットリング時でもサービス全体のダウンを防げます。
最後に、インデックス監視の運用ルールをまとめます。
- 毎週1回はインデックスごとの使用率とレイテンシのトレンドをレビューし、ボトルネックを早期発見する
- インデックスの追加・削除は必ずステージング環境で事前検証し、本番適用前にロールバック手順を文書化する
- オンデマンドモードを利用している場合はスロットリングは発生しませんが、コストが急増するリスクがあるため、コストアラームを別途設定する(例:1日あたりの予算を超過したら通知)
インデックスは「作って終わり」ではなく、運用と共に育てていくものです。
CloudWatchとAuto Scalingを適切に組み合わせ、アラートに即座に反応できる体制を整えておけば、インデックス関連の障害で夜間に起こされることは格段に減るはずです。
まとめ:インデックス設計はアクセスパターンが全てであり、運用で育てるもの

ここまで長々とDynamoDBのインデックス設計について語ってきましたが、最後に全体を俯瞰して、もう一度本質に立ち返りましょう。
DynamoDBにおけるインデックス設計の核心は、アクセスパターンを正確に予測し、それに合わせてキー構造とインデックス構成を最適化することに尽きます。
RDBのように正規化された汎用スキーマを後からチューニングする発想は、ここでは通用しません。
最初の設計がそのままシステムのパフォーマンス上限を決める。
だからこそ、設計フェーズでの徹底的な要件定義と、本番稼働後の継続的な観測・改善が車の両輪となるのです。
それでは、これまでの議論を実践的なチェックリストとして再構成してみましょう。
インデックス設計に取り組む際、以下のポイントを必ず押さえてください。
- アクセスパターンを漏れなく洗い出し、各クエリに必ずパーティションキーを割り当てる。ScanやFilterExpressionは最終手段とし、すべての検索をQueryで捌けるようにします
- パーティションキーはカーディナリティが最大で、かつ書き込みが均等に分散される属性を選ぶ。タイムスタンプやステータスのような偏りやすい値は避け、必要なら合成キーやサフィックスで分散させます
- LSIはテーブル作成時にしか定義できないという制約を常に意識し、将来のソート軸をあらかじめ見越して設計する。不安があればGSIで代替するか、合成ソートキーで柔軟性を確保します
- GSIはクロスパーティション検索の救世主ですが、結果整合性と書き込みコスト増というトレードオフを定量的に評価する。読み取り頻度が書き込みを上回る場合にのみ導入し、射影は必要最小限に絞ります
- オーバーロードインデックスを活用してGSI総数を抑える場合は、型属性(entity_type)を必ず設け、クエリ時に異種エンティティを適切にフィルタリングする。パーティションキーのプレフィックス設計で、さらに効率的な絞り込みを目指します
- 射影タイプはINCLUDEを基本とし、ALLは読み取り専用に近い極めて特殊なケースに限定する。頻繁に更新される属性は射影から外し、インデックスの書き込み負荷を最小化します
- インデックスの追加・削除はメンテナンス計画を立て、CloudWatchで進捗と影響をリアルタイムに監視する。Auto Scalingとアラートを組み合わせ、スロットリングを未然に防ぐ運用体制を構築します
これらの原則を守るだけでも、大半のプロジェクトで深刻なインデックス障害は回避できるはずです。
しかし、それで終わりではありません。
本番稼働後も、アクセスパターンはビジネス要件やユーザー行動の変化に伴って動的に変わります。
あるクエリの頻度が予想以上に増えれば、そのクエリを支えるGSIのプロビジョニングを見直す必要があります。
逆に、使われなくなったインデックスは積極的に削除してコストを節約すべきです。
また、新たなレポート機能が追加されれば、そのための新しいGSIやオーバーロードパターンの追加が求められます。
このように、インデックス設計は一度完成させるものではなく、システムの成長とともに進化させるものだという認識が欠かせません。
では、具体的に運用フェーズで何をすればよいのか。
私が推奨するのは、定期的なインデックス棚卸しの習慣化です。
四半期に一度、以下の項目をレビューするミーティングを設けてください。
- 各インデックスの消費キャパシティとレイテンシの推移をCloudWatchダッシュボードで確認する
- アプリケーションログから実際に発行されているクエリの分布を集計し、設計時の想定と乖離がないか検証する
- 未使用または極端に使用頻度の低いインデックスを特定し、削除の是非を判断する
- 新しいアクセスパターンが発生していないか、プロダクト開発チームと情報共有する
この棚卸しを継続することで、インデックス構成は常に最新の要件に最適化され、無駄なコストやパフォーマンスの劣化を防げます。
また、このプロセスで得られた知見は、次回の新規テーブル設計にも活かせる貴重な資産となるでしょう。
最後に、エンジニアとして心に留めておいてほしいのは、完璧な最初の設計を目指すよりも、変更に強い運用仕組みを整えることの方が現実的だという点です。
LSIのように後から変更が効かない制約があるからこそ、GSIやオーバーロード、射影の調整といった「後からでも修正可能なレイヤー」を上手に活用し、変更に対応できる余裕を設計に残しておくことが重要です。
DynamoDBは、適切にインデックスを制御できれば驚くほどスケーラブルで高速なデータベースになります。
そのポテンシャルを最大限に引き出すのは、アクセスパターンを読み切り、データと共にインデックスを育てていくあなたの手腕です。
今日から、目の前のテーブルのインデックス構成を見直してみてください。
きっと、最適化の余地が見つかるはずです。


コメント