クエリ最適化
クエリ最適化
💡 たとえ話
図書館で本を探す場面を想像してください:
- フルテーブルスキャン:図書館のすべての棚を一つずつ探す → 遅い
- インデックス:まず目録カードを確認し、棚番号を知って直接向かう → 速い
- 実行計画:図書館員が事前に「著者インデックスで探す方がタイトルより速い」と教えてくれる → EXPLAIN
- スロークエリログ:図書館が「10分以上かかった本の検索」を記録し、棚の最適化に活用する → スロークエリログ
- クエリの書き換え:「『科学』を含むすべての本」と聞く代わりに、まずカテゴリを絞り込んでから精确に検索する → クエリアプローチの最適化
📖 コアコンセプト
1. EXPLAIN実行計画
EXPLAIN はクエリ最適化のコアツールです。データベースがSQL文をどのように実行するかを教えてくれます。
EXPLAIN SELECT * FROM users WHERE username = 'alice';
主要フィールドの説明:
| フィールド | 意味 | 注目ポイント |
|---|---|---|
type |
アクセスタイプ | ALL(フルスキャン)→index→range→ref→eq_ref→const、右にいくほど良い |
key |
実際に使用されたインデックス | NULL はインデックス未使用を意味する |
rows |
推定スキャン行数 | 小さいほど良い |
Extra |
追加情報 | Using filesort(ソート必要)、Using temporary(一時テーブル必要)は要注意 |
possible_keys |
使用可能なインデックス | インデックスが選択されているかの分析に役立つ |
-- 実行計画の確認
EXPLAIN SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id, u.name;
-- MySQL 8.0以降では実際の実行統計を確認可能
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 100;
type アクセスタイプの説明:
最悪から最善へ:
ALL → フルテーブルスキャン(最適化必須)
index → フルインデックススキャン(ALLより少し良い)
range → インデックス範囲スキャン(WHERE id > 100)
ref → 非一意インデックス検索(WHERE username = 'alice')
eq_ref → 一意インデックス検索(主キーでのJOIN)
const → 定数検索(WHERE id = 1、最速)
system → システムテーブル(稀に出現)
2. インデックス最適化戦略
-- 1. WHERE、JOIN、ORDER BYでよく使用されるフィールドにインデックスを作成
CREATE INDEX idx_user_email ON users(email);
CREATE INDEX idx_order_user_created ON orders(user_id, created_at);
-- 2. カバリングインデックス:クエリフィールドがすべてインデックス内にあり、テーブルルックアップが不要
-- 頻繁に実行する場合:SELECT id, user_id, created_at FROM orders WHERE user_id = ?
CREATE INDEX idx_order_covering ON orders(user_id, created_at, id);
-- 3. プレフィックスインデックス:長い文字列フィールドの先頭N文字のみインデックス
CREATE INDEX idx_user_name_prefix ON users(username(10));
-- 4. 複合インデックスは最左プレフィックスルールに従う
CREATE INDEX idx_abc ON table_name(a, b, c);
-- マッチ可能: WHERE a=1 | WHERE a=1 AND b=2 | WHERE a=1 AND b=2 AND c=3
-- マッチ不可: WHERE b=2 | WHERE c=3 | WHERE b=2 AND c=3
-- 5. テーブルのインデックス使用状況を確認
SHOW INDEX FROM orders;
-- 6. インデックスのカーディナリティを確認(カーディナリティが高いほど識別力が高い)
SELECT
INDEX_NAME,
COLUMN_NAME,
CARDINALITY
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_NAME = 'orders';
3. フルテーブルスキャンの回避
-- ✅ フルテーブルスキャンを引き起こすパターン
-- 1. インデックス列に関数を使用
SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- ✅ 範囲クエリに書き換え
SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- 2. インデックス列で算術演算
SELECT * FROM orders WHERE id + 1 = 100;
-- ✅ 次のように書き換え
SELECT * FROM orders WHERE id = 99;
-- 3. 暗黙の型変換(phoneはVARCHAR、INTを渡す)
SELECT * FROM users WHERE phone = 13800138000;
-- ✅ 次のように書き換え
SELECT * FROM users WHERE phone = '13800138000';
-- 4. 先頭ワイルドカードのLIKE
SELECT * FROM users WHERE name LIKE '%alice%';
-- ✅ あいまい検索が必要な場合は全文インデックスを検討
ALTER TABLE users ADD FULLTEXT INDEX ft_name(name);
SELECT * FROM users WHERE MATCH(name) AGAINST('alice' IN BOOLEAN MODE);
-- 5. OR条件はインデックスを無効化する可能性あり
SELECT * FROM users WHERE status = 1 OR age > 25;
-- ✅ UNIONを使用
SELECT * FROM users WHERE status = 1
UNION
SELECT * FROM users WHERE age > 25;
-- 6. NOT IN / NOT EXISTSはフルテーブルスキャンを引き起こす可能性あり
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
-- ✅ LEFT JOIN + IS NULLを使用
SELECT u.* FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL;
4. クエリの書き換えテクニック
-- 1. INの代わりにEXISTSを使用(大量データセットでより効率的)
-- 遅い
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- 速い
SELECT * FROM users u WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000
);
-- 2. UNIONの代わりにUNION ALLを使用(重複除去が不要な場合)
-- UNIONは重複除去とソートを行うが、UNION ALLは行わない
SELECT name FROM users_2023 UNION ALL SELECT name FROM users_2024;
-- 3. SELECT *を避け、必要な列のみ選択
-- 遅い
SELECT * FROM orders WHERE user_id = 1;
-- 速い
SELECT id, order_no, total_amount, status FROM orders WHERE user_id = 1;
-- 4. ページネーション最適化(深層ページネーション問題)
-- 遅い(OFFSET 100000は100100行をスキャンする必要がある)
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 100000;
-- 速い(カーソルページネーション、前ページの最後のidを記憶)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
-- 5. ループ操作の代わりにバッチ操作
-- 遅い(ループ挿入)
INSERT INTO logs (msg) VALUES ('a');
INSERT INTO logs (msg) VALUES ('b');
INSERT INTO logs (msg) VALUES ('c');
-- 速い(バッチ挿入)
INSERT INTO logs (msg) VALUES ('a'), ('b'), ('c');
-- 6. WHERE句のインデックス列に <> や != の使用を避ける
SELECT * FROM users WHERE status != 0;
-- ✅ ステータス値が少数の場合、次のように書き換え
SELECT * FROM users WHERE status IN (1, 2, 3);
5. スロークエリログ
-- スロークエリログの設定を確認
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- スロークエリログを有効化
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1秒を超えるクエリをログに記録
SET GLOBAL log_queries_not_using_indexes = 'ON'; -- インデックス未使用のクエリをログに記録
-- スロークエリログの分析
-- mysqldumpslowツールを使用
-- mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
-- pt-query-digest(Percona Toolkit)を使用
-- pt-query-digest /var/log/mysql/slow.log
6. チューニングチェックリスト
| ステップ | アクション | ツール |
|---|---|---|
| 1 | スロークエリログを有効化し、遅いSQLを発見 | slow_query_log |
| 2 | EXPLAINで実行計画を分析 | EXPLAIN |
| 3 | インデックスがヒットしているか確認 | type フィールド |
| 4 | filesort/temporaryがないか確認 | Extra フィールド |
| 5 | SQLを書き換えるかインデックスを追加 | DDL / SQL書き換え |
| 6 | 最適化結果を検証 | 実行時間の比較 |
7. よくあるパフォーマンスの落とし穴
| 落とし穴 | 説明 | 解決策 |
|---|---|---|
| SELECT * | すべての列を選択し、カバリングインデックスを利用できない | 必要な列のみ選択 |
| 大きなOFFSET | 深層ページネーションで大量データをスキャン | カーソルページネーション |
| N+1クエリ | ループ内で関連データを1つずつクエリ | バッチクエリまたはJOIN |
| インデックスなしJOIN | 大きなテーブルをインデックスなしでJOIN | JOIN列にインデックスを追加 |
| 大きなトランザクション | 長時間ロックを保持 | トランザクション範囲を縮小 |
| 誤ったデータ型 | 数値にVARCHARを使用 | 適切な型を選択 |
💡 基本構文
-- EXPLAIN基本使用法
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0+
-- クエリ実行時間の確認
SET profiling = 1;
SELECT ...;
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;
-- インデックスの確認
SHOW INDEX FROM table_name;
-- 特定のインデックスを強制使用
SELECT * FROM orders FORCE INDEX (idx_user_id) WHERE user_id = 100;
-- インデックスを無視(比較テスト用)
SELECT * FROM orders IGNORE INDEX (idx_user_id) WHERE user_id = 100;
EXPLAIN はあなたの最高の友です。
データベース方言の比較
データベースによって構文に大きな違いがあります。一般的な操作の比較を示します:
LIMITページネーション
-- MySQL / PostgreSQL / SQLite
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 20;
-- SQL Server
SELECT * FROM orders ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- SQL Server 2012以前
SELECT TOP 10 * FROM orders WHERE id NOT IN (SELECT TOP 20 id FROM orders ORDER BY id);
-- Oracle 12c+
SELECT * FROM orders ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
-- Oracle 12c以前
SELECT * FROM (SELECT o.*, ROWNUM rn FROM orders o WHERE ROWNUM <= 30) WHERE rn > 20;
オートインクリメント主キー
-- MySQL
CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
-- またはデフォルト値を使用
INSERT INTO users (name) VALUES ('Alice'); -- idが自動生成
-- PostgreSQL
CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(50));
-- PostgreSQL 10+
CREATE TABLE users (id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR(50));
-- SQLite
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
-- SQLiteはROWIDもサポート
INSERT INTO users (name) VALUES ('Alice');
-- SQL Server
CREATE TABLE users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(50));
-- Oracle
CREATE TABLE users (id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name VARCHAR2(50));
文字列関数
-- 文字列連結
SELECT CONCAT('Hello', ' ', 'World'); -- MySQL, PostgreSQL
SELECT 'Hello' || ' ' || 'World'; -- PostgreSQL, SQLite, Oracle
SELECT name + ' ' + email FROM users; -- SQL Server
-- 部分文字列
SELECT SUBSTRING('Hello World', 1, 5); -- MySQL, SQL Server
SELECT SUBSTR('Hello World', 1, 5); -- PostgreSQL, SQLite, Oracle
-- 文字列長
SELECT LENGTH('Hello'); -- MySQL, PostgreSQL, SQLite
SELECT LEN('Hello'); -- SQL Server
-- 大文字小文字変換
SELECT UPPER('hello'), LOWER('HELLO'); -- すべてのデータベース
SELECT UCASE('hello'), LCASE('HELLO'); -- MySQLは追加でサポート
-- 空白トリム
SELECT TRIM(' Hello '); -- すべてのデータベース
SELECT LTRIM(' Hello'), RTRIM('Hello '); -- MySQL, SQL Server, PostgreSQL
-- 置換
SELECT REPLACE('Hello World', 'World', 'SQL'); -- すべてのデータベース
日付関数
-- 現在時刻
SELECT NOW(); -- MySQL, PostgreSQL
SELECT CURRENT_TIMESTAMP; -- すべてのデータベース
SELECT GETDATE(); -- SQL Server
SELECT datetime('now'); -- SQLite
-- 日付算術
SELECT DATE_ADD('2024-01-01', INTERVAL 30 DAY); -- MySQL
SELECT '2024-01-01'::DATE + INTERVAL '30 days'; -- PostgreSQL
SELECT DATEADD(DAY, 30, '2024-01-01'); -- SQL Server
SELECT date('2024-01-01', '+30 days'); -- SQLite
-- 年/月/日の抽出
SELECT YEAR(created_at), MONTH(created_at), DAY(created_at) FROM orders; -- MySQL, SQL Server
SELECT EXTRACT(YEAR FROM created_at) FROM orders; -- PostgreSQL, MySQL 8.0+
SELECT strftime('%Y', created_at) FROM orders; -- SQLite
-- 日付フォーマット
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') FROM orders; -- MySQL
SELECT TO_CHAR(created_at, 'YYYY-MM-DD') FROM orders; -- PostgreSQL, Oracle
SELECT FORMAT(created_at, 'yyyy-MM-dd') FROM orders; -- SQL Server
SELECT strftime('%Y-%m-%d', created_at) FROM orders; -- SQLite
条件式
-- IF式
SELECT IF(score >= 60, 'Pass', 'Fail') FROM exams; -- MySQL
SELECT IIF(score >= 60, 'Pass', 'Fail') FROM exams; -- SQL Server
SELECT CASE WHEN score >= 60 THEN 'Pass' ELSE 'Fail' END FROM exams; -- すべてのデータベース
-- COALESCE(最初の非NULL値を返す)
SELECT COALESCE(nickname, username, 'Anonymous') FROM users; -- すべてのデータベース
-- NULLIF(等しい場合NULLを返す)
SELECT NULLIF(a, b); -- すべてのデータベース
論理型
-- MySQL: 組み込みのBOOLEAN型なし、TINYINT(1)を使用
CREATE TABLE users (is_active TINYINT(1) DEFAULT 1);
SELECT * FROM users WHERE is_active = TRUE; -- TRUEは1と等しい
-- PostgreSQL: 組み込みのBOOLEAN型
CREATE TABLE users (is_active BOOLEAN DEFAULT TRUE);
SELECT * FROM users WHERE is_active = TRUE;
-- SQLite: 組み込みのBOOLEAN型なし、INTEGERを使用
CREATE TABLE users (is_active INTEGER DEFAULT 1);
SELECT * FROM users WHERE is_active = 1;
-- SQL Server: 組み込みのBIT型
CREATE TABLE users (is_active BIT DEFAULT 1);
SELECT * FROM users WHERE is_active = 1;
UPSERT(存在すれば更新、しなければ挿入)
-- MySQL
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON DUPLICATE KEY UPDATE view_count = view_count + 1;
-- PostgreSQL
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON CONFLICT (article_id) DO UPDATE SET view_count = stats.view_count + 1;
-- SQLite
INSERT INTO stats (article_id, view_count) VALUES (1, 1)
ON CONFLICT(article_id) DO UPDATE SET view_count = view_count + 1;
-- SQL Server
MERGE INTO stats AS target
USING (SELECT 1 AS article_id, 1 AS view_count) AS source
ON target.article_id = source.article_id
WHEN MATCHED THEN UPDATE SET view_count = target.view_count + 1
WHEN NOT MATCHED THEN INSERT (article_id, view_count) VALUES (source.article_id, source.view_count);
ウィンドウ関数のサポート
-- すべての主要データベースがサポート(MySQL 8.0+、PostgreSQL、SQL Server、SQLite 3.25+)
SELECT
name,
score,
RANK() OVER (ORDER BY score DESC) AS ranking,
ROW_NUMBER() OVER (PARTITION BY class_id ORDER BY score DESC) AS class_rank
FROM students;
-- MySQL 5.7以前はウィンドウ関数非対応。変数でシミュレーション
-- GROUP_CONCAT / STRING_AGG
SELECT category_id, GROUP_CONCAT(name SEPARATOR ',') FROM products GROUP BY category_id; -- MySQL
SELECT category_id, STRING_AGG(name, ',') FROM products GROUP BY category_id; -- PostgreSQL
例:スロークエリの特定と最適化(難易度⭐⭐)
元のスロークエリ:
-- 過去30日間のユーザーごとの注文合計金額をクエリ(usersテーブル10万行、ordersテーブル100万行と仮定)
EXPLAIN
SELECT u.name, u.email, SUM(o.total_amount) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY u.id, u.name, u.email
ORDER BY total_spent DESC
LIMIT 20;
EXPLAIN分析の結果:
type: ALL(ordersテーブルのフルテーブルスキャン)rows: 1000000(数百万行をスキャン)Extra: Using temporary; Using filesort
最適化手順:
-- 1. ordersテーブルに複合インデックスを追加
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
-- 2. クエリを最適化:先にフィルタリングしてからJOIN
SELECT u.name, u.email, sub.total_spent
FROM users u
INNER JOIN (
SELECT user_id, SUM(total_amount) AS total_spent
FROM orders
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY user_id
ORDER BY total_spent DESC
LIMIT 20
) sub ON u.id = sub.user_id
ORDER BY sub.total_spent DESC;
-- 3. 再度EXPLAINで最適化効果を確認
EXPLAIN SELECT u.name, u.email, sub.total_spent ...
-- type: ref、rows: 顕著に減少
例:深層ページネーションの最適化(難易度⭐⭐)
問題のあるクエリ:
-- ページ10000をクエリ(1ページあたり20件)、OFFSET 200000
SELECT id, title, created_at
FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 200000;
-- インデックスがあっても200020行をスキャンする必要があり、非常に遅い
解決策1:カーソルページネーション(推奨)
-- 前ページの最後のcreated_atとidを記憶
-- 前ページの最後の項目:created_at='2024-03-15 10:30:00', id=50001
SELECT id, title, created_at
FROM articles
WHERE status = 1
AND (created_at < '2024-03-15 10:30:00'
OR (created_at = '2024-03-15 10:30:00' AND id < 50001))
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 20行のみスキャン、超高速
解決策2:遅延JOIN
-- まず主キーをクエリし、その後JOINで全データを取得
SELECT a.id, a.title, a.created_at
FROM articles a
INNER JOIN (
SELECT id FROM articles
WHERE status = 1
ORDER BY created_at DESC
LIMIT 20 OFFSET 200000
) b ON a.id = b.id;
-- サブクエリはカバリングインデックスを使用し、メインクエリは主キーでデータを取得
解決策3:ビジネス層の最適化
-- 可能であれば、最大ページネーション深度を制限
-- 最初の1000件のみ閲覧可能とし、ユーザーには検索で絞り込むよう促す
SELECT id, title, created_at
FROM articles
WHERE status = 1
AND category_id = 5 -- フィルタ条件を追加
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
🔧 シナリオ1:ECサイト商品リストクエリの最適化
-- 元のクエリ:複数条件フィルタリング + ソート + ページネーション
SELECT p.id, p.name, p.price, p.sales_count, c.name AS category_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.status = 1
AND p.category_id IN (10, 11, 12, 13)
AND p.price BETWEEN 50 AND 500
ORDER BY p.sales_count DESC
LIMIT 20;
-- 最適化:
-- 1. 主要フィルタ条件をカバーする複合インデックスを作成
CREATE INDEX idx_product_filter ON products(status, category_id, price, sales_count);
-- 2. EXPLAINでfilesortが表示される場合、インデックス順序を調整
CREATE INDEX idx_product_sort ON products(status, category_id, sales_count DESC, price);
-- 3. categoriesテーブルが小さい場合、カテゴリ名をproductsテーブルに非正規化
ALTER TABLE products ADD COLUMN category_name VARCHAR(50);
-- INSERT/UPDATE時に同期的に更新
🔧 シナリオ2:統計レポートクエリの最適化
-- 元のクエリ:月次注文統計(大規模データセット、毎回遅い)
SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS order_count,
SUM(total_amount) AS revenue
FROM orders
WHERE created_at >= '2023-01-01'
GROUP BY month
ORDER BY month;
-- 最適化オプション1:事前計算サマリーテーブル
CREATE TABLE monthly_order_stats (
month_key VARCHAR(7) PRIMARY KEY COMMENT 'フォーマット: 2024-01',
order_count INT NOT NULL DEFAULT 0,
revenue DECIMAL(15,2) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 定期ジョブで毎日更新(増分計算)
INSERT INTO monthly_order_stats (month_key, order_count, revenue)
SELECT
DATE_FORMAT(created_at, '%Y-%m'),
COUNT(*),
SUM(total_amount)
FROM orders
WHERE created_at >= CURDATE() - INTERVAL 1 DAY
GROUP BY DATE_FORMAT(created_at, '%Y-%m')
ON DUPLICATE KEY UPDATE
order_count = VALUES(order_count),
revenue = VALUES(revenue),
updated_at = NOW();
-- サマリーテーブルをクエリ(ミリ秒レスポンス)
SELECT * FROM monthly_order_stats ORDER BY month_key;
❓ よくある質問
質問:インデックスは多いほど良いですか? 回答: いいえ。インデックスはストレージスペースを消費し、INSERT/UPDATE/DELETE操作を遅くします(書き込み操作のたびにインデックスを更新する必要がある)。本当に高速化が必要なクエリにのみインデックスを作成し、未使用のインデックスは定期的にクリーンアップしてください。
質問:複合インデックスのフィールド順序はどう選べば良いですか? 回答: カーディナリティの高いフィールドを最初に配置します(例:user_idはstatusよりカーディナリティが高い)。等価クエリフィールドを範囲クエリフィールドの前に配置し、実際のクエリ条件の組み合わせに基づいて決定します。
質問:クエリが既に速い場合、さらに最適化すべきですか? 回答: レスポンス時間が許容範囲内であれば、過度な最適化は不要です。ただし、データの増加とともにパフォーマンスが低下する可能性があることに留意し、負荷テストやインデックス戦略で事前に計画しましょう。
質問:EXPLAINが表示する行数は正確ですか? 回答: 完全ではなく、統計に基づいた推定値です。実際の実行ではより多くの行、またはより少ない行をスキャンする場合があります。
ANALYZE TABLEを実行して統計を更新すると、推定精度が向上します。
📖 まとめ
このレッスンでは、SQLクエリ最適化の方法を体系的に学びました:
- EXPLAINは実行計画分析のコアツール。
type、key、rows、Extraに注目 - インデックス最適化:適切にインデックスを作成し、最左プレフィックスルールに従い、カバリングインデックスを活用
- フルテーブルスキャンの回避:インデックス列に関数や算術演算を使わず、暗黙の型変換に注意し、LIKE %xxx%に慎重に
- クエリの書き換え:INの代わりにEXISTS、大きなOFFSETの代わりにカーソルページネーション、ループの代わりにバッチ操作
- スロークエリログ:ボトルネックSQLを特定し、的確に最適化
- データベース方言:MySQL/PostgreSQL/SQLite/SQL Serverは構文に大きな違いがある。クロスデータベースSQL作成時には注意が必要
📝 演習
- 次のSQLにEXPLAIN分析を実行し、最適化してください:SQL
SELECT * FROM orders WHERE YEAR(created_at) = 2024 AND user_id IN (SELECT id FROM users WHERE status = 1); - 記事リストページ(カテゴリフィルタリング、時系列ソート、ページネーション対応)に適したインデックス戦略を設計してください。
LIMIT 20 OFFSET 100000を使用するクエリをカーソルページネーションに書き換えてください。
次のレッスン →28-project.md



