DynamoDBのインデックス設計で失敗しないための基本!検索効率を最大化してコストを最適化するアプローチ

DynamoDBインデックス設計の最適化を象徴する構造図。パーティションキーとセカンダリインデックスの関係性を示す データベース

DynamoDBを使い始めて間もない頃、私は「とりあえずインデックスを増やせば検索が速くなる」と安易に考えていました。
しかし実際には、不適切なインデックス設計がスキャン処理の増大や予想外のコスト高騰を招き、システム全体のパフォーマンスを低下させる結果となりました。
NoSQLデータベースの設計において、インデックスは単なる付属物ではなく、データアクセスパターンを根本から規定する重要な設計要素です。

本記事では、DynamoDBのインデックス設計における基本的な原則と、実務で即座に活かせる最適化手法を解説します。
以下のポイントを押さえることで、検索効率を最大化しつつコストを適切に抑制できるようになります。

  • プライマリキーとソートキーの使い分けを理解し、データの一意性と範囲検索を両立させる
  • ローカルセカンダリインデックス(LSI)とグローバルセカンダリインデックス(GSI)の特性を把握し、適材適所で活用する
  • アクセスパターンを先に設計し、インデックスをその後に構築する「逆説的アプローチ」を実践する
  • プロビジョンドスループットの見積もりを正確に行い、読み込みキャパシティユニット(RCU)の無駄を防ぐ
  • Sparse Indexの考え方を活かし、不要なデータの読み込みを排除する

DynamoDBは「使えば使うほどコストがかかる」構造のサービスです。
そのため、インデックス設計の段階でどの属性に対してどのようなクエリが発生するかを厳密に見極めることが、長期的な運用コストとシステムの応答性を左右します。
以下、具体的な設計手法とコード例を交えながら詳しく見ていきましょう。

  1. DynamoDBインデックス設計の基本:プライマリキーとソートキーの使い分け
    1. パーティションキーの役割と設計上の注意点
    2. ソートキーの活用と範囲検索の実現
    3. プライマリキーの2パターンと選択基準
    4. 設計の第一歩としての意識
  2. アクセスパターン駆動設計:クエリを先に設計する逆説的アプローチ
    1. なぜアクセスパターンを先に考える必要があるのか
    2. アクセスパターンの洗い出し手順
    3. 実際の設計例:ECサイトの注文履歴テーブル
    4. 設計の反復と進化
  3. GSI(グローバルセカンダリインデックス)の特性と最適な活用方法
    1. GSIのコスト構造とプロビジョンドスループットの見積もり
    2. GSIの投影属性を最適化してストレージコストを削減する
  4. LSI(ローカルセカンダリインデックス)の特性とGSIとの使い分け
    1. LSIの制約条件と適用すべきシナリオ
    2. LSIとGSIを使い分ける判断基準まとめ
  5. Sparse Indexの考え方:不要なデータ読み込みを排除する設計
    1. Sparse Indexの実装例とメリット
  6. インデックス設計で陥りやすい失敗パターンとその対策
    1. ホットパーティション問題の発生と回避策
    2. 過剰なインデックス作成によるコスト増大
    3. 適切ないパーティションキー選択による性能劣化
  7. 実践的なインデックス設計の手順と検証方法
    1. アクセスパターンの洗い出しと整理
    2. インデックス設計のドラフト作成とレビュー
    3. 実際のクエリ性能を計測して最適化する
  8. まとめ:DynamoDBインデックス設計の本質と継続的な改善

DynamoDBインデックス設計の基本:プライマリキーとソートキーの使い分け

DynamoDBテーブル設計の概念図。パーティションキーとソートキーの関係性を示す図解

DynamoDBの設計を語る上で、まず押さえておくべきはプライマリキーの構造です。
RDBMSで慣れ親しんだ「テーブルに主キーを1つ設定する」という発想では、DynamoDBの真価を引き出すことはできません。
DynamoDBのプライマリキーは、パーティションキー(Partition Key)とソートキー(Sort Key)の2要素で構成される複合キーの設計思想を持っています。
この構造を理解しないままインデックス設計に踏み込むと、後から重大な修正が必要になるケースが少なくありません。

パーティションキーの役割と設計上の注意点

パーティションキーは、データをどの物理ノードに分散配置するかを決定する唯一の指標です。
DynamoDBは、このキーの値に基づいてハッシュ計算を行い、データを複数のストレージノードに分散させます。
そのため、パーティションキーの選択は、システム全体のスケーラビリティと応答性に直結します。

理想的なパーティションキーは、書き込みと読み込みが各パーティションに均等に分散するような属性です。
例えば、ユーザーIDやUUIDなど、値の分布がランダムに近い属性が適しています。
逆に、タイムスタンプや連番をパーティションキーに採用すると、特定のパーティションにアクセスが集中するホットパーティションが発生し、スロットリング(制限)を受けるリスクが高まります。

実務でよく見られる失敗例として、「created_at」という属性をパーティションキーに設定したケースがあります。
これでは最新のデータが常に同一パーティションに書き込まれ、書き込みスループットの上限に急速に到達してしまいます。

ソートキーの活用と範囲検索の実現

ソートキーは、同一パーティションキー内のデータを辞書順に並べ替える役割を担います。
この特性を活かすことで、等価検索だけでなく範囲検索も効率的に実現できます。

例えば、SNSのタイムライン機能を想定してみましょう。
パーティションキーを「user_id」、ソートキーを「posted_at(投稿日時)」とすれば、特定ユーザーの投稿を時系列順に取得するクエリは、パーティションキーで絞り込んだ上でソートキーの範囲指定で完結します。
これはDynamoDBにおいて最も効率的なクエリパターンの一つです。

import boto3
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Posts')

# user_idが「user_001」の投稿を、2024年1月以降のものから取得
response = table.query(
    KeyConditionExpression=Key('user_id').eq('user_001') & Key('posted_at').gte('2024-01-01T00:00:00Z')
)
items = response['Items']

このクエリは、該当パーティション内のデータをソートキー順にスキャンするだけで済むため、フルテーブルスキャンと比較して圧倒的に高速かつ低コストです。

