自力で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 oDISTINCT 削除(膨張しないので不要) 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 ScanIndex 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 Joinrows=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 ScanIndex Scan / Bitmap Heap Scan に変わり、読むブロック数が激減した。
ただし SubPlan 1loops=232(相関サブクエリ)と fan-out の構造は残ったまま
インデックスは「同じ悪い構造を、速く実行する」だけで、構造は直していない。

③ SQLリライト+インデックス(相関サブクエリを事前集約に、NOT INNOT 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 INNested 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時代の「目」になる

タイムマシンは無い。けれど、過去の苦労は、未来の判断力になる。