SQL パフォーマンス最適化 — カバリングINDEX・MVの応用

応用カバリングINDEXLATERALウィンドウフレーム / 移動平均マテリアライズドビューパーティショニング / PruningPostgreSQL対応全5問
1 / 5 · 学習中 0 / 5 問完了
QUESTION 6

カバリングインデックス — Index Only Scan でテーブルに触れない

INCLUDEIndex Only ScanVisibility MapIndex設計
前提知識

通常の Index Scan は「index で行の位置を特定 → ヒープ(テーブル本体)へ飛んで値を読む」の2段階です。出力したい列がすべて index 内に含まれていると、ヒープ訪問そのものを省略できる Index Only Scan が選ばれます。PostgreSQL 11+ では INCLUDE 句で「検索キーではないが、index リーフに同梱したい列」を追加できます。

-- ✓ カバリングインデックス:amount は検索キーでなく「同梱」
CREATE INDEX idx_orders_cover
  ON orders (customer_id, created_at) INCLUDE (amount);

-- ✗ (customer_id, created_at) のみ → amount のためヒープ訪問が毎行発生
Visibility Map:Index Only Scan が「本当にヒープを読まずに済む」のは、対象ページが全行可視(all-visible)と記録されている場合だけです。VACUUM が走っていないテーブルでは Heap Fetches が増えて効果が薄れるため、EXPLAIN (ANALYZE)Heap Fetches: 0 を確認するのが運用上の合格ラインです。
問題

orders(1000万行)に対し、ダッシュボードから「顧客101の 2024-06-01 以降の注文日と金額の一覧」が毎秒数百回流れます。ヒープ訪問ゼロ(Index Only Scan)で返せるインデックスを定義し、それを活かす SELECT 文を書いてください。

使用テーブル(概念図・8行)
- orders(実体1000万行 / 例として8行)
order_idcustomer_idcreated_atamountnote(巨大列)
11002024-05-011200
21012024-06-10800
31012024-07-222000
41012024-08-303000
51022024-04-15500
61012024-09-051500
71032024-07-01700
81012024-05-20900
期待出力
created_atamount
2024-06-10800
2024-07-222000
2024-08-303000
2024-09-051500
QUESTION 7

LATERAL JOIN — 「各行ごとの Top-N」を index 直撃で取る

LATERALTop-N per groupIndex Scan相関サブクエリ
前提知識

LATERAL は「左側の行を参照できるサブクエリを JOIN に置ける」構文です。各顧客の最新N件のような「グループごとの Top-N」を、顧客ごとに index を1回ずつ引く Nested Loop として素直に表現できます。全行に ROW_NUMBER を振ってから捨てるウィンドウ方式と違い、各グループで LIMIT N の時点で走査が止まるのが強みです。

-- 各 c に対して「c の最新2件」だけを index で取るループになる
FROM customers c
CROSS JOIN LATERAL (
  SELECT ... FROM orders o
  WHERE  o.customer_id = c.customer_id   -- 左側 c を参照できる!
  ORDER BY o.created_at DESC LIMIT 2
) recent
使い分けの目安:顧客数が少なく orders が巨大なら LATERAL(グループ数 × Index Scan)、全顧客×全注文を一括処理するバッチなら ウィンドウ関数(1パス全走査)。前提となる index は両者共通で (customer_id, created_at DESC) です。
問題

customers(3行)と orders(実体1000万行 / 例8行、(customer_id, created_at DESC) に index あり)から、各顧客の最新2注文を取得してください。出力列は name, created_at, amount、name 昇順 → created_at 降順で。注文が無い顧客は出力不要です。

使用テーブル
- customers(3行・駆動表)
customer_idname
101Alice
102Bob
103Carol
- orders(例8行・(customer_id, created_at DESC) に index)
order_idcustomer_idcreated_atamount
11012024-05-011200
21012024-06-10800
31012024-07-222000
41022024-08-303000
51022024-04-15500
61032024-09-051500
71032024-07-01700
81032024-05-20900
期待出力
namecreated_atamount
Alice2024-07-222000
Alice2024-06-10800
Bob2024-08-303000
Bob2024-04-15500
Carol2024-09-051500
Carol2024-07-01700
QUESTION 8

ウィンドウフレーム — ROWS BETWEEN で移動平均を1パスで計算

ウィンドウ関数ROWS BETWEEN移動平均ROWS vs RANGE
前提知識

ウィンドウ関数のフレーム句は「各行から見てどの範囲の行を集計対象にするか」を決めます。ROWS BETWEEN 2 PRECEDING AND CURRENT ROW なら「自分と直前2行」、つまり3行移動平均。テーブルはソート済み状態を1パス走査するだけで、自己結合なしに移動集計が完成します。

