EXERCISE 60分

佐藤CTOからの指令:「ECサイトのDBがボトルネックになっている。クエリ最適化、インデックス設計、コネクション管理、パーティショニングの全てを駆使して、システム全体のDB性能を改善してほしい。」

#ミッション難易度目安時間
1スロークエリの最適化★★☆15分
2インデックス戦略の設計★★★15分
3コネクションプール設計★★☆15分
4パーティショニング設計★★★15分

Mission 1: スロークエリの最適化

以下の3つのスロークエリの実行計画を分析し、最適化せよ。

-- クエリA: 商品検索(現在 1.2秒)
SELECT p.*, c.name AS category_name,
       (SELECT AVG(rating) FROM reviews r WHERE r.product_id = p.id) AS avg_rating,
       (SELECT COUNT(*) FROM reviews r WHERE r.product_id = p.id) AS review_count
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.status = 'active'
  AND p.price BETWEEN 1000 AND 5000
  AND p.name ILIKE '%ワイヤレス%'
ORDER BY p.created_at DESC
LIMIT 20 OFFSET 10000;

-- クエリB: 売上集計(現在 8秒)
SELECT
  DATE_TRUNC('day', o.created_at) AS day,
  COUNT(*) AS order_count,
  SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.created_at >= NOW() - INTERVAL '90 days'
  AND o.status IN ('completed', 'shipped')
GROUP BY DATE_TRUNC('day', o.created_at)
ORDER BY day DESC;

-- クエリC: ユーザーアクティビティ(現在 3秒)
SELECT u.id, u.name, u.email,
       MAX(o.created_at) AS last_order_date,
       COUNT(DISTINCT o.id) AS order_count,
       SUM(o.total_amount) AS lifetime_value
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status != 'cancelled'
WHERE u.created_at >= NOW() - INTERVAL '1 year'
GROUP BY u.id, u.name, u.email
HAVING COUNT(DISTINCT o.id) > 0
ORDER BY lifetime_value DESC
LIMIT 100;
解答例
-- クエリA 最適化
-- 問題1: 相関サブクエリ → JOIN に変換
-- 問題2: ILIKE はインデックスが効かない → pg_trgm or 全文検索
-- 問題3: OFFSET 10000 → カーソルベースに変更

-- pg_trgm 拡張の有効化
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_products_name_trgm ON products USING gin (name gin_trgm_ops);

-- 最適化後
SELECT p.*, c.name AS category_name,
       rs.avg_rating, rs.review_count
FROM products p
JOIN categories c ON p.category_id = c.id
LEFT JOIN (
  SELECT product_id,
         AVG(rating) AS avg_rating,
         COUNT(*) AS review_count
  FROM reviews
  GROUP BY product_id
) rs ON rs.product_id = p.id
WHERE p.status = 'active'
  AND p.price BETWEEN 1000 AND 5000
  AND p.name ILIKE '%ワイヤレス%'
  AND (p.created_at, p.id) < ($last_created_at, $last_id)  -- カーソルベース
ORDER BY p.created_at DESC, p.id DESC
LIMIT 20;

-- さらに: マテリアライズドビューで review_stats を事前集計
CREATE MATERIALIZED VIEW product_review_stats AS
SELECT product_id, AVG(rating) AS avg_rating, COUNT(*) AS review_count
FROM reviews GROUP BY product_id;
CREATE UNIQUE INDEX ON product_review_stats (product_id);

-- クエリB 最適化
-- 問題: 90日分の JOIN が重い → マテリアライズドビューで日次集計

CREATE MATERIALIZED VIEW daily_order_stats AS
SELECT
  DATE_TRUNC('day', o.created_at) AS day,
  o.status,
  COUNT(*) AS order_count,
  SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
GROUP BY DATE_TRUNC('day', o.created_at), o.status;

-- 最適化後のクエリ(ミリ秒単位で完了)
SELECT day, SUM(order_count) AS order_count, SUM(revenue) AS revenue
FROM daily_order_stats
WHERE day >= NOW() - INTERVAL '90 days'
  AND status IN ('completed', 'shipped')
GROUP BY day
ORDER BY day DESC;

-- クエリC 最適化
-- 問題: LEFT JOIN + GROUP BY + HAVING が重い
-- 対策: 注文統計を事前集計、HAVING を WHERE に変換