プライマリキーの2パターンと選択基準

DynamoDBのプライマリキーには、以下の2つの構成パターンがあります。

パターン名 構成 用途 ユニーク性の担保
単一キー パーティションキーのみ 1項目=1エンティティのマッピング パーティションキーの値が一意
複合キー パーティションキー+ソートキー 1:Nのリレーションシップ表現 パーティションキーとソートキーの組み合わせが一意

複合キーを採用する場合、パーティションキーとソートキーの組み合わせで一意性が担保される点に注意が必要です。
つまり、同一パーティションキー内ではソートキーの値が重複してはいけません。
この制約を逆手に取り、ソートキーにバージョン番号やタイムスタンプを含めることで、履歴管理テーブルのような設計も可能です。

設計の第一歩としての意識

プライマリキーの設計は、後から容易に変更できない点が最大の特徴です。
テーブル作成後にパーティションキーやソートキーの属性を変更することはできないため、初期設計段階でアクセスパターンを十分に検討することが不可欠です。

私が推奨するのは、まず「このテーブルに対してどのようなクエリが発生するか」を全て列挙し、その中で最も頻度が高く、レイテンシ要件が厳しいクエリをプライマリキーで直接サポートするというアプローチです。
残りのクエリはセカンダリインデックスで補完しますが、それらの設計もプライマリキーの構造に大きく依存します。

この基本を押さえた上で、次の章ではGSIやLSIといったセカンダリインデックスの特性と、それらを使いこなすための具体的な手法について解説していきます。

アクセスパターン駆動設計:クエリを先に設計する逆説的アプローチ

DynamoDBアクセスパターン設計のフローチャート。クエリ優先の設計プロセス

RDBMSの設計手法に慣れているエンジニアにとって、DynamoDBの設計は最初に違和感を覚えることが多いです。
なぜなら、DynamoDBでは「テーブルスキーマを先に定義し、その後にクエリを書く」という従来の流れが通用しないからです。
DynamoDBの正しい設計プロセスは、アクセスパターン(どのようなクエリが発生するか)を先に洗い出し、そのクエリを最も効率的に実行できるテーブル構造とインデックスを後から構築するという、一見すると逆説的に見えるアプローチです。

なぜアクセスパターンを先に考える必要があるのか

DynamoDBはスキーマレスではありますが、クエリの柔軟性がRDBMSと比較して大幅に制限されています。
RDBMSであれば、テーブル構造を決めた後に「WHERE句でこの条件を追加しよう」「JOINで別テーブルから情報を取ってこよう」と気軽にクエリを変更できます。
しかしDynamoDBでは、クエリで使用できる属性はテーブル作成時またはインデックス作成時にあらかじめ決めておく必要があります。

この制約の根本には、DynamoDBの分散アーキテクチャがあります。
データはパーティションキーに基づいて複数のノードに分散されており、特定のパーティションキーを指定しないクエリ(スキャン)は全ノードを横断する処理となり、コストとレイテンシが指数関数的に増大します。
そのため、「後から必要になった条件で検索したい」という要求に対応するには、事前にその属性をキーまたはインデックスに組み込んでおくしかありません。

アクセスパターンの洗い出し手順

実務では、以下の手順でアクセスパターンを整理することをおすすめします。

  • ステップ1:エンティティと属性の一覧を作成する まず、システム内で扱う主要なエンティティ(ユーザー、商品、注文など)と、その属性をブレインストーミング的に列挙します。ここでは正規化を意識する必要はありません
  • ステップ2:各エンティティに対するCRUD操作を全て列挙する Create(作成)、Read(読み込み)、Update(更新)、Delete(削除)のそれぞれについて、「誰が」「どの条件で」「どのような頻度で」操作するかを具体的に書き出します
  • ステップ3:Read操作を「キー指定検索」「範囲検索」「フルスキャン相当」に分類する キー指定検索はプライマリキーで対応可能です。範囲検索はソートキーまたはセカンダリインデックスが必要です。フルスキャン相当のものは、極力インデックスでカバーするか、仕様変更を検討します
  • ステップ4:各クエリの頻度とレイテンシ要件を数値化する 「1秒間に100回発生し、100ms以内に応答が必要」といった具合に、優先順位を明確にします

この洗い出しを行う際、「将来的にこういうクエリも必要かもしれない」という憶測は極力排除してください。
DynamoDBの設計は、実際に発生するアクセスパターンに最適化すべきであり、想定しすぎると不要なインデックスが増えてコストが膨らむだけです。

実際の設計例:ECサイトの注文履歴テーブル

具体例として、ECサイトの注文履歴テーブルを考えてみましょう。
想定されるアクセスパターンは以下の通りです。

  1. ユーザーが自身の注文履歴を時系列順に閲覧する(頻度:高、レイテンシ:厳しい)
  2. 管理者が特定の注文IDで詳細を確認する(頻度:中、レイテンシ:厳しい)
  3. システムが特定の商品IDで注文を検索する(頻度:低、レイテンシ:緩やか)
  4. ユーザーが注文ステータスで絞り込んで検索する(頻度:中、レイテンシ:普通)

これらを整理すると、パターン1と2が最も重要であることがわかります。
パターン1は「user_id」でパーティションを分け、「ordered_at」でソートすれば、プライマリキーのみで完結します。
パターン2は「order_id」で直接取得したいため、「order_id」をパーティションキーとする別テーブル、またはGSIを検討します。

# アクセスパターン1:ユーザーごとの注文履歴を時系列順に取得
response = table.query(
    KeyConditionExpression=Key('user_id').eq('user_001') & Key('ordered_at').lte('2024-12-31T23:59:59Z'),
    ScanIndexForward=False,  # 降順(最新順)
    Limit=20
)

# アクセスパターン2:注文IDで直接取得(GSI「OrderIdIndex」を利用)
response = table.query(
    IndexName='OrderIdIndex',
    KeyConditionExpression=Key('order_id').eq('ORD-2024-12345')
)

