ストーリー
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が大きい場合の問題と、カーソルベースページネーションを検討しよう。
回答例
原因:
user_idにインデックスがない → Seq ScanOFFSET 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分