カバリングインデックス — Index Only Scan でテーブルに触れない
通常の 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 のためヒープ訪問が毎行発生
EXPLAIN (ANALYZE) の Heap Fetches: 0 を確認するのが運用上の合格ラインです。orders(1000万行)に対し、ダッシュボードから「顧客101の 2024-06-01 以降の注文日と金額の一覧」が毎秒数百回流れます。ヒープ訪問ゼロ(Index Only Scan)で返せるインデックスを定義し、それを活かす SELECT 文を書いてください。
| order_id | customer_id | created_at | amount | note(巨大列) |
|---|---|---|---|---|
| 1 | 100 | 2024-05-01 | 1200 | … |
| 2 | 101 | 2024-06-10 | 800 | … |
| 3 | 101 | 2024-07-22 | 2000 | … |
| 4 | 101 | 2024-08-30 | 3000 | … |
| 5 | 102 | 2024-04-15 | 500 | … |
| 6 | 101 | 2024-09-05 | 1500 | … |
| 7 | 103 | 2024-07-01 | 700 | … |
| 8 | 101 | 2024-05-20 | 900 | … |
| created_at | amount |
|---|---|
| 2024-06-10 | 800 |
| 2024-07-22 | 2000 |
| 2024-08-30 | 3000 |
| 2024-09-05 | 1500 |
LATERAL JOIN — 「各行ごとの Top-N」を index 直撃で取る
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
(customer_id, created_at DESC) です。customers(3行)と orders(実体1000万行 / 例8行、(customer_id, created_at DESC) に index あり)から、各顧客の最新2注文を取得してください。出力列は name, created_at, amount、name 昇順 → created_at 降順で。注文が無い顧客は出力不要です。
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
| order_id | customer_id | created_at | amount |
|---|---|---|---|
| 1 | 101 | 2024-05-01 | 1200 |
| 2 | 101 | 2024-06-10 | 800 |
| 3 | 101 | 2024-07-22 | 2000 |
| 4 | 102 | 2024-08-30 | 3000 |
| 5 | 102 | 2024-04-15 | 500 |
| 6 | 103 | 2024-09-05 | 1500 |
| 7 | 103 | 2024-07-01 | 700 |
| 8 | 103 | 2024-05-20 | 900 |
| name | created_at | amount |
|---|---|---|
| Alice | 2024-07-22 | 2000 |
| Alice | 2024-06-10 | 800 |
| Bob | 2024-08-30 | 3000 |
| Bob | 2024-04-15 | 500 |
| Carol | 2024-09-05 | 1500 |
| Carol | 2024-07-01 | 700 |
ウィンドウフレーム — ROWS BETWEEN で移動平均を1パスで計算
ウィンドウ関数のフレーム句は「各行から見てどの範囲の行を集計対象にするか」を決めます。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 は値の範囲(同値は全部仲間)でフレームを切ります。さらに重要な罠:フレーム省略時のデフォルトは RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。同日データがあると「同値行を全部含む累計」になり、意図とズレます。移動集計は必ず ROWS を明示が鉄則です。daily_sales(日次売上)から、日付昇順で「3日移動平均」付きの一覧を取得してください。出力列は sales_date, sales, ma3(移動平均は小数1桁に丸め)。先頭2日は「存在する行だけ」で平均します。
| sales_date | sales |
|---|---|
| 2024-09-01 | 100 |
| 2024-09-02 | 200 |
| 2024-09-03 | 300 |
| 2024-09-04 | 600 |
| 2024-09-05 | 300 |
| 2024-09-06 | 900 |
| sales_date | sales | ma3 |
|---|---|---|
| 2024-09-01 | 100 | 100.0 |
| 2024-09-02 | 200 | 150.0 |
| 2024-09-03 | 300 | 200.0 |
| 2024-09-04 | 600 | 366.7 |
| 2024-09-05 | 300 | 400.0 |
| 2024-09-06 | 900 | 600.0 |
マテリアライズドビュー — 重い集計は「事前計算して保存」する
毎回同じ重い集計(月次売上など)を実行するのは無駄です。マテリアライズドビュー(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;
orders(1000万行)への月次売上集計がダッシュボードで毎分実行され、DB 負荷の主因になっています。(1) 月次売上 MV を定義し、(2) 参照を止めずに更新できるよう一意 index を付与し、(3) ダッシュボード用の SELECT(month 昇順)を書いてください。
| order_id | created_at | amount |
|---|---|---|
| 1 | 2024-07-03 | 1200 |
| 2 | 2024-07-18 | 800 |
| 3 | 2024-08-02 | 2000 |
| 4 | 2024-08-21 | 3000 |
| 5 | 2024-09-05 | 1500 |
| 6 | 2024-09-28 | 700 |
| month | total | order_count |
|---|---|---|
| 2024-07-01 | 2000 | 2 |
| 2024-08-01 | 5000 | 2 |
| 2024-09-01 | 2200 | 2 |
パーティション・プルーニング — WHERE で「読む区画」ごと減らす
テーブルパーティショニングは、巨大テーブルをパーティションキー(多くは日付)で物理的に複数の子テーブルに分割する仕組みです。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'); -- 月単位の区画
DATE_TRUNC(created_at) のようにキー列を関数で包むと範囲が特定できず全区画スキャンになります(Sargable の原則がそのまま適用)。月単位 RANGE パーティション化された orders(12区画 × 各約100万行)から、2024-08 の売上合計と件数を取得してください。Pruning が効く WHERE(半開区間 >= / <)で書くこと。出力列は total, order_count。
| 子テーブル | 範囲 (FROM 〜 TO) | 行数 |
|---|---|---|
| orders_2024_07 | 07-01 〜 08-01 | 約100万 |
| orders_2024_08 | 08-01 〜 09-01 | 約100万 |
| orders_2024_09 | 09-01 〜 10-01 | 約100万 |
| …(他9区画) | … | … |
| order_id | created_at | amount |
|---|---|---|
| 3 | 2024-08-02 | 2000 |
| 4 | 2024-08-21 | 3000 |
| 9 | 2024-08-30 | 1000 |
| total | order_count |
|---|---|
| 6000 | 3 |