EXERCISE 90分

ストーリー

ECサイトの本番環境から「レスポンスが遅い」というアラートが飛んできた。高橋アーキテクトがスロークエリログを渡してくる。

「この6つのクエリが全体レスポンスの80%を占めている。それぞれの原因を特定し、改善案を出してくれ。実行計画を読んで、根拠を持って対策するんだ」


ミッション概要

項目内容
目標6つのスロークエリの原因特定と改善
前提テーブルusers(100万行), orders(500万行), products(10万行), order_items(2000万行)
評価基準原因特定の正確さ、改善策の適切さ、実行計画の理解
制限時間90分

テーブル定義

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  status VARCHAR(20) NOT NULL DEFAULT 'active',
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  user_id INT NOT NULL REFERENCES users(id),
  status VARCHAR(20) NOT NULL DEFAULT 'pending',
  total DECIMAL(12, 2) NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name VARCHAR(200) NOT NULL,
  category VARCHAR(50) NOT NULL,
  price DECIMAL(10, 2) NOT NULL,
  stock INT NOT NULL DEFAULT 0
);

CREATE TABLE order_items (
  id SERIAL PRIMARY KEY,
  order_id INT NOT NULL REFERENCES orders(id),
  product_id INT NOT NULL REFERENCES products(id),
  quantity INT NOT NULL,
  unit_price DECIMAL(10, 2) NOT NULL
);
-- 既存インデックス: 各テーブルのPK, users.email のUNIQUE のみ

Mission 1: ユーザー検索の改善

スロークエリ

SELECT * FROM users
WHERE LOWER(email) = 'tanaka@example.com';
-- Execution Time: 1200ms (Seq Scan)

課題: このクエリが遅い原因を特定し、改善せよ。

ヒント

LOWER() 関数がカラムに適用されると、既存のインデックスが使われなくなる。

回答例

原因: LOWER() 関数がemail カラムに適用されているため、UNIQUE INDEX が使えず Seq Scan になっている。

改善策:

-- 式インデックスを追加
CREATE INDEX idx_users_lower_email ON users(LOWER(email));

-- クエリはそのまま
SELECT * FROM users WHERE LOWER(email) = 'tanaka@example.com';
-- → Index Scan using idx_users_lower_email
-- Execution Time: 0.05ms

代替策: アプリケーション側でメール登録時に小文字に正規化し、通常のインデックスを使う。


Mission 2: 注文一覧の改善

スロークエリ

SELECT * FROM orders
WHERE user_id = 12345
ORDER BY created_at DESC
LIMIT 20 OFFSET 5000;
-- Execution Time: 800ms

課題: このページネーションクエリを改善せよ。

ヒント

OFFSETが大きい場合の問題と、カーソルベースページネーションを検討しよう。

回答例

原因:

  1. user_id にインデックスがない → Seq Scan
  2. OFFSET 5000 で5,000行をスキップ → 無駄なI/O

改善策:

-- インデックス追加
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at DESC);

-- カーソルベースページネーションに変更
SELECT * FROM orders
WHERE user_id = 12345
  AND created_at < '2024-03-15 10:30:00'  -- 前ページの最後のcreated_at
ORDER BY created_at DESC
LIMIT 20;
-- → Index Scan, Execution Time: 0.1ms

Mission 3: 売上レポートの改善

スロークエリ

SELECT
  p.category,
  SUM(oi.quantity * oi.unit_price) AS total_sales,
  COUNT(DISTINCT o.id) AS order_count
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.created_at >= '2024-01-01'
  AND o.created_at < '2024-04-01'
GROUP BY p.category
ORDER BY total_sales DESC;
-- Execution Time: 15000ms

課題: この集計クエリを改善せよ。

ヒント

JOINカラムへのインデックス、WHERE条件のインデックス、マテリアライズドビューの活用を検討しよう。

回答例

原因: JOINカラムにインデックスがなく、大量のSeq Scanが発生。

改善策:

-- インデックス追加
CREATE INDEX idx_orders_created ON orders(created_at);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
CREATE INDEX idx_order_items_product_id ON order_items(product_id);

-- さらに最適化: マテリアライズドビュー
CREATE MATERIALIZED VIEW monthly_category_sales AS
SELECT
  date_trunc('month', o.created_at) AS month,
  p.category,
  SUM(oi.quantity * oi.unit_price) AS total_sales,
  COUNT(DISTINCT o.id) AS order_count
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
GROUP BY date_trunc('month', o.created_at), p.category;

