NoSQL 設計の意思決定
NoSQL の設計は アクセスパターンが先、schema が後 が大原則。RDB と全く違う流儀で、5 つの意思決定を順に扱う。
判断 1:アクセスパターンを徹底的に列挙する
設計を始める前に、想定される全アクセスパターンをスプレッドシートにする
例:オンラインストアの注文管理
| # | パターン | 入力 | 出力 | 頻度 |
|---|---|---|---|---|
| 1 | ユーザーの最新注文 10 件 | userId | Order × 10 | 高 |
| 2 | 注文 ID から詳細 | orderId | Order | 高 |
| 3 | 商品の過去 30 日の注文 | productId | Order × 多 | 中 |
| 4 | ステータス別注文一覧 | status | Order × 多 | 低 |
| 5 | 月次売上集計 | month | Aggregate | 低 |
この表ができていなければ DynamoDB の schema は決められない。RDB のように「とりあえず正規化」では NoSQL は破綻する。
表ができたら頻度で篩いにかける
頻度の高いものから設計に組み込む。低頻度のものは:
- DynamoDB の Scan で済ます(コスト高だが許容)
- OLAP DB に逃がす(CDC で複製、第 17 章)
- Search engine に逃がす
「全パターンを DynamoDB だけで賄おうとしない」という判断が重要。
判断 2:Partition Key の選定
PK の選び方がスケール特性を決定する。
良い PK の条件
- 高カーディナリティ: 値の種類が多い
- 書き込みが分散される: 特定値に偏らない
- アクセスパターンの主軸: 「このキーで取れれば 80% のクエリが満たせる」もの
典型的なパターン
| パターン | PK | 性質 |
|---|---|---|
| User-centric | USER#<id> | user 単位の操作が多いアプリ |
| Tenant-centric | TENANT#<id> | マルチテナント SaaS |
| Time-bucketed | EVENT#<date>#SHARD#<n> | イベントログ、時系列 |
| Composite | <entity>#<id> | 複数 entity を 1 テーブルに |
Hot Partition を避ける
// ❌ 全イベントが同じ PK
{ PK: 'EVENTS', SK: 'TS#2026-05-09T10:00:00Z' }
// → 単一 partition に集中
// ✅ 時間 bucket + shard suffix
const shard = Math.floor(Math.random() * 10);
{ PK: `EVENTS#2026-05-09#${shard}`, SK: 'TS#2026-05-09T10:00:00Z' }
// → 0-9 の 10 partition に分散
トレードオフ:読み出し時に 10 partition を query して merge する必要がある。書き込みのスループットと読み出しの複雑さのバランスで決める。
Write Sharding パターン
writeer が tenant 単位で集中する場合:
// ❌ tenant の全データが同じ PK
{ PK: `TENANT#${tenantId}`, SK: `EVENT#${ts}` }
// ✅ tenant + shard
const shard = hash(eventId) % 10;
{ PK: `TENANT#${tenantId}#${shard}`, SK: `EVENT#${ts}` }
これも query 側のコストはあるが、1 tenant が他を倒さない Bulkhead 的効果が得られる。これは第 16 章 共通基盤の Multi-tenant 設計でも再登場。
判断 3:Sort Key の使い方
PK 内では SK で並びと範囲が決まる。SK の設計次第で取れるクエリが変わる。
範囲クエリ
// PK = USER#123, SK = ORDER#<timestamp>
// 「最新 10 件の注文」
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
ExpressionAttributeValues: { ':pk': 'USER#123', ':prefix': 'ORDER#' },
ScanIndexForward: false, // 新しい順
Limit: 10,
1:N の表現
// User の Order と Address を 1 partition に
{ PK: 'USER#123', SK: 'PROFILE', data: { name: 'Alice' } }
{ PK: 'USER#123', SK: 'ADDRESS#home', data: { ... } }
{ PK: 'USER#123', SK: 'ADDRESS#work', data: { ... } }
{ PK: 'USER#123', SK: 'ORDER#2026-05-09', data: { ... } }
{ PK: 'USER#123', SK: 'ORDER#2026-05-08', data: { ... } }
PK = USER#123 で全部取れる。SK begins_with 'ORDER#' で注文だけ。1 回の query で関連データが揃うのが NoSQL の威力。
Sparse Index 的活用
SK のプレフィックスを使い分けることで、複数の “view” を 1 partition に持てる。
// active な user は ACTIVE#... プレフィックス、そうでなければなし
{ PK: 'TENANT#xxx', SK: 'ACTIVE#USER#123' } // active
{ PK: 'TENANT#xxx', SK: 'USER#456' } // inactive
// active な user だけ取得
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
ExpressionAttributeValues: { ':pk': 'TENANT#xxx', ':prefix': 'ACTIVE#' },
判断 4:GSI / LSI の選択
Global Secondary Index (GSI): PK / SK と全く違う axis でクエリしたいとき。
Local Secondary Index (LSI): 同じ PK 内で別の SK ソートが欲しいとき。
GSI が必要な例
// メインテーブル: PK = USER#<id>, SK = ORDER#<ts>
// 「特定 product を含む注文を全 user 横断で取りたい」
// → GSI を追加
GSI1: GSI1PK = PRODUCT#<productId>, GSI1SK = TS#<timestamp>
GSI は 裏で別テーブルを作って自動で同期する仕組み。書き込みコストが 2 倍以上になる代わりに、別 axis のクエリが可能。
GSI の罠
- 書き込みが伝播するまで eventually consistent(Strong は不可)
- GSI を増やすほど書き込みコストが増える
- 2024 年に上限 25 個に拡張(以前は 5 / 20)── これが Houlihan の “STD deprecated” 発言の根拠の一つ
判断軸
| 場面 | 戦略 |
|---|---|
| 同一 PK 内で別 sort 順が欲しい | LSI |
| 全く別の axis で query | GSI |
| クエリが滅多に走らない | Scan で済ます or Stream で OLAP に |
| 強整合性が必要 | GSI ではなく メインテーブル設計を見直す |
判断 5:Single Table か Multi Table か
第 10 章で見たとおり、Houlihan が 2024 年に “STD deprecated” と発言した。だが完全に Multi Table に振るのも違う。
判断フレーム
graph TB
Q1{1 query で複数 entity を<br/>取りたいパターンがある?}
Q1 -->|Yes| Q2{entity 数は?}
Q1 -->|No| MT[Multi Table]
Q2 -->|2-3 個| ST[Single Table 妥当]
Q2 -->|10+ 個| Q3{頻度は?}
Q3 -->|高頻度| ST
Q3 -->|低頻度| MT2[Multi Table + denormalize]
style ST fill:#e1ffe1
style MT fill:#fff4e1
style MT2 fill:#fff4e1
実用上の指針
- 2026 年現在: デフォルトは Multi Table。entity ごとにテーブル分け
- 「1 query で複数 entity を取る必要がある」場面でだけ Single Table を検討
- ただし アクセスパターン駆動の思考(一緒にアクセスされるものを一緒に置く)は維持
これは Single Table 信者から離れた、より穏当な選択肢。アクセスパターンの厳格な分析と、データモデルの可読性のバランスを取る。
NoSQL 設計の意思決定マトリクス
graph TB
Q1[アクセスパターン列挙] --> A1[頻度 × Pattern の表]
Q2[PK 選定] --> A2[高カーディナリティ + Hot 回避]
Q3[SK 設計] --> A3[範囲・1:N・Sparse]
Q4[GSI/LSI] --> A4[別 axis なら GSI / 同 PK なら LSI]
Q5[Single/Multi Table] --> A5[Multi がデフォルト / 必要なら Single]
style Q1 fill:#e1f5ff
style Q2 fill:#e1f5ff
style Q3 fill:#e1f5ff
style Q4 fill:#e1f5ff
style Q5 fill:#e1f5ff
ドメインから見た NoSQL
NoSQL の設計は、Aggregate Root の概念に近い部分がある。
// PK = USER#123 が Aggregate Root のような働き
// 同じ PK 内のすべての item は、1 回の query で取れる
// → Aggregate ≒ 同一 PK のデータ集合
// Repository の save() は、Aggregate 全体を 1 partition に書く
async save(user: User): Promise<void> {
const items = userToItems(user); // 複数 item に分解
await ddb.transactWrite({ // 25 件以内なら atomic
TransactItems: items.map(item => ({ Put: { TableName: '...', Item: item } })),
});
}
Aggregate ≒ Partition Key 内のデータ と捉えると、DDD と NoSQL が綺麗に接続する。これは第 18 章「両派を行き来する」で深掘りする。
ただし注意:Aggregate が大きすぎると 25 件制限に当たる。これは PostgreSQL より厳しい制約で、Aggregate の小ささをより強く要求する。
この章の要点
- アクセスパターンの列挙が最初。全パターンを表にする
- PK は高カーディナリティ + Hot Partition 回避(time bucket + shard suffix)
- SK で範囲・1:N・Sparse Index 的な活用
- GSI は別 axis、LSI は同 PK 別 sort。書き込みコストが増える
- 2026 年現在は Multi Table がデフォルト、必要なら Single Table
- Aggregate ≒ Partition Key 内のデータ集合、と捉えると DDD と接続できる
次章への問いかけ
NoSQL は Inside data の世界の中で、RDB と違う流儀を提示した。
次は完全に Outside data の世界 ── Stream / Event Sourcing。Kreps と Kleppmann が “Turning the database inside out” と言った世界観に降りる。