これ、誰得?
この記事は「DynamoDBのテーブル設計、一度決めたら変えなくて済むようにしたい」という一心で整理したパターンの話です。
githubリポジトリ>
背景:よくある設計の問題
DynamoDB の設計記事でよく見るのはこの2択です。
- Single Table Design:テーブル1つにすべてを詰め込む。アクセスパターンを事前に全部決め切る必要があり、後から変えると全体に影響する
- テーブルを用途ごとに分ける:直感的だが、テーブルが増えるほど管理コストとコストの把握が難しくなる
どちらも「ある程度設計が固まってから決める」前提に見えます。でも実際は作りながら変わる。
2テーブルに分ける
データを2種類に分けます。
| 種類 | 特性 | 例 |
|---|---|---|
| ControlPlane | 変化が少なく、参照頻度が高い | ユーザー設定、マスターデータ、依存関係 |
| DataPlane | 書き込み頻度が高く、時系列で参照する | 観測値、チャット履歴、タスクログ |
この2つはアクセスパターンもコスト特性も根本的に違います。分けた方が自然で、かつこの2つ以上に増える理由がほとんどないというのがポイントです。
ControlPlaneTable
構成データ・マスターデータ用。
| 属性 | 値 |
|---|---|
| PK |
resource_key(例: profile#user01, theme#01) |
| SK |
event_key(例: config#latest, version#2026-01-01T00:00:00Z) |
SK の使い分けが肝です。
config#latest → 上書き。最新版だけ残したいデータ
version#<日時> → 上書きしない。変更履歴が自動で蓄積される
このSK設計だけで「最新版の取得」と「バージョン履歴」が同居します。将来ユーザー数が増えても、PK に resource_key の粒度を調整するだけで対応できます。
GSI
index_dependency_ancestor … 依存関係の逆引き(related_event_key → resource_key)
index_entity_search … エンティティ単位のアクティビティ検索
index_system_activity_search … リソース単位のアクティビティ検索
コード例(Python)
# 最新版を保存(上書き)
control.put_item(Item={
"resource_key": "profile#user01",
"event_key": "config#latest",
"value": {"text": "個人投資家", "updatedAt": now},
})
# バージョン履歴として保存(タイムスタンプSKで上書きしない)
control.put_item(Item={
"resource_key": "profile#user01",
"event_key": f"version#{now}",
"value": {"text": "初版", "savedAt": now},
})
# 履歴一覧(新しい順)
control.query(
KeyConditionExpression=(
Key("resource_key").eq("profile#user01") &
Key("event_key").begins_with("version#")
),
ScanIndexForward=False,
)
DataPlaneTable
時系列・蓄積データ用。
| 属性 | 値 |
|---|---|
| PK |
event_key_sharded(例: observation$0#obs_20260601_abc12345) |
| SK |
data_type_indexed(例: value$0) |
| TTL |
ttl 属性(Epoch秒)で自動削除 |
PK にシャードID($0)を含めています。
最初は $0 固定の constant shard で運用し、書き込みスループットがボトルネックになったらシャード数を増やすだけです。最初から最適化する必要がなく、増えてから対応できます。
ユーザー分離
publisher_resource_key に {リソース種別}#{userId} を入れます。
observations#user01
chat_sessions#user01
observations#user02
これだけでテーブルを分けずにユーザーごとのデータが分離されます。ユーザーが増えても、テーブル設計を変える必要がありません。
GSI
index_publisher_resource_key … 発行者単位の一覧
index_ancestor_event_key … スレッド/セッション単位の集約
index_ancestor_event_key_as_timeline … 同上・時系列順
index_unified_timeline … イベント横断の時系列ビュー
コード例(Python)
from decimal import Decimal
# 観測値を記録
data.put_item(Item={
"event_key_sharded": f"observation$0#{obs_id}",
"data_type_indexed": "value$0",
"publisher_resource_key": f"observations#{user_id}",
"timeline": now,
"value": {
"key": "USDJPY",
"value": Decimal("149.3"), # boto3はfloat非対応
"category": "forex",
},
})
# ユーザーの観測値を新しい順に50件
data.query(
IndexName="index_publisher_resource_key",
KeyConditionExpression=Key("publisher_resource_key").eq(f"observations#{user_id}"),
ScanIndexForward=False,
Limit=50,
)
value属性にそのまま入れる
ドメインオブジェクトは value 属性に JSON でそのまま格納します。
"value": {
"id": obs_id,
"key": "WTI",
"value": Decimal("82.5"),
"category": "commodity",
"timestamp": now,
}
DynamoDB 側のスキーマ変更なしにドメインモデルを進化させられます。フィールドを追加しても過去データはそのまま残り、アプリ側で None チェックするだけです。
RDS だとカラム追加のたびにマイグレーションが必要になるところを、DynamoDB のスキーマレスな特性を意図的に活かす形です。
実際に何が入ったか
例えば、私はこの数日で、生成AIを利用した株研究アプリを自前で作成していますが、
以下がすべてこの2テーブルに収まっています。
| データ | テーブル | resource/publisher key |
|---|---|---|
| ユーザープロフィール | Control | profile#userId |
| テーマ定義・設定 | Control | theme#id |
| ポートフォリオ | Control | portfolio#userId |
| ウォッチリスト | Control | watchlist#userId |
| 観測値 | Data | observations#userId |
| チャットセッション | Data | chat_sessions#userId |
| チャットメッセージ | Data | chat_session#sessionId |
テーブルは2つのまま増えていません。新しいデータ種別が出てきたとき、どちらに入れるかを考えるだけで設計が進みます。
チャットにしろ、通知にしろ、状態値にしろ、うまく概念を統一化できます。
作り直しが起きにくい理由
この設計が「ずっと使える」と感じる理由を整理すると:
- SK設計(config / version) はデータの追加でも壊れない
- publisher_resource_key はユーザーが増えてもテーブルを変えなくていい
- シャードID はボトルネックになってから増やせばいい
- value属性 がドメインモデルの変化を吸収する
どれも「今は小さいから妥協した設計」ではなく、規模が変わっても同じパターンで書けることを意図しています。
デプロイ
CloudFormation で管理しています。Prefix パラメータで環境ごとにテーブル名を分けられます。
aws cloudformation deploy \
--template-file cloudformation/tables.yaml \
--stack-name my-app-tables \
--parameter-overrides Prefix=my-app \
--capabilities CAPABILITY_IAM
テーブル名・ARN は Outputs としてエクスポートされ、アプリ側スタックから Fn::ImportValue で参照できます。DeletionPolicy: Retain でスタック削除時のデータ消失も防止しています。
ローカル開発(LocalStack)
docker compose up -d localstack
bash scripts/init-local.sh
AWS_ENDPOINT_URL=http://localhost:4566 python python/example.py
AWS_ENDPOINT_URL=http://localhost:4566 npx tsx typescript/example.ts
補足
大規模なマルチテナント・超高頻度書き込みが求められる場合はシャード設計の見直しが必要になります。
例:Hot Partitionの対策について
event_key_shardedにシャードの付与が出来るので、ここを分離しておくことで、PKの占有範囲を変えることができます。
ここを例えば、request_idや{YYYYMMDD}にすることで、アクセス頻度をさらに分散可能に設計が可能です。
おわりに
気軽にスケーリングしやすい設計になっているはずなので、
興味があればぜひ使ってみてください!
リポジトリには、よくあるデザインパターンを追加したものをまとめて
ライブラリすると便利かもなと思いました。