-- インデックス追加
CREATE INDEX idx_orders_user_status ON orders (user_id, status)
  WHERE status != 'cancelled';

-- 最適化後
WITH user_stats AS (
  SELECT
    user_id,
    MAX(created_at) AS last_order_date,
    COUNT(*) AS order_count,
    SUM(total_amount) AS lifetime_value
  FROM orders
  WHERE status != 'cancelled'
  GROUP BY user_id
  HAVING COUNT(*) > 0
)
SELECT u.id, u.name, u.email,
       us.last_order_date, us.order_count, us.lifetime_value
FROM users u
JOIN user_stats us ON u.id = us.user_id
WHERE u.created_at >= NOW() - INTERVAL '1 year'
ORDER BY us.lifetime_value DESC
LIMIT 100;

Mission 2: インデックス戦略の設計

以下のテーブルとクエリパターンに対して、最適なインデックスセットを設計せよ。インデックスの総数は8個以内に収めること。

-- テーブル定義
-- orders: 5000万行、月500万行増加
-- order_items: 2億行
-- products: 100万行
-- users: 300万行

-- 主要クエリパターン(実行頻度順)
-- Q1 (50%): ユーザーの注文一覧
SELECT * FROM orders WHERE user_id = ? AND status != 'cancelled' ORDER BY created_at DESC LIMIT 20;

-- Q2 (20%): 商品別売上集計(日次バッチ)
SELECT product_id, SUM(quantity * unit_price) FROM order_items
JOIN orders ON orders.id = order_items.order_id
WHERE orders.created_at BETWEEN ? AND ? GROUP BY product_id;

-- Q3 (15%): ステータス別注文数(ダッシュボード、1分キャッシュ)
SELECT status, COUNT(*) FROM orders WHERE created_at >= NOW() - INTERVAL '24 hours' GROUP BY status;

-- Q4 (10%): 商品検索(カテゴリ + 価格帯 + ソート)
SELECT * FROM products WHERE category_id = ? AND price BETWEEN ? AND ? AND status = 'active' ORDER BY rating DESC LIMIT 20;

-- Q5 (5%): 未処理注文のキュー
SELECT * FROM orders WHERE status = 'pending' AND created_at < NOW() - INTERVAL '5 minutes' ORDER BY created_at ASC LIMIT 100;
解答例
-- ESR ルール(Equality → Sort → Range)を適用

-- Q1 用: user_id(E) → created_at(S) + status フィルタ
-- 最頻出クエリなので最優先
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at DESC)
  WHERE status != 'cancelled';
-- 部分インデックスでサイズ削減(cancelled は全体の5%と想定)

-- Q2 用: order_items の結合効率化
CREATE INDEX idx_order_items_order_id ON order_items (order_id)
  INCLUDE (product_id, quantity, unit_price);
-- カバリングインデックスで order_items テーブルアクセスを回避

-- Q2 用: orders の日付範囲スキャン
-- orders は時系列で物理順序あり → BRIN が効率的
CREATE INDEX idx_orders_created_brin ON orders USING brin (created_at)
  WITH (pages_per_range = 64);

-- Q3 用: 直近24時間のステータス集計
-- Q3 は1分キャッシュなので専用インデックスの優先度は低い
-- idx_orders_created_brin で十分(created_at での絞り込み後に集計)

-- Q4 用: category_id(E) → rating(S) → price(R)
CREATE INDEX idx_products_cat_rating_price ON products (category_id, rating DESC, price)
  WHERE status = 'active';
-- 部分インデックス + ESR ルール

-- Q5 用: pending のみの部分インデックス
CREATE INDEX idx_orders_pending_queue ON orders (created_at ASC)
  WHERE status = 'pending';
-- pending は全体の0.1%程度、非常に小さなインデックス

-- 基本的な外部キーインデックス(JOIN用)
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_order_items_product_id ON order_items (product_id);

-- 合計: 7インデックス(8個以内)
-- 不要なインデックスを作らない判断も重要

Mission 3: コネクションプール設計

以下の構成に対して、コネクションプールの設計を行え。

