{
    "componentChunkName": "component---src-templates-post-js",
    "path": "/untitled-8/",
    "result": {"data":{"ghostPost":{"id":"Ghost__Post__6a7b21fc781e37000184ee1b","title":"自力でSQLチューニングした経験は、AIの答えを疑うための武器になる","slug":"untitled-8","featured":false,"feature_image":null,"excerpt":"サイロをプールに変える、という仕事\nこんにちは、Anti-Patternの塚本です。\n以前、サイロ化していたデータベースを共通のプール（統合基盤）へ移行するプロジェクトを担当した。\n\n1つの基盤に集約すれば、当然そのぶん1つのテーブルが抱えるデータ量は一気に増える。データは繋がって扱いやすくなる一方で、それまで問題なく動いていたクエリが、量に負けて遅くなっていった。\n\n当時、いまのようなAIは無かった。だから、やることは全部手作業だった。\n\n * EXPLAIN ANALYZE を取って実行計画を読む\n * どこで Seq Scan が出ているか、Nested Loop が何回まわっているかを目で追う\n * インデックスを見直す\n * SQLそのものを書き換える\n * 不要になったテーブルの結合を削る\n\n一つ直しては計画を取り、また直しては計画を取る。地味で、時間のかかる作業だった。\nそれでも、遅いクエリが速くなっていく手応えは確かにあった。\n\n\n----------------------------------------------------------------------","custom_excerpt":null,"visibility":"public","created_at_pretty":"11 August, 2026","published_at_pretty":"26 August, 2026","updated_at_pretty":"26 August, 2026","created_at":"2026-08-11T22:22:04.000+09:00","published_at":"2026-08-26T11:39:35.000+09:00","updated_at":"2026-08-26T11:39:35.000+09:00","meta_title":null,"meta_description":null,"og_description":null,"og_image":null,"og_title":null,"twitter_description":null,"twitter_image":null,"twitter_title":null,"authors":[{"name":"takeshi tsukamoto","slug":"zhong","bio":null,"profile_image":null,"twitter":null,"facebook":null,"website":null}],"primary_author":{"name":"takeshi tsukamoto","slug":"zhong","bio":null,"profile_image":null,"twitter":null,"facebook":null,"website":null},"primary_tag":null,"tags":[],"plaintext":"サイロをプールに変える、という仕事\nこんにちは、Anti-Patternの塚本です。\n以前、サイロ化していたデータベースを共通のプール（統合基盤）へ移行するプロジェクトを担当した。\n\n1つの基盤に集約すれば、当然そのぶん1つのテーブルが抱えるデータ量は一気に増える。データは繋がって扱いやすくなる一方で、それまで問題なく動いていたクエリが、量に負けて遅くなっていった。\n\n当時、いまのようなAIは無かった。だから、やることは全部手作業だった。\n\n * EXPLAIN ANALYZE を取って実行計画を読む\n * どこで Seq Scan が出ているか、Nested Loop が何回まわっているかを目で追う\n * インデックスを見直す\n * SQLそのものを書き換える\n * 不要になったテーブルの結合を削る\n\n一つ直しては計画を取り、また直しては計画を取る。地味で、時間のかかる作業だった。\nそれでも、遅いクエリが速くなっていく手応えは確かにあった。\n\n\n--------------------------------------------------------------------------------\n\n「もしAIがあったら」を、いま再現してみた\n当時を振り返るために、同じような状況を小さく作って検証してみた。\n\n * Docker上の PostgreSQL 16\n * テナントIDをキーにした Row Level Security（RLS） を設定した3テーブル（各1万件）\n * 3テーブルを複雑に結合し、わざと遅いSQLを書く\n * 実行計画を取り、チューニングして、また実行計画を取る\n\n遅いSQLには、当時よく見た「あるある」を意図的に仕込んだ。\n\n仕込んだアンチパターン直し方相関サブクエリで行ごとに集計事前集約したサブクエリと JOIN無駄な結合で行が膨張 → DISTINCT でごまかす\n事前集約して重複を出さないIN (サブクエリ) / NOT IN (サブクエリ)JOIN / NOT EXISTS（アンチジョイン）テーブルをまたぐ OR 条件\nOR + EXISTS に整理1行しか返らないテーブルの不要な結合削除改善前のSQL（わざと遅い版）\nやりたいことは「そのテナント内で、status=active かつ paid の注文があり、\ncancelled の注文が無く、（2025-06-01より後に作成 or pending の注文を持つ）ユーザーを、\npaid合計金額の降順で並べる」というもの。それを、あえて悪い書き方でやる。\n\nSELECT DISTINCT                                   -- ← 膨張を隠すための DISTINCT\n    u.user_id,\n    u.email,\n    (SELECT COALESCE(SUM(o2.amount), 0)           -- ← 相関サブクエリ（行ごとに集計）\n       FROM orders o2\n      WHERE o2.user_id = u.user_id\n        AND o2.status = 'paid') AS total_paid\nFROM users u\nJOIN tenants t ON t.tenant_id = u.tenant_id       -- ← 1行しか返らないのに結合（不要）\nJOIN orders  o ON o.user_id  = u.user_id          -- ← fan-out（行が2倍に膨張）\nWHERE u.status = 'active'\n  AND u.user_id IN (                              -- ← IN (サブクエリ)\n        SELECT o3.user_id FROM orders o3 WHERE o3.status = 'paid')\n  AND u.user_id NOT IN (                          -- ← NOT IN (サブクエリ)\n        SELECT o4.user_id FROM orders o4 WHERE o4.status = 'cancelled')\n  AND ( u.created_at > DATE '2025-06-01'          -- ← テーブルをまたぐ OR\n        OR u.user_id IN (\n             SELECT o5.user_id FROM orders o5 WHERE o5.status = 'pending') )\nORDER BY total_paid DESC, u.user_id;\n\n\n改善後のSQL（リライト版）\n同じ結果を返しつつ、悪い構造を潰した版がこちら。\n\nSELECT\n    u.user_id,\n    u.email,\n    COALESCE(pa.total_paid, 0) AS total_paid\nFROM users u\nJOIN (                                             -- ★相関サブクエリ → 事前集約に\n    SELECT user_id, SUM(amount) AS total_paid      --   paid合計を「一度だけ」計算\n      FROM orders\n     WHERE status = 'paid'\n     GROUP BY user_id\n) pa ON pa.user_id = u.user_id                     -- INNER JOIN が「paid が有る」= 旧 IN を兼ねる\nWHERE u.status = 'active'\n  AND NOT EXISTS (                                 -- ★NOT IN → NOT EXISTS（アンチジョイン）\n        SELECT 1 FROM orders oc\n         WHERE oc.user_id = u.user_id AND oc.status = 'cancelled')\n  AND ( u.created_at > DATE '2025-06-01'           -- ★OR は EXISTS で整理\n        OR EXISTS (\n             SELECT 1 FROM orders op\n              WHERE op.user_id = u.user_id AND op.status = 'pending') )\nORDER BY total_paid DESC, u.user_id;\n-- 事前集約で重複が出ないので DISTINCT も不要／不要な tenants 結合も削除\n\n\n変更点を対応づけると、こうなる。\n\n改善前改善後効果(SELECT SUM ...) 相関サブクエリGROUP BY で事前集約して JOIN行ごとの全走査（232回）→ 1回にJOIN\norders o ＋ DISTINCT削除（膨張しないので不要）fan-out（232行）が消えるuser_id IN (SELECT ...)\n事前集約サブクエリとの INNER JOIN に吸収半結合を1つに統合NOT IN (SELECT ...)NOT EXISTSAnti Join \nになり効率化＋NULL安全JOIN tenants t削除無駄な結合を除去そして、改善を「インデックス追加」と「SQLリライト」の2系統に切り分けて計測した。\n\n結果\nパターン実行時間① ベースライン（インデックス無し・遅いSQL）102.9 ms② インデックスのみ追加（遅いSQLのまま）2.0 ms③\nSQLリライト＋インデックス（最終形）1.1 ms実行計画の中身もはっきり変わった。\n\n * ① … 相関サブクエリ由来の Seq Scan が何度も走り、Nested Loop が行ごとに繰り返される\n * ② … インデックスが効いて Seq Scan が Index Scan / Bitmap Heap Scan に。ただし相関・膨張の構造そのものは残る\n * ③ … 相関サブクエリが消え、Hash Join ＋ 事前集約に置き換わる。約96倍速くなった\n\n……当時、私が何時間もかけて手で辿り着いた場所に、いまなら数分で着く。\n正直、これを見たときは少し複雑な気持ちになった。\n\n\n--------------------------------------------------------------------------------\n\n実行計画の実物 ── 何が遅さを生んでいたのか\n「約96倍」と言われても、ピンと来ないと思う。\nなので、実際に取得した実行計画（EXPLAIN (ANALYZE, BUFFERS)）の本物を貼る。\nまずは①ベースライン。長いので、遅さの犯人まわりを抜粋した。\n\n Unique  (cost=2172.60..2172.63 rows=3 width=56)\n         (actual time=102.742..102.784 rows=116 loops=1)     -- 見積り rows=3 / 実際 116\n   Buffers: shared hit=18976                                 -- 合計 約19,000 ブロックに触れている\n   ->  Sort ...\n         ->  Hash Join  (... rows=3 ...) (actual ... rows=232 loops=1)   -- fan-out で 232 行に膨張\n               Hash Cond: (o.user_id = u.user_id)\n               ->  Seq Scan on orders o   (actual ... rows=1000 loops=1) -- 無駄な結合相手を全走査\n                     Rows Removed by Filter: 9000\n               ->  Hash -> Nested Loop -> Nested Loop ...\n                     ->  Seq Scan on orders o3 (paid 抽出)   Rows Removed by Filter: 9333\n                     SubPlan 2   -- NOT IN (cancelled)\n                       ->  Seq Scan on orders o4            Rows Removed by Filter: 9833\n                     SubPlan 3   -- OR IN (pending)\n                       ->  Seq Scan on orders o5            Rows Removed by Filter: 9834\n               SubPlan 1        -- ★相関サブクエリ（total_paid を行ごとに合計）\n                 ->  Aggregate (actual time=0.421..0.421 rows=1 loops=232)   -- 232回くり返す\n                       Buffers: shared hit=17168                             -- ★全体の約90%\n                       ->  Seq Scan on orders o2 (actual ... rows=1 loops=232)\n                             Filter: (o2.user_id = u.user_id AND o2.status='paid' ...)\n                             Rows Removed by Filter: 9999    -- 毎回1万件を走査して1件だけ残す\n Planning Time: 0.596 ms\n Execution Time: 102.878 ms\n\n\nこの計画から読み取れること\n1. 犯人は「相関サブクエリ」だった（SubPlan 1）\ntotal_paid を「1行ごとに SELECT SUM(...) FROM orders WHERE user_id = u.user_id」で\n計算していた。その結果が loops=232 ── 232回もorders全体を走査している。\nRows Removed by Filter: 9999 が象徴的で、毎回1万件読んで9,999件を捨て、欲しいのは1件。\nBuffers: shared hit=17168 は、このSubPlan 1だけで全体（18,976）の約90%のブロック読みを\n占めていることを意味する。遅さの9割はここにあった。\n\n2. Seq Scan が乱れ打ち\norders に対して o（不要な結合）・o3（paid抽出）・o4（NOT IN）・o5（OR）・o2（相関）と、\n同じ1万件のテーブルを何度も頭から全走査している。インデックスが無いので当然こうなる。\n\n3. DISTINCT が\"膨張\"を隠していた\n最上段は rows=116 なのに、途中の Hash Join は rows=232。\n不要な JOIN orders o でユーザーあたり2件に膨らみ、それを Unique（＝DISTINCT）で\n半分に潰していた。\"とりあえずDISTINCT\"は、たいてい設計の綻びを覆い隠している。\n\n4. 見積りが外れている\nプランナの見積りは rows=3、実際は rows=232 / 116。\n統計と実データが乖離していると、プランナは誤った戦略を選びやすい。\n\n直したら、計画はこう変わった\n② インデックスのみ（遅いSQLはそのまま、tenant_id先頭の複合インデックスを追加）\n\n Execution Time: 2.006 ms          -- 102.9ms → 2.0ms\n Buffers: shared hit=2885 read=9   -- 18,976 → 2,894 ブロック\n   SubPlan 1\n     ->  Index Scan using ix_orders_tenant_user_status ... loops=232\n           Buffers: shared hit=762                            -- 17,168 → 762（約22分の1）\n\n\nSeq Scan が Index Scan / Bitmap Heap Scan に変わり、読むブロック数が激減した。\nただし SubPlan 1 の loops=232（相関サブクエリ）と fan-out の構造は残ったまま。\nインデックスは「同じ悪い構造を、速く実行する」だけで、構造は直していない。\n\n③ SQLリライト＋インデックス（相関サブクエリを事前集約に、NOT INをNOT EXISTSに）\n\n Execution Time: 1.075 ms\n Buffers: shared hit=595 read=3    -- さらに 595 ブロックへ\n ->  Nested Loop Anti Join         -- NOT EXISTS が「アンチジョイン」になった\n       ->  Hash Join               -- 相関サブクエリ → 事前集約との Hash Join\n             ->  HashAggregate (paid を user 単位で1回だけ集計) rows=500\n\n\nSubPlan 1（相関サブクエリ）が消え、ordersのpaid集計は一度だけ行われてHash Joinで\n結ばれる。NOT INはNested Loop Anti Joinに化けた。DISTINCTも不要になった（膨張が無い）。\n\nまとめると\n① baseline② index only③ rewrite + indexExecution Time102.878 ms2.006 ms1.075 ms\n触れたブロック数 (Buffers)18,9762,894598orders への Seq Scan5回0回0回相関サブクエリ SubPlan 1あり\n(loops=232)あり (loops=232)無しDISTINCT(Unique)必要必要不要 * ① → ②（インデックス）：Seq Scan が消え、触れるブロックが約7分の1に。速くはなるが構造は残る。\n * ② → ③（リライト）：相関サブクエリと膨張という構造そのものを除去。ブロックはさらに約5分の1へ。\n\nつまり、インデックスは「実行を速くする」、SQLリライトは「仕事量そのものを減らす」。\n効くレイヤーが違う。当時の私が手作業でやっていたのは、まさにこの両方だった。\n\n\n--------------------------------------------------------------------------------\n\nタイムマシンは無い\nここで大事なことを一つ。過去には戻れない。\n\n「あの頃AIがあれば、あんなに時間をかけずに済んだのに」と思っても、\nタイムマシンがあるわけではない。あの数百時間は返ってこない。\n\nだから、その事実を嘆くよりも、これからどう使うかに頭を切り替えたい。\n教訓は、たぶんこうだ。\n\n> 知識が乏しいこと、時間がかかりそうなことは、まずAIに相談したほうがいい。\n\n\n実行計画の読み方を一から調べる時間、アンチパターンの直し方を思い出す時間、\n「この結合、本当に要る？」を一つずつ検証する時間 ──\nそういう**\"調べれば分かるが、地味に重い\"作業**こそ、AIが最も得意とするところだ。\n\n\n--------------------------------------------------------------------------------\n\nただし ── 経験があるから、AIの答えを評価できる\nここが、今回いちばん言いたいことだ。\n\nAIは一瞬でチューニング案を出す。インデックスを提案し、SQLを書き換え、\n「これで速くなります」と言う。でも、その答えが本当に正しいかは別問題だ。\n\n * 提案されたインデックスは、RLSの述語（tenant_id = ...）に本当に効くのか？\n * 書き換えたSQLは、元のSQLと同じ結果を返すのか？（今回、私は結果集合の完全一致を必ず検証した）\n * Nested Loop が消えたのは偶然か、それとも構造を直したからか？\n * インデックスの効果とSQLリライトの効果を、ちゃんと切り分けて見ているか？\n\nこういう**「AIの出力を疑い、検証し、評価する」**という営みは、\nあの手作業の経験があったからこそできる。\n\n昔サイロと格闘した時間は、タイムマシンでは取り戻せない。\nでも、その経験は 「AIの答えを鵜呑みにしない目」 として、いまも効いている。\nこれは決して無駄ではなかった、と思える。\n\n\n--------------------------------------------------------------------------------\n\nまとめ\n * サイロ→プール移行で、AIなしに手作業で性能改善をやり切った経験がある\n * 同じ状況を再現したら、いまのAI＋定石で 102.9ms → 1.1ms（約96倍） まで一気に届いた\n * 過去には戻れない。あの時間は返ってこない\n * だから 知識が乏しいこと・時間がかかることはAIに相談する ほうがいい\n * ただし、経験があるからこそAIの答えを評価できる。手を動かした過去は、AI時代の「目」になる\n\nタイムマシンは無い。けれど、過去の苦労は、未来の判断力になる。\n\n\n--------------------------------------------------------------------------------","html":"<!--kg-card-begin: markdown--><h2 id=\"%E3%82%B5%E3%82%A4%E3%83%AD%E3%82%92%E3%83%97%E3%83%BC%E3%83%AB%E3%81%AB%E5%A4%89%E3%81%88%E3%82%8B%E3%80%81%E3%81%A8%E3%81%84%E3%81%86%E4%BB%95%E4%BA%8B\">サイロをプールに変える、という仕事</h2>\n<p>こんにちは、Anti-Patternの塚本です。<br>\n以前、<strong>サイロ化していたデータベースを共通のプール（統合基盤）へ移行する</strong>プロジェクトを担当した。</p>\n<p>1つの基盤に集約すれば、当然そのぶん1つのテーブルが抱えるデータ量は一気に増える。データは繋がって扱いやすくなる一方で、それまで問題なく動いていたクエリが、量に負けて遅くなっていった。</p>\n<p>当時、いまのようなAIは無かった。だから、やることは全部手作業だった。</p>\n<ul>\n<li><code>EXPLAIN ANALYZE</code> を取って実行計画を読む</li>\n<li>どこで <code>Seq Scan</code> が出ているか、<code>Nested Loop</code> が何回まわっているかを目で追う</li>\n<li><strong>インデックスを見直す</strong></li>\n<li><strong>SQLそのものを書き換える</strong></li>\n<li><strong>不要になったテーブルの結合を削る</strong></li>\n</ul>\n<p>一つ直しては計画を取り、また直しては計画を取る。地味で、時間のかかる作業だった。<br>\nそれでも、遅いクエリが速くなっていく手応えは確かにあった。</p>\n<hr>\n<h2 id=\"%E3%80%8C%E3%82%82%E3%81%97ai%E3%81%8C%E3%81%82%E3%81%A3%E3%81%9F%E3%82%89%E3%80%8D%E3%82%92%E3%80%81%E3%81%84%E3%81%BE%E5%86%8D%E7%8F%BE%E3%81%97%E3%81%A6%E3%81%BF%E3%81%9F\">「もしAIがあったら」を、いま再現してみた</h2>\n<p>当時を振り返るために、同じような状況を小さく作って検証してみた。</p>\n<ul>\n<li>Docker上の PostgreSQL 16</li>\n<li>テナントIDをキーにした <strong>Row Level Security（RLS）</strong> を設定した3テーブル（各1万件）</li>\n<li>3テーブルを複雑に結合し、<strong>わざと遅いSQL</strong>を書く</li>\n<li>実行計画を取り、チューニングして、また実行計画を取る</li>\n</ul>\n<p>遅いSQLには、当時よく見た「あるある」を意図的に仕込んだ。</p>\n<table>\n<thead>\n<tr>\n<th>仕込んだアンチパターン</th>\n<th>直し方</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>相関サブクエリで行ごとに集計</td>\n<td>事前集約したサブクエリと <code>JOIN</code></td>\n</tr>\n<tr>\n<td>無駄な結合で行が膨張 → <code>DISTINCT</code> でごまかす</td>\n<td>事前集約して重複を出さない</td>\n</tr>\n<tr>\n<td><code>IN (サブクエリ)</code> / <code>NOT IN (サブクエリ)</code></td>\n<td><code>JOIN</code> / <code>NOT EXISTS</code>（アンチジョイン）</td>\n</tr>\n<tr>\n<td>テーブルをまたぐ <code>OR</code> 条件</td>\n<td><code>OR + EXISTS</code> に整理</td>\n</tr>\n<tr>\n<td>1行しか返らないテーブルの不要な結合</td>\n<td>削除</td>\n</tr>\n</tbody>\n</table>\n<h3 id=\"%E6%94%B9%E5%96%84%E5%89%8D%E3%81%AEsql%EF%BC%88%E3%82%8F%E3%81%96%E3%81%A8%E9%81%85%E3%81%84%E7%89%88%EF%BC%89\">改善前のSQL（わざと遅い版）</h3>\n<p>やりたいことは「そのテナント内で、<strong>status=active</strong> かつ <strong>paid の注文があり</strong>、<br>\n<strong>cancelled の注文が無く</strong>、（<strong>2025-06-01より後に作成</strong> or <strong>pending の注文を持つ</strong>）ユーザーを、<br>\n<strong>paid合計金額の降順</strong>で並べる」というもの。それを、あえて悪い書き方でやる。</p>\n<pre><code class=\"language-sql\">SELECT DISTINCT                                   -- ← 膨張を隠すための DISTINCT\n    u.user_id,\n    u.email,\n    (SELECT COALESCE(SUM(o2.amount), 0)           -- ← 相関サブクエリ（行ごとに集計）\n       FROM orders o2\n      WHERE o2.user_id = u.user_id\n        AND o2.status = 'paid') AS total_paid\nFROM users u\nJOIN tenants t ON t.tenant_id = u.tenant_id       -- ← 1行しか返らないのに結合（不要）\nJOIN orders  o ON o.user_id  = u.user_id          -- ← fan-out（行が2倍に膨張）\nWHERE u.status = 'active'\n  AND u.user_id IN (                              -- ← IN (サブクエリ)\n        SELECT o3.user_id FROM orders o3 WHERE o3.status = 'paid')\n  AND u.user_id NOT IN (                          -- ← NOT IN (サブクエリ)\n        SELECT o4.user_id FROM orders o4 WHERE o4.status = 'cancelled')\n  AND ( u.created_at &gt; DATE '2025-06-01'          -- ← テーブルをまたぐ OR\n        OR u.user_id IN (\n             SELECT o5.user_id FROM orders o5 WHERE o5.status = 'pending') )\nORDER BY total_paid DESC, u.user_id;\n</code></pre>\n<h3 id=\"%E6%94%B9%E5%96%84%E5%BE%8C%E3%81%AEsql%EF%BC%88%E3%83%AA%E3%83%A9%E3%82%A4%E3%83%88%E7%89%88%EF%BC%89\">改善後のSQL（リライト版）</h3>\n<p>同じ結果を返しつつ、悪い構造を潰した版がこちら。</p>\n<pre><code class=\"language-sql\">SELECT\n    u.user_id,\n    u.email,\n    COALESCE(pa.total_paid, 0) AS total_paid\nFROM users u\nJOIN (                                             -- ★相関サブクエリ → 事前集約に\n    SELECT user_id, SUM(amount) AS total_paid      --   paid合計を「一度だけ」計算\n      FROM orders\n     WHERE status = 'paid'\n     GROUP BY user_id\n) pa ON pa.user_id = u.user_id                     -- INNER JOIN が「paid が有る」= 旧 IN を兼ねる\nWHERE u.status = 'active'\n  AND NOT EXISTS (                                 -- ★NOT IN → NOT EXISTS（アンチジョイン）\n        SELECT 1 FROM orders oc\n         WHERE oc.user_id = u.user_id AND oc.status = 'cancelled')\n  AND ( u.created_at &gt; DATE '2025-06-01'           -- ★OR は EXISTS で整理\n        OR EXISTS (\n             SELECT 1 FROM orders op\n              WHERE op.user_id = u.user_id AND op.status = 'pending') )\nORDER BY total_paid DESC, u.user_id;\n-- 事前集約で重複が出ないので DISTINCT も不要／不要な tenants 結合も削除\n</code></pre>\n<p>変更点を対応づけると、こうなる。</p>\n<table>\n<thead>\n<tr>\n<th>改善前</th>\n<th>改善後</th>\n<th>効果</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>(SELECT SUM ...)</code> 相関サブクエリ</td>\n<td><code>GROUP BY</code> で事前集約して <code>JOIN</code></td>\n<td>行ごとの全走査（232回）→ 1回に</td>\n</tr>\n<tr>\n<td><code>JOIN orders o</code> ＋ <code>DISTINCT</code></td>\n<td>削除（膨張しないので不要）</td>\n<td>fan-out（232行）が消える</td>\n</tr>\n<tr>\n<td><code>user_id IN (SELECT ...)</code></td>\n<td>事前集約サブクエリとの <code>INNER JOIN</code> に吸収</td>\n<td>半結合を1つに統合</td>\n</tr>\n<tr>\n<td><code>NOT IN (SELECT ...)</code></td>\n<td><code>NOT EXISTS</code></td>\n<td><code>Anti Join</code> になり効率化＋NULL安全</td>\n</tr>\n<tr>\n<td><code>JOIN tenants t</code></td>\n<td>削除</td>\n<td>無駄な結合を除去</td>\n</tr>\n</tbody>\n</table>\n<p>そして、改善を「<strong>インデックス追加</strong>」と「<strong>SQLリライト</strong>」の2系統に切り分けて計測した。</p>\n<h3 id=\"%E7%B5%90%E6%9E%9C\">結果</h3>\n<table>\n<thead>\n<tr>\n<th>パターン</th>\n<th>実行時間</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>① ベースライン（インデックス無し・遅いSQL）</td>\n<td><strong>102.9 ms</strong></td>\n</tr>\n<tr>\n<td>② インデックスのみ追加（遅いSQLのまま）</td>\n<td><strong>2.0 ms</strong></td>\n</tr>\n<tr>\n<td>③ SQLリライト＋インデックス（最終形）</td>\n<td><strong>1.1 ms</strong></td>\n</tr>\n</tbody>\n</table>\n<p>実行計画の中身もはっきり変わった。</p>\n<ul>\n<li><strong>①</strong> … 相関サブクエリ由来の <code>Seq Scan</code> が何度も走り、<code>Nested Loop</code> が行ごとに繰り返される</li>\n<li><strong>②</strong> … インデックスが効いて <code>Seq Scan</code> が <code>Index Scan</code> / <code>Bitmap Heap Scan</code> に。ただし相関・膨張の<strong>構造そのものは残る</strong></li>\n<li><strong>③</strong> … 相関サブクエリが消え、<code>Hash Join</code> ＋ 事前集約に置き換わる。<strong>約96倍</strong>速くなった</li>\n</ul>\n<p>……当時、私が何時間もかけて手で辿り着いた場所に、いまなら数分で着く。<br>\n正直、これを見たときは少し複雑な気持ちになった。</p>\n<hr>\n<h2 id=\"%E5%AE%9F%E8%A1%8C%E8%A8%88%E7%94%BB%E3%81%AE%E5%AE%9F%E7%89%A9-%E2%94%80%E2%94%80-%E4%BD%95%E3%81%8C%E9%81%85%E3%81%95%E3%82%92%E7%94%9F%E3%82%93%E3%81%A7%E3%81%84%E3%81%9F%E3%81%AE%E3%81%8B\">実行計画の実物 ── 何が遅さを生んでいたのか</h2>\n<p>「約96倍」と言われても、ピンと来ないと思う。<br>\nなので、実際に取得した実行計画（<code>EXPLAIN (ANALYZE, BUFFERS)</code>）の<strong>本物</strong>を貼る。<br>\nまずは①ベースライン。長いので、遅さの犯人まわりを抜粋した。</p>\n<pre><code class=\"language-text\"> Unique  (cost=2172.60..2172.63 rows=3 width=56)\n         (actual time=102.742..102.784 rows=116 loops=1)     -- 見積り rows=3 / 実際 116\n   Buffers: shared hit=18976                                 -- 合計 約19,000 ブロックに触れている\n   -&gt;  Sort ...\n         -&gt;  Hash Join  (... rows=3 ...) (actual ... rows=232 loops=1)   -- fan-out で 232 行に膨張\n               Hash Cond: (o.user_id = u.user_id)\n               -&gt;  Seq Scan on orders o   (actual ... rows=1000 loops=1) -- 無駄な結合相手を全走査\n                     Rows Removed by Filter: 9000\n               -&gt;  Hash -&gt; Nested Loop -&gt; Nested Loop ...\n                     -&gt;  Seq Scan on orders o3 (paid 抽出)   Rows Removed by Filter: 9333\n                     SubPlan 2   -- NOT IN (cancelled)\n                       -&gt;  Seq Scan on orders o4            Rows Removed by Filter: 9833\n                     SubPlan 3   -- OR IN (pending)\n                       -&gt;  Seq Scan on orders o5            Rows Removed by Filter: 9834\n               SubPlan 1        -- ★相関サブクエリ（total_paid を行ごとに合計）\n                 -&gt;  Aggregate (actual time=0.421..0.421 rows=1 loops=232)   -- 232回くり返す\n                       Buffers: shared hit=17168                             -- ★全体の約90%\n                       -&gt;  Seq Scan on orders o2 (actual ... rows=1 loops=232)\n                             Filter: (o2.user_id = u.user_id AND o2.status='paid' ...)\n                             Rows Removed by Filter: 9999    -- 毎回1万件を走査して1件だけ残す\n Planning Time: 0.596 ms\n Execution Time: 102.878 ms\n</code></pre>\n<h3 id=\"%E3%81%93%E3%81%AE%E8%A8%88%E7%94%BB%E3%81%8B%E3%82%89%E8%AA%AD%E3%81%BF%E5%8F%96%E3%82%8C%E3%82%8B%E3%81%93%E3%81%A8\">この計画から読み取れること</h3>\n<p><strong>1. 犯人は「相関サブクエリ」だった（<code>SubPlan 1</code>）</strong><br>\n<code>total_paid</code> を「1行ごとに <code>SELECT SUM(...) FROM orders WHERE user_id = u.user_id</code>」で<br>\n計算していた。その結果が <code>loops=232</code> ── <strong>232回もorders全体を走査</strong>している。<br>\n<code>Rows Removed by Filter: 9999</code> が象徴的で、<strong>毎回1万件読んで9,999件を捨て、欲しいのは1件</strong>。<br>\n<code>Buffers: shared hit=17168</code> は、この<code>SubPlan 1</code>だけで<strong>全体（18,976）の約90%のブロック読み</strong>を<br>\n占めていることを意味する。遅さの9割はここにあった。</p>\n<p><strong>2. <code>Seq Scan</code> が乱れ打ち</strong><br>\n<code>orders</code> に対して <code>o</code>（不要な結合）・<code>o3</code>（paid抽出）・<code>o4</code>（NOT IN）・<code>o5</code>（OR）・<code>o2</code>（相関）と、<br>\n<strong>同じ1万件のテーブルを何度も頭から全走査</strong>している。インデックスが無いので当然こうなる。</p>\n<p><strong>3. <code>DISTINCT</code> が&quot;膨張&quot;を隠していた</strong><br>\n最上段は <code>rows=116</code> なのに、途中の <code>Hash Join</code> は <code>rows=232</code>。<br>\n不要な <code>JOIN orders o</code> でユーザーあたり2件に膨らみ、それを <code>Unique</code>（＝<code>DISTINCT</code>）で<br>\n半分に潰していた。<strong>&quot;とりあえずDISTINCT&quot;は、たいてい設計の綻びを覆い隠している。</strong></p>\n<p><strong>4. 見積りが外れている</strong><br>\nプランナの見積りは <code>rows=3</code>、実際は <code>rows=232</code> / <code>116</code>。<br>\n統計と実データが乖離していると、プランナは誤った戦略を選びやすい。</p>\n<h3 id=\"%E7%9B%B4%E3%81%97%E3%81%9F%E3%82%89%E3%80%81%E8%A8%88%E7%94%BB%E3%81%AF%E3%81%93%E3%81%86%E5%A4%89%E3%82%8F%E3%81%A3%E3%81%9F\">直したら、計画はこう変わった</h3>\n<p><strong>② インデックスのみ</strong>（遅いSQLはそのまま、<code>tenant_id</code>先頭の複合インデックスを追加）</p>\n<pre><code class=\"language-text\"> Execution Time: 2.006 ms          -- 102.9ms → 2.0ms\n Buffers: shared hit=2885 read=9   -- 18,976 → 2,894 ブロック\n   SubPlan 1\n     -&gt;  Index Scan using ix_orders_tenant_user_status ... loops=232\n           Buffers: shared hit=762                            -- 17,168 → 762（約22分の1）\n</code></pre>\n<p><code>Seq Scan</code> が <code>Index Scan</code> / <code>Bitmap Heap Scan</code> に変わり、読むブロック数が激減した。<br>\nただし <strong><code>SubPlan 1</code> の <code>loops=232</code>（相関サブクエリ）と fan-out の構造は残ったまま</strong>。<br>\nインデックスは「同じ悪い構造を、速く実行する」だけで、構造は直していない。</p>\n<p><strong>③ SQLリライト＋インデックス</strong>（相関サブクエリを事前集約に、<code>NOT IN</code>を<code>NOT EXISTS</code>に）</p>\n<pre><code class=\"language-text\"> Execution Time: 1.075 ms\n Buffers: shared hit=595 read=3    -- さらに 595 ブロックへ\n -&gt;  Nested Loop Anti Join         -- NOT EXISTS が「アンチジョイン」になった\n       -&gt;  Hash Join               -- 相関サブクエリ → 事前集約との Hash Join\n             -&gt;  HashAggregate (paid を user 単位で1回だけ集計) rows=500\n</code></pre>\n<p><code>SubPlan 1</code>（相関サブクエリ）が消え、<code>orders</code>のpaid集計は<strong>一度だけ</strong>行われて<code>Hash Join</code>で<br>\n結ばれる。<code>NOT IN</code>は<code>Nested Loop Anti Join</code>に化けた。<code>DISTINCT</code>も不要になった（膨張が無い）。</p>\n<h3 id=\"%E3%81%BE%E3%81%A8%E3%82%81%E3%82%8B%E3%81%A8\">まとめると</h3>\n<table>\n<thead>\n<tr>\n<th></th>\n<th>① baseline</th>\n<th>② index only</th>\n<th>③ rewrite + index</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Execution Time</td>\n<td>102.878 ms</td>\n<td>2.006 ms</td>\n<td>1.075 ms</td>\n</tr>\n<tr>\n<td>触れたブロック数 (Buffers)</td>\n<td>18,976</td>\n<td>2,894</td>\n<td>598</td>\n</tr>\n<tr>\n<td>orders への <code>Seq Scan</code></td>\n<td>5回</td>\n<td>0回</td>\n<td>0回</td>\n</tr>\n<tr>\n<td>相関サブクエリ <code>SubPlan 1</code></td>\n<td>あり (loops=232)</td>\n<td>あり (loops=232)</td>\n<td><strong>無し</strong></td>\n</tr>\n<tr>\n<td><code>DISTINCT</code>(Unique)</td>\n<td>必要</td>\n<td>必要</td>\n<td><strong>不要</strong></td>\n</tr>\n</tbody>\n</table>\n<ul>\n<li><strong>① → ②（インデックス）</strong>：Seq Scan が消え、<strong>触れるブロックが約7分の1</strong>に。速くはなるが構造は残る。</li>\n<li><strong>② → ③（リライト）</strong>：相関サブクエリと膨張という<strong>構造そのもの</strong>を除去。ブロックはさらに約5分の1へ。</li>\n</ul>\n<p>つまり、<strong>インデックスは「実行を速くする」、SQLリライトは「仕事量そのものを減らす」</strong>。<br>\n効くレイヤーが違う。当時の私が手作業でやっていたのは、まさにこの両方だった。</p>\n<hr>\n<h2 id=\"%E3%82%BF%E3%82%A4%E3%83%A0%E3%83%9E%E3%82%B7%E3%83%B3%E3%81%AF%E7%84%A1%E3%81%84\">タイムマシンは無い</h2>\n<p>ここで大事なことを一つ。<strong>過去には戻れない。</strong></p>\n<p>「あの頃AIがあれば、あんなに時間をかけずに済んだのに」と思っても、<br>\nタイムマシンがあるわけではない。あの数百時間は返ってこない。</p>\n<p>だから、その事実を嘆くよりも、<strong>これからどう使うか</strong>に頭を切り替えたい。<br>\n教訓は、たぶんこうだ。</p>\n<blockquote>\n<p><strong>知識が乏しいこと、時間がかかりそうなことは、まずAIに相談したほうがいい。</strong></p>\n</blockquote>\n<p>実行計画の読み方を一から調べる時間、アンチパターンの直し方を思い出す時間、<br>\n「この結合、本当に要る？」を一つずつ検証する時間 ──<br>\nそういう**&quot;調べれば分かるが、地味に重い&quot;作業**こそ、AIが最も得意とするところだ。</p>\n<hr>\n<h2 id=\"%E3%81%9F%E3%81%A0%E3%81%97-%E2%94%80%E2%94%80-%E7%B5%8C%E9%A8%93%E3%81%8C%E3%81%82%E3%82%8B%E3%81%8B%E3%82%89%E3%80%81ai%E3%81%AE%E7%AD%94%E3%81%88%E3%82%92%E8%A9%95%E4%BE%A1%E3%81%A7%E3%81%8D%E3%82%8B\">ただし ── 経験があるから、AIの答えを評価できる</h2>\n<p>ここが、今回いちばん言いたいことだ。</p>\n<p>AIは一瞬でチューニング案を出す。インデックスを提案し、SQLを書き換え、<br>\n「これで速くなります」と言う。<strong>でも、その答えが本当に正しいかは別問題</strong>だ。</p>\n<ul>\n<li>提案されたインデックスは、RLSの述語（<code>tenant_id = ...</code>）に本当に効くのか？</li>\n<li>書き換えたSQLは、<strong>元のSQLと同じ結果を返す</strong>のか？（今回、私は結果集合の完全一致を必ず検証した）</li>\n<li><code>Nested Loop</code> が消えたのは偶然か、それとも構造を直したからか？</li>\n<li>インデックスの効果とSQLリライトの効果を、ちゃんと切り分けて見ているか？</li>\n</ul>\n<p>こういう**「AIの出力を疑い、検証し、評価する」**という営みは、<br>\nあの手作業の経験があったからこそできる。</p>\n<p>昔サイロと格闘した時間は、タイムマシンでは取り戻せない。<br>\nでも、その経験は <strong>「AIの答えを鵜呑みにしない目」</strong> として、いまも効いている。<br>\nこれは決して無駄ではなかった、と思える。</p>\n<hr>\n<h2 id=\"%E3%81%BE%E3%81%A8%E3%82%81\">まとめ</h2>\n<ul>\n<li>サイロ→プール移行で、AIなしに手作業で性能改善をやり切った経験がある</li>\n<li>同じ状況を再現したら、いまのAI＋定石で <strong>102.9ms → 1.1ms（約96倍）</strong> まで一気に届いた</li>\n<li><strong>過去には戻れない</strong>。あの時間は返ってこない</li>\n<li>だから <strong>知識が乏しいこと・時間がかかることはAIに相談する</strong> ほうがいい</li>\n<li><strong>ただし、経験があるからこそAIの答えを評価できる</strong>。手を動かした過去は、AI時代の「目」になる</li>\n</ul>\n<p>タイムマシンは無い。けれど、過去の苦労は、未来の判断力になる。</p>\n<hr>\n<!--kg-card-end: markdown--><p></p>","url":"https://ghost.tech.anti-pattern.co.jp/untitled-8/","canonical_url":null,"uuid":"d79a6d74-bfe6-4019-843e-26023f6d3639","page":null,"codeinjection_foot":null,"codeinjection_head":null,"codeinjection_styles":null,"comment_id":"6a7b21fc781e37000184ee1b","reading_time":7}},"pageContext":{"slug":"untitled-8"}},
    "staticQueryHashes": ["176528973","2358152166","2561578252","2731221146","4145280475"]}