パターン3と4は頻度が低いため、最初からプライマリキーに組み込む必要はありません。
必要に応じて後からGSIを追加すれば十分です。
このように、頻度と重要性で優先順位をつけ、上位のパターンをプライマリキーで直接サポートするのが設計の鉄則です。

設計の反復と進化

アクセスパターンは、サービスの成長とともに変化します。
したがって、DynamoDBの設計は一度きりの作業ではなく、継続的に見直す必要があるプロセスです。
ただし、テーブル構造の変更はコストが高いため、初期設計時に「将来の拡張性」をどこまで考慮に入れるかは難しい判断です。

私の経験則としては、「現在の要件を満たす最小限の設計」から始め、実際の運用データとアクセスログを監視しながら段階的に最適化する方針が最も現実的です。
過剰な予測設計は、多くの場合、不要なインデックスとコストの増大を招きます。

このアクセスパターン駆動の考え方を土台に、次の章ではセカンダリインデックスの具体的な種類と特性について、より深く掘り下げていきます。

GSI(グローバルセカンダリインデックス)の特性と最適な活用方法

DynamoDB GSIの構造を説明する図。パーティションキーとソートキーの別構成を示す

GSI(Global Secondary Index:グローバルセカンダリインデックス)は、DynamoDBにおいてプライマリキーとは異なる属性をキーとして検索するための強力な仕組みです。
プライマリキーで直接サポートできないアクセスパターンを補完する役割を担い、実務上ほとんどのテーブルで利用される重要な機能です。
しかし、GSIは同時にコスト増大の主要因にもなり得るため、その特性を正確に理解した上で活用することが求められます。

GSIの最大の特徴は、プライマリキーとは完全に独立したパーティションキーとソートキーを持てる点にあります。
これにより、例えばプライマリキーが「user_id」で構成されているテーブルに対して、「email」や「product_id」といった別の属性で検索するインデックスを構築できます。
また、GSIにはテーブルあたり最大20個まで作成可能であり(デフォルト)、多様なクエリニーズに対応できます。

ただし、GSIは「マジックワンド」ではありません。
GSIを作成すると、DynamoDBはベーステーブルのデータを自動的にGSI側にも非同期レプリケーションします。
このレプリケーションは書き込み時に発生するため、GSIの数が増えるほど書き込み処理のオーバーヘッドが大きくなります。
また、GSIはベーステーブルとは別のストレージ領域を消費するため、ストレージコストも別途発生します。

GSIのコスト構造とプロビジョンドスループットの見積もり

GSIのコスト構造を理解するためには、まずDynamoDBのプロビジョンドスループットの仕組みを振り返る必要があります。
DynamoDBでは、テーブルとGSIそれぞれに対して読み込みキャパシティユニット(RCU)と書き込みキャパシティユニット(WCU)を個別に設定します。
そして、GSIの重要な制約として、GSIに書き込まれるデータ量はベーステーブルのWCUとは別にGSI側のWCUを消費するという点があります。

具体的な計算式は以下の通りです。

操作 ベーステーブルの消費 GSIの消費 備考
項目の書き込み ベーステーブルのWCU GSIのWCU(項目サイズに応じて) GSI側も同じ項目を書き込む
項目の更新 ベーステーブルのWCU GSIのWCU(更新後の項目サイズ) GSIキー属性が変更されると2倍消費
項目の削除 ベーステーブルのWCU GSIのWCU(削除項目のサイズ) GSI側の項目も削除される
GSI経由の読み込み なし GSIのRCU ベーステーブルにはアクセスしない

この構造により、GSIを1つ追加するだけで、書き込みコストは理論上最大で2倍に増大します。
実際にはGSIのWCU設定をベーステーブルより低くすることで抑制できますが、書き込みの遅延やスロットリングのリスクが生じます。

プロビジョンドスループットの見積もりでは、以下の点を考慮してください。

  • 書き込み頻度の高いテーブルではGSIの数を最小限に抑える 書き込みコストの増大が最も顕著に現れるため、読み込み専用や参照頻度の低いGSIは見直しの対象となります
  • GSIのWCUはベーステーブルのWCUと同じかそれ以上に設定する ベーステーブルよりGSIのWCUが低いと、書き込みレプリケーションの遅延が発生し、データの不整合期間が長引きます
  • オンデマンドキャパシティモードを検討する トラフィックが予測困難な場合、プロビジョンドではなくオンデマンドモードを利用することで、キャパシティ設計の負担を軽減できます。ただし、オンデマンドは高頻度アクセス時にプロビジョンドより高コストになる場合があります
# GSIのWCUとRCUを設定したテーブル作成例
import boto3

client = boto3.client('dynamodb')

response = client.create_table(
    TableName='Products',
    KeySchema=[
        {'AttributeName': 'product_id', 'KeyType': 'HASH'}
    ],
    AttributeDefinitions=[
        {'AttributeName': 'product_id', 'AttributeType': 'S'},
        {'AttributeName': 'category', 'AttributeType': 'S'},
        {'AttributeName': 'price', 'AttributeType': 'N'}
    ],
    ProvisionedThroughput={
        'ReadCapacityUnits': 5,
        'WriteCapacityUnits': 5
    },
    GlobalSecondaryIndexes=[
        {
            'IndexName': 'CategoryPriceIndex',
            'KeySchema': [
                {'AttributeName': 'category', 'KeyType': 'HASH'},
                {'AttributeName': 'price', 'KeyType': 'RANGE'}
            ],
            'Projection': {
                'ProjectionType': 'INCLUDE',
                'NonKeyAttributes': ['product_name', 'stock']
            },
            'ProvisionedThroughput': {
                'ReadCapacityUnits': 5,
                'WriteCapacityUnits': 5
            }
        }
    ]
)

GSIの投影属性を最適化してストレージコストを削減する

GSIのコストを抑える上で、最も効果的な手法の一つが投影属性(Projection)の最適化です。
Projectionとは、GSIにどの属性を含めるかを定義する設定であり、以下の3つの選択肢があります。