システム構成:
- Kubernetes クラスタ: Web Pod × 20(HPA で 10-30 スケール)
- PostgreSQL: Primary 1台(16 vCPU, 64GB RAM)+ Replica 2台
- max_connections = 200(Primary)、max_connections = 200(各 Replica)
- 平均 QPS: 15,000(読み取り 80%, 書き込み 20%)
- 平均クエリ時間: 読み取り 3ms、書き込み 10ms
- ピーク時 QPS: 30,000

設計すべき項目:

  1. PgBouncer の設定(プーリングモード、プールサイズ)
  2. Pod ごとのプールサイズ
  3. 読み取り/書き込み分離の設計
  4. ピーク時の安全マージン
解答例
// 1. 最適コネクション数の計算
const config = {
  primaryCpuCores: 16,
  primaryMaxConn: 200,
  replicaMaxConn: 200,
  replicaCount: 2,
  normalQps: 15000,
  peakQps: 30000,
  readRatio: 0.80,
  writeRatio: 0.20,
  avgReadMs: 3,
  avgWriteMs: 10,
  podCount: 20,
  podMaxCount: 30,
};

// Primary: CPU cores * 2 + 1 = 33
// 管理用に5コネクション確保 → 有効 195
// PgBouncer プールサイズ: min(33, 195) = 33

// Replica: 同様に 33 接続が最適
// 2台合計で 66 リードコネクション

// 2. リトルの法則で必要な同時コネクション数
// Write: 15000 * 0.20 * 0.010 = 30 同時コネクション
// Read:  15000 * 0.80 * 0.003 = 36 同時コネクション
// Peak Write: 30000 * 0.20 * 0.010 = 60
// Peak Read:  30000 * 0.80 * 0.003 = 72

// PgBouncer 設計
const pgbouncerConfig = `
[databases]
mydb_primary = host=pg-primary port=5432 dbname=mydb
mydb_replica1 = host=pg-replica1 port=5432 dbname=mydb
mydb_replica2 = host=pg-replica2 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction

; Primary: 書き込み用
; 通常30、ピーク60必要 → pool_size=33 でカバー
default_pool_size = 33
min_pool_size = 10
reserve_pool_size = 10
reserve_pool_timeout = 3

max_client_conn = 3000
; Pod 30台 × 各20コネクション = 600 < 3000

server_idle_timeout = 300
client_idle_timeout = 60
query_timeout = 30

; Replica 用は別 PgBouncer インスタンス
; 各 Replica に pool_size=33
`;

// 3. Pod ごとのプールサイズ
// Primary 接続: PgBouncer が集約するので Pod 側は大きめに設定可能
// Pod 20台の場合: 各 Pod → Primary PgBouncer: 10接続
// Pod 30台の場合: 各 Pod → Primary PgBouncer: 7接続
// → max=10, min=3 で設定

// Replica 接続: 2台にラウンドロビン
// 各 Replica PgBouncer に Pod あたり 10接続
// → max=10, min=3 per replica

const podPoolConfig = {
  primary: { max: 10, min: 3, idleTimeoutMs: 30000 },
  replica: { max: 10, min: 3, idleTimeoutMs: 30000 },
  // 読み取りは2台のReplicaにラウンドロビン
};

// 4. ピーク時の安全マージン
// Primary: 必要60 / 利用可能33+10(reserve) = 60/43 → 余裕あり
// Replica: 必要72 / 利用可能66(33×2)+20(reserve) = 72/86 → 余裕あり
// Pod スケール 30台時: 30 × 10 = 300 論理接続 → PgBouncer で 33 物理に集約
// → PgBouncer がボトルネックになる場合は PgBouncer を複数台に

Mission 4: パーティショニング設計

以下のテーブルに対して、パーティショニング戦略を設計せよ。

テーブル: audit_logs
- 現在の行数: 8億行(約500GB)
- 増加ペース: 月3000万行
- 保持期間: 2年(24ヶ月)
- 主要クエリ: 日時範囲 + user_id、日時範囲 + action_type
- バッチ処理: 月次で2年超の古いデータを削除
- 現在の問題: DELETE が遅い(ロック競合)、VACUUM が終わらない

