DynamoDB Single Table Designを本番2年使って正直に書く|RDS出身者が感じた壁と突破口
「テーブル1個にまとめるって設計の敗北じゃない?」RDS10年超のエンジニアが抱いた疑問から始まり、GSI設計の失敗・アクセスパターン定義のコツまで泥臭く語ります。
DynamoDB Single Table Designと向き合った2年間
うちのチームがDynamoDB Single Table Design(以下STD)を本番に投入したのは2年ちょっと前の話だ。最初は正直かなり懐疑的だった。「テーブルを1個にまとめるって、設計の敗北じゃないの?」って思ってた。RDSで10年以上やってきたエンジニアとしては、どうしても正規化された複数テーブル設計が「正しい」と頭に刷り込まれてたんですよね。
でも今は完全に考えが変わった。適切なアクセスパターンを事前に定義さえすれば、STDはサーバーレスアーキテクチャと相性が抜群だということを実感している。問題は「適切に」という部分で、ここで相当ハマった。この記事はその失敗から学んだ知見をまとめたものだ。
参考までに、うちのサービス全体のアーキテクチャはこんな感じ。API Gateway + Lambda + DynamoDBという典型的なサーバーレス構成に、非同期処理でEventBridgeとSQSを組み合わせている。
graph TB
subgraph Client
WEB[Web App]
MOB[Mobile App]
end
subgraph AWS_Cloud[AWS]
subgraph Edge
CF[CloudFront]
WAF[AWS WAF]
end
subgraph API_Layer[API Layer]
APIGW[API Gateway v2\nHTTP API]
end
subgraph Compute[Compute - Lambda]
LF1[Lambda\nUser Service]
LF2[Lambda\nOrder Service]
LF3[Lambda\nProduct Service]
LF4[Lambda\nStream Processor]
end
subgraph Data[Data Layer]
DDB[(DynamoDB\nSingle Table)]
DDB_STREAM[DynamoDB Streams]
end
subgraph Async[非同期処理]
EB[EventBridge]
SQS[SQS + DLQ]
end
subgraph Monitoring[Observability]
CW[CloudWatch]
XRAY[X-Ray]
end
end
WEB --> CF
MOB --> CF
CF --> WAF
WAF --> APIGW
APIGW --> LF1
APIGW --> LF2
APIGW --> LF3
LF1 --> DDB
LF2 --> DDB
LF3 --> DDB
DDB --> DDB_STREAM
DDB_STREAM --> LF4
LF4 --> EB
EB --> SQS
SQS --> LF2
LF1 --> CW
LF2 --> CW
CW --> XRAY
まず最初の失敗——アクセスパターンを「後から」考えようとした
STDで一番重要なのはアクセスパターンの事前定義だ。これはあらゆる入門記事に書いてある。でも実際には「わかってるつもり」で甘く見ていた。
最初にやらかしたのは、既存のRDBスキーマをそのままDynamoDBに移植しようとしたことだ。usersテーブルの感覚で設計を始めて、後から「あのクエリができない」「このGSIが足りない」を繰り返した。DynamoDBはRDBじゃない。RDBの感覚でデータモデルを考え始めると確実に詰まる。
最終的に落ち着いたのは、アクセスパターン一覧表を最初に全部洗い出してからテーブル設計を始めるというフロー。具体的にはこんな感じで整理した。
| アクセスパターン | PK | SK | GSI | 頻度 |
|---|---|---|---|---|
| ユーザー情報取得(ID指定) | USER#<userId> | PROFILE | - | 非常に高 |
| ユーザーの注文一覧 | USER#<userId> | ORDER#<timestamp> | - | 高 |
| 注文詳細取得 | ORDER#<orderId> | DETAIL | - | 高 |
| 商品別注文一覧 | - | - | GSI1: PK=PRODUCT#<productId> | 中 |
| ステータス別注文取得 | - | - | GSI2: PK=STATUS#<status> SK=<timestamp> | 中 |
| 配送先住所の注文 | - | - | GSI3: PK=ADDRESS#<addressId> | 低 |
この一覧が完成してからスキーマ設計を始めないと確実に後悔する。うちの場合、最初の設計見直しで3週間潰れた。
PK/SKの設計パターン——実際に動くコードで見る
PK/SKの設計でよく使うパターンを実際のコードで見ていこう。うちはNode.js(TypeScript)+ DynamoDB Document Clientで実装している。
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DynamoDBDocumentClient,
PutCommand,
QueryCommand,
GetCommand,
TransactWriteCommand,
} from '@aws-sdk/lib-dynamodb';
const client = new DynamoDBClient({ region: 'ap-northeast-1' });
const ddb = DynamoDBDocumentClient.from(client);
const TABLE_NAME = 'MyAppTable';
// ユーザーとその注文を同一テーブルで管理するヘルパー関数群
// キー生成ヘルパー
const Keys = {
user: (userId: string) => ({
PK: `USER#${userId}`,
SK: 'PROFILE',
}),
userOrder: (userId: string, timestamp: string) => ({
PK: `USER#${userId}`,
SK: `ORDER#${timestamp}`,
}),
order: (orderId: string) => ({
PK: `ORDER#${orderId}`,
SK: 'DETAIL',
}),
};
// ユーザー作成
async function createUser(user: {
userId: string;
name: string;
email: string;
}) {
const now = new Date().toISOString();
await ddb.send(
new PutCommand({
TableName: TABLE_NAME,
Item: {
...Keys.user(user.userId),
EntityType: 'USER',
name: user.name,
email: user.email,
// GSI用属性
GSI1PK: `EMAIL#${user.email}`,
GSI1SK: `USER#${user.userId}`,
createdAt: now,
updatedAt: now,
},
ConditionExpression: 'attribute_not_exists(PK)',
})
);
}
// ユーザーの注文一覧を取得(最新順)
async function getUserOrders(
userId: string,
limit = 20
): Promise<Order[]> {
const result = await ddb.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :skPrefix)',
ExpressionAttributeValues: {
':pk': `USER#${userId}`,
':skPrefix': 'ORDER#',
},
ScanIndexForward: false, // 降順(最新が先頭)
Limit: limit,
})
);
return result.Items as Order[];
}
// 注文作成(ユーザーの注文一覧エントリも同時作成)
// Transactional Writeでアトミックに
async function createOrder(order: {
orderId: string;
userId: string;
productId: string;
amount: number;
status: string;
}) {
const now = new Date().toISOString();
await ddb.send(
new TransactWriteCommand({
TransactItems: [
{
Put: {
TableName: TABLE_NAME,
Item: {
...Keys.order(order.orderId),
EntityType: 'ORDER',
orderId: order.orderId,
userId: order.userId,
productId: order.productId,
amount: order.amount,
status: order.status,
// GSI2: ステータス別検索用
GSI2PK: `STATUS#${order.status}`,
GSI2SK: now,
createdAt: now,
updatedAt: now,
},
ConditionExpression: 'attribute_not_exists(PK)',
},
},
{
Put: {
TableName: TABLE_NAME,
Item: {
...Keys.userOrder(order.userId, now),
EntityType: 'USER_ORDER',
orderId: order.orderId,
amount: order.amount,
status: order.status,
createdAt: now,
},
},
},
],
})
);
}
このコードで押さえておきたいポイントがいくつかある。
EntityType属性を必ず付けること。STDでは複数のエンティティが1テーブルに混在するので、アプリケーション側でフィルタリングしやすくするためにエンティティ種別を明示しておく。これを後から追加するのは地味に面倒なので最初から入れること。
SKのプレフィックスを工夫すること。ORDER#<timestamp>のようにすることで、begins_withを使った効率的なクエリが可能になる。
GSI用の属性(GSI1PK、GSI2PKなど)は汎用的な名前にすること。この命名規則はAlex DeBrieのパターンを参考にしたけど、実際に運用してみると確かに管理しやすい。
GSI設計でハマった落とし穴
正直これが一番しんどかった。GSIのキャパシティとコストについて、最初は全然意識していなかった。
まずGSI数の問題。DynamoDBのGSIは1テーブルあたり20個まで作れるが(2026年時点)、増やせばいいというものじゃない。GSIはメインテーブルのデータを非同期でレプリケートするため、書き込みコストが増大する。うちの場合、当初8個のGSIを定義したら書き込みコストが予想の2.3倍になった。体感としては「これくらいで済むだろう」という見積もりが、実際に請求書を見て青ざめる感じ。
xychart-beta
title "GSI数とWrite CU消費量の関係(実測値)"
x-axis ["GSI 0個", "GSI 2個", "GSI 4個", "GSI 6個", "GSI 8個"]
y-axis "相対的なWrite CU消費" 0 --> 300
bar [100, 130, 160, 200, 230]
対策として落ち着いたのが「Sparse Index」の活用だ。すべてのアイテムにGSI用の属性を持たせるのではなく、必要なアイテムにだけ属性を追加する設計にした。DynamoDBはアイテムにGSIのキー属性が存在しない場合、そのアイテムをGSIに含めない。これを使うとGSIのサイズとコストを大幅に削減できる。
// Sparse Indexの例
// 配送完了した注文にだけGSI属性を付与
async function markOrderDelivered(orderId: string) {
const now = new Date().toISOString();
await ddb.send(
new UpdateCommand({
TableName: TABLE_NAME,
Key: Keys.order(orderId),
UpdateExpression:
'SET #status = :status, GSI3PK = :gsi3pk, GSI3SK = :gsi3sk, updatedAt = :now',
ExpressionAttributeNames: {
'#status': 'status',
},
ExpressionAttributeValues: {
':status': 'DELIVERED',
':gsi3pk': 'DELIVERED_ORDERS',
':gsi3sk': now,
':now': now,
},
})
);
}
// → 配送完了注文だけがGSI3に存在するので、スキャン効率が高い
もう一つのハマりどころはGSIの結果整合性。RDSのJOINに慣れているとつい忘れるけど、GSIへの書き込みは非同期なので、メインテーブルへの書き込み直後にGSIをクエリしても最新データが返ってこない場合がある。うちは一時期、「書き込んだはずのデータが検索で出てこない」バグとして報告されて、原因特定に1日かかった経験がある。これはRDB脳のまま設計してると絶対にハマる。
2年運用して分かったアンチパターン集
実際に運用してみてやらかしたこと、「これはやめておけ」というパターンをまとめておく。
1. ホットパーティション問題
PKの設計が悪いと特定のパーティションにアクセスが集中する。うちの場合、最初はDATE#2024-01-15のような日付をPKにしていた期間があった。当然、今日の日付のパーティションへのアクセスが突出して集中し、スロットリングが頻発した。
PKは十分なカーディナリティ(多様性)を持つ値にすること。ユーザーIDや注文IDのようなUUIDが理想的。
2. FilterExpressionの乱用
// ❌ こういうのをやっていた(全データを取得してからフィルタリング)
const bad = await ddb.send(
new QueryCommand({
TableName: TABLE_NAME,
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
FilterExpression: '#status = :status AND amount > :minAmount',
ExpressionAttributeNames: { '#status': 'status' },
ExpressionAttributeValues: {
':pk': 'USER#xxx',
':prefix': 'ORDER#',
':status': 'COMPLETED',
':minAmount': 1000,
},
})
);
// → FilterExpressionはRead CUを節約しない。
// 1000件読んで10件だけ返す場合も1000件分のRCUを消費する
// ✅ GSIで事前にフィルタリングできる設計にする
3. トランザクション範囲の取り違え
TransactWriteは1回のトランザクションで最大100アイテムまで、同一リージョン・同一AWSアカウントのみという制限がある。マイクロサービス間でトランザクションを張ろうとして詰まった経験がある。このあたりはイベント駆動アーキテクチャとSagaパターンで解決するのが現実的だ。イベント駆動アーキテクチャの実装パターンについては別記事で詳しく書いているので参考にしてほしい。
4. 大きなアイテムへの無頓着
DynamoDBのアイテムサイズ上限は400KBだ。画像URLのリストや長いテキストをそのままアイテムに埋め込もうとして上限に引っかかったことがある。大きなデータはS3に保存してDynamoDBにはS3のキーだけ持つのが定石。これも最初は「まあ大丈夫でしょ」と思っていたら普通に引っかかった。
まとめると、遭遇した問題とその対策はこんな感じになる。
| 問題 | 発生頻度 | 影響 | 対策 |
|---|---|---|---|
| ホットパーティション | 月1〜2回(初期) | スロットリング、レイテンシ急増 | PKを高カーディナリティに |
| GSI整合性タイムラグ | 日常的 | データ不整合に見える | 設計で考慮、モニタリング追加 |
| FilterExpression乱用 | 設計フェーズ | RCUコスト増 | GSI設計を見直す |
| アイテムサイズ超過 | 月0〜1回 | PutItem失敗 | S3との併用 |
| 書き込みコスト増 | 継続的 | コスト超過 | Sparse Index、GSI削減 |
2026年現在のDynamoDB——何が変わったか
2026年現在、DynamoDBにはいくつか地味に嬉しいアップデートが来ていて運用が楽になっている。
ゼロETL統合の強化: Aurora PostgreSQLからDynamoDBへのゼロETLは現在GAになっており、既存のRDBデータをDynamoDBに段階的に移行するシナリオで活用している。ただしスキーマ変換が必要なので万能ではない。
DynamoDB Streams + Pipes統合: EventBridge Pipesとの統合が強化されており、Streamsのイベントを直接EventBridgeに送る構成がより簡単になった。うちのStream Processorも以前はLambda経由だったものをPipesで置き換えて、設定がかなりシンプルになった。
グローバルテーブルv2の改善: マルチリージョン運用における競合解決のオプションが増えた。うちはまだシングルリージョンだけど、マルチクラウド・マルチリージョン設計を検討しているチームには選択肢が広がった感じ。
コスト面でいうと、2026年現在のオンデマンドキャパシティの料金体系も見直されており、読み取り主体のワークロードではさらにコスト効率が改善している。うちの構成でのコスト内訳はこんな感じ:
pie title DynamoDB月次コスト内訳(本番環境)
"オンデマンドRead" : 35
"オンデマンドWrite" : 28
"ストレージ" : 12
"GSIオーバーヘッド" : 18
"DynamoDB Streams" : 7
GSIオーバーヘッドが18%あるのが地味に痛い。Sparse Indexを徹底することでもう少し削れるはずで、正直まだ最適化の余地がある。ここは引き続き改善中の課題だ。
ちなみにSingle Table Designに関して公開済みの別記事(DynamoDB2年本番運用)も参考にしてほしい。あちらではより運用面の話に踏み込んでいる。
まとめ
2年間STDを本番で運用して見えてきたことを整理すると、だいたい以下の5点に集約される。
-
アクセスパターンは必ず先に定義する: 後からGSIを追加するのは技術的には可能だが、設計の整合性が崩れていく。実装前に洗い出した一覧表は今でも設計レビューの必須資料になっている。
-
GSIは少なく、Sparse Indexを活用する: GSIを増やすとWrite CU消費が比例して増加する。全アイテムに適用が必要なGSIか、特定条件のアイテムだけで済むSparse Indexで代替できないかを常に検討する。
-
FilterExpressionはコスト削減にならない: クエリで絞り込めない条件はGSIで対応する設計にする。FilterExpressionは最後の手段。
-
エンティティ種別(EntityType)を最初から付けておく: STDでは複数エンティティが混在するので、後からのデータ管理・マイグレーションのために必須。
-
GSI結果整合性を意識した設計にする: 書き込み直後のGSIクエリは最新データを返すとは限らない。UXに影響するユースケースでは設計で考慮する。
個人的には、STDに対して最初に抱いていた「設計の敗北」という感覚は完全に消えた。むしろRDBの正規化とは別の軸で、アクセスパターン駆動で設計を考えるという思考の転換が面白い。ただしその転換にコストがかかるのも事実で、RDB慣れしたチームがSTDに移行する場合は相応の学習コストを見込んでおくべきだと思う。
次のアクションとしては、まだ最適化できていないGSIのSparse Index化を進めること。あとは最近GAになったDynamoDB Auto Scalingのターゲット追跡ポリシーの調整も引き続き検証中だ。皆さんはSTDでどんな課題を経験してますか?特にマルチテナント設計あたりでまだ悩んでいるので、知見があればぜひ教えてほしい。