目次を表示する

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

OLTP 設計の意思決定 ─ Aggregate 境界・Hot row・Index・進化・非正規化

OLTP 設計の意思決定

OLTP の本質を理解した上で、実際にスキーマを設計する場面で何を決めるか。本章では 5 つの意思決定を順に扱う。

判断 1:Aggregate 境界 = トランザクション境界?

第 2 章で見たとおり、DDD の Aggregate は「1 トランザクションで一貫性を保つ単位」。原則は「1 トランザクション = 1 Aggregate」。

PostgreSQL では複数行の atomic update が自由なので、Aggregate の境界はある程度大きく取れる。だが大きく取りすぎると別の問題が出る。

Aggregate を大きくしすぎた失敗

// ❌ 大きすぎる Aggregate
class Customer {
  orders: Order[];      // 過去 10 年分の注文
  invoices: Invoice[];  // 全請求書
  addresses: Address[]; // 配送先履歴
}

// save() が走るたびに巨大なトランザクション
async save(customer: Customer) {
  await tx.update('customers', ...);
  for (const order of customer.orders) await tx.upsert('orders', ...);
  // ...
}

ロック競合、スループット低下、long-running transaction によるレプリ遅延。MVCC の dead tuple 蓄積も加速する。

Aggregate を小さく保つ判断

Vernon の原則: Aggregate を小さく保て。複数 Aggregate にまたがる更新は ID 参照 + eventually consistent で扱う。

// ✅ Aggregate を分割
class Customer {
  // 識別と基本情報のみ
}
class Order {
  customerId: CustomerId; // 参照のみ
}
class Invoice {
  customerId: CustomerId;
}

CustomerOrder の整合性は、ドメインイベントeventually で保証する。これが第 12 章 Stream への伏線にもなる。

PostgreSQL での実装

複数 Aggregate を 1 トランザクションで更新したい場合(例:顧客作成と最初の注文)、1 つのユースケースとして扱い、その場合だけは複数 Aggregate にまたがる Trans を許容する:

// 例外的なケース:1 つのユースケースで複数 Aggregate
async createCustomerWithFirstOrder(...) {
  await this.db.transaction(async (tx) => {
    const customerRepo = new CustomerRepo(tx);
    const orderRepo = new OrderRepo(tx);

    await customerRepo.save(customer);
    await orderRepo.save(order);
  });
}

判断軸:複数 Aggregate を atomic にしたい場面が頻発するなら、Aggregate の切り方が間違っている可能性が高い。

判断 2:Hot row 問題と ID 設計

OLTP で見落とされがちな問題:全ての書き込みが特定の行に集中する

❌ シーケンシャルな主キー

CREATE TABLE orders (
  id BIGSERIAL PRIMARY KEY,
  ...
);

BIGSERIAL は単調増加。INSERT のたびにインデックスの右端のページに書き込みが集中する。低トラフィックなら問題ないが、高頻度書き込みではこの last page contention がスループットの天井になる。

✅ 分散させる ID 戦略

ID 型性質適性
BIGSERIAL単調増加、64bit単一ノード、低 - 中トラフィック
UUIDv4ランダム、128bit分散書き込み、ただしインデックス効率↓
UUIDv7時系列順 + ランダム分散書き込み + インデックス効率○
ULIDUUIDv7 と類似、文字列表現同上
Snowflake IDtimestamp + worker + seq分散、Twitter 系

UUIDv7 / ULID は近年の選択肢として有力。先頭が時系列順 なので B-tree インデックスでも効率が落ちず、書き込みは分散する。

Hot row が問題になる別パターン

-- ❌ カウンタテーブル
UPDATE counters SET value = value + 1 WHERE name = 'orders';

全リクエストがこの行に殺到する。対策:

  • シャーディングcounters を 100 行に分け、ランダムに 1 つ更新。読みは SUM
  • Redis に逃がす:counter は OLTP DB から外す(第 8 章 Cache)
  • Stream で集計:イベントを log にして後段で集計(第 12 章 Stream)

判断 3:インデックス戦略

SELECT が遅い → インデックスを張る」は単純すぎる。書き込みコストとのトレードオフを見ないと運用で破綻する。

インデックスのコスト

  • INSERT / UPDATE のたびに 全インデックスを更新する
  • HOT update は インデックスされていない列を更新する場合のみ 効く(第 4 章)
  • 不要なインデックスはディスクと VACUUM コストを膨らませる

戦略

戦略用途
必要最小限:FK / 検索条件のみデフォルト
Covering IndexINCLUDE で SELECT 列を含めるクエリが特定列のみを返す場合
Partial IndexWHERE 条件付き全行ではなく一部だけ検索
Expression Index(lower(email))関数結果で検索する場合
複合 Index の順序:選択性が高い列を先頭に複数条件のクエリ

実例

-- ❌ 全列にインデックス
CREATE INDEX ON orders(customer_id);
CREATE INDEX ON orders(status);
CREATE INDEX ON orders(created_at);
CREATE INDEX ON orders(amount);
-- → 書き込み 4 倍重い、HOT update が効かない、bloat

