DynamoDB Single Table Designを本番導入して1年——失敗から学んだアクセスパターン設計
「テーブル1つに全部入れる?」と懐疑的だったチームで強行したSingle Table Design。GSI設計のやらかしや過負荷パーティション問題など、1年半の本番運用でぶつかったリアルな失敗談をまとめました。
DynamoDB Single Table Designを本番導入して1年——アクセスパターン設計の失敗と学んだこと
正直に言うと、Single Table Design(以下STD)を最初に提案したとき、チームの誰もが懐疑的だった。「テーブル1つに全部入れる? RDBの正規化と真逆じゃん」という反応は当然で、自分も確信があったわけじゃない。でも、Lambdaを中心にしたサーバーレスシステムを設計する中で、RDBの感覚でDynamoDBを使い続けることへの限界を感じていたのも事実だった。
あれから1年半。本番で踏んだ失敗も、「これは本当に使えるな」と思った瞬間も、正直に書いておこうと思う。Lambda Cold Startで本番が死んだ話と同様に、事前に知っておけば避けられた失敗が多かったので。
そもそもなぜSingle Table Designなのか、改めて整理する
DynamoDBはNoSQLの中でも、アクセスパターンを先に決めてテーブル設計するという思想が徹底されている。RDBでは「とりあえず正規化してJOINで柔軟に」という設計が許されるけど、DynamoDBにJOINはないし、後からインデックスを増やすにも上限がある(GSIは最大20個、LSIは5個)。
STDの核心は、複数エンティティを1テーブルに同居させることで、1回のリクエストで複数種類のデータを取得できるようにすること。マイクロサービスのサーバーレス環境では、これが劇的にレイテンシを下げる。
flowchart LR
A[Multi Table Design] --> B[User Table]
A --> C[Order Table]
A --> D[Product Table]
B --> E[3回のQuery]
C --> E
D --> E
E --> F[アプリで結合]
G[Single Table Design] --> H[1つのTable]
H --> I[1回のQuery]
I --> J[全データ取得]
style G fill:#2ecc71,color:#fff
style I fill:#2ecc71,color:#fff
これは理論上の話なので、「実際にどのくらい違うの?」という話をすると、うちのケースでは注文詳細ページの表示に必要なAPIコールが3回から1回になって、P99レイテンシが約40%改善した。ただし、この改善を得るためにアクセスパターン設計に2週間かけたのも事実。「設計2週間 → 実装が楽になる」というトレードオフで、個人的には悪くない投資だったと思っている。
アクセスパターン設計、最初に全部洗い出さないと後で地獄になる
STDで最初にやるべきことはアクセスパターンを全量洗い出すことなんだけど、これを「後でGSIで対応できるでしょ」と軽く見たのが失敗の始まりだった。
実際に使ったスキーマの一部を公開する。ECサイト的なシステムで、User・Order・Productが主なエンティティだった。
# PK/SK設計の基本形
PK SK データ
USER#user-123 METADATA ユーザープロフィール
USER#user-123 ORDER#2026-05-01#ord-abc 注文ヘッダ
USER#user-123 ORDER#2026-04-15#ord-xyz 注文ヘッダ
ORDER#ord-abc ITEM#prod-001 注文明細
ORDER#ord-abc ITEM#prod-002 注文明細
PRODUCT#prod-001 METADATA 商品情報
PRODUCT#prod-001 CATEGORY#electronics カテゴリ紐付け
この設計で対応できるアクセスパターンとGSI要否をまとめると:
| アクセスパターン | PK | SK条件 | GSI必要? |
|---|---|---|---|
| ユーザー情報取得 | USER#{userId} | = METADATA | No |
| ユーザーの注文一覧(新しい順) | USER#{userId} | begins_with(ORDER#) | No |
| 注文の明細取得 | ORDER#{orderId} | begins_with(ITEM#) | No |
| 商品情報取得 | PRODUCT#{productId} | = METADATA | No |
| カテゴリ別商品一覧 | CATEGORY#{category} | — | Yes (GSI1) |
| ステータス別注文検索 | STATUS#{status} | — | Yes (GSI2) |
| 商品別注文数集計 | PRODUCT#{productId} | ORDER# | Yes (GSI3) |
GSI1とGSI2は最初から想定できていたんだけど、GSI3は後から「商品別の売上レポートが欲しい」という要件が追加されて、スキーマを変更することになった。これが地味に痛かった。既存データのマイグレーションをLambdaで流す作業が発生して、1日がかりだった。
教訓:アクセスパターンは実装前にステークホルダー全員から聞き出す。後からのGSI追加はデータ移行コストが重い。
GSI設計のコード実装例
CDKで定義したテーブル定義はこんな感じ。2026年時点ではaws-cdk-lib/aws-dynamodbのv2 APIが安定していて使いやすい。
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class SingleTableStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const table = new dynamodb.TableV2(this, 'MainTable', {
tableName: 'app-single-table',
partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
billing: dynamodb.Billing.onDemand(), // PAYGで始めるのがおすすめ
pointInTimeRecovery: true,
encryption: dynamodb.TableEncryptionV2.awsManagedKey(),
removalPolicy: RemovalPolicy.RETAIN,
globalSecondaryIndexes: [
{
indexName: 'GSI1',
partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.ALL,
},
{
indexName: 'GSI2',
partitionKey: { name: 'GSI2PK', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'GSI2SK', type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.KEYS_ONLY, // コスト意識して必要最小限に
},
],
});
}
}
TableV2はTableの後継で、2026年時点ではこちらが推奨。billing.onDemand()でキャパシティ管理から解放されるのが地味に便利で、スパイクトラフィックが読めないサービス初期には特にありがたかった。
AWS構成図:サーバーレスSTDシステムの全体像
実際に運用しているシステムの構成はこんな感じ。Lambda + DynamoDB + EventBridgeを中心にしたサーバーレス構成で、イベント駆動アーキテクチャ実装ガイドでも紹介したイベント駆動パターンを組み合わせている。
graph TB
subgraph Internet
Client[クライアント]
end
subgraph AWS_Account[AWS Account]
subgraph API_Layer[API Layer]
APIGW[API Gateway v2<br/>HTTP API]
end
subgraph Lambda_Layer[Lambda Layer]
ReadFn[Read Lambda<br/>Node.js 22.x]
WriteFn[Write Lambda<br/>Node.js 22.x]
StreamFn[Stream Processor<br/>Lambda]
end
subgraph Data_Layer[Data Layer]
DDB[(DynamoDB<br/>Single Table)]
DDBStream[DynamoDB Streams]
end
subgraph Event_Layer[Event Layer]
EB[EventBridge<br/>Custom Bus]
SQS[SQS DLQ]
end
subgraph Cache_Layer[Cache Layer]
DAX[DynamoDB DAX<br/>クラスター]
end
subgraph Monitoring[Monitoring]
CW[CloudWatch<br/>Logs & Metrics]
XRay[X-Ray]
end
end
Client --> APIGW
APIGW --> ReadFn
APIGW --> WriteFn
ReadFn --> DAX
DAX --> DDB
WriteFn --> DDB
DDB --> DDBStream
DDBStream --> StreamFn
StreamFn --> EB
StreamFn --> SQS
ReadFn --> CW
WriteFn --> CW
StreamFn --> XRay
DAXを挟んでいるのは、読み取りが多いエンドポイント(商品詳細ページ)のキャッシュ目的。DAXのキャッシュヒット率が90%を超えたあたりから、DynamoDBへの直接読み取りコストが大幅に下がった。ただしDAXはVPC内にしか置けないので、LambdaもVPCに入れる必要があって、Cold Startへの影響は別途対策が必要だった(VPC Lambda + SnapStartの話はLambda SnapStart導入3ヶ月を参照)。
本番で踏んだホットパーティション問題と対処法
STDで一番怖いのはホットパーティションだと思う。DynamoDBはデータをパーティションに分散させるが、PK設計が悪いとリクエストが特定パーティションに集中して、スロットリングが起きる。
うちで実際に起きた例を挙げると、フラッシュセール時に人気商品ページへのアクセスが集中して、PRODUCT#prod-bestsellerのパーティションがスロットリングされた。読み取りは最終的にDAXに移したので解決したんだけど、書き込み(在庫更新)は直接DynamoDBに当たるので別の対策が必要だった。あのときのアラートは今でも覚えている——深夜2時にSlackが鳴り響いた。
採用した解決策は書き込みシャーディング。
// 在庫更新時にPKをシャーディング
const SHARD_COUNT = 10;
async function updateInventory(productId: string, delta: number): Promise<void> {
const shardIndex = Math.floor(Math.random() * SHARD_COUNT);
const shardedPK = `PRODUCT#${productId}#shard${shardIndex}`;
const command = new UpdateItemCommand({
TableName: 'app-single-table',
Key: {
PK: { S: shardedPK },
SK: { S: 'INVENTORY' },
},
UpdateExpression: 'ADD #stock :delta',
ExpressionAttributeNames: { '#stock': 'stock' },
ExpressionAttributeValues: { ':delta': { N: String(delta) } },
});
await ddbClient.send(command);
}
// 在庫読み取り時はシャードを集計
async function getInventory(productId: string): Promise<number> {
const keys = Array.from({ length: SHARD_COUNT }, (_, i) => ({
PK: { S: `PRODUCT#${productId}#shard${i}` },
SK: { S: 'INVENTORY' },
}));
// BatchGetItemで一括取得
const command = new BatchGetItemCommand({
RequestItems: {
'app-single-table': { Keys: keys },
},
});
const result = await ddbClient.send(command);
const items = result.Responses?.['app-single-table'] ?? [];
return items.reduce((sum, item) => {
return sum + Number(item.stock?.N ?? 0);
}, 0);
}
この方式、書き込みは分散できるけど読み取りがBatchGetItemで10回分になるので、読み取りの頻度が高い場合はDAXキャッシュと組み合わせるのが現実的。在庫数は30秒キャッシュ程度の鮮度で十分なユースケースなら、これで大幅に改善できた。正直、シャーディングの実装自体はそんなに難しくないんだけど、「そもそもシャーディングが必要な設計になっている時点でPKを見直すべきでは?」と後から思ったりもした。
運用1年で計測したコストとパフォーマンスの実績
xychart-beta
title "マルチテーブル vs Single Table: 月次コスト比較(USD)"
x-axis [1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月]
y-axis "コスト (USD)" 0 --> 500
bar [380, 395, 410, 420, 390, 350, 320, 310, 295, 280, 275, 260]
line [380, 395, 410, 420, 390, 350, 320, 310, 295, 280, 275, 260]
移行前(マルチテーブル構成)の月次コストが平均420ドルだったのに対し、STD移行後の6ヶ月平均は約310ドル。約26%削減できた。主な要因は不要なGSIの統廃合と、複数テーブルへの重複書き込みがなくなったこと。コスト削減はおまけのつもりだったけど、地味にマネージャー受けがよかった。
パフォーマンス面の改善も数字に出た:
| メトリクス | 移行前 | 移行後 | 改善率 |
|---|---|---|---|
| 注文詳細ページ P50 | 180ms | 85ms | ▲53% |
| 注文詳細ページ P99 | 620ms | 380ms | ▲39% |
| DynamoDB読み取りコスト/月 | $280 | $195 | ▲30% |
| スロットリングエラー率 | 0.8% | 0.05% | ▲94% |
スロットリングエラー率の改善はDAX導入とシャーディング対策の複合効果なので、STD単体の効果とは言い切れないけど、全体的には満足している。
正直、STDが合わないケースもある
ここは好みが分かれるかもしれないし、正直まだ検証中の部分もあるんだけど、STDを全部のユースケースに適用するのは危ないと感じている。
うちのチームで「これはSTDより別テーブルの方がよかった」となったのは、集計・分析クエリが多いエンティティだった。具体的には売上分析テーブル。アクセスパターンが事前に確定できない分析用途では、GSIを増やし続けることになって、管理コストが増えた。最終的にDynamoDB StreamsでS3に流してAthenaで分析するアーキテクチャに切り替えた。BigQuery vs Athena vs Redshift の比較でも書いたけど、分析用途はS3+Athenaが圧倒的に柔軟。
flowchart TD
DDB[DynamoDB Single Table] -->|Streams| Lambda[Stream Processor]
Lambda -->|JSONに変換| S3[S3 Data Lake]
S3 --> Athena[Athena]
S3 --> QuickSight[QuickSight]
DDB -->|リアルタイム読み取り| App[アプリケーション]
style S3 fill:#FF9900,color:#fff
style Athena fill:#8C4FFF,color:#fff
トランザクショナルな操作はDynamoDB STD、分析はS3+Athenaというパターンが、2026年時点で自分が最もおすすめできる構成だ。
STDに向いている/向いていないケースを整理すると:
| 観点 | STDが向いている | STDが向いていない |
|---|---|---|
| アクセスパターン | 比較的固定・要件が明確 | 事前に確定できない |
| クエリの種類 | トランザクション処理が中心 | 複雑なJOIN・集計が頻発 |
| チーム習熟度 | NoSQL設計の知識がある | NoSQL設計の知識がほぼない |
| スキーマの安定性 | 変更頻度が低い | エンティティ関係が複雑で頻繁に変わる |
| 優先事項 | 低レイテンシ・コスト最適化 | 分析の柔軟性 |
皆さんはどうしてます? STDを選んだ判断基準って、けっこうチームによって違いそうだなと思っていて、X(旧Twitter)で「#DynamoDBSTD」で検索するとコミュニティの議論が面白い。
まとめ
1年半の本番運用を振り返って、STDを選んで後悔はしていない。ただ、「DynamoDBはSTDが正解」という教科書的な理解で始めると必ず痛い目を見る——実際に痛い目を見たので断言できる。
| 教訓 | 具体的な対策 |
|---|---|
| アクセスパターンは全量洗い出す | 実装前にステークホルダー全員にヒアリング。後からのGSI追加はデータ移行が重い |
| GSIのProjectionTypeを意識する | ALLは避け、KEYS_ONLYかINCLUDEで必要なものだけに絞る |
| ホットパーティション対策はセットで | DAX(読み取り)+書き込みシャーディングの両方が必要 |
| 分析用途はSTDに無理やり入れない | DynamoDB Streams → S3 → Athenaに逃がす |
| STDはすべての問題を解決しない | チームの習熟度とユースケースをちゃんと評価してから導入する |
次のアクション:
- まずは小さなマイクロサービス1つで試してみる(全面移行はリスクが高い)
- Alex DeBrieの「The DynamoDB Book」は2026年版でアップデートされていて読む価値がある
- AWS公式の「DynamoDB Design Patterns」ドキュメントは2025年末に大幅改訂されたので再確認推奨
- CloudWatch Contributor Insightsを有効化してホットキーを事前に監視する
うちのチームはまだDAXのキャパシティ設計を最適化している最中で、正直ここは試行錯誤が続いている。「STDを本番で使っているよ」という方、どんな失敗をしたか教えてもらえると嬉しい。