シングルテーブル設計

DynamoDB で複数のエンティティを 1 つのテーブルに格納し、キー設計で関連データを 1 回の Query にまとめる設計手法

DynamoDB設計

シングルテーブル設計とは

シングルテーブル設計は、DynamoDB で複数のエンティティ (ユーザー、注文、商品) を 1 つのテーブルに格納し、パーティションキー (PK) とソートキー (SK) の設計でアクセスパターンを最適化する手法である。

1 つに寄せる狙いは管理の手間を減らすことではなく、取得の往復回数を減らすことにある。DynamoDB は同じ PK を持つ項目の集まり (item collection) を同じパーティションにまとめて保持し、Query は「PK が完全一致 + SK が範囲条件」という形でその並びを連続して読む。ユーザーとその注文を同じ PK に寄せておけば、1 回の Query で両方が返る。エンティティごとにテーブルを分けると、同じ画面を組むのにテーブル数だけ往復が増え、DynamoDB には結合の機能が無いので突き合わせはアプリ側のコードになる。AWS の設計ガイダンス自体が、リレーショナルデータベースの正規化とは逆に、テーブル数はできる限り少なく保つことを推奨している (2026 年 8 月時点)。

マルチテーブル vs シングルテーブル

どちらを選ぶかは、往復回数と後戻りのしやすさの取引になる。

観点マルチテーブルシングルテーブル
テーブル数エンティティごとに 1 テーブル1 テーブル
関連データの取得エンティティごとにクエリ (往復が増える)同じ PK に寄せた分は 1 回の Query
突き合わせアプリ側で結合する取得した時点でまとまっている
設計の難易度低い (RDB 的)高い (アクセスパターン駆動)
新しいアクセスパターンテーブルとクエリを足せるGSI を足すか既存項目へキー属性を書き足す
監視・権限の粒度テーブル単位で分かれる1 テーブルに混ざる (キー設計で表現する)

設計の難易度が違うのは、キー設計がそのままクエリの能力になるからである。正規化して置いておけば後から任意の結合で問いを足せるリレーショナルデータベースと違い、DynamoDB の Query は PK の完全一致が前提なので、キーに現れていない切り口では引けない。だから公式ドキュメントも、テーブルが答えるべき問いが分かるまではスキーマの設計を始めない、という順序を明示している。この順序を飛ばして先にキーを決めると、後から増えた問いに対して GSI の追加か、既存の全項目へキー属性を書き足す作業 (バックフィル) が必要になる。

設計例: EC サイト

PK と SK に種別の接頭辞を付けると、1 つのテーブルの中身は次のような並びになる。

PK              | SK                | データ
USER#123        | PROFILE           | { name: "Alice", email: "..." }
USER#123        | ORDER#001         | { total: 5000, status: "shipped" }
USER#123        | ORDER#002         | { total: 3000, status: "pending" }
ORDER#001       | ITEM#A            | { product: "TypeScript本", price: 3000 }
ORDER#001       | ITEM#B            | { product: "AWS本", price: 2000 }

USER#123 の下にプロフィールと注文が並んでいるので、SK の接頭辞で切ればプロフィール単体も注文一覧も同じ PK から引ける。ORDER#001 を PK にした行が別にあるのは、注文明細を注文単位でまとめて引くための 2 つ目の item collection である。同じ実体を複数の並びに置く形になるため、非正規化と同じく更新箇所が増える点は引き受けることになる。

アクセスパターン

先に洗い出した問いが、そのまま 1 回の取得操作に対応する。

// ユーザーのプロフィールを取得
await db.get({ Key: { pk: 'USER#123', sk: 'PROFILE' } });

// ユーザーの全注文を取得
await db.query({
  KeyConditionExpression: 'pk = :pk AND begins_with(sk, :sk)',
  ExpressionAttributeValues: { ':pk': 'USER#123', ':sk': 'ORDER#' },
});

// 注文の全アイテムを取得
await db.query({
  KeyConditionExpression: 'pk = :pk AND begins_with(sk, :sk)',
  ExpressionAttributeValues: { ':pk': 'ORDER#001', ':sk': 'ITEM#' },
});

GSI (Global Secondary Index)

キーに現れていない切り口 (「発送済みの注文を日付順に」など) は、グローバルセカンダリインデックス (GSI) で別のキーを張って引く。シングルテーブル設計では GSI 用の属性を汎用の名前 (GSI1PK / GSI1SK) にしておき、エンティティごとに違う意味の値を入れる。これを GSI オーバーロードと呼ぶ。

GSI1PK          | GSI1SK            | データ
STATUS#shipped  | 2026-04-01        | ORDER#001
STATUS#pending  | 2026-03-28        | ORDER#002
// ステータスで注文を検索 (GSI)
await db.query({
  IndexName: 'GSI1',
  KeyConditionExpression: 'GSI1PK = :status',
  ExpressionAttributeValues: { ':status': 'STATUS#shipped' },
});

同じ GSI1PK に、注文なら STATUS#shipped、商品なら CATEGORY#book のように別種の値を入れれば、1 本の GSI が複数の検索を兼ねる。公式ドキュメントも、GSI の既定の上限がテーブルあたり 20 本である一方、実際には 20 を超える数のフィールドを横断してインデックスを張れると説明している。同じ属性が項目ごとに別種の情報を持てる、という DynamoDB の性質を使うためである。

GSI には代償が 2 つある。1 つは整合性で、GSI は基本テーブルへの書き込みの後に非同期で更新される結果整合のモデルであり、書いた直後に GSI を引くと反映前の状態が返り得る (アプリからインデックスへ直接書き込むことはできない)。もう 1 つは書き込み費用で、投影対象の属性を更新すると基本テーブルとインデックスの両方に書き込みが発生する。GSI は基本テーブルのキャパシティモードを引き継ぐため、本数を増やせばその分の書き込みがそのまま乗る。

設計の手順

手順は次の順序で固定する。逆から進めると作り直しになる。

1. アクセスパターンを全て洗い出す
2. PK/SK の設計を決める
3. GSI が必要なパターンを特定
4. テストデータで検証

2 では「1 回の Query で返したい塊」を決め、3 では新しい切り口を GSI オーバーロードで賄えるか (既存の GSI1 に別の意味の値を載せられるか) を先に検討する。4 のテストデータは本番に近い偏りで作る。1 つの PK に項目が極端に集まると、その item collection が単一パーティションのスループット上限に当たり、読み書きが絞られるためである。

シングルテーブルが適さないケース

シングルテーブルは常に有利ではない。次の状況ではテーブルを分けるか、別のデータストアを併用する。

ケース推奨
アクセスパターンが不明確マルチテーブル (後から変更しやすい)
小規模プロジェクトマルチテーブル (シンプル)
条件を組み替える検索・集計が必要OpenSearch や分析用のデータストアと併用
エンティティ別に使用量を追いたいテーブル分割 (CloudWatch の指標がテーブル単位のため)
エンティティ別に権限を絞りたいキー設計で表現 (IAM の条件で絞れるのは主にパーティションキーの値)

判断の目安は、アクセスパターンが数個に固まっているか、そして 1 画面で複数のエンティティをまとめて返す必要があるかの 2 点である。前者が満たせないうちに 1 テーブルへ寄せると、効かないキーを抱えたままバックフィルを繰り返すことになる。逆に 2 点が揃っている面では、往復 1 回で画面が組める効果が大きく、テーブルを分ける設計に戻す理由は薄い。

この記事は役に立ちましたか?

関連用語

関連する記事