-- ✅ クエリパターンを見て最小限
-- 「最近の status='pending' な注文を顧客 ID で取得」
CREATE INDEX ON orders(customer_id, created_at DESC) WHERE status = 'pending';
-- partial index で対象を絞り、複合キーで効率化

判断軸:実クエリの 90% を 5 個以下のインデックスでカバーできるなら、それで十分。それを超えるなら設計を見直す。

判断 4:スキーマ進化の戦略

サービスは生きている。スキーマは変わる。変更時に運用を止めずに進化できる設計が必要。

Online schema change の制約

PostgreSQL の DDL は基本的にロックを取る:

  • ALTER TABLE ADD COLUMN ─ NULL 許容 + デフォルトなしならメタデータのみで速い(v11+)
  • ALTER TABLE ADD COLUMN ... DEFAULT 'x' ─ 全行書き換え(v11+ で定数 default は速い、関数 default は遅い)
  • CREATE INDEX ─ ロックする。CREATE INDEX CONCURRENTLY で回避
  • ALTER TABLE ALTER COLUMN TYPE ─ 全行書き換え + ロック

段階的マイグレーションの定石

1. 新列を追加(NULL 許容)
2. 新旧両方に書き込むコードをデプロイ
3. バックフィル(新列を既存行に埋める)
4. 新列を主に読むコードをデプロイ
5. 旧列を削除

1 回のリリースで全部やろうとしない。これは Sam Newman『Building Microservices』が DB 分割の章で扱う作法でもある。

大量データの ALTER は分割

-- ❌ 1000 万行の UPDATE
UPDATE huge_table SET new_col = compute(...);

-- ✅ バッチで分割
UPDATE huge_table SET new_col = compute(...) WHERE id BETWEEN 1 AND 10000;
-- 繰り返し

長時間 Trans は MVCC を圧迫し、autovacuum を妨げる。短く区切る。

判断 5:非正規化を許す勇気

本シリーズは 正規化の話はしない(スコープ外と整理した)。だがその対称として 非正規化(denormalization)の判断は OLTP 設計の重要な意思決定。

非正規化が正解になる場面

場面設計
カウンタ・サマリ注文数、いいね数集計列を持つ + 更新は別 Trans
検索結果のキャッシュ「商品名」を注文行に複製名前は変わらない前提 / 履歴重視
履歴の固定化「請求時の住所」を invoice に保存後の住所変更で履歴が変わらない
JSON 列の活用動的属性、設定スキーマ進化が頻繁な場合

Pat Helland 的視点

Pat Helland “Immutability Changes Everything” 的に言えば、「いつ起きたか」のスナップショットは immutable に保つべき。請求書は発行時点の住所を持つ。後から customers.address を更新しても、過去の請求書には影響しない ── これが非正規化の正当化。

❌ 非正規化が裏目に出るパターン

-- ❌ 全顧客の "active な注文数" を customers テーブルに保存
ALTER TABLE customers ADD active_order_count INT;
-- → 注文ステータスが変わるたびに customers を UPDATE
-- → Hot row 問題、ロック競合、HOT update が効かない

頻繁に変わる集計値を OLTP テーブルに持たせない。Cache / Stream / Read Model に逃がす(第 8 / 12 / 17 章)。

OLTP 設計の意思決定マトリクス

graph TB
  Q1[Aggregate を引く]
  Q2[ID 戦略を決める]
  Q3[インデックスを最小化]
  Q4[進化の戦略を準備]
  Q5[非正規化の許容範囲]

  Q1 --> A1[小さく保つ / Trans 境界 ≤ 1 Agg]
  Q2 --> A2[UUIDv7 推奨 / Hot row 回避]
  Q3 --> A3[partial / covering / expression]
  Q4 --> A4[段階的、5ステップ]
  Q5 --> A5[履歴は immutable / 集計は逃がす]

  style Q1 fill:#e1f5ff
  style Q2 fill:#e1f5ff
  style Q3 fill:#e1f5ff
  style Q4 fill:#e1f5ff
  style Q5 fill:#e1f5ff

これらは独立ではない。Aggregate の引き方が変われば、インデックスが変わり、スキーマ進化の経路も変わる。全体として “ドメインから入る” と “特性から入る” が合流する

この章の要点

  • Aggregate を小さく保ち、Trans 境界を 1 Aggregate に揃える
  • Hot row を避ける ID 戦略(UUIDv7 / ULID 推奨)
  • インデックスは最小限。HOT update を効かせる
  • スキーマ進化は段階的、長時間 Trans を作らない
  • 非正規化は履歴の immutability で正当化、頻繁更新は逃がす

次章への問いかけ

OLTP は「Inside data」── DB の内部で Trans 保護されるデータの話だった。だが集計・分析・履歴は、外に流れた “Outside data” として別の場所で扱われる。

次章では OLAP の本質に降りる。列指向の世界では設計の前提が逆転する