目次
1. DynamoDBの基本概念
1.1 NoSQLデータベースとしての特徴
RDBとの根本的な違い:
- 結合(JOIN)が存在しない: 複数テーブルを結合する概念がないため、必要なデータは1回のクエリで取得できるよう設計する
- 正規化不要: データの重複を許容し、読み取りパフォーマンスを優先
- スキーマレス: 項目ごとに異なる属性を持てる(ただしPrimary Keyは必須)
1.2 フルマネージドサービスの利点
- インフラ管理不要: サーバー管理、パッチ適用、バックアップが自動化
- 自動スケーリング: トラフィック増加時に自動でキャパシティ調整
- 高可用性: 複数のAZに自動レプリケーション
1.3 DynamoDBが適しているユースケース
向いている場面:
- セッション管理(認証トークン、ユーザーセッション)
- リアルタイムデータ(チャット、通知)
- 時系列データ(ログ、IoTセンサーデータ)
- キーバリューストア的な用途
向いていない場面:
- 複雑な集計クエリ(SUMやAVERAGE等)
- 頻繁なスキーマ変更が必要
- アドホックな分析クエリ
1.4 RDBとの使い分けの判断軸
- アクセスパターンが事前に明確 → DynamoDB
- 複雑なリレーションと集計 → RDB
- スケーラビリティ最優先 → DynamoDB
- データ整合性最優先 → RDB
2. データモデリングの基礎
2.1 Single Table Design(単一テーブル設計)
RDBとの最大の違い:
- RDB: エンティティごとに別テーブル(Users、Meetings、Comments等)
- DynamoDB: 1つのテーブルに全エンティティを格納
理由: JOINが存在しないため、関連データを1回のクエリで取得するには同一テーブルに配置する必要がある
具体例(会議アプリケーション):
テーブル名: AppTable
---------------------------------
PK: USER#123 SK: PROFILE
PK: USER#123 SK: MEETING#456
PK: MEETING#456 SK: METADATA
PK: MEETING#456 SK: COMMENT#789
2.2 テーブル構造の3要素
1. Primary Key(必須)
- テーブル内で項目を一意に識別
- Partition KeyまたはPartition Key + Sort Keyの組み合わせ
2. 属性(Attribute)
- 項目に含まれるデータフィールド
- 項目ごとに異なる属性セットを持てる(スキーマレスの利点)
3. 項目(Item)
- RDBの「行」に相当
- Primary Keyと0個以上の属性で構成
2.3 Primary Keyの2つのパターン
パターンA: Partition Keyのみ(Simple Primary Key)
PK: USER#123
属性: name, email, createdAt
- シンプルなキーバリューストア
- 1つのPKに対して1つの項目のみ
パターンB: Partition Key + Sort Key(Composite Primary Key)
PK: USER#123 SK: PROFILE
PK: USER#123 SK: MEETING#456
PK: USER#123 SK: MEETING#457
- 1つのPKに対して複数の項目を格納可能
- Sort Keyで範囲検索やソート順を制御
2.4 Single Table Designの適用原則
適用すべきケース:
関連データをビジネスロジック上で一緒に取得する必要がある場合
適用すべきでないケース:
- アクセスパターンが完全に独立(例: トークンとアプリデータ)
- セキュリティ境界が異なる
- TTL等の管理ポリシーが異なる
3. キーの設計原則
3.1 Partition Keyの設計思想
役割: データを物理的に分散させる基準
重要な原則:
- 均等分散: 特定のPartition Keyに負荷が集中しないようにする(ホットパーティション問題の回避)
- アクセスパターン優先: 「どのように検索するか」から逆算して設計
3.2 Sort Keyの設計思想
役割: 同一Partition Key内のデータをソート・範囲検索
活用パターン:
- 時系列データ:
2024-12-22T10:30:00Z - 階層構造:
MEETING#456#COMMENT#789 - バージョン管理:
v1,v2,v3
3.3 複合キー戦略: プレフィックスパターン
なぜプレフィックスを付けるのか:
良い例: USER#123, MEETING#456
悪い例: 123, 456
理由:
- エンティティタイプの明示: コードの可読性向上
- キーの衝突回避: 異なるエンティティで同じID(123)が存在しても区別可能
- デバッグの容易性: DynamoDB管理コンソールで一目でデータ構造を把握
3.4 Composite Primary Keyの仕組み
重要な理解:
- Partition KeyとSort Keyは別々のカラム(属性)
-
USER#123MEETING#456のように連結されるわけではない
Primary Key = {
Partition Key: "USER#123",
Sort Key: "MEETING#456"
}
(2つの属性の組み合わせ)
一意性の保証:
-
(Partition Key, Sort Key)のペアが一意である必要がある - Partition Keyが同じでも、Sort Keyが異なれば別の項目
- 同じペアで書き込むと既存項目が上書きされる
3.5 マルチテナント設計
推奨パターン:
PK: tenant1#MEETING#meeting1
SK: METADATA
利点:
- テナント間でデータが物理的に分離
- クエリ時にテナントIDを含めることで誤った横断アクセスを防止
- セキュリティ境界が明確
Partition Keyの粒度:
粗い粒度: PK: tenant1 (非推奨: ホットパーティション化)
細かい粒度: PK: tenant1#user_abc123 (推奨: 分散性が高い)
4. アクセスパターンとクエリ
4.1 Query vs Scan の違い
Query(効率的):
- Partition Keyを必ず指定
- 特定のパーティションのみスキャン
- Sort Keyで範囲指定可能
- コスト: 取得した項目分のRCUのみ消費
Scan(非効率):
- テーブル全体をスキャン
- フィルタは取得後に適用
- コスト: テーブル全体のRCUを消費(フィルタで絞っても)
4.2 基本的なQuery操作
TypeScriptでの実装例:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";
const client = new DynamoDBClient({ region: "ap-northeast-1" });
const docClient = DynamoDBDocumentClient.from(client);
// 特定会議の全データ取得
const command = new QueryCommand({
TableName: "AppTable",
KeyConditionExpression: "PK = :pk",
ExpressionAttributeValues: {
":pk": "tenant1#MEETING#meeting1"
}
});
const result = await docClient.send(command);
console.log(result.Items);
4.3 Sort Keyを使った条件指定
begins_with(前方一致):
// 特定会議の意見のみ取得
QueryCommand({
KeyConditionExpression: "PK = :pk AND begins_with(SK, :sk_prefix)",
ExpressionAttributeValues: {
":pk": "tenant1#MEETING#meeting1",
":sk_prefix": "OPINION#"
}
})
BETWEEN(範囲指定):
// 特定期間の意見のみ取得
QueryCommand({
KeyConditionExpression: "PK = :pk AND SK BETWEEN :start AND :end",
ExpressionAttributeValues: {
":pk": "tenant1#MEETING#meeting1",
":start": "OPINION#2025-12-18T10:00:00.000Z",
":end": "OPINION#2025-12-18T12:00:00.000Z"
}
})
ソート順の制御:
QueryCommand({
KeyConditionExpression: "PK = :pk AND begins_with(SK, :sk_prefix)",
ExpressionAttributeValues: {
":pk": "tenant1#MEETING#meeting1",
":sk_prefix": "OPINION#"
},
ScanIndexForward: false // false = 降順(新しい順), true = 昇順(古い順)
})
4.4 GetItem vs Query の使い分け
GetItem(1項目を直接取得):
import { GetCommand } from "@aws-sdk/lib-dynamodb";
const command = new GetCommand({
TableName: "AuthTokens",
Key: {
userId: "tenant1#user_abc123",
tokenType: "id_token"
}
});
const result = await docClient.send(command);
console.log(result.Item);
使い分けの基準:
- Primary Key(PK+SK)が完全に分かっている → GetItem(最速)
- PKは分かるがSKで範囲検索したい → Query
- 複数項目を取得したい → Query
4.5 フィルタ式の使い方と注意点
Query後にさらに絞り込み:
QueryCommand({
KeyConditionExpression: "PK = :pk AND begins_with(SK, :sk_prefix)",
FilterExpression: "role = :role", // Query後にフィルタ
ExpressionAttributeValues: {
":pk": "tenant1#MEETING#meeting1",
":sk_prefix": "PARTICIPANT#",
":role": "moderator"
}
})
重要な注意点:
- FilterExpressionはQuery後に適用される
- RCUはフィルタ前の全項目で消費される
- 頻繁にフィルタするなら、GSIまたはSort Keyの設計を見直す
4.6 GSI(Global Secondary Index)の活用
GSIの基本概念
GSIとは: クエリしやすくするために、自分で追加の検索用キーを用意する仕組み
重要な理解:
- GSI用に新しい属性を追加する必要がある
- Base TableのPK/SKとは別のキーでインデックスを構築
GSIが必要なパターン
パターン1: 逆引き検索
Primary Key: PK: tenant1#MEETING#meeting1, SK: PARTICIPANT#user_abc123
実現できないクエリ: 「ユーザーが参加している全会議の一覧」
GSI設計:
PK: tenant1#USER#user_abc123
SK: MEETING#meeting1#2025-12-18T10:00:00Z
パターン2: 時系列検索(会議横断)
Primary Key: PK: tenant1#MEETING#meeting1, SK: OPINION#timestamp
実現できないクエリ: 「テナント内の全会議から最新の意見を取得」
GSI設計:
PK: tenant1#OPINION
SK: timestamp#meeting1#opinion-id
パターン3: ステータスフィルタ
Primary Key: PK: tenant1#USER#user_abc123, SK: PROFILE
実現できないクエリ: 「テナント内のアクティブなユーザー一覧」
GSI設計:
PK: tenant1#ACTIVE_USERS
SK: userId
GSIの実装方法
Base Table書き込み時にGSI用の属性を追加:
import { PutCommand } from "@aws-sdk/lib-dynamodb";
const command = new PutCommand({
TableName: "AppTable",
Item: {
// Base TableのPrimary Key
PK: "tenant1#MEETING#meeting1",
SK: "PARTICIPANT#user_abc123",
// GSI用のキー(自分で追加)
GSI_PK: "tenant1#USER#user_abc123",
GSI_SK: "MEETING#meeting1#2025-12-18T10:00:00Z",
// 通常の属性
joinedAt: "2025-12-18T10:00:00Z",
role: "moderator"
}
});
await docClient.send(command);
GSIを使ったQuery:
const command = new QueryCommand({
TableName: "AppTable",
IndexName: "UserMeetingsIndex", // GSI名を指定
KeyConditionExpression: "GSI_PK = :gsi_pk AND begins_with(GSI_SK, :gsi_sk_prefix)",
ExpressionAttributeValues: {
":gsi_pk": "tenant1#USER#user_abc123",
":gsi_sk_prefix": "MEETING#"
},
ScanIndexForward: false // 最新参加順
});
const result = await docClient.send(command);
console.log(result.Items);
GSIのコスト注意点
- GSIは追加のストレージとキャパシティを消費
- 書き込み時にBase TableとGSI両方に書き込み(WCU2倍)
- 本当に必要なアクセスパターンか慎重に検討
5. キャパシティ管理
5.1 キャパシティの2つのモード
オンデマンドモード(推奨: スタートアップ・不規則な負荷):
- 使った分だけ課金
- 自動スケーリング
- キャパシティ計画不要
- コスト: やや高め(プロビジョニングの約5倍)
プロビジョニングモード(推奨: 安定した負荷・コスト最適化):
- RCU/WCUを事前に設定
- 予測可能なコスト
- Auto Scalingで自動調整可能
- コスト: 安い(ただし設定ミスでスロットリングのリスク)
5.2 RCU(Read Capacity Unit)の計算
1 RCU = 以下のいずれか:
- 最大4KBの項目を強力な整合性読み込みで1回/秒
- 最大4KBの項目を結果整合性読み込みで2回/秒
計算例:
シナリオ: 8KBの項目を強力な整合性で100回/秒読み込む
計算:
項目サイズ: 8KB ÷ 4KB = 2 RCU/項目
必要RCU: 2 × 100 = 200 RCU
結果整合性の場合:
8KBの項目を結果整合性で100回/秒読み込む
計算:
項目サイズ: 8KB ÷ 4KB = 2
結果整合性: 2 ÷ 2 = 1 RCU/項目
必要RCU: 1 × 100 = 100 RCU
5.3 WCU(Write Capacity Unit)の計算
1 WCU = 最大1KBの項目を1回/秒書き込み
計算例:
シナリオ: 3KBの項目を50回/秒書き込む
計算:
項目サイズ: 3KB → 切り上げで3 WCU/項目
必要WCU: 3 × 50 = 150 WCU
5.4 GSIのキャパシティ
重要: GSIは独立したキャパシティを消費
Base Tableに1項目書き込み + GSI 2個の場合:
Base Table: 3KB → 3 WCU
GSI1: 3KB → 3 WCU
GSI2: 3KB → 3 WCU
合計: 9 WCU消費
5.5 コスト最適化のポイント
1. 結果整合性読み込みを活用:
QueryCommand({
TableName: "AppTable",
KeyConditionExpression: "PK = :pk",
ExpressionAttributeValues: { ":pk": "tenant1#MEETING#meeting1" },
ConsistentRead: false // 結果整合性(RCUが半分)
})
2. バッチ操作を使う:
import { BatchGetCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb";
// 最大100項目を1回で取得
const batchGetCommand = new BatchGetCommand({
RequestItems: {
"AppTable": {
Keys: [
{ PK: "tenant1#MEETING#meeting1", SK: "METADATA" },
{ PK: "tenant1#MEETING#meeting2", SK: "METADATA" }
]
}
}
});
// 最大25項目を1回で書き込み
const batchWriteCommand = new BatchWriteCommand({
RequestItems: {
"AppTable": [
{
PutRequest: {
Item: { PK: "tenant1#MEETING#meeting1", SK: "PARTICIPANT#user1" }
}
}
]
}
});
3. TTL機能でストレージ削減:
// トークンテーブルでの設定
{
userId: "tenant1#user_abc123",
tokenType: "id_token",
token: "eyJhbGc...",
expiresAt: 1703260800,
ttl: 1703260800 // ← この時刻になると自動削除(無料)
}
4. オンデマンド vs プロビジョニングの判断:
- 開発初期・トラフィック予測困難 → オンデマンド
- 安定稼働・コスト削減優先 → プロビジョニング + Auto Scaling
5.6 スタート時の推奨設定
AuthTokens: オンデマンドモード
AppTable: オンデマンドモード
GSI: オンデマンドモード(Base Tableと同じモード必須)
理由:
- 初期はトラフィックパターンが不明
- キャパシティ不足でサービス停止のリスク回避
- データ蓄積後にプロビジョニングへ移行検討
6. 実践的な設計例
6.1 トークンテーブルの設計
テーブル定義:
テーブル名: AuthTokens
Partition Key: userId (String)
Sort Key: tokenType (String)
TTL属性: ttl
モード: オンデマンド
データ構造:
{
userId: "tenant1#user_abc123", // Partition Key
tokenType: "id_token", // Sort Key
token: "eyJhbGc...", // 暗号化済みトークン
expiresAt: 1703260800, // Unix timestamp
createdAt: 1703174400,
ttl: 1703260800 // DynamoDB TTL用
}
{
userId: "tenant1#user_abc123",
tokenType: "refresh_token",
token: "eyJhbGc...",
expiresAt: 1703347200,
createdAt: 1703174400,
ttl: 1703347200
}
アクセスパターン:
// 1. ユーザーの全トークン取得
Query(userId = "tenant1#user_abc123")
// 2. 特定トークンのみ取得
Query(userId = "tenant1#user_abc123", tokenType = "id_token")
// 3. 特定トークンを直接取得
GetItem(userId = "tenant1#user_abc123", tokenType = "id_token")
// 4. トークン削除(ログアウト)
DeleteItem(userId = "tenant1#user_abc123", tokenType = "id_token")
設計のポイント:
- マルチテナント対応で
tenant#user形式のPartition Key - TTL機能で期限切れトークンを自動削除
- GSI不要(トークン検証は常にuserIdが分かっている)
6.2 会議アプリケーションテーブルの設計
テーブル定義:
テーブル名: AppTable
Partition Key: PK (String)
Sort Key: SK (String)
モード: オンデマンド
GSI: UserMeetingsIndex
Partition Key: GSI_PK (String)
Sort Key: GSI_SK (String)
データ構造:
// 会議メタデータ
{
PK: "tenant1#MEETING#meeting1",
SK: "METADATA",
title: "四半期振り返り会議",
description: "Q4の振り返りと改善点の議論",
createdAt: "2025-12-18T09:00:00Z",
status: "active"
}
// 会議の意見
{
PK: "tenant1#MEETING#meeting1",
SK: "OPINION#2025-12-18T10:25:09.584Z#opinion-1766053509584",
content: "集団思考の傾向が見られるため、デビルズアドボケートの導入を提案します",
userId: "tenant1#USER#user_abc123",
userName: "西島康孝",
createdAt: "2025-12-18T10:25:09.584Z"
}
// 会議の参加者
{
PK: "tenant1#MEETING#meeting1",
SK: "PARTICIPANT#user_abc123",
GSI_PK: "tenant1#USER#user_abc123", // GSI用
GSI_SK: "MEETING#meeting1#2025-12-18T09:00:00Z", // GSI用
userId: "tenant1#USER#user_abc123",
userName: "西島康孝",
role: "moderator",
joinedAt: "2025-12-18T09:00:00Z"
}
アクセスパターン:
// 1. 会議の全データ取得(メタデータ + 意見 + 参加者)
Query(PK = "tenant1#MEETING#meeting1")
// 2. 会議の意見のみ取得(時系列順)
Query(
PK = "tenant1#MEETING#meeting1",
SK begins_with "OPINION#"
)
// 3. 会議の参加者のみ取得
Query(
PK = "tenant1#MEETING#meeting1",
SK begins_with "PARTICIPANT#"
)
// 4. 特定期間の意見のみ取得
Query(
PK = "tenant1#MEETING#meeting1",
SK BETWEEN "OPINION#2025-12-18T10:00" AND "OPINION#2025-12-18T11:00"
)
// 5. ユーザーの参加会議一覧(GSI使用)
Query(
IndexName = "UserMeetingsIndex",
GSI_PK = "tenant1#USER#user_abc123",
GSI_SK begins_with "MEETING#",
ScanIndexForward = false // 最新参加順
)
設計のポイント:
- Single Table Designで関連データを1回のQueryで取得
- Sort Keyに時刻を含めることで時系列ソートとGSI不要化
- マルチテナント対応で全てのPKに
tenant#プレフィックス - ユーザー視点の検索にはGSI(UserMeetingsIndex)を活用
6.3 Railsでの実装例
# app/services/dynamodb/meeting_service.rb
class DynamoDB::MeetingService
def initialize
@client = Aws::DynamoDB::Client.new(region: 'ap-northeast-1')
end
# 会議の全データを取得
def get_meeting(tenant_id, meeting_id)
response = @client.query(
table_name: 'AppTable',
key_condition_expression: 'PK = :pk',
expression_attribute_values: {
':pk' => "#{tenant_id}#MEETING##{meeting_id}"
}
)
format_meeting_data(response.items)
end
# 会議に意見を追加
def add_opinion(tenant_id, meeting_id, user_id, content)
timestamp = Time.now.utc.iso8601(3)
opinion_id = "opinion-#{Time.now.to_i * 1000}"
@client.put_item(
table_name: 'AppTable',
item: {
'PK' => "#{tenant_id}#MEETING##{meeting_id}",
'SK' => "OPINION##{timestamp}##{opinion_id}",
'content' => content,
'userId' => user_id,
'createdAt' => timestamp
}
)
end
# ユーザーの参加会議一覧を取得(GSI使用)
def get_user_meetings(tenant_id, user_id)
response = @client.query(
table_name: 'AppTable',
index_name: 'UserMeetingsIndex',
key_condition_expression: 'GSI_PK = :gsi_pk AND begins_with(GSI_SK, :gsi_sk_prefix)',
expression_attribute_values: {
':gsi_pk' => "#{tenant_id}#USER##{user_id}",
':gsi_sk_prefix' => 'MEETING#'
},
scan_index_forward: false # 最新参加順
)
response.items
end
private
def format_meeting_data(items)
{
metadata: items.find { |item| item['SK'] == 'METADATA' },
opinions: items.select { |item| item['SK'].start_with?('OPINION#') }
.sort_by { |item| item['SK'] },
participants: items.select { |item| item['SK'].start_with?('PARTICIPANT#') }
}
end
end
6.4 Lambda(DynamoDB Streams)での実装例
import { DynamoDBStreamEvent, DynamoDBRecord } from 'aws-lambda';
// 項目の変更を検知して処理
export const handler = async (event: DynamoDBStreamEvent) => {
for (const record of event.Records) {
await processRecord(record);
}
};
async function processRecord(record: DynamoDBRecord) {
const eventName = record.eventName; // INSERT, MODIFY, REMOVE
const newItem = record.dynamodb?.NewImage;
const oldItem = record.dynamodb?.OldImage;
if (!newItem) return;
const pk = newItem.PK?.S;
const sk = newItem.SK?.S;
// 新しい意見が投稿された場合
if (eventName === 'INSERT' && sk?.startsWith('OPINION#')) {
await notifyNewOpinion({
meetingId: pk,
userId: newItem.userId?.S,
content: newItem.content?.S
});
}
// 参加者が追加された場合
if (eventName === 'INSERT' && sk?.startsWith('PARTICIPANT#')) {
await notifyNewParticipant({
meetingId: pk,
userId: newItem.userId?.S,
role: newItem.role?.S
});
}
}
async function notifyNewOpinion(data: any) {
// SNS、SES、WebSocket等で通知
console.log('New opinion posted:', data);
}
async function notifyNewParticipant(data: any) {
// 参加者追加の通知処理
console.log('New participant joined:', data);
}
付録: よくある質問と回答
Q1: いつGSIを作成すべきか?
A: Base TableのPrimary Keyでは実現できないアクセスパターンがある場合のみ。例:
- 「ユーザーの参加会議一覧」(会議→ユーザーの逆引き)
- 「全会議の最新意見」(会議横断の時系列検索)
- 「アクティブなユーザー一覧」(ステータスフィルタ)
Sort Keyに時刻を含めることで、多くの時系列検索はGSI不要で実現できる。
Q2: Partition Keyにどこまで情報を詰め込むべきか?
A: アクセスパターンと分散性のバランスで判断:
-
tenant1: 粗すぎる(ホットパーティション化) -
tenant1#USER#user_abc123: ちょうど良い(推奨) -
tenant1#USER#user_abc123#SESSION#session_xyz: 細かすぎる(管理が煩雑)
基本は「エンティティタイプ + ID」レベルが最適。
Q3: トークンテーブルとアプリテーブルを分ける理由は?
A: 以下の理由で分離すべき:
- アクセスパターンの独立性(トークン検証とアプリ機能は別)
- セキュリティ境界の分離(暗号化・アクセス制御が異なる)
- TTL管理ポリシーの違い
- キャパシティ最適化(トークン検証は高頻度)
Q4: 結果整合性と強力な整合性はどう使い分ける?
A:
- 結果整合性(デフォルト): ダッシュボード表示、一覧取得等、若干の遅延が許容できる場合
- 強力な整合性: 金融取引、在庫管理、認証トークン検証等、最新データが必須の場合
迷ったら結果整合性で開始し、問題があれば強力な整合性に変更。
Q5: Scanは絶対に使ってはいけない?
A: 以下の場合は許容される:
- 管理画面でのデータエクスポート(低頻度)
- バッチ処理でのデータ移行
- 開発環境でのデバッグ
本番環境の通常フローでは避けるべき。どうしても必要ならGSIの追加を検討。
まとめ: DynamoDB設計の黄金ルール
- アクセスパターンを先に定義する: データ構造ではなく、どう検索するかから設計
- Primary Keyで80%解決する: GSIは必要最小限に
- Sort Keyを賢く使う: 時刻やプレフィックスで多様なクエリに対応
- マルチテナントはPartition Keyに含める: セキュリティと分散性を両立
- オンデマンドモードでスタート: トラフィックパターン確立後にプロビジョニングへ
- TTLで自動削除: ストレージコスト削減
- 結果整合性を活用: RCUを半減
- Scanは避ける: どうしても必要ならGSI追加
作成日: 2025-12-22
最終更新: 2025-12-22