投影タイプ 含まれる属性 ストレージ効率 用途
KEYS_ONLY プライマリキーとGSIキーのみ 最高 GSIで絞り込み後、ベーステーブルに戻るケース
INCLUDE 上記+指定した非キー属性 中 頻繁に参照する属性をGSIに含めたいケース
ALL ベーステーブルの全属性 最低 GSI単独で全情報を取得したいケース

KEYS_ONLYはストレージ効率が最も高く、GSIのサイズを最小限に抑えられます。
ただし、実際のデータを取得する際には、GSIの結果からベーステーブルに対して個別のGetItemを発行する必要があり、読み込みレイテンシが増大します。

INCLUDEは、頻繁に参照される属性のみをGSIに含める中間的な選択肢です。
例えば、商品一覧表示で「product_name」と「price」が必須で、「description」は詳細画面でのみ必要な場合、INCLUDEで前者2属性のみを投影すれば、一覧表示のクエリをGSI単独で完結させつつ、ストレージコストを抑制できます。

ALLは利便性は高いものの、ベーステーブルとほぼ同じサイズのGSIが作成されるため、ストレージコストと書き込みWCUが最大になります。
大規模テーブルでは避けるべき選択です。

投影属性の選定では、「このGSIで取得するクエリに、どの属性が必須か」を厳密に見極めることが重要です。
不要な属性を投影に含めていると、それだけストレージコストと書き込みコストが無駄に増大します。
運用開始後も、GSIの利用状況をCloudWatchで監視し、実際に参照されていない属性があればテーブル更新時にProjectionを見直すことをおすすめします。

このように、GSIは強力なツールですが、コストと性能のトレードオフを常に意識した設計が不可欠です。
次の章では、GSIとは異なる特性を持つLSI(ローカルセカンダリインデックス)について解説し、両者の使い分けを明確にしていきます。

LSI(ローカルセカンダリインデックス)の特性とGSIとの使い分け

DynamoDB LSIとGSIの違いを比較する構造図

LSI(Local Secondary Index:ローカルセカンダリインデックス)は、GSIと同様にセカンダリインデックスの一種ですが、根本的に異なるアーキテクチャを持っています。
LSIは「ローカル」という名称が示す通り、ベーステーブルのパーティションキーと同じ属性をパーティションキーとして共有します。
つまり、LSIはベーステーブルの各パーティション内部に構築されるインデックスであり、パーティションをまたいだ検索は行えません。
この特性は一見すると制約に見えますが、特定のユースケースではGSIよりも優れた性能とコスト効率を発揮します。

LSIの最大のメリットは、ベーステーブルと同じパーティション内に存在するため、書き込み時のレプリケーションオーバーヘッドがGSIと比較して小さい点です。
GSIではベーステーブルとは別のパーティションにデータが分散されるため、書き込み時に別途WCUを消費しますが、LSIは同一パーティション内でソート順を管理するだけです。
そのため、書き込みコストの増大が相対的に抑えられます。
また、LSIはベーステーブルのストレージと同じパーティションを共有するため、ストレージコストもGSIほど増大しません。

ただし、LSIには重要な制約がいくつか存在します。
まず、LSIはテーブル作成時にのみ定義でき、後から追加・削除・変更はできません。
これはGSIとは大きく異なる点で、GSIはテーブル作成後でも自由に追加・削除できます。
したがって、LSIを利用する場合は、初期設計段階でその必要性を確実に見極めておく必要があります。

LSIの制約条件と適用すべきシナリオ

LSIの制約を整理すると、以下の通りです。

制約項目 LSI GSI 影響
作成タイミング テーブル作成時のみ いつでも可能 LSIは事前設計が必須
パーティションキー ベーステーブルと同一 任意の属性 LSIはパーティション横断不可
最大数 テーブルあたり5個 テーブルあたり20個 LSIは限定的
ソートキー 必須 任意(省略可) LSIは範囲検索前提
WCU消費 ベーステーブルと共有 別途消費 LSIは書き込みコスト低

これらの制約から、LSIが最も有効なシナリオは以下のようなケースです。

  • 同一エンティティ内での複数のソート順が必要な場合 例えば、掲示板の投稿テーブルで、プライマリキーのソートキーが「created_at(作成日時)」である一方、「updated_at(更新日時)」でもソートしたい場合です。パーティションキーは同じ「thread_id」で、LSIのソートキーを「updated_at」とすれば、同一スレッド内の投稿を更新日時順に取得できます
  • パーティション内での集計クエリが頻発する場合 同一パーティション内のデータを異なる属性で絞り込みたい場合、LSIはGSIよりも低レイテンシで応答できます
  • 書き込みコストを極力抑えたい場合 GSIの追加によるWCU増大を避けつつ、セカンダリなソート順を提供したい場合です
# LSIを含むテーブル作成例
import boto3

client = boto3.client('dynamodb')

response = client.create_table(
    TableName='ForumPosts',
    KeySchema=[
        {'AttributeName': 'thread_id', 'KeyType': 'HASH'},
        {'AttributeName': 'created_at', 'KeyType': 'RANGE'}
    ],
    AttributeDefinitions=[
        {'AttributeName': 'thread_id', 'AttributeType': 'S'},
        {'AttributeName': 'created_at', 'AttributeType': 'S'},
        {'AttributeName': 'updated_at', 'AttributeType': 'S'}
    ],
    LocalSecondaryIndexes=[
        {
            'IndexName': 'UpdatedAtIndex',
            'KeySchema': [
                {'AttributeName': 'thread_id', 'KeyType': 'HASH'},
                {'AttributeName': 'updated_at', 'KeyType': 'RANGE'}
            ],
            'Projection': {
                'ProjectionType': 'ALL'
            }
        }
    ],
    ProvisionedThroughput={
        'ReadCapacityUnits': 5,
        'WriteCapacityUnits': 5
    }
)

# LSIを使ったクエリ:同一スレッド内の投稿を更新日時順に取得
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('ForumPosts')

response = table.query(
    IndexName='UpdatedAtIndex',
    KeyConditionExpression=Key('thread_id').eq('thread_001'),
    ScanIndexForward=False
)

