「なんとなく遅い」を「なぜ遅いか」に変える
SQLを書いていると、同じ結果が出るのに実行時間が大きく異なるクエリに出会うことがある。「なんとなく遅い」と感じたとき、多くの人は書き方を変えて試行錯誤する。しかしそれは暗闇の中を手探りするようなものだ。
EXPLAIN(実行計画)はその暗闇を照らすライトだ。SQLエンジンが「このクエリをどう実行するか」の計画を事前に見せてくれる。どこでどれだけの行を読んでいるか、どの順序でテーブルを結合しているか、インデックスを使っているかどうかが分かる。
原因が分かれば対策が打てる。この記事では Prestoを念頭に置きながら、EXPLAIN の読み方と、通販データベースでよく遭遇する遅さのパターンとその改善方法を解説する。
EXPLAIN の基本的な使い方
クエリの先頭に EXPLAIN を付けるだけで実行計画が表示される。実際にデータは読まれない。
-- 通常のクエリ
SELECT customer_id, SUM(total_amount) AS total
FROM orders
WHERE status = 'completed'
GROUP BY customer_id;
-- EXPLAINを付けて実行計画を確認
EXPLAIN
SELECT customer_id, SUM(total_amount) AS total
FROM orders
WHERE status = 'completed'
GROUP BY customer_id;Presto では EXPLAIN の出力がツリー形式で表示される。
- Output[customer_id, total]
- Aggregate(FINAL)[customer_id]
- LocalExchange[HASH][$hashvalue]
- Aggregate(PARTIAL)[customer_id]
- ScanFilterProject[table = orders, filterPredicate = (status = 'completed')]
Estimates: {rows: 1000000, cpu: 1.00M, memory: 0B, network: 0B}読み方は「下から上へ」。
最も下の ScanFilterProject が最初に実行され、結果が上へ渡されていく。
EXPLAIN ANALYZE ― 実際に実行して実績値を見る
EXPLAIN は推定値だが、EXPLAIN ANALYZE は実際にクエリを実行して「実際に何行処理したか」を見せてくれる。重いクエリには使いにくいが、開発・デバッグ時に強力だ。
EXPLAIN ANALYZE
SELECT customer_id, SUM(total_amount) AS total
FROM orders
WHERE status = 'completed'
GROUP BY customer_id;出力に rows: 推定値 / actual: 実際値 が追加され、推定と実績のズレが分かる。大きくズレている箇所が「統計情報が古い」あるいは「クエリの書き方が最適でない」箇所のヒントになる。
遅さのパターンと改善策
通販データベースでよく遭遇する「遅さのパターン」を5つ整理する。
パターン1:フルスキャンが発生している
症状:ScanFilterProject の rows が想定より多い。テーブル全件を読んでいる。
原因:WHERE 句の条件が絞り込みとして効いていない、またはパーティションが活用されていない。
-- ❌ 遅い例:関数でラップするとパーティション列が効かなくなる
SELECT *
FROM orders
WHERE EXTRACT(YEAR FROM order_date) = 2024;
-- ✅ 速い例:パーティション列を直接比較する
SELECT *
FROM orders
WHERE order_date >= DATE '2024-01-01'
AND order_date < DATE '2025-01-01';EXTRACT(YEAR FROM order_date) のように関数でラップすると、パーティションが評価できず全件スキャンになる。日付の範囲条件は必ず >= と < で直接比較する。
-- ❌ 遅い例:タイムスタンプの変換を WHERE 句で行う
WHERE DATE_TRUNC('day', ordered_at) = DATE '2024-12-01'
-- ✅ 速い例:範囲条件に変換する
WHERE ordered_at >= TIMESTAMP '2024-12-01 00:00:00'
AND ordered_at < TIMESTAMP '2024-12-02 00:00:00'パターン2:結合順序が非効率
症状:HashJoin の左側(ビルド側)に大きなテーブルが来ている。
原因:Presto はコストベースの最適化を行うが、統計情報が不正確な場合に非効率な結合順序を選ぶことがある。
-- ❌ 遅い例:大きなテーブルを左に置いている
SELECT o.*, c.rank
FROM orders o -- 数千万行
JOIN customers c ON o.customer_id = c.customer_id -- 数十万行
-- ✅ 速い例:小さいテーブルをビルド側(右側)に置く
-- Presto では通常自動最適化されるが、ヒントを与えることもできる
SELECT /*+ broadcast(c) */ o.*, c.rank
FROM orders o
JOIN customers c ON o.customer_id = c.customer_idbroadcast ヒントは小さいテーブルを全ノードにブロードキャストし、シャッフルコストを削減する。顧客マスタのような小テーブルとの結合で効果的だ。
パターン3:デカルト積(意図しない全件結合)が発生している
症状:CrossJoin が実行計画に現れる、または HashJoin の出力行数が想定より桁違いに多い。
原因:JOIN 条件が不足しているか、CROSS JOIN を意図せず書いてしまっている。
-- ❌ 意図しないCROSS JOIN(ON句の書き忘れ)
SELECT o.*, p.price
FROM orders o
JOIN product_price_history p -- ON句がない!全件×全件の結合
WHERE o.status = 'completed';
-- ✅ 正しくON句を書く
SELECT o.*, p.price
FROM orders o
JOIN product_price_history p
ON o.product_id = p.product_id
AND o.order_date >= p.valid_from
AND (o.order_date < p.valid_to OR p.valid_to IS NULL)
WHERE o.status = 'completed';EXPLAIN で rows が「注文数 × 価格履歴数」になっていたら、デカルト積が起きているサインだ。
パターン4:サブクエリが繰り返し実行されている(相関サブクエリ)
症状:実行計画に NestedLoop が現れ、外側の行数分だけ内側が繰り返し実行されている。
原因:外側の行ごとにサブクエリを評価する相関サブクエリを使っている。
-- ❌ 遅い例:相関サブクエリ(外側の1行ごとにSELECTが走る)
SELECT
customer_id,
total_amount,
(
SELECT MAX(order_date)
FROM orders o2
WHERE o2.customer_id = o1.customer_id
AND o2.order_date < o1.order_date
) AS prev_order_date
FROM orders o1
WHERE status = 'completed';
-- ✅ 速い例:ウィンドウ関数で書き換える(1回のスキャンで完結)
SELECT
customer_id,
total_amount,
LAG(order_date) OVER (
PARTITION BY customer_id
ORDER BY order_date ASC
) AS prev_order_date
FROM orders
WHERE status = 'completed';相関サブクエリは多くの場合、ウィンドウ関数で書き換えることができる。ウィンドウ関数はテーブルを1回スキャンするだけで結果を出せるため、桁違いに速い。
パターン5:SELECT * による過剰なデータ読み込み
症状:ScanFilterProject の cpu コストが異常に高い。
原因:SELECT * で不要なカラムも含めて全列読み込んでいる。
-- ❌ 遅い(かつコストが高い)例
SELECT *
FROM order_items
WHERE order_date >= DATE '2024-01-01';
-- ✅ 速い(かつコストが安い)例:必要なカラムだけ指定
SELECT order_id, product_id, quantity, unit_price
FROM order_items
WHERE order_date >= DATE '2024-01-01';Treasure Data は列指向ストレージ(カラムストア)を採用している。SELECT するカラムを絞るだけで読み込みデータ量が減り、クエリが速くなるだけでなくpresto split hourも削減できる。パフォーマンスの観点からも最重要だ。
改善前後を比較するチェックリスト
クエリを書いたあとに EXPLAINを確認し、次のチェックリストを使う。
□ ScanFilterProject の推定 rows は妥当か? → 想定より多い場合は WHERE 句の絞り込みが効いていない□ 日付条件で関数ラップを使っていないか? → EXTRACT や DATE_TRUNC を WHERE で使うとパーティション非効率 → >= と < の範囲条件に変換する□ JOIN の行数が急増している箇所はないか? → ON 句の条件漏れ、または非効率な結合順序を疑う□ NestedLoop(相関サブクエリ)が使われていないか? → ウィンドウ関数・CTE・JOIN への書き換えを検討する□ SELECT * を使っていないか? → 必要なカラムのみを明示する□ 小テーブルを broadcast ヒントで最適化できるか? → 数万行以下のマスタテーブルとの JOIN で効果的
実務での改善サイクル
EXPLAINでボトルネックの箇所を特定する- 上記のパターンに照らし合わせて原因を特定する
- 書き換え候補を考え、
EXPLAIN ANALYZEで実際の改善効果を確認する - 改善後のクエリを本番に適用する
大規模なデータを扱う Treasure Data では、改善前後でスキャン量(Bytes Read)を比較することが直接コスト削減に繋がる。クエリの最適化はパフォーマンスと経済性の両方に効く。
実務での運用ヒント
① 開発時は必ず EXPLAIN を確認する習慣を持つ
結果が正しく出ても、EXPLAIN を見ずに本番に適用するのは危険だ。開発環境でも本番と同じデータ量を想定した EXPLAIN の確認を習慣化する。
② Presto の統計情報の鮮度を意識する
Presto はコストベースの最適化に統計情報を使う。統計情報が古いと推定行数が大きくズレ、非効率な実行計画が選ばれる。
③ WITH 句(CTE)のマテリアライズを活用する
同じサブクエリを複数箇所で参照する場合、CTE として定義することでPrestoが結果をキャッシュ(マテリアライズ)する場合がある。ただしPrestoのCTEが常にマテリアライズされるわけではなく、エンジンの判断による。確実に1回だけ計算させたい場合は一時テーブルに書き出す設計も有効だ。
まとめ
EXPLAINを使ったクエリ診断のポイントをまとめる。
- 読み方は下から上へ :
ScanFilterProject→HashJoin→Aggregate→Outputの順に処理が流れる rowsの推定値を見る :想定より多い箇所がボトルネック候補- パターン5つを覚える :フルスキャン・非効率結合・デカルト積・相関サブクエリ・SELECT *
- 改善後は
EXPLAIN ANALYZEで実績値を確認する - Treasure Data では列を絞ることがコスト削減に直結する
「なんとなく遅い」の原因を特定せずに書き換えを繰り返すのは時間の無駄だ。EXPLAINという「診断ツール」を使って根本原因を特定してから対処する習慣が、SQL力を一段階引き上げる。