AVG(sales) OVER (
  ORDER BY sales_date                          -- フレームの並び順
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW    -- 自分+直前2行 = 3行窓
)
ROWS と RANGE の違い:ROWS物理的な行数RANGE値の範囲(同値は全部仲間)でフレームを切ります。さらに重要な罠:フレーム省略時のデフォルトは RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。同日データがあると「同値行を全部含む累計」になり、意図とズレます。移動集計は必ず ROWS を明示が鉄則です。
問題

daily_sales(日次売上)から、日付昇順で「3日移動平均」付きの一覧を取得してください。出力列は sales_date, sales, ma3(移動平均は小数1桁に丸め)。先頭2日は「存在する行だけ」で平均します。

使用テーブル
- daily_sales(6行・sales_date に index)
sales_datesales
2024-09-01100
2024-09-02200
2024-09-03300
2024-09-04600
2024-09-05300
2024-09-06900
期待出力
sales_datesalesma3
2024-09-01100100.0
2024-09-02200150.0
2024-09-03300200.0
2024-09-04600366.7
2024-09-05300400.0
2024-09-06900600.0
QUESTION 9

マテリアライズドビュー — 重い集計は「事前計算して保存」する

MATERIALIZED VIEWREFRESH CONCURRENTLY事前計算鮮度設計
前提知識

毎回同じ重い集計(月次売上など)を実行するのは無駄です。マテリアライズドビュー(MV)はクエリ結果を実体テーブルとして保存する仕組みで、参照側は計算済みの小さな表を読むだけになります。鮮度は REFRESH MATERIALIZED VIEW のタイミングで制御します。

-- 定義時に一度だけ集計が走り、結果が実体化される
CREATE MATERIALIZED VIEW mv_monthly_sales AS
SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS total
FROM orders GROUP BY 1;

-- 更新:CONCURRENTLY なら参照をブロックしない(一意 index 必須)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales;
通常 VIEW との違い:VIEW は「クエリの別名」で参照のたびに元テーブルを集計します。MV は「結果のスナップショット」で参照は速いが鮮度が古くなる「許容できる古さ(例:1時間)」を先に決め、その間隔で REFRESH するのが MV 設計の出発点です。
問題

orders(1000万行)への月次売上集計がダッシュボードで毎分実行され、DB 負荷の主因になっています。(1) 月次売上 MV を定義し、(2) 参照を止めずに更新できるよう一意 index を付与し、(3) ダッシュボード用の SELECT(month 昇順)を書いてください。

使用テーブル(概念図・6行)
- orders(実体1000万行 / 例として6行)
order_idcreated_atamount
12024-07-031200
22024-07-18800
32024-08-022000
42024-08-213000
52024-09-051500
62024-09-28700
期待出力
monthtotalorder_count
2024-07-0120002
2024-08-0150002
2024-09-0122002
QUESTION 10

パーティション・プルーニング — WHERE で「読む区画」ごと減らす

PARTITION BY RANGEPruningパーティションキー関数適用の罠
前提知識

テーブルパーティショニングは、巨大テーブルをパーティションキー(多くは日付)で物理的に複数の子テーブルに分割する仕組みです。WHERE 句がキーの範囲を特定できると、プランナは該当しない区画をプラン段階で丸ごと除外(Pruning)します。index が「行を絞る」のに対し、Pruning は「読むテーブル自体を絞る」一段上の足切りです。

CREATE TABLE orders (
  order_id bigint, created_at date, amount int, ...
) PARTITION BY RANGE (created_at);            -- パーティションキー

CREATE TABLE orders_2024_08 PARTITION OF orders
  FOR VALUES FROM ('2024-08-01') TO ('2024-09-01');  -- 月単位の区画
Pruning が効く条件:WHERE がパーティションキーに対して素の比較(=, <, >, BETWEEN)であること。DATE_TRUNC(created_at) のようにキー列を関数で包むと範囲が特定できず全区画スキャンになります(Sargable の原則がそのまま適用)。
問題

月単位 RANGE パーティション化された orders(12区画 × 各約100万行)から、2024-08 の売上合計と件数を取得してください。Pruning が効く WHERE(半開区間 >= / <)で書くこと。出力列は total, order_count

パーティション構成(概念図)
- orders(親)= 12 の子区画の集合
子テーブル範囲 (FROM 〜 TO)行数
orders_2024_0707-01 〜 08-01約100万
orders_2024_0808-01 〜 09-01約100万
orders_2024_0909-01 〜 10-01約100万
…(他9区画)
- orders_2024_08 内(例として3行)
order_idcreated_atamount
32024-08-022000
42024-08-213000
92024-08-301000
期待出力
totalorder_count
60003