目次を表示する

DB 設計の軸 2026 ─ ドメイン駆動と特性駆動の二つの流派を行き来する 19 章

NoSQL 設計の意思決定 ─ アクセスパターン・Hot partition・GSI

NoSQL 設計の意思決定

NoSQL の設計は アクセスパターンが先、schema が後 が大原則。RDB と全く違う流儀で、5 つの意思決定を順に扱う。

判断 1:アクセスパターンを徹底的に列挙する

Rick Houlihan のテンプレート

設計を始める前に、想定される全アクセスパターンをスプレッドシートにする

例:オンラインストアの注文管理

#パターン入力出力頻度
1ユーザーの最新注文 10 件userIdOrder × 10
2注文 ID から詳細orderIdOrder
3商品の過去 30 日の注文productIdOrder × 多
4ステータス別注文一覧statusOrder × 多
5月次売上集計monthAggregate

この表ができていなければ DynamoDB の schema は決められない。RDB のように「とりあえず正規化」では NoSQL は破綻する。

表ができたら頻度で篩いにかける

頻度の高いものから設計に組み込む。低頻度のものは:

  • DynamoDB の Scan で済ます(コスト高だが許容)
  • OLAP DB に逃がす(CDC で複製、第 17 章)
  • Search engine に逃がす

全パターンを DynamoDB だけで賄おうとしない」という判断が重要。

判断 2:Partition Key の選定

PK の選び方がスケール特性を決定する

良い PK の条件

  1. 高カーディナリティ: 値の種類が多い
  2. 書き込みが分散される: 特定値に偏らない
  3. アクセスパターンの主軸: 「このキーで取れれば 80% のクエリが満たせる」もの

典型的なパターン

パターンPK性質
User-centricUSER#<id>user 単位の操作が多いアプリ
Tenant-centricTENANT#<id>マルチテナント SaaS
Time-bucketedEVENT#<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 で queryGSI
クエリが滅多に走らない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” と言った世界観に降りる。