LSIとGSIを使い分ける判断基準まとめ

実務でLSIとGSIのどちらを選択するか迷った場合、以下のフローで判断すると効率的です。

  1. パーティションキーを変更したいか? 「はい」の場合はGSIのみが選択肢です。LSIはパーティションキーを変更できません
  2. テーブル作成後にインデックスを追加したい可能性があるか? 「はい」の場合はGSIを選びます。LSIは後から追加できません
  3. 同一パーティション内で異なるソート順が必要か? 「はい」で、かつテーブル作成時に確定しているならLSIが適しています
  4. 書き込みコストを最小限に抑えたいか? 「はい」の場合、LSIはベーステーブルとWCUを共有するため、GSIより有利です
  5. インデックス数が5個を超える可能性があるか? 「はい」の場合はGSIを選びます。LSIは最大5個までです

LSIとGSIは互いに排他的な選択肢ではなく、同一テーブルに両方を混在させることも可能です。
例えば、プライマリキーと同じパーティションキーで異なるソート順が必要なケースにはLSIを、全く別の属性で検索したいケースにはGSIを、という使い分けが有効です。

ただし、インデックスの数が増えるほど運用の複雑さとコストが増大するため、「本当に必要なインデックスは何か」を冷静に見極めることが何より重要です。
次の章では、Sparse Indexという少し高度なテクニックを取り上げ、不要なデータの読み込みを排除する設計手法について解説します。

Sparse Indexの考え方:不要なデータ読み込みを排除する設計

DynamoDB Sparse Indexの概念図。条件を満たすデータのみをインデックス化

DynamoDBのインデックス設計において、しばしば見落とされがちながら、コスト削減と性能向上に大きく貢献するテクニックがSparse Index(スパースインデックス)です。
この考え方は、DynamoDBのインデックスが「テーブルの全項目を必ずインデックス化する」という前提を根本から覆すものです。
実は、DynamoDBのセカンダリインデックスは特定の条件を満たす項目のみをインデックスに含めるという設計が可能であり、これを意図的に活用することで、不要なデータの読み込みを排除し、クエリ効率を飛躍的に高めることができます。

Sparse Indexの核心は、インデックスのキー属性を持たない項目は、自動的にそのインデックスから除外されるというDynamoDBの仕様にあります。
例えば、GSIのパーティションキーに「is_published」という属性を設定した場合、「is_published」属性を持たない項目、またはその値がnullである項目は、GSIに一切含まれません。
このシンプルな仕様を逆手に取ることで、インデックスのサイズを劇的に縮小し、検索対象を必要な項目のみに絞り込むことが可能になります。

このアプローチの利点は、単なるストレージ節約にとどまりません。
インデックスに含まれる項目数が減ることで、クエリ時のスキャン範囲が狭まり、RCUの消費が削減されます。
また、インデックスがコンパクトになることで、 DynamoDBの内部キャッシュ効率も向上し、レイテンシの低減にも寄与します。
大規模テーブルで頻繁に発生する「ほとんどの項目が不要な条件に一致する」ようなクエリでは、Sparse Indexの効果は特に顕著です。

Sparse Indexを適用する際の重要なポイントは、「どの項目をインデックスに含めるか」をビジネスロジックと密接に連動させることです。
単なる技術的な最適化ではなく、アプリケーションのデータアクセスパターンを深く理解した上で、インデックスキー属性の付与タイミングを制御する必要があります。

Sparse Indexの実装例とメリット

具体例として、タスク管理システムを考えてみましょう。
テーブルには全てのタスクが格納されていますが、実際に頻繁に検索されるのは「期限切れになったタスク」や「優先度が高い未完了タスク」など、全体のごく一部です。
この場合、期限切れタスク専用のGSIを作成し、そのパーティションキーに「overdue_flag」という属性を設定します。

import boto3
from boto3.dynamodb.conditions import Key

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Tasks')

# 期限切れタスク専用のSparse GSIを使ったクエリ
# overdue_flag属性を持つ項目のみがインデックスに含まれる
response = table.query(
    IndexName='OverdueTasksIndex',
    KeyConditionExpression=Key('overdue_flag').eq('true'),
    FilterExpression='status <> :completed',
    ExpressionAttributeValues={':completed': 'completed'}
)

overdue_tasks = response['Items']

この設計では、「overdue_flag」属性は期限が切れた時点で初めて項目に付与されます。
期限前のタスクや完了済みタスクはこの属性を持たないため、GSIには含まれません。
結果として、期限切れタスクの検索は極めて少ない項目数のインデックスをスキャンするだけで完結し、RCUを大幅に節約できます。

もう一つの例として、記事の「おすすめ」機能を考えます。
プライマリキーで全記事を管理しつつ、「featured_at」という属性を持つ記事のみを対象としたGSIを作成すれば、おすすめ記事一覧の取得が非常に高速になります。

# おすすめ記事専用のSparse GSIクエリ
response = table.query(
    IndexName='FeaturedArticlesIndex',
    KeyConditionExpression=Key('featured_at').begins_with('2024'),
    ScanIndexForward=False,
    Limit=10
)

featured_articles = response['Items']

「featured_at」属性はおすすめに選ばれた記事にのみ設定されるため、通常の記事はインデックスに含まれません。
このシンプルな設計により、おすすめ記事の取得はインデックス全体が数十件程度に抑えられるため、O(1)に近い応答速度を実現できます。

Sparse Indexのメリットを整理すると、以下の通りです。

  • ストレージコストの削減 インデックスに含まれる項目数が減るため、GSIのストレージ料金が低下します
  • RCU消費の削減 スキャン対象が絞られるため、クエリあたりの読み込みキャパシティユニットが節約されます
  • クエリレイテンシの低減 インデックスサイズが小さくなることで、DynamoDBの検索処理が高速化します
  • キャッシュ効率の向上 インデックスがコンパクトになるため、メモリキャッシュのヒット率が高まります

