2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

これ、誰得?

この記事は「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}にすることで、アクセス頻度をさらに分散可能に設計が可能です。

おわりに

気軽にスケーリングしやすい設計になっているはずなので、
興味があればぜひ使ってみてください!

リポジトリには、よくあるデザインパターンを追加したものをまとめて
ライブラリすると便利かもなと思いました。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?