CREATE TABLE audit_logs (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL,
    action_type VARCHAR(50) NOT NULL,
    resource    VARCHAR(200),
    payload     JSONB,
    ip_address  INET,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

設計すべき項目:

  1. パーティション戦略の選択と理由
  2. DDL(テーブル定義 + パーティション作成)
  3. 古いデータの効率的な削除方法
  4. パーティション管理の自動化スクリプト
解答例
-- 1. レンジパーティショニング(月単位)
-- 理由:
--   - 日時範囲クエリが主要 → パーティションプルーニングが効く
--   - 月次の古データ削除 → DROP PARTITION で瞬時(DELETE + VACUUM 不要)
--   - 月3000万行/パーティション → 管理しやすいサイズ

-- 2. DDL
CREATE TABLE audit_logs (
    id          BIGSERIAL,
    user_id     BIGINT NOT NULL,
    action_type VARCHAR(50) NOT NULL,
    resource    VARCHAR(200),
    payload     JSONB,
    ip_address  INET,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (id, created_at)  -- パーティションキーを含む
) PARTITION BY RANGE (created_at);

-- 24ヶ月分 + 3ヶ月先のパーティションを作成
-- (例: 2024-01 〜 2026-03)
CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE audit_logs_2025_02 PARTITION OF audit_logs
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- ... 以降同様

CREATE TABLE audit_logs_default PARTITION OF audit_logs DEFAULT;

-- パーティションごとのインデックス
CREATE INDEX ON audit_logs (user_id, created_at DESC);
CREATE INDEX ON audit_logs (action_type, created_at DESC);

-- 3. 古いデータの削除(DROP PARTITION)
-- DELETE の代わりに DETACH → DROP で瞬時に完了
ALTER TABLE audit_logs DETACH PARTITION audit_logs_2023_01;
DROP TABLE audit_logs_2023_01;
-- ロック競合なし、VACUUM 不要、ストレージ即時解放
// 4. パーティション管理の自動化
class AuditLogPartitionManager {
  constructor(
    private db: Pool,
    private retentionMonths: number = 24,
    private futureMonths: number = 3
  ) {}

  // 月次 cron で実行
  async maintain(): Promise<{
    created: string[];
    dropped: string[];
  }> {
    const created = await this.createFuturePartitions();
    const dropped = await this.dropExpiredPartitions();

    // Slack 通知
    console.log(`Partitions created: ${created.join(', ')}`);
    console.log(`Partitions dropped: ${dropped.join(', ')}`);

    return { created, dropped };
  }

  private async createFuturePartitions(): Promise<string[]> {
    const created: string[] = [];
    const now = new Date();

    for (let i = 0; i <= this.futureMonths; i++) {
      const start = new Date(now.getFullYear(), now.getMonth() + i, 1);
      const end = new Date(now.getFullYear(), now.getMonth() + i + 1, 1);
      const name = `audit_logs_${this.fmt(start)}`;

      try {
        await this.db.query(`
          CREATE TABLE IF NOT EXISTS ${name}
          PARTITION OF audit_logs
          FOR VALUES FROM ('${this.toDateStr(start)}')
                      TO ('${this.toDateStr(end)}')
        `);
        created.push(name);
      } catch (e: any) {
        if (!e.message.includes('already exists')) throw e;
      }
    }
    return created;
  }

  private async dropExpiredPartitions(): Promise<string[]> {
    const dropped: string[] = [];
    const cutoff = new Date();
    cutoff.setMonth(cutoff.getMonth() - this.retentionMonths);

    const result = await this.db.query(`
      SELECT inhrelid::regclass::text AS name
      FROM pg_inherits
      WHERE inhparent = 'audit_logs'::regclass
    `);

    for (const row of result.rows) {
      const match = row.name.match(/audit_logs_(\d{4})_(\d{2})/);
      if (!match) continue;
      const partDate = new Date(parseInt(match[1]), parseInt(match[2]) - 1, 1);

      if (partDate < cutoff) {
        await this.db.query(`ALTER TABLE audit_logs DETACH PARTITION ${row.name}`);
        await this.db.query(`DROP TABLE ${row.name}`);
        dropped.push(row.name);
      }
    }
    return dropped;
  }

  private fmt(d: Date): string {
    return `${d.getFullYear()}_${String(d.getMonth()+1).padStart(2,'0')}`;
  }
  private toDateStr(d: Date): string {
    return d.toISOString().split('T')[0];
  }
}