CREATE INDEX idx_mv_month ON monthly_category_sales(month);

-- 定期リフレッシュ
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_category_sales;

-- クエリ
SELECT category, SUM(total_sales), SUM(order_count)
FROM monthly_category_sales
WHERE month >= '2024-01-01' AND month < '2024-04-01'
GROUP BY category
ORDER BY SUM(total_sales) DESC;
-- Execution Time: 5ms

Mission 4: N+1クエリの改善

スロークエリ

// アプリケーションコード
const orders = await db.query('SELECT * FROM orders WHERE status = $1 LIMIT 100', ['pending']);
for (const order of orders) {
  const items = await db.query(
    'SELECT oi.*, p.name FROM order_items oi JOIN products p ON p.id = oi.product_id WHERE oi.order_id = $1',
    [order.id]
  );
  // ... 処理
}
// 合計: 1 + 100 = 101回のクエリ

課題: N+1問題を解消せよ。

ヒント

JOINで1クエリにまとめるか、IN句で2クエリにまとめることを検討しよう。

回答例

原因: ループ内で個別クエリを実行するN+1問題。

改善策:

-- 1クエリで取得
SELECT
  o.id AS order_id,
  o.status,
  o.total,
  oi.id AS item_id,
  oi.quantity,
  oi.unit_price,
  p.name AS product_name
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status = 'pending'
ORDER BY o.id
LIMIT 500;  -- 注文100件 × アイテム数を想定
// TypeScript: 2クエリに分割
const orders = await db.query(
  'SELECT * FROM orders WHERE status = $1 LIMIT 100',
  ['pending']
);

const orderIds = orders.map(o => o.id);
const items = await db.query(
  `SELECT oi.*, p.name AS product_name
   FROM order_items oi
   JOIN products p ON p.id = oi.product_id
   WHERE oi.order_id = ANY($1)`,
  [orderIds]
);

// メモリ上でグルーピング
const itemsByOrder = new Map<number, typeof items>();
for (const item of items) {
  const group = itemsByOrder.get(item.order_id) ?? [];
  group.push(item);
  itemsByOrder.set(item.order_id, group);
}
// 合計: 2回のクエリ

必要なインデックス:

CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);

Mission 5: 在庫確認の改善

スロークエリ

SELECT * FROM products
WHERE stock > 0
  AND category = 'electronics'
ORDER BY price ASC
LIMIT 20;
-- Execution Time: 500ms (Seq Scan + Sort)

課題: この検索クエリを改善せよ。

ヒント

複合インデックスのカラム順序と、部分インデックスの活用を検討しよう。

回答例

原因: インデックスがなく Seq Scan + ソートが発生。

改善策:

-- 複合インデックス(WHERE + ORDER BY をカバー)
CREATE INDEX idx_products_cat_price ON products(category, price)
WHERE stock > 0;  -- 部分インデックス: 在庫ありのみ

-- クエリはそのまま
SELECT * FROM products
WHERE stock > 0
  AND category = 'electronics'
ORDER BY price ASC
LIMIT 20;
-- → Index Scan, Execution Time: 0.1ms

Mission 6: 重複排除の改善

スロークエリ

SELECT DISTINCT user_id
FROM orders
WHERE created_at >= '2024-01-01'
  AND created_at < '2024-02-01';
-- Execution Time: 3000ms

課題: 月間アクティブユーザーの取得を改善せよ。

ヒント

DISTINCT の代わりに GROUP BY を検討し、インデックスの活用を考えよう。

回答例

原因: 500万行のSeq Scan + DISTINCT処理(ソートまたはHash)。

改善策:

-- インデックス追加
CREATE INDEX idx_orders_created_user ON orders(created_at, user_id);

-- DISTINCT → GROUP BY に変更(オプティマイザが効率的に処理)
SELECT user_id
FROM orders
WHERE created_at >= '2024-01-01'
  AND created_at < '2024-02-01'
GROUP BY user_id;
-- → Index Only Scan, Execution Time: 50ms

-- さらに最適化が必要な場合: HyperLogLog 等の近似カウント

達成チェックリスト

  • Mission 1: 式インデックスの必要性を理解し適用できた
  • Mission 2: カーソルベースページネーションに変更できた
  • Mission 3: マテリアライズドビューを活用できた
  • Mission 4: N+1問題を解消できた
  • Mission 5: 部分インデックスを設計できた
  • Mission 6: DISTINCTの最適化手法を適用できた

推定所要時間: 90分