はじめに
DynamoDBには長らく「できるだけ1つのテーブルにまとめろ(single-table design)」という強いベストプラクティスがありました。
しかしここ最近、現場では「本当にそうか?」という揺り戻しが起きています。
さらに昨年末(2025年11月)、AWSがGSI(グローバルセカンダリインデックス)にマルチ属性複合キーを導入したことで、これまでsingle-table設計を陰で支えていた「ある職人芸」が不要になりつつあります。
この記事では、
- なぜsingle-tableが推奨されてきたのか
- なぜ今multi-table論が盛り上がっているのか
- GSIマルチ属性キーで何が変わるのか
を、1つの同じアプリを題材に、single-table版とmulti-table版の両方を実際に設計しながら見ていきます。
題材:企業向けスキルアセスメントサービス
例として、こんなBtoBサービスを考えます。
- 企業(テナント)が契約し、その企業に所属するユーザー(会員)が診断(アセスメント)を受ける
- 診断結果が蓄積され、本人のマイページや企業の管理ダッシュボードから参照される
エンティティの関係はシンプルな2段の1対多です。
そして、このサービスが満たしたいアクセスパターンは次の4つです。
| # | アクセスパターン | 主な利用者 |
|---|---|---|
| 1 |
userId でユーザー情報を取得 |
アプリ全般 |
| 2 |
companyId で所属ユーザー一覧を取得 |
管理画面 |
| 3 | あるユーザーの診断結果を新しい順に取得 | マイページ |
| 4 | 企業 × 診断タイプ × ステータス × 期間 で結果を絞り込み | 管理ダッシュボード |
ポイントは #4 です。「自社の、"ストレスチェック"診断の、"完了"した結果を、今月分だけ見たい」といった、複数の属性をANDで重ねる検索。ここがsingle-table時代に一番つらかった部分で、後半の主役になります。
1. なぜsingle-tableであるべきと言われていたのか
DynamoDBはJOINを持ちません。RDBのように「ユーザーと、そのユーザーの結果を結合して取得」ということが標準ではできません。そこで生まれた発想が「一緒に取得するデータは、一緒に置いておく」というものです。
これを突き詰めると、PK(パーティションキー)とSK(ソートキー)をエンティティごとに使い分けて(キーオーバーロード)、複数の種類のデータを1つのテーブルに同居させる設計になります。
single-table版の設計
テーブルは AppTable 1つだけ。PK / SK にプレフィックス付きの値を入れて、エンティティを区別します。
| エンティティ | PK | SK |
|---|---|---|
| Company(メタ情報) | COMPANY#{companyId} |
#META |
| User | COMPANY#{companyId} |
USER#{userId} |
| AssessmentResult | USER#{userId} |
RESULT#{createdAt}#{resultId} |
実際にデータを入れると、テーブルの中身はこうなります。同じPKを持つアイテムがひとかたまり(item collection)になっている点に注目してください。
AppTable
┌──────────────────────┬────────────────────────────────┬─────────────────────────────┐
│ PK │ SK │ 属性(抜粋) │
├──────────────────────┼────────────────────────────────┼─────────────────────────────┤
│ COMPANY#c001 │ #META │ name: "Acme社" │ ┐
│ COMPANY#c001 │ USER#u123 │ name: "山田" │ ├ item collection A
│ COMPANY#c001 │ USER#u456 │ name: "佐藤" │ ┘ (企業c001のかたまり)
├──────────────────────┼────────────────────────────────┼─────────────────────────────┤
│ USER#u123 │ RESULT#1781000000#r001 │ type:"stress", status:"done"│ ┐
│ USER#u123 │ RESULT#1781600000#r002 │ type:"skill", status:"done"│ ├ item collection B
│ USER#u123 │ RESULT#1782200000#r003 │ type:"stress", status:"todo"│ ┘ (ユーザーu123のかたまり)
└──────────────────────┴────────────────────────────────┴─────────────────────────────┘
この設計の気持ちよさは、関連データが1クエリで取れることです。
📝 上の図の
typeは紙幅の都合によるassessmentTypeの略記です。また本記事のコードは可読性優先の簡略版で、ddbは AWS SDK for JavaScript v3 のDynamoDBDocument(DynamoDBDocument.from(client))インスタンスを想定しています。
// #2 企業c001に所属するユーザー一覧 ── item collection A を1クエリで
await ddb.query({
TableName: "AppTable",
KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
ExpressionAttributeValues: { ":pk": "COMPANY#c001", ":prefix": "USER#" },
});
// #3 ユーザーu123の結果を新しい順に ── item collection B を1クエリで
await ddb.query({
TableName: "AppTable",
KeyConditionExpression: "PK = :pk AND begins_with(SK, :prefix)",
ExpressionAttributeValues: { ":pk": "USER#u123", ":prefix": "RESULT#" },
ScanIndexForward: false, // 新しい順
});
JOINも複数回の読み込みもなく、1回のQueryで関連データがまとめて返ってくる。この「関連データをまとめて取る」アクセスパターンでは、読み込み回数もレイテンシも小さく済みます。これがsingle-tableが推奨されてきた最大の理由です。少なくともこのパターンに関しては、single-tableに分があります。
つらくなるのは「複数属性で検索したい」とき
問題は #4「企業 × 診断タイプ × ステータス × 期間」 です。ベーステーブルのPK/SKではこの検索はできないので、GSIを張ることになります。
ところが、従来のGSIはパーティションキーもソートキーも1属性しか持てません。複数の属性で絞り込みたい場合、それらを#で連結した合成キー(synthetic key)を、アプリ側で自分で作るしかありませんでした。
// 書き込み時:GSI検索のためだけに、連結キーを"製造"する
const item = {
PK: "USER#u123",
SK: `RESULT#${createdAt}#${resultId}`,
// ↓ GSI1 で #4 を検索するためだけに用意する属性
GSI1PK: `COMPANY#${companyId}`,
// createdAt は数値だが、連結する以上は文字列に変換するしかない(← つらみ②の伏線)
GSI1SK: `${assessmentType}#${status}#${toYmd(createdAt)}`, // 例: "stress#done#2026-06-10"
// ...実データ
};
// #4 の検索:連結文字列の前方一致で頑張る
await ddb.query({
TableName: "AppTable",
IndexName: "GSI1",
KeyConditionExpression: "GSI1PK = :pk AND begins_with(GSI1SK, :sk)",
ExpressionAttributeValues: {
":pk": "COMPANY#c001",
":sk": "stress#done#2026-06", // 期間は月単位までしか素直に効かない…
},
});
この合成キー方式には、地味だが効いてくる3つのつらみがあります。
-
順序を後から変えられない。
assessmentType#status#createdAtという連結順が、そのままクエリ可能性を固定します。「ステータスを指定せずタイプと期間だけで引きたい」となった瞬間、この連結順では引けません。 -
型が文字列に潰れる。
createdAtは本来は数値(エポック秒)ですが、連結すると文字列になります。そのため2026-06-01 〜 06-15のような範囲検索をしようとすると、合成キー全体に対する「文字列の辞書順BETWEEN」(予期せぬ文字が混じるとバグの温床)に頼るか、前方一致(begins_with)で引ける**「月単位」などの粗い粒度で妥協**するしかなくなります。 -
後付けがしんどい。 このGSIを後から追加すると、アプリ側で既存の全アイテムに
GSI1SKを埋め直すバックフィルが必要になります。
この「合成キーという職人芸」を許容できるかどうかが、実はsingle-table採用の隠れた踏み絵でした。
2. なぜ今、multi-table論が盛んなのか
ここ1〜2年、「何でもかんでも1テーブルに詰めるのは考え直したほうがいい」という意見が目立つようになりました。象徴的なのが、AWS公式ドキュメント自体のトーンの変化です。single-table designが流行した2019年頃、公式ドキュメントには「適切に設計されたアプリケーションのほとんどは1テーブルしか必要としない」という強い断定がありました。当時の記事がこぞって引用したこの一文が、ブームの震源のひとつでもあります。ところが現在のドキュメントを読むと、この断定はすでに消えています。「原則としてテーブル数はできるだけ少なく保つべき」としつつ「高ボリュームの時系列データや、アクセスパターンが大きく異なるデータセットは例外」と明記され、別ページでは「大半のケースでは単一テーブルの使用を検討することを推奨」という含みのある言い回しに変わっています。AWS自身が、原理主義から一歩引いたと読み取れます。
現場の判断軸としても、次のような点が重視されるようになっています。
- チームが大きい/複数チームでテーブルを共有すると壊れやすい。 あるエンティティのキー構造を変えると、同じテーブルを使う別チームのクエリが巻き添えで壊れます。
- エンティティごとに運用設定を変えたい。 キャパシティモード、TTL、バックアップ、テーブルクラス(Standard / Standard-IA)はテーブル単位の設定。single-tableだと全エンティティで共通になってしまう。
- アクセスパターンがまだ固まっていない。 初期のプロダクトでは「あとからクエリが増える」のが普通。multi-tableなら、影響範囲をエンティティ単位に閉じてGSIを足したり作り直したりできる。
要するに、single-tableの「1クエリで関連データが取れる」という性能上のうまみと、multi-tableの「読みやすさ・保守性・運用の独立性」のトレードオフを、ワークロードとチーム構成に応じて選ぶ、という考え方が定着してきた、ということです。
multi-table版の設計
同じアプリをmulti-tableで組み直すとこうなります。
| テーブル | PK | SK | GSI |
|---|---|---|---|
Companies |
companyId |
— | — |
Users |
userId |
— |
byCompany:PK=companyId, SK=createdAt
|
AssessmentResults |
resultId |
— |
byUser:PK=userId, SK=createdAt ほか |
┌─ Companies ────────────────┐
│ PK: companyId │
│ name │
└────────────────────────────┘
┌─ Users ────────────────────┐
│ PK: userId │
│ companyId, name │
│ GSI "byCompany": │
│ PK=companyId, SK=createdAt ← 企業の所属ユーザーを引く
└────────────────────────────┘
┌─ AssessmentResults ────────┐
│ PK: resultId │
│ userId, assessmentType, │
│ status, createdAt │
│ GSI "byUser": │
│ PK=userId, SK=createdAt ← ユーザーの結果を引く
│ GSI "byCompany"(#4用): │
│ PK=companyId, ... ← 管理ダッシュボードの絞り込み
└────────────────────────────┘
それぞれのテーブルが独立するので、
- #4 用のGSIを
AssessmentResultsにだけ足しても、ユーザー取得系には一切影響しない - テーブルごとにキャパシティ・TTL・バックアップを個別に設定できる
一方で、ユーザーとその結果を一緒に取りたい場合は複数回の読み込み(BatchGetItem など)が必要になり、single-tableの「1クエリで全部」という魔法は失われます。
ここで一度、論点を整理しておきます。
single-tableの最大の動機は「関連データの結合コストを下げる」ことだった。そしてその結合を成立させていたのが、
#連結による合成キーという職人芸だった。
この「職人芸」に、2025年11月、AWSが正面から手を入れてきました。
3. GSIマルチ属性キーで何が変わるのか
昨年末(2025年11月19日)、DynamoDBはGSIで最大8属性(パーティションキー最大4、ソートキー最大4)の複合キーをサポートしました。これまで「1属性ずつ」しか持てなかったGSIのキーに、複数の属性をそのまま指定できるようになったということです。
1章のつらみの大半が解消される
1章で「GSI検索のためだけに手で連結していた」キーが、こう書けるようになります。
ここでは2章のmulti-table版(AssessmentResults テーブル)を例にします。single-table版(AppTable)でも考え方は同じで、GSI1PK にオーバーロードした値(COMPANY#c001 など)を入れ、ソートキー側を [assessmentType, status, createdAt] のマルチ属性キーにする過渡期の設計にすれば、同じ検索がそのまま書けます。
// 新:連結をやめ、属性をそのままGSIキーに使える
// GSI1 を PK=companyId / SK=[assessmentType, status, createdAt] で定義
const item = {
resultId,
userId,
companyId,
assessmentType: "stress",
status: "done",
createdAt: 1781072000, // 2026-06頃。数値のまま保持できる!
// GSI1PK / GSI1SK を手で組み立てる必要はもうない
};
// #4 の検索:それぞれ独立した属性条件で書ける
await ddb.query({
TableName: "AssessmentResults",
IndexName: "GSI1",
KeyConditionExpression:
"companyId = :c AND assessmentType = :t AND #st = :s AND createdAt BETWEEN :from AND :to",
ExpressionAttributeNames: { "#st": "status" },
ExpressionAttributeValues: {
":c": "c001",
":t": "stress",
":s": "done",
":from": 1780272000, // 2026-06-01。数値なので素直に範囲検索できる
":to": 1782863999, // 2026-06-30
},
});
なお、この書き方が成立するのは「左のソートキー属性から順に等価(=)で固定し、範囲検索(
BETWEENなど)は一番右の1つだけ」という原則を守る場合に限ります(詳細は後述の「ハマりどころ」)。
1章で挙げた3つのつらみのうち、2つはここできれいに解消されます(残り1つは後述します)。
| 従来のつらみ | マルチ属性キーでどうなるか |
|---|---|
②createdAt が文字列に潰れて範囲検索しづらい |
数値型のまま保持でき、指定したソートキー属性のうち最も右のものを BETWEEN で範囲検索できる |
| ③GSI後付け時に(アプリ側の)バックフィルが必要 | 既存の自然な属性をそのまま使うので、アプリ側で全アイテムに合成キーを書き込む移行作業が不要になり、後付けしやすい |
加えて、書き込み時に GSI1SK を手で連結・分解するコードそのものが消えるので、アプリ側の実装もシンプルになります。
⚠️ ここで言う「バックフィル不要」は、アプリ側で既存アイテムに合成キー属性を書き込む移行作業が要らない、という意味です。DynamoDBが新しいGSIを構築する内部処理(AWSがまさに "Backfilling" と呼ぶフェーズ)は従来どおり走ります。テーブルが大きいほど構築に時間もリソースもかかり、
ACTIVEになるまではそのGSIにクエリできません。「一瞬でGSIを生やせる魔法」ではない点は注意してください。
⚠️ ただし「後付けが手軽になった」ことと「適当に作ってよい」は別です。GSIは射影(Projection)した属性しか返しません。
KEYS_ONLYのまま放置すると、GSIクエリではキー(+インデックスキー)しか取れず、他の属性が必要ならアプリ側でベーステーブルを別途読み直すことになります。LSIと違い、GSIには非投影属性をベーステーブルから自動取得する仕組みはありません。クエリで返したい属性はINCLUDEかALLで投影しておく、という基本は従来どおり重要です。
ただし、過信は禁物(ハマりどころ)
「8属性なんでも自由に検索できる」と読むと足をすくわれます。実際に触ってわかる制約が4つあります。
-
変わったのはGSIだけ。 ベーステーブルの
PK/SKは従来どおり1属性ずつのままです。USER#...のような連結が世界から消えるわけではありません(この記事のmulti-table版でも、ベーステーブルのキー設計自体は変えていません)。 -
ソートキーは「左から順」にしか効かない。 複数のソートキー属性は階層的に評価され、範囲条件(
BETWEENなど)が使えるのは、クエリで指定したソートキー属性のうち最も右側の1つだけです。SK=[assessmentType, status, createdAt]なら、assessmentType・statusを完全一致で確定させたうえで、その右のcreatedAtを範囲検索する、という使い方になります。 -
PKに属性を詰め込みすぎない。 パーティションキーを構成する属性は、すべて等価指定が必須です。だからこそ、このユースケースで
PK=[companyId, assessmentType]のようにPK側へ属性を寄せるのは悪手になります。assessmentTypeが常に等価必須になり、「companyIdだけで(診断タイプを問わず)企業の結果を一覧する」クエリが投げられなくなるからです(1章の合成キー版はGSI1PK=COMPANY#c001だったので、この柔軟性を残せていました)。PKはcompanyId単体に留め、絞り込みたい属性はソートキー側(SK=[assessmentType, status, createdAt])に並べるのがこのユースケースの定石です。こうすれば企業単位の検索の柔軟性を残したまま #4 も実現でき、しかも先ほどのKeyConditionExpressionは1文字も変わりません。 -
そして①「順序を後から変えられない」は、実は解消されていません。 ここが一番の注意点です。属性が独立して見えても、評価順は作成時のキースキーマで固定され、GSIのキースキーマは後から変更できません。
SK=[assessmentType, status, createdAt]で作ったあとに「assessmentTypeを指定せずstatusと期間だけで引きたい」となれば、結局別のGSIを足すことになります。この点は合成キー時代と本質的に同じです。
ただし、ここで②③の効きどころが活きます。順序を変えた新しいGSIを足したくなっても、**既存属性の組み合わせを指定するだけで(アプリ側の合成キー書き込みなしに)**追加できる。つまりマルチ属性キーは「順序問題そのもの」は消さないけれど、「順序を変えたくなったときに払うコスト」を大きく下げてくれる、と理解するのが正確です。
補足:multi-tableは「全部バラす」ことではない
ここまで読むと「じゃあ multi-table って、エンティティの数だけテーブルを作ることなの?」と思うかもしれません。それは違います。multi-tableの勘所は**テーブルを分ける"粒度"**にあります。
たとえば、このサービスで診断コンテンツが増えていったとします。ストレスチェック診断、スキル診断、適性診断……。このとき、診断タイプごとにテーブルを分けるのはアンチパターンです。なぜなら、タイプが何であれアクセスパターンが同じ(「ユーザーの結果一覧」「企業×タイプ×ステータス×期間の検索」)だからです。
正しいのは、「診断」というドメインを1つのテーブル(AssessmentResults)に切り出し、その中ではタイプを assessmentType 属性で区別する設計です。
┌─ Companies テーブル(企業ドメイン)
ドメインで分ける ─┼─ Users テーブル(ユーザードメイン)
└─ AssessmentResults テーブル(診断ドメイン)
│
└─ この中では“まとめる”
ストレスチェックも、スキル診断も、適性診断も同居
assessmentType 属性 + GSIマルチ属性キーで区別・検索
つまり実務での設計は、
- ドメインの境界でテーブルを分ける(=multi-table的な判断)
- ドメインの中では、アクセスパターンを共有するデータを同居させ、キーで区別する(=single-table的な判断)
という2階層の使い分けになります。「全部1テーブル」か「全部バラす」かの二択ではありません。診断タイプが10個に増えても、テーブルもGSIも増やさず、属性を足すだけで対応できる——マルチ属性キーは、この「ドメイン内でまとめる」を支える道具として効いてきます。
まとめ:で、結局どう選ぶのか
新機能を踏まえて、私が今使っている判断の目安はこうです。
ざっくり言葉にすると、
- single-table寄り:関連データを必ず一緒に引く/超高スループット/アクセスパターンがほぼ固まっている
- multi-table寄り:複数チームで触る/エンティティごとに運用設定を分けたい/アクセスパターンがまだ動く
そして、GSIマルチ属性キーはこの判断そのものを覆すものではないけれど、「合成キーの職人芸がつらいからmulti-tableに逃げる」「逆に職人芸を我慢してsingle-tableに寄せる」といった、"つらさ起点"の意思決定を減らしてくれる機能だと捉えています。設計を、技術的制約ではなくワークロードとチームの都合で素直に選べるようになった、という点でとても良いアップデートだと思います。
最後に...ちょっとしたお知らせ
2026年9月11日(金)の19:30より、弊社主催の交流イベント「Open Sapeet!」を開催します。
今回は、Sapeetのエンジニアをはじめとする社員が登壇し、AIやプロダクト開発、これからの働き方についてお話しする予定です。
トークのあとは、軽食やドリンクを楽しみながらゆるりと交流できる時間もご用意しています。
スタートアップのリアルな開発現場を知りたい方、Sapeetの雰囲気を少し覗いてみたい方は、ぜひお気軽に遊びに来てください。
イベント詳細・お申し込みはこちら:https://connpass.com/event/398374/