ただし、Sparse Indexは「後付け」が難しい側面があります。
インデックスキー属性を動的に付与・削除するロジックは、アプリケーション側で厳密に管理する必要があり、データ不整合のリスクも伴います。
そのため、初期設計段階でSparse Indexの適用可能性を検討し、属性の付与ルールを明確に定義しておくことが重要です。

このテクニックを理解した上で、次の章ではインデックス設計で陥りやすい具体的な失敗パターンと、その対策について解説していきます。

インデックス設計で陥りやすい失敗パターンとその対策

DynamoDBインデックス設計の失敗例と成功例を対比する図

DynamoDBのインデックス設計は、正しく行えば強力な性能向上手段となりますが、一方で些細なミスが重大な運用障害や予想外のコスト増大を招くリスクも孕んでいます。
これまで解説してきた基本概念を踏まえた上で、実務で頻繁に遭遇する失敗パターンとその対策を整理することは、設計品質を担保する上で極めて重要です。
以下では、特に影響が大きい3つの失敗パターンについて、具体的な事例を交えながら解説します。

ホットパーティション問題の発生と回避策

ホットパーティションは、DynamoDB運用において最も頻繁に発生し、かつ影響が大きい問題の一つです。
これは、特定のパーティションキー値に対するアクセスが極端に集中し、そのパーティションに割り当てられたスループット容量を超えてしまう現象です。
DynamoDBは各パーティションに対してスループット上限を設けており、この上限を超えるとスロットリング(リクエストの制限)が発生し、レスポンスが遅延したりエラーが返ったりします。

典型的な失敗例は、タイムスタンプや連番をパーティションキーに採用したケースです。
例えば、ログデータのテーブルで「date(日付)」をパーティションキーにした場合、当日のログは全て同一パーティションに書き込まれ、書き込みスループットの上限に急速に到達します。
同様に、SNSのタイムラインで「popular_user_id」をパーティションキーにした場合、人気ユーザーの投稿への読み込みアクセスが集中し、読み取りスロットリングが発生します。

回避策としては、以下のアプローチが有効です。

  • パーティションキーにランダム性を持たせる UUIDやハッシュ値をパーティションキーの接頭辞・接尾辞に加えることで、データを均等に分散させます。ただし、特定のエンティティを検索する際には、全てのバリエーションを試す必要があるため、検索効率とのトレードオフに注意が必要です
  • 書き込みシャーディングを採用する 例えば、ログデータの場合、パーティションキーを「date#random_suffix」のように構成し、同一日付のデータを複数のパーティションに分散させます。読み込み時は全シャードを並列クエリして結果をマージします
  • Adaptive Capacityを活用する DynamoDBはAdaptive Capacity機能により、ホットパーティションに他のパーティションの未使用キャパシティを自動的に再配分します。ただし、これはあくまで緩和策であり、根本的な設計の見直しを代替するものではありません
# 書き込みシャーディングの例:ログデータを4つのシャードに分散
import random
import boto3
from datetime import datetime

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('Logs')

# パーティションキーを「date#shard_id」形式にする
today = datetime.now().strftime('%Y-%m-%d')
shard_id = random.randint(0, 3)
partition_key = f"{today}#{shard_id}"

table.put_item(
    Item={
        'date_shard': partition_key,
        'timestamp': datetime.now().isoformat(),
        'message': 'Application started',
        'level': 'INFO'
    }
)

過剰なインデックス作成によるコスト増大

「検索速度を上げるためにインデックスを増やせばよい」という安易な発想は、DynamoDBでは大きな落とし穴になります。
GSIは1つ追加するごとに、書き込みコストが理論上最大で2倍に増大するため、インデックスの数が増えるほどWCUの消費が急激に膨らみます。
また、各GSIは独自のストレージ領域を消費するため、ストレージコストも比例して増加します。

実務でよく見られるのは、「将来的に必要になるかもしれない」という理由で予防的にGSIを複数作成してしまうケースです。
運用開始後にこれらのインデックスが実際に利用されていないことに気づき、削除するまでに数ヶ月間、無駄なコストが発生し続けるという状況は決して稀ではありません。

対策としては、以下を徹底してください。

  • インデックスは必要最小限に抑える アクセスパターンの洗い出しを徹底し、実際に高頻度で発生するクエリのみをインデックス化の対象とします
  • CloudWatchでインデックスの利用状況を監視する GSIごとのConsumedReadCapacityを監視し、利用率が極めて低いインデックスは削除を検討します
  • オンデマンドモードを検討する インデックス数が多く、個別のキャパシティ設定が煩雑な場合は、オンデマンドモードに切り替えることで管理負荷を軽減できます。ただし、高頻度アクセス時にはプロビジョンドより高コストになる可能性があります
失敗パターン 原因 影響 対策
ホットパーティション パーティションキーの偏り スロットリング、レイテンシ悪化 ランダム性導入、シャーディング
過剰なインデックス 予防的なGSI作成 WCU増大、ストレージコスト増 利用状況監視、最小限設計
不適切なパーティションキー 設計段階の検討不足 全体的な性能劣化 アクセスパターン先出し設計

適切ないパーティションキー選択による性能劣化

パーティションキーの選択は、DynamoDB設計において最も重要な判断の一つですが、「一意性を担保したいから主キーに近い属性を選ぼう」というRDBMS的な発想がしばしば性能劣化を招きます。
DynamoDBでは、パーティションキーの目的は「データの分散」にあり、一意性の担保はプライマリキー全体(パーティションキー+ソートキー)で行われます。

例えば、商品マスタのテーブルで「sku(商品コード)」をパーティションキーにした場合、商品コードは一意であるため問題ないように見えます。
しかし、「カテゴリ別に商品一覧を取得したい」というクエリが頻発するのであれば、パーティションキーを「category_id」、ソートキーを「sku」とする複合キーの方が、一覧取得のクエリ効率は格段に高まります。
skuで直接取得したい場合は、skuをパーティションキーとするGSIを別途作成すれば、両方のアクセスパターンを効率的にサポートできます。

