トピック入口と開始文 へ戻る。Practice / Interviewでは解説を先に見せず、一問ずつ回答を待つ。
解説図:回答後の解説:LIMITの下で扱う行
このページの解説を理解し、疑問を深掘りしたいときは、次のプロンプトをコピーしてChatGPTに貼ってください。気になる見出しや図名があれば、末尾に追記できます。
https://github.com/MFQWKMR4/tech2026 の次のファイルを読み、
database-query-plans の振り返り・解説を一緒に読む復習セッション(learn)を始めてください。
- src/content/docs/career/index.md
- src/content/docs/database/query-plans.mdx
- public/diagrams/database-query-plans/limit-work.svg
- reviews/database-query-plans.yaml
- sessions/2026-09-08-database-query-plans-01.yaml
- sessions/2026-09-14-database-query-plans-02.yaml
- labs/query-plan-distribution.sql
- docs/verification/issue-18-query-plans.txt
- diagrams/database-query-plans/prepared.json
- labs/database-query-plans.sql
- docs/verification/issue-8-query-plans.txt
reviewにこれより新しいsessionがあれば、そちらも確認してください。
最初に実際に参照できたファイルとcommitを短く示してください。
読めないファイルは読めないと伝え、内容を推測しないでください。
図のJSONは要素・矢印・処理順の根拠として読み、描画を見たとは扱わないでください。
まず、私が気になっている節や図があるか一つだけ聞いて、回答を待ってください。
指定がなければneeds_revisitに関係する解説から始めてください。
一節ずつ、説明 → 私の疑問 → 具体例や図による深掘り、の順で進めてください。
追加疑問が出たらそちらを優先し、一度に説明を進めすぎないでください。
区切りで自分の言葉で説明できるか一問ずつ確かめ、ヒント後の理解と自発的な説明を区別してください。
教材の例・Codexの検証結果を、私が実施した実績として扱わないでください。
終了時は .github/ISSUE_TEMPLATE/session-sync.md に従い、session_type: learn で
終了時は実際の回答を根拠に、CHATGPT.mdと既存テンプレートに従ってYAML+Session narrativeを含む [Session Sync] GitHub IssueをMFQWKMR4/tech2026に作成し、URLを返してください。作成できない場合は未作成と明示し、コピー可能なタイトルと本文を返してください。
参照した見出し・図のpathを残し、教材に戻したい説明をrepository_requestsに含めてください。
最終レビュー:2026-09-14 · 説明した(Explained)。ヒント後の理解と自力の説明を区別して記録しています。
次回、自力で確かめたいこと 2026-09-14 (2026-09-14-database-query-plans-02): 実際のPostgreSQL EXPLAIN (ANALYZE, BUFFERS)出力を木構造の下側から読み、各nodeのactual time、rows、loops、Buffersから支配的コストを自力で特定する。 2026-09-14 (2026-09-14-database-query-plans-02): Nested LoopとHash Joinについて、build/probe側、外側行数、selectivity、index lookup回数、memory/spillを条件にして、説明なしでjoin strategyを選び直す。 2026-09-14 (2026-09-14-database-query-plans-02): 推定誤差を、統計の古さ、単一列skew、複数列相関、parameterized Generic Planに切り分け、対応策を対応付ける短い再テストを行う。 2026-09-14 (2026-09-14-database-query-plans-02): Sort Method、Memory、Disk、Buffers、Rows Removed by Filterを含むplanを読み、行数見積もりが正しいのに遅いケースを診断する。 2026-09-14のセッション Issue #8で未解決だったEXPLAIN ANALYZEのrows/loops、Nested LoopとHash Join、Index ScanとSeq Scanの判断を、演習上の実行計画を使って再学習した。最初のplanからusers 30,000行ごとにordersのIndex Scanが走る構造を、反復構造の解説前に捉えた。active userが10人ならNested Loopが有利なことは基本動作の説明後に判断できた。説明後はestimated/actual rowsの乖離について、統計の古さ、複数列相関、単一列skew、Generic/Custom Planを区別して一連の診断観点を自分の言葉で整理した。一方、Hash Joinのbuild/probe、ANALYZEが収集する統計、拡張統計、巨大tenantだけをCustom Planへ分ける実装、BUFFERS・sort/hash spillの読解は解説が必要で、実際のPostgreSQL出力を使った再確認が残った。
演習planの反復構造を説明。JOIN選択・統計の診断は解説後に再構成した。実planのBUFFERS/spill読解は未確認。
問い・回答の流れをIssueで読む
2026-09-08のセッション 注文一覧に顧客名を追加して遅くなったケースを起点に、APMによる切り分け、slow query確認、N+1、JOIN、インデックス、返却行数、複合インデックス、paginationまで条件を変えながら判断した。アプリケーション側からボトルネックを切り分ける発想、JOINによるN+1解消、複合インデックスと書き込みコスト、cursor paginationの性質は説明できた。一方、実行計画を実際に読んだ経験はなく、plannerがIndex Scan/Seq Scanを選ぶ理由、estimated/actual rows、ANALYZE/auto-analyze、Nested Loop/Hash Joinの判断は解説が必要だった。本人から実行計画の読み方を別セッションで集中的に学習したいとの希望が出て終了した。
APM/logによる切り分けとpagination・複合indexの書き込みコストを自発的に提示。N+1/JOINはquery回数の説明後、統計・loopsは解説後の理解。実行計画の自力読解は未実施で、JOIN戦略の問いは未回答。tie breakerの問いのヒント有無はunknown。
問い・回答の流れをIssueで読む
回答で示せたこと(ヒントの有無を含む) 2026-09-08 (2026-09-08-database-query-plans-01): まずAPI内部のどこが遅いかを切り分ける方針を自発的に提示した。回答:『APMとかがあるならAPMを見るし、ないならログからタイムスタンプなどを見て、APIのどの部分が遅くなっているかを確認したい』。ヒント: なし。 2026-09-08 (2026-09-08-database-query-plans-01): DBアクセス部分が遅いという条件に対し、DB側のslow query logを確認する案を自発的に提示した。回答:『DB側のスロークエリログとかは出てないかな?』。ヒント: なし。 2026-09-08 (2026-09-08-database-query-plans-01): クエリ回数の説明・約100回という追加条件の後、N+1の構造を認識しJOIN案を組み立てた。回答要約: 注文一覧後のfor loopで顧客SQLを発行する状態に対しorders.customer_idとcustomersをJOINして一度に取得する案を提示。ヒント: query回数を見る観点の説明あり。N+1の名称が本人の認識より先に提示されたかはmetadataとnarrativeで食い違うためunknown。 2026-09-08 (2026-09-08-database-query-plans-01): 特定顧客だけ大量の注文を持つ条件で、まずcustomer_idのインデックス有無を確認する判断を提示した。回答:『テーブル定義とかを見てインデックスついてるかなっていう、そういうの見ますね』。ヒント: なし。 2026-09-08 (2026-09-08-database-query-plans-01): 数十万件を全件返却している条件で、全件返却の要件自体を疑い、LIMITを用いたpaginationを自発的に提案した。回答要約: 全件返却は通常不要であり、ORDER BYとLIMITを使って数件ずつ取得する。ヒント: なし。 2026-09-08 (2026-09-08-database-query-plans-01): WHERE customer_id + ORDER BY created_at + LIMITという条件から複合インデックスの必要性を判断した。回答:『created_atでORDER BYするんだったら複合インデックスがいる』。trade-offとして『書き込みに若干コストがかかる』と説明した。ヒント: customer_id単体indexはあるが複合indexはない、という条件のみ。 2026-09-08 (2026-09-08-database-query-plans-01): cursor paginationのtrade-offとして任意ページへのジャンプが難しくなることを説明した。回答要約: 前ページ最後の位置を起点に次ページを取るため連続移動には向くが、完全に別のページへ直接飛ぶにはOFFSET的な考え方が必要になる。ヒント: OFFSETがSQL句であり深いOFFSETでは読み飛ばしが発生すること、keyset/cursor paginationの基本形の解説あり。 2026-09-08 (2026-09-08-database-query-plans-01): 任意ページジャンプ要件がない条件ではcursor paginationを選択し、性能以外にデータ追加・削除時の安定性を理由として挙げた。回答:『絶対カーソルページネーション』『新しいデータが増えたり減ったりした時でも対応ができる』。ヒント: 直前にOFFSETとcursorの特徴の説明あり。 2026-09-08 (2026-09-08-database-query-plans-01): created_atが重複するcursorについて、order_id等をtie breakerにして複合的に一意な順序を作る必要性を説明した。回答要約: created_atだけではカーソル位置を一意に特定できないため、order_idなどを追加する。本人から『これもさっきやった』と既習内容である旨の発言あり。 ヒント: この問いではunknown(既習との本人発言あり)。 2026-09-08 (2026-09-08-database-query-plans-01): SQLやindex定義が変わっていないのに急に遅くなる条件で、データ量変化により実行計画が変化する可能性を想起した。回答:『データ量が変わって実行計画が変わったみたいな話は聞いたことある』。ヒント: なし。 2026-09-08 (2026-09-08-database-query-plans-01): estimated rows=100 / actual rows=300000という説明後、統計情報が実態を反映していない可能性を自分の言葉で説明した。回答:『データが更新とかINSERTされたときにうまく統計データが取れなかったってことかな』『統計データの算出がおかしいってことはわかるのかな』。ヒント: planner、selectivity、estimated/actual rows、ANALYZEの説明あり。 2026-09-08 (2026-09-08-database-query-plans-01): autovacuum_analyze_scale_factorの説明後、値を小さくすると少ない変更量でauto-analyzeが実行対象になりやすくなることを説明できた。回答:『起動の条件が緩くなる』『もうちょっとでも変更したらauto analyzeが実行される、頻度が増える』。ヒント: auto-analyzeの閾値とscale factorの説明あり。 2026-09-08 (2026-09-08-database-query-plans-01): EXPLAIN ANALYZEでNested Loop内のIndex Scanがloops=300000という条件に対し、同じIndex Scanの大量反復が重いポイントだと認識した。回答:『インデックススキャンが300,000回繰り返されてる。この繰り返しは何か防げないのかな』。ヒント: actual time/loopsと実行計画ノードの読み方の説明あり。 2026-09-14 (2026-09-14-database-query-plans-02): 演習上のNested Loop planで、usersの各行に対してordersのIndex Scanが反復される構造を自発的に捉えた。回答:『ユーザー、この行数ごとにインデックススキャンも入っている感じかな』。ヒント: 問題文でactual time・rows・loopsを見るよう指定したが、反復構造の説明は未提示。 2026-09-14 (2026-09-14-database-query-plans-02): users.statusへのindexで18msが2msになっても、ordersへの30,000回のIndex Scanが残るため全体は大きく改善しないと説明した。回答:『全体的な仕組みが変わってないから、ユーザーそれぞれに対してインデックススキャンでオーダーを見つけるので、大きくは高速化しない』。ヒント: 直前にloops=30,000と概算600msを説明済み。 2026-09-14 (2026-09-14-database-query-plans-02): active userが10人ならNested Loopの反復が10回で済むため、Hash JoinよりNested Loop + Index Scanが有利と判断した。回答:『ネスティッドループの回数が10回しか走らない』『前者の方が効率は良さそう』。ヒント: Nested LoopとHash Joinの基本動作を直前に説明済み。 2026-09-14 (2026-09-14-database-query-plans-02): 推定10行・実測30,000行の見積もり誤差はrowsを比較すると回答した。回答:『ローズかな。行数』。ヒント: なし。ただしestimated側とactual側の具体的な位置はその後説明。 2026-09-14 (2026-09-14-database-query-plans-02): 自動統計更新の仕組みとしてautovacuumを想起し、全体に対して一定割合以上の更新または定期的な確認を候補に挙げた。回答:『オートバキュームってやつかな』『全体に対して一定割合以上の大きな更新が入ったとき。か定期的か』。ヒント: PostgreSQLに自動化機構があることと、動作タイミングを考えるよう質問した。 2026-09-14 (2026-09-14-database-query-plans-02): Analyzeを頻繁に実行するtrade-offとしてI/Oと計算資源の増加を挙げた。回答:『負荷がかかるから、I/Oが増えたりとかメモリの使用量が上がったり』。ヒント: なし。CPU、cache、catalog、planning安定性はその後補足。 2026-09-14 (2026-09-14-database-query-plans-02): 拡張統計を無制限に作らず重要な組み合わせに絞るべきと判断し、CPU負荷とlatencyを理由にした。回答:『CPU負荷とかレイテンシーがかかりますよね。なんで重要なやつだけやるべき』。ヒント: なし。主なコストがANALYZE・保存・planning側である点はその後補足。 2026-09-14 (2026-09-14-database-query-plans-02): Prepared Statementで具体値がplan作成後に入るため、頻出値42を考慮できない可能性を推測した。回答:『実行計画でプランした後に実際の値が入れられるからとかそういうことじゃないの』。ヒント: Prepared StatementのSQLと、統計があっても起こるという条件のみ。 2026-09-14 (2026-09-14-database-query-plans-02): Custom Planを常時強制するtrade-offとして、実行ごとのplan作成負荷を説明した。回答:『実行ごとに計画作りが入るから負荷は高くなる』。ヒント: Custom Planは渡された値を見て実行ごとに計画する、という説明済み。 2026-09-14 (2026-09-14-database-query-plans-02): セッション後半の総括で、統計の古さ、複数列相関、単一列skew、Prepared StatementのGeneric/Custom Planを区別して診断の流れを自分の言葉で再構成した。回答要約: まずANALYZE/auto-analyze、次にAND条件での列相関と拡張統計、単一列の最頻値、最後にPrepared Statementでは値が後から入りGeneric Planが偏りを見られないためCustom Planを検討すると説明した。ヒント: 各要素は直前までに解説済みで、初回独力の定着確認ではない。 2026-09-14 (2026-09-14-database-query-plans-02): 1,000万行中500万行が一致する条件で、Index ScanよりSeq Scanが適切になり得るためindex追加が必ずしも高速化につながらないと説明した。回答:『半分ヒットするような場合はインデックスがついていても結果としてシーケンシャルスキャンの方が良いと判断される場合があり、そのような場合は高速化しない』。ヒント: 直前に大量一致ではindexからheapへ大量アクセスするより連続走査が有利になり得ることを説明済み。 自力では説明しきれなかったこと 2026-09-08 (2026-09-08-database-query-plans-01): 実行計画を実際に読んだ経験がなく、Index Scanが存在してもSeq Scanを選ぶ理由を自力では説明できなかった。回答:『これは正直データベースの知識ないときついでしょう。ちょっと教えて欲しいレベル』。ヒント: その後、plannerが候補planの推定costを比較すること、selectivity、大量行取得ではSeq Scanが有利になり得ることを説明した。 2026-09-14 (2026-09-14-database-query-plans-02)追記: 大量一致でSeq Scanとなる理由を説明後に回答。独力再確認は残る。 2026-09-08 (2026-09-08-database-query-plans-01): EXPLAIN ANALYZEの具体的な読み方、主要node、actual time、loopsの読み取りを自力では説明できなかった。回答:『無理だね。わかんないね』『結局絞り込みとかデータアクセスの仕方とかだよな』。ヒント: Seq Scan / Index Scan / Sort / Hash Join / Nested Loop、estimated vs actual rows、actual time、loopsを見る観点を説明した。 2026-09-14 (2026-09-14-database-query-plans-02)追記: rows比較と反復構造の説明は進展。BUFFERS/spillを含む実planは未確認。 2026-09-08 (2026-09-08-database-query-plans-01): Nested LoopとHash Joinのtrade-off、どの条件でNested Loopが有利になるかは未回答のまま終了した。回答:『難しいなぁ。難しくなってきた。ここは一旦ここまで』『実行計画の見方みたいなのをがっつり別セッションでやりたい』。ヒント: Nested Loopは外側の各行ごとに内側を処理し、Hash Joinはハッシュ表を作って突き合わせるという基本説明あり。 2026-09-14 (2026-09-14-database-query-plans-02)追記: 今回、基本動作の解説後に少量外側ならNested Loopと回答。build/probeと独力選択は再確認が残る。 2026-09-14 (2026-09-14-database-query-plans-02): Hash Joinの仕組みを知らないことを明示した。回答:『ちょっとハッシュジョインが何かわかってないんですけど、ネスティッドループもわかってない』。Hash Mapと平均O(1)の照合は推測できたが、どちらをbuild側にし、もう片方をscan/probeするかは未習得だった。ヒント: その後、active userからhash tableを作りordersを一度走査する例を説明。 2026-09-14 (2026-09-14-database-query-plans-02): ANALYZEがどの統計をどう更新するかは自力では説明できなかった。回答:『これまでの統計情報とかから出してるはずなんですけど、そのズレが修正されるからか』。ヒント: その後、現在データのsampleからnull率、distinct数、MCV、histogram等を更新することを説明。 2026-09-14 (2026-09-14-database-query-plans-02): 巨大tableでAuto Analyzeを早める設定について、status列だけを確認する案を出したが、table単位のscale factor/threshold調整は出なかった。回答:『そのステータス分布だけチェックするみたいな感じかな』。ヒント: その後、table単位のautovacuum_analyze_scale_factor/thresholdと列単位SET STATISTICSの役割を区別して説明。 2026-09-14 (2026-09-14-database-query-plans-02): 通常tenantはGeneric Plan、巨大tenantだけCustom Planへ分ける具体的なapplication/SQL構造は出せなかった。回答:『アプリケーションSQLの構造がどう反映するのかすらわかんない』。ヒント: その後、巨大tenant経路だけSET LOCAL plan_cache_mode=force_custom_planを使う例と、固定allowlistのliteral SQL、さらに大規模なら分離設計を説明。 2026-09-14 (2026-09-14-database-query-plans-02): estimated/actual rowsが一致しても遅い場合の次の観測として、EXPLAIN (ANALYZE, BUFFERS)、cache hit/read、sort/hash spill、lock/storage latencyを自力では挙げられなかった。回答:『普通にデータ量が多い』『インデックスがついてないとか』。ヒント: その後、各nodeのactual time/loops、BUFFERS、Rows Removed、spill等を説明したが再確認は未実施。 理由・設計を詰めたいこと 2026-09-08 (2026-09-08-database-query-plans-01): 単発SQLが5〜10msなのにDB区間全体が800msという条件で、最初にquery回数を確認する発想は出ず、network RTTやDBアクセス内部の内訳を疑った。回答:『ネットワークのラウンドトリップタイムとか』『この内訳を調べないといけない』。ヒント: その後、単発が速くても多数回発行されれば遅くなるためquery回数を確認し、N+1を疑う観点を提示した。 2026-09-08 (2026-09-08-database-query-plans-01): customer_idにindexが存在するのに大量行を持つ顧客だけ遅い条件では、indexが探索コストを下げても大量行を読むコストは残る点を自力では説明できなかった。回答:『インデックスはそんな万能ではないのか』『数十万件あるってことはわかんないです』。ヒント: 大量行のread/JOIN/responseコストはindexだけでは消えないと説明した。 2026-09-08 (2026-09-08-database-query-plans-01): auto-analyzeが追いつかない巨大テーブルで定期ANALYZEとtable単位のauto-analyze調整を比較する問いでは判断根拠を持っていなかった。回答:『どっちがいいの全然わからないよ』。ヒント: auto-analyze調整を基本とし、scale factorを下げる場合の負荷とのtrade-off、定期batchでは更新直後からbatch実行まで統計が古い点を説明した。 2026-09-14 (2026-09-14-database-query-plans-02): active userが10人のHash Joinについて『全オーダーのハッシュテーブルを作る必要がある』と捉えた。Nested Loopが有利という結論は正しかったが、通常は小さいactive users側をbuildし、orders側をscan/probeする点が曖昧だった。ヒント: その後訂正。 2026-09-14 (2026-09-14-database-query-plans-02): 計算資源が無限ならHash Joinも不利ではないと考えたが、orders全走査のelapsed time、I/O帯域、cache汚染、他queryへの影響が残る点を考慮できていなかった。回答:『資源が計算リソースが無限にあるならハッシュジョインの方も不利とは言えない』。ヒント: その後補足。 2026-09-14 (2026-09-14-database-query-plans-02): statusとregionの相関による10倍の見積もり誤差を、主にsamplingと実データのずれとして説明した。回答:『普通にサンプリングするとずれる』。ヒント: その後、単一列統計しかなく列を独立と仮定する構造的問題であり、sample量だけでは解消しない場合があると説明。 修正が必要な説明 2026-09-14 (2026-09-14-database-query-plans-02): 単一列tenant_idのskewに対しても拡張統計を使うと判断した。回答:『この場合はやっぱり拡張を使わないと』。ヒント: その後、単一列MCVと列のstatistics targetで扱い、複数列拡張統計は基本不要と説明。
実行計画の読解を練習したい場合は、上のプロンプトに「AのSQLとplanから一問ずつ練習したい」と追記してください。説明から始める復習と、自力で読む練習を希望に合わせて切り替えます。
再現SQL と実測出力全文 を対にして使う。以下のA〜Gは同じSQLファイルの順番。これは2026-09-08にCodexがPostgreSQL 18.6 の専用Docker DBで実行した教材検証で、本人の実施記録ではない。
docker compose exec -T postgres psql -U learner -d tech2026 -v ON_ERROR_STOP= 1 -f /labs/database-query-plans.sql
合成データだけを新規schemaに作り、最後にROLLBACKする。parallel実行とJITは読みやすさのためこのtransaction内で無効化。ordersのauto-analyzeもlab内で無効化し、統計更新のタイミングを制御する。本番向け設定ではない。random_page_cost=4、seq_page_cost=1、work_mem=4MB。キャッシュ・機器・統計サンプルで時間やplanは変わり、特定nodeの出現を保証するテストではない。
APMやログで遅い区間を確認し、DB区間では単発SQLの時間とリクエスト当たりの発行回数 を並べる。演習上、1回8msでも直列100回なら約800msになる。slow queryの閾値以下でもN+1は起きる。network、connection pool待ち、lock待ち、response生成も候補として測る。
注文ごとの顧客取得ならJOINやまとめ取得が候補。ただし1対多JOINは行数を増幅し得るため、関係と返却件数を確認する。検索が速くても数十万行のread・JOIN・転送コストは残る。paginationの要件とSQLの絞り込みを先に確かめる。
葉のscanが行を読み、親のJOINやSortへ渡し、根が結果を返す。字下げで親子を追う。「常に一番下を全部実行」ではなく、Nested Loopは内側を繰り返し、Limitは途中で要求を止め、SortやHashには準備が必要になる。
costはmsではなくplannerの比較用見積もり。rowsはnodeが出力する 行数で、読んだ全行数ではない。actual time=a..bは最初の行までと完了までのms、反復時は1実行当たりの平均。actual rowsも平均なので、総出力の目安はrows × loops。親の時間は子の処理を含むため全nodeを合算しない。丸め誤差もある。PostgreSQL 18: Using EXPLAIN
EXPLAINは見積もり、EXPLAIN ANALYZEはSQLを実行して計測する。BUFFERSのshared hitはPostgreSQL buffer内、readはそこへの読み込みで、物理diskアクセスと同義ではない。通常のEXPLAIN ANALYZEは結果行をクライアントへ送らず、APIの転送・serialization時間をそのまま表さない。更新SQLにも実行作用があるため、まず専用DBで読む。EXPLAIN
ordersは10,000行。Aではcustomer_idが1〜10,000の各1行。Bでは総行数・SQL・index定義を保ち、9,000行のcustomer_idを42に更新した。CはBと同じデータをANALYZEした直後。UPDATEは物理配置やdead tupleも変えるため、分布だけの純粋な速度比較ではない。B対Cでは統計更新の影響を分けて読む。
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42 ;
UPDATE orders SET customer_id = 42 WHERE id <= 9000 ;
実測のnode抜粋(全文は上のリンク):
A: Index Scan using orders_customer_idx on orders
(cost=0.29..8.30 rows=1 width=117) (actual time=0.003..0.003 rows=1.00 loops=1)
B: Index Scan using orders_customer_idx on orders
(cost=0.29..8.30 rows=1 width=117) (actual time=0.008..0.564 rows=9000.00 loops=1)
(cost=0.00..471.00 rows=9000 width=117) (actual time=0.182..0.580 rows=9000.00 loops=1)
Filter: (customer_id = 42)
Rows Removed by Filter: 1000
問いA :見積もりと実測はそれぞれ何行か? 回答後にBを提示して差を聞き、次にCへ進む。
回答後の解説:indexの有無と採用は別 Aは1行だけ探すindex lookup。Bは見積もり1行に対し実際は9,000行で、古い統計が実態を反映していない。Cでは見積もりが9,000行となりSeq Scanを選んだ。大半の行が一致すると、index経由でtableへ多数アクセスするより順次読む候補の推定costが小さくなり得る。中間にはBitmap Scan等もある。
今回BのExecution Timeは0.781ms、Cは0.784msで、ANALYZEによる高速化は観測していない。確認できたのは見積もりと選択planの変化。小さく温まったDBで、選ばれたplanが常に実測最速とは限らない。「Seq Scanだから悪い」と結論しない。
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42
ORDER BY created_at DESC , id DESC LIMIT 20 ;
CREATE INDEX orders_customer_created_id_idx
ON orders(customer_id, created_at DESC , id DESC );
D: Limit (actual time=1.164..1.166 rows=20.00 loops=1)
-> Sort (actual time=1.163..1.163 rows=20.00 loops=1)
Sort Key: created_at DESC, id DESC
Sort Method: top-N heapsort Memory: 35kB
-> Seq Scan on orders (actual time=0.130..0.519 rows=9000.00 loops=1)
E: Limit (actual time=0.020..0.022 rows=20.00 loops=1)
-> Index Scan using orders_customer_created_id_idx on orders
(cost=0.29..1762.55 rows=9000 width=117)
(actual time=0.019..0.020 rows=20.00 loops=1)
問いD :Sortが20行しか出していないのに、どこで9,000行を扱ったか? 回答後にEとの違いを聞く。
回答後の解説:返す行数と読む行数 DのSortは上位20行を残すが、選ぶために子から9,000行を受け取る。Eは絞り込み後の並びをindexで満たし、Limitが20行で止める。Eの推定9,000と実測20の差は早期終了であり、Bと同じ統計不良ではない。index追加には書き込み・容量・保守コストがある。Indexes and ORDER BY
同じ20行でも、その下の仕事は違う。 上の実測planのDとEを図へ対応づける。小片は行の集まりを示す模式表現で、個数・面積は実件数や時間に比例しない。
図を拡大する
Dで20なのはSortの出力 。上位20を確定するために9,000候補を受け取るが、全候補をメモリへ保存するという意味ではない。EではLimitの停止が子に伝わり、Index Scanの出力が20行で終わる。index内部の探索や可視性確認を含め「物理的に20行・20ページしか触らない」とは読まない。
この図の件数は上の保存済み実測に対応し、今回新しくDB実験した値ではない。木の字下げとrows/loopsは実測planを正本として読む。PostgreSQL 18 EXPLAIN (2026-09-09再確認)。
深いOFFSETも読み飛ばす行が必要。連続移動なら、同値時のidを含めたcursorを検討する。既存のcursor解説 を参照。cursorは順序キー更新まで含めたsnapshot一貫性を保証しない。
-- F: ordersは10行、customers.idはprimary key
EXPLAIN (ANALYZE, BUFFERS)
SELECT o . id , c . name FROM orders o JOIN customers c ON c . id = o . customer_id
EXPLAIN (ANALYZE, BUFFERS)
SELECT o . id , c . name FROM orders o JOIN customers c ON c . id = o . customer_id ;
F: Nested Loop (actual time=0.005..0.012 rows=10.00 loops=1)
-> Index Scan using orders_pkey on orders o
(actual time=0.002..0.003 rows=10.00 loops=1)
-> Index Scan using customers_pkey on customers c
(actual time=0.001..0.001 rows=1.00 loops=10)
Index Cond: (id = o.customer_id)
G: Hash Join (actual time=1.152..2.226 rows=10000.00 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on orders o (actual time=0.158..0.492 rows=10000.00 loops=1)
-> Hash (actual time=0.991..0.991 rows=10000.00 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 636kB
-> Seq Scan on customers c (actual time=0.002..0.411 rows=10000.00 loops=1)
問いF :内側のindexは何回呼ばれ、合計何行返したか? 回答後に、外側が大きくなった場合の代案を一つ聞く。
回答後の解説:反復と準備コストのtrade-off Fは外側10行に対し内側lookupを10回行い、計10行返す。平均約0.001ms × 10が内側処理の目安。Index Scanの名前だけで安いと判断せずloopsを見る。ただし大量反復でも総時間・buffersを確認し、回数だけで障害原因と断定しない。DB内部のNested LoopはAPIから100回SQLを送るN+1とは異なる。
外側が少なく内側indexでよく絞れるならNested Loopは準備が軽い。Gはcustomersを読んでHashを準備し、ordersを照合する。多数の等値照合で候補になるが、メモリ・準備時間が必要で、分割やtemp I/Oが出る場合もある。必ずHash Joinが勝つわけではなく、Merge JoinやMemoizeも候補。FとGは返す行数が異なるため、表示時間をそのままJOIN方式の勝敗にしない。Using EXPLAIN
B→Cが短い因果例になる。データ更新後も古いplanner statisticsを使うと、estimated rowsとactual rowsが離れることがある。ANALYZEは列の分布等を採取し、次の計画の見積もりに使う。乖離は古さの証拠とは限らず、列間相関、偏り、サンプリングの限界等も調べる。必要なら統計targetや拡張統計を検討するが、採取・計画コストも増える。Planner Statistics
通常はautovacuumによるauto-analyzeを使い、統計の鮮度を確認する。VACUUMは不要な行版の領域再利用等、ANALYZEはplanner向け統計更新を担う。Routine Vacuuming
PostgreSQL 18の目安は、前回ANALYZE以降の変更件数が autovacuum_analyze_threshold + autovacuum_analyze_scale_factor × 推定table行数 を超えること。既定値は50と0.1。演習上の追加条件 で1億行なら約1,000万変更、0.01に下げると約100万変更で候補になる。閾値を超えた瞬間に実行される保証はなく、workerの空きや確認周期も関係する。Vacuuming configuration
table単位で下げれば変化へ追従しやすいが、ANALYZEのCPU・I/O負荷や他tableとの競合が増える。last_autoanalyze、n_mod_since_analyze、autoanalyze_count等とplanの乖離・DB負荷を併せて見る。大規模loadの終了時刻が明確なら直後の明示ANALYZEも候補。日次batchだけだと更新直後から次回batchまで統計が古い時間が残る。今回のlabでは自動起動や巨大tableの負荷は検証していない。
対象SQL・parameter・返す行数・発行回数とAPIの遅い区間を対応づける。
planの木で、どのnodeから行が増え、どこで反復・sortされるか追う。
推定と実測の差を見つけ、Limitの早期終了と統計の誤推定を区別する。
時間・loops・buffers・temp I/Oから、影響の大きい処理を一つ選ぶ。
統計更新・SQL・index等を一つずつ変え、同じ条件で行数・plan・実時間を再確認する。
過去planがなければ、当時のデータだけでなく統計・設定・バージョンの再現可能性も確認する。実行計画だけでnetworkやpool待ちを説明し尽くしたとしない。
補強元:Issue #8 、session 2026-09-08-database-query-plans-01。「planをどう読むか」「indexがあるのに全表走査する理由」「統計とauto-analyzeの関係」を一般化して補強。一次資料はPostgreSQL 18、確認日2026-09-08。実測SQL・結果は18.6。
図表改善(2026-09-09):説明用SVGはSVG自体が正本。前提と判断は本文、状態・比較は図、通信順序・依存関係は既存図、実測は元の記録を参照する。教材編集を新しい学習実績にはしない。
補強元は2026-09-14-database-query-plans-02 / Issue #18 。以下は2026-09-21にCodexがPostgreSQL 18.6 / aarch64 の専用Docker DBで測定した値。本人の実測ではなく、セッションで使ったusers 30,000・概算600msの演習planとも別物です。
再現SQL ・実測全文 。新schemaに合成データを作りROLLBACKする。JIT/parallelはtransaction内で無効化、JOIN比較時のwork_memは4MB。全体の速度比較ではなく、木・行数・反復・メモリの観測に使う。
docker compose exec -T postgres psql -U learner -d tech2026 -v ON_ERROR_STOP= 1 -f /labs/query-plan-distribution.sql
usersは30,000、ordersは300,000。SQLとindexは同じで、users.activeがtrueの人数を10→30,000にしANALYZEする。データ更新はdead tupleや配置・cacheにも影響するので、分布だけを変えた厳密な速度実験とはしない。
SELECT o . id , o . payload FROM users u JOIN orders o ON o . user_id = u . id WHERE u . active ;
J1 Nested Loop rows=100 loops=1 shared hit=263
Seq Scan users rows=10 loops=1 shared hit=133
Bitmap Heap Scan orders rows=10 loops=10 shared hit=130
Bitmap Index Scan rows=10 loops=10 shared hit=30
J2 Hash Join rows=300000 loops=1 shared hit=5439
Seq Scan orders rows=300000 loops=1
Hash rows=30000 loops=1 Batches=1 Memory=1311kB
Seq Scan users rows=30000 loops=1
これは全文からactual rows/loops等を抜粋したもので、EXPLAINの全項目ではない。J1の内側は単純Index ScanではなくBitmap Heap/Index Scanになった。期待するnode名へ結果を書き換えず、実際の木を読む。
問い:J1で内側は何行を合計で返し、J2でhash tableを作るのはどちらか? その準備が必要でもJ2を選ぶ理由を説明してください。
回答後の解説:rows×loopsとbuild/probe J1内側は平均10行×10回=100行。usersをfilterした10人に対応するordersを探す。shared hit=130はnode全実行の累計なので、rowsと同じつもりでloops倍しない。親には子のbuffersが含まれるため木全体を足さない。
J2はHashの子であるusersの30,000行から表をbuildし、ordersの300,000行をprobeする。小さい側が候補になりやすいが、実際のbuild側は必ず木で確認する。lookupを大量反復するコストと、全走査・buildのコストを比較する。J1/J2のExecution Timeは0.757/59.806msだが、返す行数が100/300,000なので方式の勝敗ではない。Index/Seq Scanの同一SQL比較はこのページの既存A〜Cを使う。
観測
疑う原因
次の確認
対応とtrade-off
大量更新の後に乖離
古い統計
pg_stat_all_tablesのlast_analyze/last_autoanalyze/n_mod_since_analyze
ANALYZE、tableのauto-analyze閾値。採取負荷と鮮度の交換
特定の単一値だけ件数が巨大
単一列skew
pg_statsのmost_common_vals/freqs、n_distinct等
列のSET STATISTICSを検討しANALYZE。sampling・保存・planning負荷
AND条件だけ大きくずれる
複数列の相関
各条件単独と組合せの実件数、pg_stats_ext
適合するdependencies/mcv等をCREATE STATISTICSしてANALYZE。全組合せを作らない
値によって良いplanが違う
値を使わないGeneric Plan
EXPLAIN EXECUTEの$1/具体値、pg_prepared_statementsのgeneric_plans/custom_plans
Customと比較。planning負荷と実行短縮を両方測る
rowsは合うのに遅い
処理量・反復・spill・lock等
time/loops/BUFFERS/Sort Method/Batches、実行中wait event
計画精度以外へ。index追加やwork_mem全体増量へ直行しない
ANALYZEは過去queryの結果を覚える処理ではなく、現在のデータ分布をsampleする。複数列の独立性仮定の問題はsampleを増やすだけでは直らない。拡張統計も全種類の条件・JOIN推定へ万能に使えるわけではない。PostgreSQL 18:統計 ・統計更新
再確認:①単一tenantが90%を占める、②statusとregionが強く連動、③batch後だけずれる。この三つで、最初の確認を一つずつ選んでください。
回答後の解説:原因を決める前の観測 ①は単一列のMCVと対象値のplanを見る。parameter付きならGeneric/Customも比較する。②は単独条件と組合せの推定差を見て多列統計の適合を考える。③は更新件数とlast_analyze等を確認し、ANALYZE前後で同じSQLを比べる。どれも観測だけで原因確定とはしない。
PREPAREしたSQLが常にGenericになるわけではない。既定autoでは初期のCustomの推定cost等とGenericを比較して採用を決める。比較用labではforce_generic_plan / force_custom_planをtransaction内で明示して違いを観測した。実運用の既定autoの選択を実証した例ではない。PREPARE
通信順序図:値と計画の使い分け
図は概念的なPREPARE/EXECUTEの往復。Genericは実行時に値を受け取るが、値固有の分布を使ってplanを選び直さない。Customは今回の値を計画へ使う。SQLプロトコルの全メッセージや計測時間を表していない。
実測
値
node / estimated rows → actual
Planning / Execution(ms)
P1 Generic
通常99999
Index Scan / 32 → 1
0.201 / 0.038
P2 同じGeneric
巨大42
Index Scan / 32 → 90,000
0.003 / 8.062
P3 Custom
通常99999
Index Scan / 3 → 1
0.058 / 0.011
P4 Custom
巨大42
Seq Scan / 90,180 → 90,000
0.023 / 7.238
Generic側のIndex Condはtenant_id = $1、Custom側は具体値になった。今回の巨大tenantではSeq Scanへ変化したが、キャッシュが温まる順序・単発測定の違いがある。これだけで本番の効果量を保証しない。planningとexecutionを分け、代表値・頻度・同一負荷で繰り返して総コストを見る。
実装の概念例は、同じ接続をtransaction中保持し、巨大tenant経路だけ次のように設定する。通常経路はまず既定autoの観測から始め、「通常は必ずGeneric」と決めつけない。
SET LOCAL plan_cache_mode = force_custom_plan;
EXECUTE tenant_query( 42 );
Prepared Statementは接続単位なのでpoolの接続寿命・driverのprepare機能・proxyの動作を確かめる。SET LOCALはtransaction終了で戻る。tenant値を文字列連結する必要はない。まずbind parameterを保持する方式で、巨大tenantの識別条件・対象率・戻す条件を管理する。
問い:全文のS1/H1で、メモリ内に収まらなかった証拠を一つずつ探してください。
回答後の解説:sortとhashのtemp I/O S1は100,000行の推定と実測が一致しても、Sort Method: external merge Disk: 11696kB、temp read=5833 written=6145が出た。Execution Timeは162.263ms。H1はJ2と同じJOINで、work_memだけを64kBへ下げ、HashがBatches: 8 Memory Usage: 198kB、Hash Joinがtemp read=4303 written=4303となった。J2のBatches=1に対し複数batchへ分けた実例で、Execution Timeは112.947msだった。
低いwork_memは現象を見せるlab条件で、本番推奨値ではない。hashの上限にはhash_mem_multiplierも関係する。メモリはquery全体の単一枠ではなく、複数sort/hash・同時query・parallel workerで増え得る。必要な行・列を減らす案と対象queryの設定変更を比較する。shared readはOS cacheを含み得るため物理diskと断定しない。EXPLAIN ・work_mem
対象PostgreSQL 18、公式資料確認2026-09-21。実測は18.6。次回はこの出力をヒントなしで読めるかを一問ずつ確認し、教材追加を定着の証拠にはしない。