はじめに
今回は、Amazon DynamoDBをデータベースとして使用する際のテーブル設計を考えてみました。
少しでも参考になれば幸いです。
DynamoDBは、AWSが提供するフルマネージドな「NoSQL」データベースです。
リレーショナルデータベース(PostgreSQLやMySQL)のようにテーブルはありますが、
・JOINなし
・固定スキーマが緩い
・水平スケール前提
・ミリ秒単位の高速レスポンス
といった特徴があります(長所と短所があります)
詳しくは公式ドキュメントをご参照ください。
要件
今回は以下の要件でアプリケーションを実装する想定で、テーブルを設計してみました。
・LIFFアプリ
・簡易的なスタンプラリー機能を実装する
・獲得予定のスタンプは2つ
・アクセスしたユーザーごとに各スタンプの獲得状態を保持する
・各スタンプ獲得時にPushメッセージを送信する
・各スタンプの獲得日時を記録したい
・Pushメッセージ送信状況も把握したい
今回の要件にAmazon DynamoDBが最適な理由
- スタンプ数が将来的に増減しても柔軟
→ RDBの場合スタンプ追加のたびにカラム追加が必要になるが、DynamoDBはItemを増やすだけでOK - アクセスパターンがDynamoDBに非常に合っている
→ 今回の要件では、基本自分の情報にさえアクセスすれば良いため、RDBを使った複数ユーザーアクセスよるトランザクション処理やテーブルの結合などが不要 - 運用コスト
→ 一番の理由です。DynamoDBはRDSと比較したとき複雑な処理が苦手な分、今回のような簡単な要件の場合、サーバーレスの恩恵により運用コストを大幅に節約できます
アクセスパターン(概要)
| # | パターン | 用途 |
|---|---|---|
| 1 | LineUserIdでユーザー情報+全スタンプ獲得状況を取得 | LIFF起動時 |
| 2 | LineUserId + StampIdで特定スタンプの獲得状態を取得/更新 | スタンプ獲得処理 |
| 3 | 全ユーザー一覧を取得(登録日時順) | 管理画面(必要な場合) |
| 4 | スタンプマスタ一覧を取得(order順) | スタンプ一覧表示 |
| 5 | 単一スタンプマスタを取得 | Flex Message送信時の参照 |
テーブル定義(シングルテーブル)
メインテーブル: line-event
| 属性 | 型 | 役割 |
|---|---|---|
PK |
String | パーティションキー |
SK |
String | ソートキー |
GSI1PK |
String | GSI1パーティションキー |
GSI1SK |
String | GSI1ソートキー |
entityType |
String | エンティティ種別 (USER / USER_STAMP / STAMP_MASTER) |
※GSIの用途:全ユーザー一覧(管理用)、スタンプマスタ一覧
エンティティ設計
1. ユーザー (USER)
{
"PK": "EVENT#stamp-rally-202606#USER#U1234567890abcdef",
"SK": "PROFILE",
"entityType": "USER",
"eventId": "stamp-rally-202606",
"lineUserId": "U1234567890abcdef",
"createdAt": "2026-05-23T10:00:00.000Z",
"updatedAt": "2026-05-23T10:00:00.000Z",
"GSI1PK": "EVENT#stamp-rally-202606#USERS",
"GSI1SK": "2026-05-23T10:00:00.000Z#U1234567890abcdef"
}
メインテーブルにPutItemした瞬間、「Item」の中にGSI1PK, GSI1SK属性が含まれていれば、それを見てDynamoDBが裏側で勝手にGSI側のレコードを作る(コピー)してくれます。
GSI1SKの意図
GSI1SKを"2026-05-23T10:00:00.000Z#U1234567890abcdef"のようにしている理由は以下のとおりです。
① ISO 8601は文字列のままソートできる
DynamoDBのSK(Sort Key)は文字列の辞書順(lexicographic)でソートされます(日付型は存在しません)
ISO 8601形式(YYYY-MM-DDTHH:`mm:ss.sssZ)は文字列として並べても時系列順になるという性質があるため採用しています。
※UNIXタイムスタンプ(1716459600)だと桁が変わると壊れてしまいます。
※ISO 8601は文字列ソートで時系列を保証する世界標準フォーマットです。
② 衝突回避
ISO 8601のみをSKにすると、発生する確率は極めて低いですが、同じミリ秒に2人が同時に登録した場合に衝突してしまいます(GSIは、PK+SKの組が同じItemを許容しません)
そのため、末尾にUserIdを付与することで一意性を保証しつつ、頭のISO 8601文字列による日付順ソートを可能にしています。
{
"TableName": "line-event",
"IndexName": "GSI1",
"KeyConditionExpression": "GSI1PK = :pk AND GSI1SK BETWEEN :start AND :end",
"ExpressionAttributeValues": {
":pk": "EVENT#stamp-rally-202606#USERS",
":start": "2026-05-23",
":end": "2026-05-23T23:59:59.999Z#~"
}
}
2. ユーザー × スタンプ獲得状態 (USER_STAMP)
{
"PK": "EVENT#stamp-rally-202606#USER#U1234567890abcdef",
"SK": "STAMP#001",
"entityType": "USER_STAMP",
"eventId": "stamp-rally-202606",
"lineUserId": "U1234567890abcdef",
"stampId": "001",
"acquiredAt": "2026-05-23T11:30:00.000Z",
"flexMessageSent": true,
"flexMessageSentAt": "2026-05-23T11:30:02.123Z"
}
PK = EVENT#xxx#USER#U123 で1クエリ実行すると「プロフィール + 全スタンプ獲得状況」が一括で取得できます。
3. スタンプマスタ (STAMP_MASTER)
{
"PK": "EVENT#stamp-rally-202606#STAMP#001",
"SK": "MASTER",
"entityType": "STAMP_MASTER",
"eventId": "stamp-rally-202606",
"stampId": "001",
"name": "第1スタンプ",
"order": 1,
"isActive": true,
"flexMessageContent": {
"type": "flex",
"altText": "スタンプを獲得しました!",
"contents": {
"//": "LINE Messaging API の Flex Message JSON をそのまま格納"
}
},
"GSI1PK": "EVENT#stamp-rally-202606#STAMP_MASTERS",
"GSI1SK": "001"
}
- スタンプ追加時は
STAMP#003,STAMP#004などをPutするだけ(スキーマ変更不要) - 新イベントは
EVENT#stamp-2027xmasのように別キー空間で追加できます - PKを分けることで、パーティションの偏りを分散させます
なぜユーザー(PROFILE)Itemが必要なのか
・未獲得ユーザーも記録できる
→スタンプを獲得していないユーザーも追跡可能になります
・将来の属性追加に対応
→スタンプ獲得は別のItem(SK = STAMP#001)で管理し、プロフィールは独立して持つことで、責務を分離しつつ将来の属性追加に対応しやすくなります
なぜネストさせないのか
{
"PK": "EVENT#xxx#USER#U1234...",
"SK": "PROFILE",
"lineUserId": "U1234...",
"createdAt": "2026-05-23T10:00:00.000Z",
"stamps": {
"001": {
"acquiredAt": "2026-05-23T11:30:00.000Z",
"flexMessageSent": true,
"flexMessageSentAt": "2026-05-23T11:30:02.123Z"
},
"002": {
"acquiredAt": "2026-05-23T14:00:00.000Z",
...
}
}
}
上記のような「1ユーザー = 1レコードに全部入れる」がDynamoDBではあまり推奨されていません。
① GSIを貼れない
「スタンプ#001を取得したユーザー一覧」を取得したい場合に、stamps.001.acquired = trueに対してGSI を貼ることができません。結果「全件Scan」が必要になってしまいます。
Itemを独立させることで、"GSI2PK": "STAMP#001"などをつけることが可能になり、Queryだけで取得可能になります。
DynamoDBはQueryできる形にデータを持つことが推奨されています。
① 同時実行で衝突する
ユーザーがスタンプ#001と#002をほぼ同時に獲得した場合(複数タブ・連打・並列リクエスト等)、同時に同じItemを更新することで、片方の更新が上書きされてしまい、片方の獲得が失われる(Lost Update問題)が発生する可能性があります。
→これを防ぐには楽観ロック(バージョン番号)や条件式チェインが必要となり、実装が複雑になります。
別々のItemにわけることで衝突することなく、DynamoDBのItem単位の原子性が保証されます。
② Item Size制限
DynamoDBの1Itemは最大400KBです(2026年5月時点)
スタンプの数が増えたり、将来要件が変わった場合に、ハードリミットに当たって設計やり直しになるのがネスト構成の最大リスクです。
分割版は何個増えても各Itemが独立しているのでサイズ問題が発生しません。
③ 書き込みコスト(WCU)が膨れる
DynamoDBのWCU課金は「書き込み時のItemサイズ」に比例(1KB単位で切り上げ)します。
④ 部分的な読み書きができない
「スタンプ#001の獲得状態だけ知りたい」といった要件の場合、ネスト構成だとPROFILEItem全体を取得する必要があります。
・RCU(読み込みコスト)もItem全体サイズで課金される
・不要な情報まで取得することによるネットワーク帯域の消費
といった負担が発生します。
別々のItemにわけることで、「全部欲しい」場合はQuery1発でまとめて取れるし、「特定のだけ欲しい」場合はGetItemで最小限取ることが可能になります。柔軟性が圧倒的に高くなります。
DynamoDBでもネストが妥当なケース
- 配列要素が固定(変わらない) <-> 要素が増える可能性がある(ネストNG)
- 全要素を常に一緒に読み書きする <-> 個別アクセスがある(ネストNG)
- 要素数が少なく上限が明確(〜数個) <-> 上限が読めない(ネストNG)
- 要素が独立して書き換わらない <-> 同時に書き換わる(ネストNG)
アクセスパターン(実装早見表)
| パターン | 操作 | キー条件 |
|---|---|---|
| 特定ユーザー情報 x 全スタンプ取得情報 | Query | PK = EVENT#xxx#USER#U123 |
| 特定ユーザーの特定スタンプ取得情報 | GetItem |
PK = EVENT#xxx#USER#U123SK = STAMP#001
|
| ユーザー登録(二重登録防止) | PutItem |
PK = EVENT#xxx#USER#U123SK = PROFILEConditionExpression: attribute_not_exists(PK)(存在チェック + 書き込みを1リクエストでアトミックにおこない、同一 PK/SK の既存アイテム上書きを禁止) |
| スタンプ獲得(二重獲得防止) | PutItem |
PK = EVENT#xxx#USER#U123SK = STAMP#001ConditionExpression: attribute_not_exists(PK)
|
| Flex送信完了マーク | UpdateItem |
PK = EVENT#xxx#USER#U123SK = STAMP#001SET flexMessageSent = true, flexMessageSentAt = :now
|
| 全ユーザー一覧(日時順) | Query GSI1 | GSI1PK = EVENT#xxx#USERS |
| スタンプマスタ一覧(order順) | Query GSI1 | GSI1PK = EVENT#xxx#STAMP_MASTERS |
| 単一マスタ取得 | GetItem |
PK = EVENT#xxx#STAMP#001SK = MASTER
|
今回は以上になります!