このように、DynamoDBのパーティションキー設計では「どの属性でデータを分散させるか」が第一の関心であり、一意性はその副産物として考えるべきです。
アクセスパターンを先に洗い出し、最も頻度の高いクエリをプライマリキーで直接サポートする設計思想を徹底することで、性能劣化を未然に防ぐことができます。

これらの失敗パターンを理解した上で、次の章では実践的なインデックス設計の手順と、設計後の検証方法について具体的に解説していきます。

実践的なインデックス設計の手順と検証方法

DynamoDBインデックス設計から検証までのワークフロー図

これまでに解説してきた理論的な知見を、実際のプロジェクトにどのように落とし込むかという点は、多くのエンジニアにとって最も重要な関心事です。
DynamoDBのインデックス設計は、一度きりの作業ではなく、継続的な改善プロセスとして捉えるべきであり、初期設計から運用後の検証までの一連の流れを体系化しておくことで、品質と効率の両立が可能になります。
以下では、私が実務で採用している設計手順と検証方法を段階的に解説します。

アクセスパターンの洗い出しと整理

インデックス設計の第一歩は、アクセスパターンの徹底的な洗い出しです。
この作業を疎かにすると、後から「このクエリに対応するインデックスがない」という事態になり、テーブルの再作成や複雑な移行作業が必要になるリスクが高まります。

洗い出しの際には、以下の観点でアクセスパターンを文書化することをおすすめします。

  • アクター(誰がアクセスするか) エンドユーザー、管理者、バッチ処理、外部システムなど
  • 操作の種別(CRUDのどれか) 読み込みの場合は単一項目取得か範囲検索か
  • 検索条件(どの属性で絞り込むか) 等価検索、範囲検索、部分一致など
  • 頻度とピーク時間帯 1秒あたりのリクエスト数、特定時間帯への集中の有無
  • レイテンシ要件 ミリ秒単位の厳密な要件があるか、数秒程度で許容されるか

これらの情報を整理する際、スプレッドシートや専用の設計ドキュメントに表形式でまとめると、後のレビュー工程で大変役立ちます。
特に頻度とレイテンシ要件は、プライマリキーとセカンダリインデックスの優先順位を決定する上で決定的な指標となります。

# アクセスパターンを整理するためのサンプルデータ構造
access_patterns = [
    {
        "actor": "エンドユーザー",
        "operation": "Read",
        "condition": "user_id(等価)+ created_at(範囲)",
        "frequency": "100 req/sec",
        "peak": "18:00-22:00",
        "latency_requirement": "< 50ms",
        "priority": "高"
    },
    {
        "actor": "管理者",
        "operation": "Read",
        "condition": "status(等価)+ updated_at(範囲)",
        "frequency": "5 req/sec",
        "peak": "なし",
        "latency_requirement": "< 500ms",
        "priority": "中"
    }
]

洗い出しが完了したら、次に優先順位の付けを行います。
頻度が高くレイテンシ要件が厳しいアクセスパターンを上位に配置し、これらをプライマリキーで直接サポートできるようテーブル構造を検討します。
残りのパターンは、セカンダリインデックスやアプリケーション側の処理で補完します。

インデックス設計のドラフト作成とレビュー

アクセスパターンが整理できたら、次にインデックス設計のドラフトを作成します。
ドラフトには、テーブル名、プライマリキーの構成、GSI/LSIの一覧、各インデックスのキー属性と投影タイプを含めます。

ドラフト作成時の重要なチェックポイントは以下の通りです。

  • プライマリキーで上位のアクセスパターンをカバーできているか 頻度の高いクエリがプライマリキーのKeyConditionExpressionで完結するよう設計できているか確認します
  • GSIの数は最小限か 「将来的に必要かもしれない」という理由での予防的GSI作成を避け、現時点で確実に必要なもののみに絞ります
  • ホットパーティションのリスクはないか パーティションキーの値の分布を想定し、特定の値にアクセスが集中しないか検証します
  • LSIの必要性は確定しているか LSIは後から追加できないため、テーブル作成時に本当に必要か慎重に判断します

ドラフトが完成したら、チーム内レビューを実施することを強く推奨します。
DynamoDBの設計は個人の経験に左右されやすく、複数の視点からのレビューによって見落としを防ぐことができます。
レビューでは、アクセスパターン表とドラフトを照らし合わせ、「このクエリはどのインデックスを使うのか」を一つひとつ確認していきます。

# インデックス設計ドラフトの検証用クエリシミュレーション
# 各アクセスパターンに対して想定されるクエリをコード化し、実行計画を確認する

def validate_query_plan(table_name, index_name, key_condition, filter_expr=None):
    """
    設計ドラフトに基づき、クエリが想定通りのインデックスを使用するか検証する
    """
    dynamodb = boto3.client('dynamodb')

    query_params = {
        'TableName': table_name,
        'IndexName': index_name,
        'KeyConditionExpression': key_condition,
        'ReturnConsumedCapacity': 'TOTAL'
    }

    if filter_expr:
        query_params['FilterExpression'] = filter_expr

    # 実際にはここでexplain相当の処理を行う
    # 現状はConsumedCapacityでRCUを確認
    response = dynamodb.query(**query_params)

    return {
        'consumed_rcu': response['ConsumedCapacity']['CapacityUnits'],
        'item_count': response['Count'],
        'scanned_count': response['ScannedCount']
    }

実際のクエリ性能を計測して最適化する

設計ドラフトがレビューを通過したら、次に実際の環境で性能を計測します。
DynamoDBの設計は、理論上は完璧に見えても、実際のデータ量やアクセスパターンでは予想外のボトルネックが発生する場合があります。
そのため、本番と同等のデータ量を投入した検証環境での負荷テストは不可欠です。

性能計測では、以下のメトリクスに注目してください。

メトリクス 確認方法 目標値の目安 改善策
クエリレイテンシ CloudWatchのLatencyメトリクス P99で50ms以下 インデックス見直し、キャッシュ導入
消費RCU ConsumedReadCapacityUnits プロビジョニングの80%以下 インデックス最適化、クエリ見直し
消費WCU ConsumedWriteCapacityUnits プロビジョニングの80%以下 バッチ書き込み、インデックス削減
スロットリング回数 ThrottledRequests 0 キャパシティ増加、パーティション設計見直し
スキャン件数 vs 返却件数 ScannedCount / Count 1.5以下 FilterExpressionの見直し、インデックス追加

