DynamoDB シングルテーブル設計:1 つのテーブルで複数のエンティティをモデル化する
SQL から来ると、エンティティごとに 1 テーブル(workspaces、documents、comments)という直感が働きます。DynamoDB では、画面が関連するエンティティをまとめて読む場合、その直感はしばしば間違いです。
DynamoDB のシングルテーブル設計とは?
シングルテーブル設計は、すべてのエンティティ(ワークスペース、ドキュメント、コメント)を 1 つの
DynamoDB テーブルに格納し、オーバーロードしたパーティションキーとソートキーのプレフィックスで区別します。キーがエンティティではなくアクセスパターンを中心に設計されているため、親とそのすべての子は 1 つのアイテムコレクションに収まり、単一の Query で返ってきます。結合も N+1 の読み取りもありません。
1 つのテーブル、1 つのパーティションに複数のアイテムタイプ
汎用的なキー名(PK、SK)を選び、エンティティの種類を値にエンコードします。
| PK | SK | attributes |
|---|---|---|
| WS#acme | DOC#a1 | title, status=review |
| WS#acme | DOC#a2 | title, status=review |
| WS#acme | META | name, plan, seats |
これで、1 つの Query PK = "WS#acme" が、すべてのドキュメントとワークスペースのレコードを、
1 回の課金対象の読み取り(1 ページ、最大 1 MB)でソートキー順に返します。DOC# は META より前に並びます。SK begins_with "DOC#" はそれをドキュメントだけに絞り込みます。
視覚的には、オーバーロードされたアイテムが 1 つのパーティションキーの下に、単一のアイテムコレクションとして積み重なります。
名詞ではなく、アクセスパターンから始める
シングルテーブル設計は、アプリケーションが行う読み書きに番号を振ったリストから始まります。それぞれのパターンは、ベーステーブルかオーバーロードした GSI に対する Query または GetItem にコンパイルできなければなりません。
頻度の高いパターンに Scan が必要なら、キーが間違っています。詳しい手順は
DynamoDB でのデータモデリングの方法にあります。
3 パターンのアプリなら、たとえばこうなります。
- あるワークスペース id のワークスペースとすべてのドキュメントを取得する(高頻度)。
- ワークスペース横断でレビュー待ちのドキュメントをすべて日付順に一覧する(中頻度)。
- 共有リンクの id でドキュメントを 1 件取得する(高頻度)。
パターン 1 は PK = WS#<id> に対するベーステーブルの Query です。パターン 2 は
GSI1PK = STATUS#REVIEW、GSI1SK を日付として着地します(後述のオーバーロードした GSI)。
パターン 3 は、パターン 1 の WS#<id> / DOC#<id> の行とは別のアイテムコレクションとして
PK = DOC#<id>, SK = META を使うかもしれません。両方の経路がホットならドキュメントのサマリーを両方に複製し、片方がまれなら 2 回目の Query を受け入れます。
1 つの GSI で複数のパターンに応える
同じ手口はインデックスでも使えます。アイテムに汎用的な GSI1PK/GSI1SK を載せれば、各アイテムがそれらの属性に何を書き込むかに応じて、1 つの GSI が複数のアクセスパターンを担います。
| PK | SK | GSI1PK | GSI1SK |
|---|---|---|---|
| WS#acme | DOC#a1 | STATUS#REVIEW | 2026-01-04 |
| WS#acme | DOC#a2 | STATUS#REVIEW | 2026-01-05 |
これで Query GSI1 WHERE GSI1PK = "STATUS#REVIEW" が、レビュー中のドキュメントを日付順に一覧します。ベーステーブルには答えられないパターンです。別のエンティティは GSI1 を独自の意味で再利用できます(例: PLAN#team の下にプラン別のワークスペース)。1 つのインデックスで、多くのクエリを。
レビュー中のドキュメントはすべてその 1 つの GSI キー値を共有するため、インデックスへの書き込みはすべて 1 つのキーに集中します。中程度の量なら問題ありません。パーティションあたり毎秒 1,000 書き込みユニットに近づいたら、ホットパーティションで示すようにシャーディングし(STATUS#REVIEW#0..N-1)、各シャードをクエリします。
多対多: 隣接リスト
関係(多くのチームに属するユーザー、多くのユーザーを持つチーム)については、id を入れ替えてエッジを
2 回書きます: PK=USER#1, SK=TEAM#9 と PK=TEAM#9, SK=USER#1。どちらの側をクエリしても他方が一覧されます。結合テーブルの DynamoDB 版です。
読み取りコスト: 1 回の Query と多数の GetItem
ワークスペースのレコード(1 KB)と 12 件のドキュメントのサマリー(各 1 KB)が PK = WS#acme を共有しているとしましょう。結果整合性のある Query 1 回は 13 KB を読み、これは 4 KB ブロック 4 つに切り上がり、各 0.5 読み取りユニットで 2 ユニットです。
13 回の GetItem は、各アイテムを個別に切り上げます: 13 × 0.5 = 6.5 ユニット。
13 個のキーの BatchGetItem は往復を減らしますが、計測は同じ 6.5 です。ワークスペース id をキーにした 2 つのテーブル(ワークスペースに GetItem、12 KB のドキュメントに Query)は、
2 回のリクエストで 0.5 + 1.5 = 2 ユニットです。
| アクセスの仕方(1 KB × 13 アイテム、結果整合性) | リクエスト数 | 読み取りユニット |
|---|---|---|
Query PK = WS#acme(1 テーブル) | 1 | 2 |
2 テーブル: ワークスペースに GetItem + ドキュメントに Query | 2 | 2 |
13 個のキーの BatchGetItem | 1 | 6.5 |
完全なプライマリキーで 13 × GetItem | 13 | 6.5 |
Scan + ワークスペース id でのフィルター | 1 ページ以上 | テーブル全体 |
つまりコストの差を生むのは、アイテムごとの読み取りと Scan であって、テーブルの数ではありません。テーブルを分けると、テーブルごとに 1 回の切り上げ(1 回の読み取りにつき、テーブルあたり最大で結果整合性
0.5 ユニットの追加)と往復 1 回を払います。シングルテーブルの利点は、リクエストが 1 回で済むことです。
数字はアイテムサイズで変わります(各 6 KB のドキュメントなら、1 件で 4 KB ブロックを 2 つまたぎます)。だからこそアイテムサイズ計算ツールが設計ツールの隣に置いてあります。書き込みは 1 KB の切り上げ境界に従い(1 キロバイトあたり 1 書き込みユニット、トランザクション内では倍)、太ったドキュメントの行は親のパーティションに非正規化するコストが高くつきます。
すべてのアイテムに型を付け、インデックスキーを省いて GSI から外す
Query がソートキーのプレフィックスが混ざった結果を返したときにアプリケーションコードで分岐できるよう、文字列の Type 属性(EntityType、値は Workspace や Document
など)を足します。
GSI はスパースにできます。GSI1PK/GSI1SK を持つアイテムだけがインデックスに現れます。公開済みのドキュメント(GSI1PK/GSI1SK は空ではなく存在しない。DynamoDB は空文字列をインデックスキーとして拒否します)はレビューキューから外れ、インデックスのストレージを消費しません。
2 つのエンティティ型が 1 つのインデックスを共有するときは、それぞれのプレフィックス(STATUS#、
PLAN#、EMAIL#)が何を意味するのかを、ベーステーブルのプレフィックスを記録しているのと同じ場所に書いておきましょう。
シングルテーブルにしないとき
シングルテーブル設計はトレードオフです。1 回のリクエストでの読み取りと引き換えに、読みにくく変えにくいスキーマを抱えます。重い分析、無関係なエンティティに対する単純な CRUD、独立してスケールしたり障害を起こしたりするエンティティには、テーブルを分けるほうが合います。
| シグナル | テーブルを分ける寄り | シングルテーブル寄り |
|---|---|---|
| 親と子を常にまとめて取得する | ✓ | |
| アクセスパターンが既知で高頻度 | ✓ | |
| いくつかの無関係なエンティティに対する単純な CRUD | ✓ | |
| スケール、バックアップ、障害が別々のエンティティ | ✓ | |
| 重いアドホック分析 | ✓(S3 へエクスポート + Athena) |
分析には、運用テーブルはそのままにしてエクスポートをクエリします。PITR を有効にし、
ExportTableToPointInTime で S3 へエクスポートし(読み取りキャパシティを消費しません)、そのファイルを
Athena でクエリします。フローチャート付きの判断の全体はシングルテーブル設計を使うべきでないときにあります。
誤った形のコスト
テーブルを分けると、テーブルごとに 1 回のリクエストが必要になり、ワークスペースを組み立て直すためにクライアント側でつなぎ合わせることになります。Scan が現れるのは子テーブルが親の id をキーにしていないときだけで、それが Scan の落とし穴です。まずアクセスパターンをモデル化し、それからキーを設計して各パターンを Query にしてください。
モデル化していなかったアドホックな質問には、DynoTable の SQL
Workbench(有料プラン)が、フィルター、GROUP BY、集計をクライアント側で実行します。
その JOIN は、結合先の属性がそのテーブルのパーティションキー(ソートキーのないテーブルの場合)か
GSI のパーティションキーであれば、別々のテーブルをまたいで動作します。
1 つのシングルテーブルのレイアウト内での自己結合は拒否されるため、その場合はアイテムコレクションを
Query します。
稼働中のシングルテーブルのレイアウトを進化させるというのは、たいてい PK/SK の意味をその場で付け替えることではなく、GSI や新しいアイテム型を足すことです。プレフィックスの取り決めは API のバージョンのように扱いましょう。DOC# を途中で流用するのではなく、DOC#v2# を足します。
すべてのアイテムのキーを変える必要があるときは、移行として実行します。
新しいキー属性を二重書き込みするライターをデプロイし、スロットリングした並列 Scan と条件付きの
UpdateItem(attribute_exists(PK) AND attribute_not_exists(GSI2PK)。Scan が読んだあとに削除されたアイテムが、キーだけのスタブとして再作成されないようにします)で古いアイテムをバックフィルし、GSI2 をオンラインで追加してから、読み取りを切り替えます。DynamoDB の移行で各ステップを説明しています。
設計は無料のシングルテーブル設計ツールで下書きしましょう。パターンのリストを PK/SK/GSI のレイアウトに変え、サンプルアイテム、CreateTable の JSON、コストの目安を添えて出力するので、コミットする前にいくつかの案を比べられます。これらのアイテムが 1 回の読み取りでいくらかかるかはアイテムサイズ&キャパシティ計算機で見積もりましょう。
SDK のコードを出荷する段になったら、そこから生まれる Query のプログラムをクエリビルダーで組み立ててください。パーティションキーとソートキーの仕組みがまだ曖昧なら、複合プライマリキーを読みましょう。シングルテーブル設計は、その大部分が、属性名を共有した規律ある複合キーの使い方です。
DynoTable を試してシングルテーブルのスキーマをブラウズしましょう。WS#acme でキークエリを実行すると、グリッドがそのアイテムコレクションをソートキー順に一覧します。