自力でSQLチューニングした経験は、AIの答えを疑うための武器になる
サイロをプールに変える、という仕事
こんにちは、Anti-Patternの塚本です。
以前、サイロ化していたデータベースを共通のプール(統合基盤)へ移行するプロジェクトを担当した。
1つの基盤に集約すれば、当然そのぶん1つのテーブルが抱えるデータ量は一気に増える。データは繋がって扱いやすくなる一方で、それまで問題なく動いていたクエリが、量に負けて遅くなっていった。
当時、いまのようなAIは無かった。だから、やることは全部手作業だった。
EXPLAIN ANALYZEを取って実行計画を読む- どこで
Seq Scanが出ているか、Nested Loopが何回まわっているかを目で追う - インデックスを見直す
- SQLそのものを書き換える
- 不要になったテーブルの結合を削る
一つ直しては計画を取り、また直しては計画を取る。地味で、時間のかかる作業だった。
それでも、遅いクエリが速くなっていく手応えは確かにあった。
「もしAIがあったら」を、いま再現してみた
当時を振り返るために、同じような状況を小さく作って検証してみた。
- Docker上の PostgreSQL 16
- テナントIDをキーにした Row Level Security(RLS) を設定した3テーブル(各1万件)
- 3テーブルを複雑に結合し、わざと遅いSQLを書く
- 実行計画を取り、チューニングして、また実行計画を取る
遅いSQLには、当時よく見た「あるある」を意図的に仕込んだ。
| 仕込んだアンチパターン | 直し方 |
|---|---|
| 相関サブクエリで行ごとに集計 | 事前集約したサブクエリと JOIN |
無駄な結合で行が膨張 → DISTINCT でごまかす |
事前集約して重複を出さない |
IN (サブクエリ) / NOT IN (サブクエリ) |
JOIN / NOT EXISTS(アンチジョイン) |
テーブルをまたぐ OR 条件 |
OR + EXISTS に整理 |
| 1行しか返らないテーブルの不要な結合 | 削除 |
改善前のSQL(わざと遅い版)
やりたいことは「そのテナント内で、status=active かつ paid の注文があり、
cancelled の注文が無く、(2025-06-01より後に作成 or pending の注文を持つ)ユーザーを、
paid合計金額の降順で並べる」というもの。それを、あえて悪い書き方でやる。
SELECT DISTINCT -- ← 膨張を隠すための DISTINCT
u.user_id,
u.email,
(SELECT COALESCE(SUM(o2.amount), 0) -- ← 相関サブクエリ(行ごとに集計)
FROM orders o2
WHERE o2.user_id = u.user_id
AND o2.status = 'paid') AS total_paid
FROM users u
JOIN tenants t ON t.tenant_id = u.tenant_id -- ← 1行しか返らないのに結合(不要)
JOIN orders o ON o.user_id = u.user_id -- ← fan-out(行が2倍に膨張)
WHERE u.status = 'active'
AND u.user_id IN ( -- ← IN (サブクエリ)
SELECT o3.user_id FROM orders o3 WHERE o3.status = 'paid')
AND u.user_id NOT IN ( -- ← NOT IN (サブクエリ)
SELECT o4.user_id FROM orders o4 WHERE o4.status = 'cancelled')
AND ( u.created_at > DATE '2025-06-01' -- ← テーブルをまたぐ OR
OR u.user_id IN (
SELECT o5.user_id FROM orders o5 WHERE o5.status = 'pending') )
ORDER BY total_paid DESC, u.user_id;
改善後のSQL(リライト版)
同じ結果を返しつつ、悪い構造を潰した版がこちら。
SELECT
u.user_id,
u.email,
COALESCE(pa.total_paid, 0) AS total_paid
FROM users u
JOIN ( -- ★相関サブクエリ → 事前集約に
SELECT user_id, SUM(amount) AS total_paid -- paid合計を「一度だけ」計算
FROM orders
WHERE status = 'paid'
GROUP BY user_id
) pa ON pa.user_id = u.user_id -- INNER JOIN が「paid が有る」= 旧 IN を兼ねる
WHERE u.status = 'active'
AND NOT EXISTS ( -- ★NOT IN → NOT EXISTS(アンチジョイン)
SELECT 1 FROM orders oc
WHERE oc.user_id = u.user_id AND oc.status = 'cancelled')
AND ( u.created_at > DATE '2025-06-01' -- ★OR は EXISTS で整理
OR EXISTS (
SELECT 1 FROM orders op
WHERE op.user_id = u.user_id AND op.status = 'pending') )
ORDER BY total_paid DESC, u.user_id;
-- 事前集約で重複が出ないので DISTINCT も不要/不要な tenants 結合も削除
変更点を対応づけると、こうなる。
| 改善前 | 改善後 | 効果 |
|---|---|---|
(SELECT SUM ...) 相関サブクエリ |
GROUP BY で事前集約して JOIN |
行ごとの全走査(232回)→ 1回に |
JOIN orders o + DISTINCT |
削除(膨張しないので不要) | fan-out(232行)が消える |
user_id IN (SELECT ...) |
事前集約サブクエリとの INNER JOIN に吸収 |
半結合を1つに統合 |
NOT IN (SELECT ...) |
NOT EXISTS |
Anti Join になり効率化+NULL安全 |
JOIN tenants t |
削除 | 無駄な結合を除去 |
そして、改善を「インデックス追加」と「SQLリライト」の2系統に切り分けて計測した。
結果
| パターン | 実行時間 |
|---|---|
| ① ベースライン(インデックス無し・遅いSQL) | 102.9 ms |
| ② インデックスのみ追加(遅いSQLのまま) | 2.0 ms |
| ③ SQLリライト+インデックス(最終形) | 1.1 ms |
実行計画の中身もはっきり変わった。
- ① … 相関サブクエリ由来の
Seq Scanが何度も走り、Nested Loopが行ごとに繰り返される - ② … インデックスが効いて
Seq ScanがIndex Scan/Bitmap Heap Scanに。ただし相関・膨張の構造そのものは残る - ③ … 相関サブクエリが消え、
Hash Join+ 事前集約に置き換わる。約96倍速くなった
……当時、私が何時間もかけて手で辿り着いた場所に、いまなら数分で着く。
正直、これを見たときは少し複雑な気持ちになった。
実行計画の実物 ── 何が遅さを生んでいたのか
「約96倍」と言われても、ピンと来ないと思う。
なので、実際に取得した実行計画(EXPLAIN (ANALYZE, BUFFERS))の本物を貼る。
まずは①ベースライン。長いので、遅さの犯人まわりを抜粋した。
Unique (cost=2172.60..2172.63 rows=3 width=56)
(actual time=102.742..102.784 rows=116 loops=1) -- 見積り rows=3 / 実際 116
Buffers: shared hit=18976 -- 合計 約19,000 ブロックに触れている
-> Sort ...
-> Hash Join (... rows=3 ...) (actual ... rows=232 loops=1) -- fan-out で 232 行に膨張
Hash Cond: (o.user_id = u.user_id)
-> Seq Scan on orders o (actual ... rows=1000 loops=1) -- 無駄な結合相手を全走査
Rows Removed by Filter: 9000
-> Hash -> Nested Loop -> Nested Loop ...
-> Seq Scan on orders o3 (paid 抽出) Rows Removed by Filter: 9333
SubPlan 2 -- NOT IN (cancelled)
-> Seq Scan on orders o4 Rows Removed by Filter: 9833
SubPlan 3 -- OR IN (pending)
-> Seq Scan on orders o5 Rows Removed by Filter: 9834
SubPlan 1 -- ★相関サブクエリ(total_paid を行ごとに合計)
-> Aggregate (actual time=0.421..0.421 rows=1 loops=232) -- 232回くり返す
Buffers: shared hit=17168 -- ★全体の約90%
-> Seq Scan on orders o2 (actual ... rows=1 loops=232)
Filter: (o2.user_id = u.user_id AND o2.status='paid' ...)
Rows Removed by Filter: 9999 -- 毎回1万件を走査して1件だけ残す
Planning Time: 0.596 ms
Execution Time: 102.878 ms
この計画から読み取れること
1. 犯人は「相関サブクエリ」だった(SubPlan 1)
total_paid を「1行ごとに SELECT SUM(...) FROM orders WHERE user_id = u.user_id」で
計算していた。その結果が loops=232 ── 232回もorders全体を走査している。
Rows Removed by Filter: 9999 が象徴的で、毎回1万件読んで9,999件を捨て、欲しいのは1件。
Buffers: shared hit=17168 は、このSubPlan 1だけで全体(18,976)の約90%のブロック読みを
占めていることを意味する。遅さの9割はここにあった。
2. Seq Scan が乱れ打ち
orders に対して o(不要な結合)・o3(paid抽出)・o4(NOT IN)・o5(OR)・o2(相関)と、
同じ1万件のテーブルを何度も頭から全走査している。インデックスが無いので当然こうなる。
3. DISTINCT が"膨張"を隠していた
最上段は rows=116 なのに、途中の Hash Join は rows=232。
不要な JOIN orders o でユーザーあたり2件に膨らみ、それを Unique(=DISTINCT)で
半分に潰していた。"とりあえずDISTINCT"は、たいてい設計の綻びを覆い隠している。
4. 見積りが外れている
プランナの見積りは rows=3、実際は rows=232 / 116。
統計と実データが乖離していると、プランナは誤った戦略を選びやすい。
直したら、計画はこう変わった
② インデックスのみ(遅いSQLはそのまま、tenant_id先頭の複合インデックスを追加)
Execution Time: 2.006 ms -- 102.9ms → 2.0ms
Buffers: shared hit=2885 read=9 -- 18,976 → 2,894 ブロック
SubPlan 1
-> Index Scan using ix_orders_tenant_user_status ... loops=232
Buffers: shared hit=762 -- 17,168 → 762(約22分の1)
Seq Scan が Index Scan / Bitmap Heap Scan に変わり、読むブロック数が激減した。
ただし SubPlan 1 の loops=232(相関サブクエリ)と fan-out の構造は残ったまま。
インデックスは「同じ悪い構造を、速く実行する」だけで、構造は直していない。
③ SQLリライト+インデックス(相関サブクエリを事前集約に、NOT INをNOT EXISTSに)
Execution Time: 1.075 ms
Buffers: shared hit=595 read=3 -- さらに 595 ブロックへ
-> Nested Loop Anti Join -- NOT EXISTS が「アンチジョイン」になった
-> Hash Join -- 相関サブクエリ → 事前集約との Hash Join
-> HashAggregate (paid を user 単位で1回だけ集計) rows=500
SubPlan 1(相関サブクエリ)が消え、ordersのpaid集計は一度だけ行われてHash Joinで
結ばれる。NOT INはNested Loop Anti Joinに化けた。DISTINCTも不要になった(膨張が無い)。
まとめると
| ① baseline | ② index only | ③ rewrite + index | |
|---|---|---|---|
| Execution Time | 102.878 ms | 2.006 ms | 1.075 ms |
| 触れたブロック数 (Buffers) | 18,976 | 2,894 | 598 |
orders への Seq Scan |
5回 | 0回 | 0回 |
相関サブクエリ SubPlan 1 |
あり (loops=232) | あり (loops=232) | 無し |
DISTINCT(Unique) |
必要 | 必要 | 不要 |
- ① → ②(インデックス):Seq Scan が消え、触れるブロックが約7分の1に。速くはなるが構造は残る。
- ② → ③(リライト):相関サブクエリと膨張という構造そのものを除去。ブロックはさらに約5分の1へ。
つまり、インデックスは「実行を速くする」、SQLリライトは「仕事量そのものを減らす」。
効くレイヤーが違う。当時の私が手作業でやっていたのは、まさにこの両方だった。
タイムマシンは無い
ここで大事なことを一つ。過去には戻れない。
「あの頃AIがあれば、あんなに時間をかけずに済んだのに」と思っても、
タイムマシンがあるわけではない。あの数百時間は返ってこない。
だから、その事実を嘆くよりも、これからどう使うかに頭を切り替えたい。
教訓は、たぶんこうだ。
知識が乏しいこと、時間がかかりそうなことは、まずAIに相談したほうがいい。
実行計画の読み方を一から調べる時間、アンチパターンの直し方を思い出す時間、
「この結合、本当に要る?」を一つずつ検証する時間 ──
そういう**"調べれば分かるが、地味に重い"作業**こそ、AIが最も得意とするところだ。
ただし ── 経験があるから、AIの答えを評価できる
ここが、今回いちばん言いたいことだ。
AIは一瞬でチューニング案を出す。インデックスを提案し、SQLを書き換え、
「これで速くなります」と言う。でも、その答えが本当に正しいかは別問題だ。
- 提案されたインデックスは、RLSの述語(
tenant_id = ...)に本当に効くのか? - 書き換えたSQLは、元のSQLと同じ結果を返すのか?(今回、私は結果集合の完全一致を必ず検証した)
Nested Loopが消えたのは偶然か、それとも構造を直したからか?- インデックスの効果とSQLリライトの効果を、ちゃんと切り分けて見ているか?
こういう**「AIの出力を疑い、検証し、評価する」**という営みは、
あの手作業の経験があったからこそできる。
昔サイロと格闘した時間は、タイムマシンでは取り戻せない。
でも、その経験は 「AIの答えを鵜呑みにしない目」 として、いまも効いている。
これは決して無駄ではなかった、と思える。
まとめ
- サイロ→プール移行で、AIなしに手作業で性能改善をやり切った経験がある
- 同じ状況を再現したら、いまのAI+定石で 102.9ms → 1.1ms(約96倍) まで一気に届いた
- 過去には戻れない。あの時間は返ってこない
- だから 知識が乏しいこと・時間がかかることはAIに相談する ほうがいい
- ただし、経験があるからこそAIの答えを評価できる。手を動かした過去は、AI時代の「目」になる
タイムマシンは無い。けれど、過去の苦労は、未来の判断力になる。