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;
}
Customer と Order の整合性は、ドメインイベント や 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 | 時系列順 + ランダム | 分散書き込み + インデックス効率○ |
| ULID | UUIDv7 と類似、文字列表現 | 同上 |
| Snowflake ID | timestamp + 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 Index:INCLUDE で SELECT 列を含める | クエリが特定列のみを返す場合 |
Partial Index:WHERE 条件付き | 全行ではなく一部だけ検索 |
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 の本質に降りる。列指向の世界では設計の前提が逆転する。