負荷テストの際には、実際のアクセスパターンを再現したシナリオを作成することが重要です。
単純なベンチマークツールではなく、アプリケーションの実際のフローに近い形でリクエストを生成し、複数のクエリが同時に発生する状況をシミュレートしてください。

計測結果に基づき、以下のような最適化を検討します。

  • レイテンシが目標を超える場合 プライマリキーまたはGSIのキー属性を見直し、クエリがインデックスを効率的に利用できるか確認します。FilterExpressionで大量のデータをフィルタリングしている場合は、キー条件での絞り込みを強化します
  • RCU/WCUの消費が予想を超える場合 GSIの投影属性をKEYS_ONLYまたはINCLUDEに縮小し、インデックスサイズを削減します。または、Sparse Indexの適用を検討します
  • スロットリングが発生する場合 ホットパーティションが発生していないかパーティションキーの分布を確認し、必要に応じてシャーディングを導入します
# 負荷テスト後の性能分析スクリプト例
import boto3

cloudwatch = boto3.client('cloudwatch')

def analyze_table_performance(table_name, start_time, end_time):
    """
    指定された期間のテーブル性能メトリクスを収集する
    """
    metrics = [
        'ConsumedReadCapacityUnits',
        'ConsumedWriteCapacityUnits',
        'ThrottledRequests',
        'UserErrors'
    ]

    results = {}
    for metric in metrics:
        response = cloudwatch.get_metric_statistics(
            Namespace='AWS/DynamoDB',
            MetricName=metric,
            Dimensions=[{'Name': 'TableName', 'Value': table_name}],
            StartTime=start_time,
            EndTime=end_time,
            Period=300,
            Statistics=['Sum', 'Average']
        )
        results[metric] = response['Datapoints']

    return results

このように、設計から検証、最適化までの一連のサイクルを確立することで、DynamoDBのインデックス設計の品質を継続的に高めることができます。
次の章では、これまで解説してきた内容を総括し、DynamoDBインデックス設計の本質について整理します。

まとめ:DynamoDBインデックス設計の本質と継続的な改善

DynamoDBインデックス設計の全体像をまとめた概念図

本記事を通じて、DynamoDBのインデックス設計における基本原則から実践的な手法、そして陥りやすい失敗パターンまでを幅広く解説してきました。
ここまでの内容を振り返り、DynamoDBインデックス設計の本質を整理し、今後の運用に活かすための指針を示したいと思います。

DynamoDBのインデックス設計において、最も重要なのは「データの保存方法ではなく、データへのアクセス方法を先に設計する」という逆説的アプローチです。
RDBMSで育ってきたエンジニアにとっては、当初違和感を覚えるかもしれませんが、これはDynamoDBの分散アーキテクチャに根ざした必然です。
DynamoDBはパーティションキーに基づいてデータを物理的に分散させるため、どの属性でデータを分割し、どの属性でソートするかが、クエリの性能とコストを決定づけます。

プライマリキーの設計では、パーティションキーとソートキーの使い分けを正確に理解することが第一歩です。
パーティションキーはデータ分散の指標であり、ソートキーは同一パーティション内での範囲検索を実現する手段です。
この基本構造を踏まえた上で、GSIとLSIを適材適所で活用し、Sparse Indexの考え方を取り入れることで、不要なデータの読み込みを排除し、コストを最適化することが可能になります。

GSIは強力なツールですが、同時にコスト増大の主要因でもあります。
GSIを追加するごとに書き込みコストが増大し、ストレージも別途消費します。
そのため、GSIの数は必要最小限に抑え、投影属性をKEYS_ONLYまたはINCLUDEで最適化することが重要です。
一方、LSIはベーステーブルと同じパーティションキーを共有するため、書き込みコストの増大が相対的に小さいという利点がありますが、テーブル作成後の追加ができないという制約があります。
これらの特性を理解し、アクセスパターンの優先順位に基づいて適切に選択する判断力が求められます。

インデックス設計で失敗を防ぐためには、ホットパーティションの回避、過剰なインデックス作成の抑制、そして適切なパーティションキー選択の3つを常に意識してください。
これらの失敗パターンは、設計段階での十分な検討と、運用後の継続的な監視によって未然に防ぐことができます。
特にCloudWatchでのメトリクス監視は、インデックスの利用状況やボトルネックの早期発見に不可欠です。

実践的な設計プロセスとしては、アクセスパターンの洗い出し、ドラフト作成とレビュー、そして実環境での性能計測という3段階のサイクルを確立することをおすすめします。
このサイクルを回すことで、理論と実践の乖離を最小限に抑え、データに基づいた設計の最適化が可能になります。

最後に、DynamoDBのインデックス設計は一度きりの作業ではなく、継続的な改善プロセスであることを改めて強調したいと思います。
サービスの成長に伴い、アクセスパターンは変化し、当初の設計が必ずしも最適とは限りません。
新しい機能の追加、ユーザー数の増加、ビジネス要件の変化など、様々な要因がインデックス設計に影響を与えます。
そのため、定期的なレビューと、必要に応じたインデックスの追加・変更・削除を行う姿勢が重要です。

ただし、インデックスの変更はコストがかかるため、「小さく始めて、データとフィードバックに基づいて段階的に最適化する」というアプローチが最も現実的です。
過剰な予測設計は、多くの場合、不要なインデックスと無駄なコストを生み出すだけです。

DynamoDBは、正しく設計すれば驚異的なスケーラビリティと低レイテンシを提供してくれる優れたデータベースサービスです。
その真価を引き出す鍵は、インデックス設計の段階でアクセスパターンを深く理解し、コストと性能のトレードオフを冷静に見極めることにあります。
本記事の内容が、皆様のDynamoDB運用の一助となれば幸いです。

コメント

タイトルとURLをコピーしました