PostgreSQLのパーティションで全パーティションスキャンされるケースを確認してみる

はじめに

PostgreSQLのパーティションテーブルは、大量のデータを効率的に管理するための機能である。
パーティションプルーニング(Partition Pruning)により、クエリ実行時に不要なパーティションをスキップできるため、パフォーマンスが向上する。

ただし、クエリの書き方によってはパーティションプルーニングが効かず、全パーティションがスキャンされてしまうケースがある。
業務でもあったのだが、どのようなケースで全パーティションスキャンが発生するのかを実際に検証してみる。

環境

Windows 11 Professional
PostgreSQL 17
Docker Desktop 4.49.0 (208700)

準備

同じ環境を使用したい場合は、以下のリポジトリに今回の検証内容をコミットしている。

パーティションプルーニングとは

パーティションプルーニングは、クエリの条件に基づいて、アクセスする必要のないパーティションを実行計画の段階で除外する技術である。

パーティションプルーニングのメリット

  • I/O削減
    不要なパーティションを読まない

  • メモリ効率
    スキャン対象データが減少

  • 実行時間短縮
    処理するデータ量が減る

今回作成するテーブル構成

今回は以下の売上データ(sales)テーブルを使用する。

テーブル構造

カラム名データ型説明
idBIGSERIAL主キー(自動採番)
sale_dateDATE売上日(パーティションキー)
customer_idINTEGER顧客ID
product_idINTEGER商品ID
amountDECIMAL(10, 2)金額
regionVARCHAR(50)地域(North/South/East/West)
statusVARCHAR(20)ステータス(pending/completed/cancelled)
created_atTIMESTAMP作成日時

パーティション構成

パーティション名範囲データ件数
sales_2024_012024-01-01 ~ 2024-01-31500件
sales_2024_022024-02-01 ~ 2024-02-29500件
sales_2024_032024-03-01 ~ 2024-03-31500件
sales_2024_042024-04-01 ~ 2024-04-30500件
sales_2024_052024-05-01 ~ 2024-05-31500件
sales_2024_062024-06-01 ~ 2024-06-30500件
合計2024年1月~6月3,000件

インデックス構成

インデックス名対象カラム種類
idx_sales_sale_datesale_date単一
idx_sales_customer_idcustomer_id単一
idx_sales_product_idproduct_id単一
idx_sales_regionregion単一
idx_sales_statusstatus単一
idx_sales_customer_productcustomer_id, product_id複合
idx_sales_region_statusregion, status複合

プルーニングが効く例について

-- 2024年3月のデータのみを検索
SELECT * FROM sales 
WHERE sale_date >= '2024-03-01' 
AND sale_date < '2024-04-01';
-- → sales_2024_03 パーティションのみスキャン

結果

Seq Scan on sales_2024_03 sales  (cost=0.00..13.50 rows=500 width=49) (actual time=0.012..0.050 rows=500 loops=1)
  Filter: ((sale_date >= '2024-03-01'::date) AND (sale_date < '2024-04-01'::date))
Planning Time: 0.116 ms
Execution Time: 0.069 ms
  • 1つのパーティションのみスキャン(sales_2024_03)
  • 500件のデータをすべて返却(rows=500)
  • 実行時間は0.069msと高速
  • パーティションプルーニングが正常に機能している

プルーニングが効かない例

-- 関数を使った条件
SELECT * FROM sales 
WHERE EXTRACT(MONTH FROM sale_date) = 3;
-- → 全パーティションスキャン

結果

Append  (cost=0.00..81.06 rows=12 width=48) (actual time=0.124..0.365 rows=500 loops=1)
  ->  Seq Scan on sales_2024_01 sales_1  (cost=0.00..13.50 rows=2 width=49) (actual time=0.063..0.063 rows=0 loops=1)
        Filter: (EXTRACT(month FROM sale_date) = '3'::numeric)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_02 sales_2  (cost=0.00..13.50 rows=2 width=48) (actual time=0.056..0.056 rows=0 loops=1)
        Filter: (EXTRACT(month FROM sale_date) = '3'::numeric)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_03 sales_3  (cost=0.00..13.50 rows=2 width=49) (actual time=0.004..0.060 rows=500 loops=1)
        Filter: (EXTRACT(month FROM sale_date) = '3'::numeric)
  ->  Seq Scan on sales_2024_04 sales_4  (cost=0.00..13.50 rows=2 width=49) (actual time=0.056..0.056 rows=0 loops=1)
        Filter: (EXTRACT(month FROM sale_date) = '3'::numeric)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_05 sales_5  (cost=0.00..13.50 rows=2 width=48) (actual time=0.056..0.056 rows=0 loops=1)
        Filter: (EXTRACT(month FROM sale_date) = '3'::numeric)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_06 sales_6  (cost=0.00..13.50 rows=2 width=48) (actual time=0.054..0.054 rows=0 loops=1)
        Filter: (EXTRACT(month FROM sale_date) = '3'::numeric)
        Rows Removed by Filter: 500
Planning Time: 0.217 ms
Execution Time: 0.389 ms
  • 全6パーティションがスキャンされている(sales_2024_01 ~ sales_2024_06)
  • 各パーティションで500件ずつ読み込んで、フィルタで除外している(Rows Removed by Filter: 500)
  • 最終的に500件のみ返却(sales_2024_03のデータ)
  • 実行時間は0.389msだが、不要なパーティションも読み込んでいるため無駄が発生

検証用テーブルの作成

パーティションテーブルの作成

売上データを月単位でパーティション分割する。

-- 親テーブルの作成(パーティションテーブル)
CREATE TABLE sales (
    id BIGSERIAL,
    sale_date DATE NOT NULL,
    customer_id INTEGER NOT NULL,
    product_id INTEGER NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    region VARCHAR(50) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) PARTITION BY RANGE (sale_date);

パーティションの作成

2024年1月から6月まで、月ごとにパーティションを作成する。

-- 2024年1月分
CREATE TABLE sales_2024_01 PARTITION OF sales
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

-- 2024年2月分
CREATE TABLE sales_2024_02 PARTITION OF sales
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

-- 2024年3月分
CREATE TABLE sales_2024_03 PARTITION OF sales
    FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');

-- 2024年4月分
CREATE TABLE sales_2024_04 PARTITION OF sales
    FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');

-- 2024年5月分
CREATE TABLE sales_2024_05 PARTITION OF sales
    FOR VALUES FROM ('2024-05-01') TO ('2024-06-01');

-- 2024年6月分
CREATE TABLE sales_2024_06 PARTITION OF sales
    FOR VALUES FROM ('2024-06-01') TO ('2024-07-01');

インデックスの作成

パーティションキーと、その他のカラムにインデックスを作成する。

-- パーティションキー(sale_date)のインデックス
CREATE INDEX idx_sales_sale_date ON sales (sale_date);

-- パーティションキー以外のカラムのインデックス
CREATE INDEX idx_sales_customer_id ON sales (customer_id);
CREATE INDEX idx_sales_product_id ON sales (product_id);
CREATE INDEX idx_sales_region ON sales (region);
CREATE INDEX idx_sales_status ON sales (status);

-- 複合インデックス
CREATE INDEX idx_sales_customer_product ON sales (customer_id, product_id);
CREATE INDEX idx_sales_region_status ON sales (region, status);

テストデータの挿入

各月に500件ずつ、合計3,000件のデータを挿入する。

-- 2024年1月分のデータ(500件)
INSERT INTO sales (sale_date, customer_id, product_id, amount, region, status)
SELECT
    DATE '2024-01-01' + (i % 31) * INTERVAL '1 day' AS sale_date,
    (random() * 1000)::INTEGER + 1 AS customer_id,
    (random() * 50)::INTEGER + 1 AS product_id,
    (random() * 10000)::DECIMAL(10, 2) + 100 AS amount,
    CASE (random() * 4)::INTEGER
        WHEN 0 THEN 'North'
        WHEN 1 THEN 'South'
        WHEN 2 THEN 'East'
        ELSE 'West'
    END AS region,
    CASE (random() * 3)::INTEGER
        WHEN 0 THEN 'pending'
        WHEN 1 THEN 'completed'
        ELSE 'cancelled'
    END AS status
FROM generate_series(1, 500) AS i;

-- 同様に2月~6月分のデータも挿入(省略)

統計情報の更新

ANALYZE sales;

パーティション情報の確認

SELECT 
    schemaname,
    tablename,
    pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE tablename LIKE 'sales%'
ORDER BY tablename;

実行結果

スキーマ名テーブル名サイズ
publicsales0 bytes
publicsales_2024_01232 kB
publicsales_2024_02232 kB
publicsales_2024_03232 kB
publicsales_2024_04232 kB
publicsales_2024_05232 kB
publicsales_2024_06232 kB

実際に試してみる

パーティションプルーニングが効くケース

ケース1: 正しい範囲指定

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date >= '2024-03-01' 
AND sale_date < '2024-04-01';

実行計画

Seq Scan on sales_2024_03 sales  (cost=0.00..13.50 rows=500 width=49) (actual time=0.040..0.082 rows=500 loops=1)
  Filter: ((sale_date >= '2024-03-01'::date) AND (sale_date < '2024-04-01'::date))
Planning Time: 0.163 ms
Execution Time: 0.103 ms

結果: sales_2024_03パーティションのみスキャン

ケース2: BETWEEN句の使用

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date BETWEEN '2024-03-01' AND '2024-03-31';

実行計画

Seq Scan on sales_2024_03 sales  (cost=0.00..13.50 rows=500 width=49) (actual time=0.017..0.068 rows=500 loops=1)
  Filter: ((sale_date >= '2024-03-01'::date) AND (sale_date <= '2024-03-31'::date))
Planning Time: 0.218 ms
Execution Time: 0.088 ms

結果: sales_2024_03パーティションのみスキャン

ケース3: 計算式

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date > '2024-03-01'::DATE + INTERVAL '1 day'
AND  sale_date < '2024-04-01'::DATE - INTERVAL '1 day'

実行計画

Seq Scan on sales_2024_03 sales  (cost=0.00..13.50 rows=451 width=49) (actual time=0.028..0.090 rows=451 loops=1)
  Filter: ((sale_date > '2024-03-02 00:00:00'::timestamp without time zone) AND (sale_date < '2024-03-31 00:00:00'::timestamp without time zone))
  Rows Removed by Filter: 49
Planning Time: 0.199 ms
Execution Time: 0.116 ms
  • 1つのパーティションのみスキャン(sales_2024_03)
  • 右辺の計算式('2024-03-01' + INTERVAL '1 day')は実行前に評価される
  • フィルタ条件は2024-03-022024-03-31に変換済み
  • 451件を返却し、49件はフィルタで除外(Rows Removed by Filter)
  • 右辺での計算はプルーニングに影響しない

結果: sales_2024_03パーティションのみスキャン

ケース4: OR条件で不連続な範囲

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date BETWEEN '2024-01-01' AND '2024-01-31'
   OR sale_date BETWEEN '2024-06-01' AND '2024-06-30';

実行計画

Append  (cost=0.00..37.00 rows=1000 width=48) (actual time=0.013..0.151 rows=1000 loops=1)
  ->  Seq Scan on sales_2024_01 sales_1  (cost=0.00..16.00 rows=500 width=49) (actual time=0.012..0.055 rows=500 loops=1)
        Filter: (((sale_date >= '2024-01-01'::date) AND (sale_date <= '2024-01-31'::date)) OR ((sale_date >= '2024-06-01'::date) AND (sale_date <= '2024-06-30'::date)))
  ->  Seq Scan on sales_2024_06 sales_2  (cost=0.00..16.00 rows=500 width=48) (actual time=0.005..0.055 rows=500 loops=1)
        Filter: (((sale_date >= '2024-01-01'::date) AND (sale_date <= '2024-01-31'::date)) OR ((sale_date >= '2024-06-01'::date) AND (sale_date <= '2024-06-30'::date)))
Planning Time: 0.161 ms
Execution Time: 0.184 ms
  • 2つのパーティションのみスキャン(sales_2024_01とsales_2024_06)
  • OR条件で不連続な範囲を指定しているが、プルーニングが正常に機能
  • 各パーティションから500件ずつ、合計1,000件を返却
  • 中間の4パーティション(02~05)は完全にスキップされている
  • PostgreSQLはOR条件の各部分を個別に評価してプルーニング可能
  • 実行時間0.184msと高速

結果: 2つのパーティション(sales_2024_01sales_2024_06)のみスキャン

理由: OR条件でも、PostgreSQLは各条件を評価してプルーニングできる。

パーティションプルーニングが効かないケース

ケース1: 関数を使った条件

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE EXTRACT(YEAR FROM sale_date) = 2024
AND EXTRACT(MONTH FROM sale_date) = 3;

実行計画

Append  (cost=0.00..96.03 rows=6 width=48) (actual time=0.197..0.579 rows=500 loops=1)
  ->  Seq Scan on sales_2024_01 sales_1  (cost=0.00..16.00 rows=1 width=49) (actual time=0.103..0.103 rows=0 loops=1)
        Filter: ((EXTRACT(year FROM sale_date) = '2024'::numeric) AND (EXTRACT(month FROM sale_date) = '3'::numeric))
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_02 sales_2  (cost=0.00..16.00 rows=1 width=48) (actual time=0.090..0.090 rows=0 loops=1)
        Filter: ((EXTRACT(year FROM sale_date) = '2024'::numeric) AND (EXTRACT(month FROM sale_date) = '3'::numeric))
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_03 sales_3  (cost=0.00..16.00 rows=1 width=49) (actual time=0.004..0.093 rows=500 loops=1)
        Filter: ((EXTRACT(year FROM sale_date) = '2024'::numeric) AND (EXTRACT(month FROM sale_date) = '3'::numeric))
  ->  Seq Scan on sales_2024_04 sales_4  (cost=0.00..16.00 rows=1 width=49) (actual time=0.089..0.089 rows=0 loops=1)
        Filter: ((EXTRACT(year FROM sale_date) = '2024'::numeric) AND (EXTRACT(month FROM sale_date) = '3'::numeric))
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_05 sales_5  (cost=0.00..16.00 rows=1 width=48) (actual time=0.086..0.087 rows=0 loops=1)
        Filter: ((EXTRACT(year FROM sale_date) = '2024'::numeric) AND (EXTRACT(month FROM sale_date) = '3'::numeric))
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_06 sales_6  (cost=0.00..16.00 rows=1 width=48) (actual time=0.098..0.098 rows=0 loops=1)
        Filter: ((EXTRACT(year FROM sale_date) = '2024'::numeric) AND (EXTRACT(month FROM sale_date) = '3'::numeric))
        Rows Removed by Filter: 500
Planning Time: 0.411 ms
Execution Time: 0.608 ms
  • 全6パーティションがスキャンされている(sales_2024_01 ~ sales_2024_06)
  • 各パーティションで500件ずつ読み込み、フィルタで不一致データを除外(Rows Removed by Filter: 500)
  • 最終的に500件のみ返却(sales_2024_03のデータ)
  • EXTRACT関数によりパーティションキーが変換されるため、プランニング時に範囲を判断できない
  • 実行時間0.608msは、プルーニング成功時の約5倍

結果: 全6パーティションスキャン

理由: パーティションキーに関数を適用すると、PostgreSQLは実行時まで値が確定できないため、全パーティションをスキャンする。

ケース2: 型変換を伴う条件

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date::TEXT LIKE '2024-03%';

実行計画

Append  (cost=0.00..88.56 rows=12 width=48) (actual time=0.164..0.423 rows=500 loops=1)
  ->  Seq Scan on sales_2024_01 sales_1  (cost=0.00..14.75 rows=2 width=49) (actual time=0.080..0.080 rows=0 loops=1)
        Filter: ((sale_date)::text ~~ '2024-03%'::text)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_02 sales_2  (cost=0.00..14.75 rows=2 width=48) (actual time=0.078..0.078 rows=0 loops=1)
        Filter: ((sale_date)::text ~~ '2024-03%'::text)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_03 sales_3  (cost=0.00..14.75 rows=2 width=49) (actual time=0.005..0.067 rows=500 loops=1)
        Filter: ((sale_date)::text ~~ '2024-03%'::text)
  ->  Seq Scan on sales_2024_04 sales_4  (cost=0.00..14.75 rows=2 width=49) (actual time=0.060..0.060 rows=0 loops=1)
        Filter: ((sale_date)::text ~~ '2024-03%'::text)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_05 sales_5  (cost=0.00..14.75 rows=2 width=48) (actual time=0.059..0.059 rows=0 loops=1)
        Filter: ((sale_date)::text ~~ '2024-03%'::text)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_06 sales_6  (cost=0.00..14.75 rows=2 width=48) (actual time=0.059..0.059 rows=0 loops=1)
        Filter: ((sale_date)::text ~~ '2024-03%'::text)
        Rows Removed by Filter: 500
Planning Time: 0.226 ms
Execution Time: 0.479 ms
  • 全6パーティションがスキャンされている(sales_2024_01 ~ sales_2024_06)
  • 型変換(::TEXT)により、DATE型がTEXT型に変換されている
  • LIKE演算子(~~)でパターンマッチングを実行
  • 各パーティションで500件読み込み、2,500件をフィルタで除外
  • 型変換によりパーティション範囲の判断が不可能
  • 実行時間0.479msは、プルーニング成功時の約4倍

結果: 全6パーティションスキャン

理由: 型変換(キャスト)により、PostgreSQLはパーティション範囲を判断できなくなる。

ケース3: 否定条件

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date != '2024-03-15';

実行計画

実行計画

```txt
Append  (cost=0.00..88.42 rows=2984 width=48) (actual time=0.022..0.375 rows=2984 loops=1)
  ->  Seq Scan on sales_2024_01 sales_1  (cost=0.00..12.25 rows=500 width=49) (actual time=0.020..0.064 rows=500 loops=1)
        Filter: (sale_date <> '2024-03-15'::date)
  ->  Seq Scan on sales_2024_02 sales_2  (cost=0.00..12.25 rows=500 width=48) (actual time=0.007..0.047 rows=500 loops=1)
        Filter: (sale_date <> '2024-03-15'::date)
  ->  Seq Scan on sales_2024_03 sales_3  (cost=0.00..12.25 rows=484 width=49) (actual time=0.005..0.039 rows=484 loops=1)
        Filter: (sale_date <> '2024-03-15'::date)
        Rows Removed by Filter: 16
  ->  Seq Scan on sales_2024_04 sales_4  (cost=0.00..12.25 rows=500 width=49) (actual time=0.003..0.035 rows=500 loops=1)
        Filter: (sale_date <> '2024-03-15'::date)
  ->  Seq Scan on sales_2024_05 sales_5  (cost=0.00..12.25 rows=500 width=48) (actual time=0.003..0.036 rows=500 loops=1)
        Filter: (sale_date <> '2024-03-15'::date)
  ->  Seq Scan on sales_2024_06 sales_6  (cost=0.00..12.25 rows=500 width=48) (actual time=0.003..0.035 rows=500 loops=1)
        Filter: (sale_date <> '2024-03-15'::date)
Planning Time: 0.227 ms
Execution Time: 0.465 ms
  • 全6パーティションがスキャンされている(sales_2024_01 ~ sales_2024_06)
  • 否定条件(<>)により、全パーティションに該当データが存在する可能性がある
  • 合計2,984件を返却(3,000件中16件のみ除外)
  • sales_2024_03で16件のみフィルタで除外(2024-03-15のデータ)
  • 否定条件では、どのパーティションも除外できない

結果: 全6パーティションスキャン

理由: 否定条件では、どのパーティションにも該当するデータが存在する可能性があるため、全パーティションをスキャンする必要がある。

ケース4: サブクエリで動的に値を決定

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date = (SELECT MAX(sale_date) FROM sales);

実行計画

実行計画

```txt
Append  (cost=5.25..64.36 rows=99 width=48) (actual time=0.039..0.046 rows=16 loops=1)
  InitPlan 2 (サブクエリによるMAX値取得)
    ->  Result  (cost=0.97..0.98 rows=1 width=4) (actual time=0.027..0.028 rows=1 loops=1)
          InitPlan 1
            ->  Limit  (cost=0.88..0.97 rows=1 width=4) (actual time=0.025..0.026 rows=1 loops=1)
                  ->  Append  (cost=0.88..252.59 rows=3000 width=4) (actual time=0.024..0.024 rows=1 loops=1)
                        ->  Index Only Scan Backward using sales_2024_06_sale_date_idx on sales_2024_06 sales_13  (cost=0.15..39.61 rows=500 width=4) (actual time=0.024..0.024 rows=1 loops=1)
                              Heap Fetches: 1
                        ->  Index Only Scan Backward using sales_2024_05_sale_date_idx on sales_2024_05 sales_12  (cost=0.15..39.59 rows=500 width=4) (never executed)
                              Heap Fetches: 0
                        ->  Index Only Scan Backward using sales_2024_04_sale_date_idx on sales_2024_04 sales_11  (cost=0.15..39.61 rows=500 width=4) (never executed)
                        ->  Index Only Scan Backward using sales_2024_03_sale_date_idx on sales_2024_03 sales_10  (cost=0.15..39.59 rows=500 width=4) (never executed)
                        ->  Index Only Scan Backward using sales_2024_02_sale_date_idx on sales_2024_02 sales_9  (cost=0.15..39.61 rows=500 width=4) (never executed)
                        ->  Index Only Scan Backward using sales_2024_01_sale_date_idx on sales_2024_01 sales_8  (cost=0.15..39.59 rows=500 width=4) (never executed)
  ->  Bitmap Heap Scan on sales_2024_01 sales_1  (cost=4.27..10.47 rows=16 width=49) (never executed)
        Recheck Cond: (sale_date = (InitPlan 2).col1)
        ->  Bitmap Index Scan on sales_2024_01_sale_date_idx  (cost=0.00..4.27 rows=16 width=0) (never executed)
              Index Cond: (sale_date = (InitPlan 2).col1)
  ->  Bitmap Heap Scan on sales_2024_02 sales_2  (cost=4.28..10.49 rows=17 width=48) (never executed)
        Recheck Cond: (sale_date = (InitPlan 2).col1)
        ->  Bitmap Index Scan on sales_2024_02_sale_date_idx  (cost=0.00..4.28 rows=17 width=0) (never executed)
              Index Cond: (sale_date = (InitPlan 2).col1)
  ->  Bitmap Heap Scan on sales_2024_03 sales_3  (cost=4.27..10.47 rows=16 width=49) (never executed)
        Recheck Cond: (sale_date = (InitPlan 2).col1)
        ->  Bitmap Index Scan on sales_2024_03_sale_date_idx  (cost=0.00..4.27 rows=16 width=0) (never executed)
              Index Cond: (sale_date = (InitPlan 2).col1)
  ->  Bitmap Heap Scan on sales_2024_04 sales_4  (cost=4.28..10.49 rows=17 width=49) (never executed)
        Recheck Cond: (sale_date = (InitPlan 2).col1)
        ->  Bitmap Index Scan on sales_2024_04_sale_date_idx  (cost=0.00..4.28 rows=17 width=0) (never executed)
              Index Cond: (sale_date = (InitPlan 2).col1)
  ->  Bitmap Heap Scan on sales_2024_05 sales_5  (cost=4.27..10.47 rows=16 width=48) (never executed)
        Recheck Cond: (sale_date = (InitPlan 2).col1)
        ->  Bitmap Index Scan on sales_2024_05_sale_date_idx  (cost=0.00..4.27 rows=16 width=0) (never executed)
              Index Cond: (sale_date = (InitPlan 2).col1)
  ->  Bitmap Heap Scan on sales_2024_06 sales_6  (cost=4.28..10.49 rows=17 width=48) (actual time=0.009..0.014 rows=16 loops=1)
        Recheck Cond: (sale_date = (InitPlan 2).col1)
        Heap Blocks: exact=5
        ->  Bitmap Index Scan on sales_2024_06_sale_date_idx  (cost=0.00..4.28 rows=17 width=0) (actual time=0.005..0.005 rows=16 loops=1)
              Index Cond: (sale_date = (InitPlan 2).col1)
Planning Time: 0.605 ms
Execution Time: 0.091 ms
  • 全6パーティションに対してスキャン計画が作成されている
  • InitPlan(サブクエリ)で全パーティションからMAX値を取得(逆順インデックススキャンで最大値を効率的に取得)
  • 実際にはsales_2024_06のみ実行された(actual timeがある)が、プランニング時には全パーティションが対象
  • サブクエリの結果は実行時(0.027ms時点)に確定
  • 実行時プルーニングが効いて、最終的にはsales_2024_06のみスキャン(16件返却)
  • プランニング時にはプルーニング不可だが、実行時最適化により効率的に処理

結果: プランニング時は全6パーティション対象、実行時プルーニングで1パーティションのみスキャン

理由: サブクエリの結果は実行時にしか決まらないため、プランニング時にはプルーニングできない。ただし、実行時には最適化が行われる。

ケース5: 左辺に計算式

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date + INTERVAL '1 day' > '2024-03-01';

実行計画

Append  (cost=0.00..86.01 rows=1002 width=48) (actual time=0.144..0.450 rows=2000 loops=1)
  ->  Seq Scan on sales_2024_01 sales_1  (cost=0.00..13.50 rows=167 width=49) (actual time=0.080..0.080 rows=0 loops=1)
        Filter: ((sale_date + '1 day'::interval) > '2024-03-01 00:00:00'::timestamp without time zone)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_02 sales_2  (cost=0.00..13.50 rows=167 width=48) (actual time=0.060..0.060 rows=0 loops=1)
        Filter: ((sale_date + '1 day'::interval) > '2024-03-01 00:00:00'::timestamp without time zone)
        Rows Removed by Filter: 500
  ->  Seq Scan on sales_2024_03 sales_3  (cost=0.00..13.50 rows=167 width=49) (actual time=0.003..0.068 rows=500 loops=1)
        Filter: ((sale_date + '1 day'::interval) > '2024-03-01 00:00:00'::timestamp without time zone)
  ->  Seq Scan on sales_2024_04 sales_4  (cost=0.00..13.50 rows=167 width=49) (actual time=0.004..0.063 rows=500 loops=1)
        Filter: ((sale_date + '1 day'::interval) > '2024-03-01 00:00:00'::timestamp without time zone)
  ->  Seq Scan on sales_2024_05 sales_5  (cost=0.00..13.50 rows=167 width=48) (actual time=0.005..0.052 rows=500 loops=1)
        Filter: ((sale_date + '1 day'::interval) > '2024-03-01 00:00:00'::timestamp without time zone)
  ->  Seq Scan on sales_2024_06 sales_6  (cost=0.00..13.50 rows=167 width=48) (actual time=0.003..0.051 rows=500 loops=1)
        Filter: ((sale_date + '1 day'::interval) > '2024-03-01 00:00:00'::timestamp without time zone)
Planning Time: 0.216 ms
Execution Time: 0.508 ms
  • 全6パーティションがスキャンされている(sales_2024_01 ~ sales_2024_06)
  • 左辺に計算式(sale_date + '1 day')があるため、パーティションキーが変換される
  • 合計2,000件を返却(sales_2024_03以降の4パーティション)
  • sales_2024_01と02では全件除外(Rows Removed by Filter: 500)
  • 各行ごとに計算が必要なため、パーティション範囲の事前判断が不可能
  • 実行時間0.508msは、プルーニング成功時の約4倍

結果: 全6パーティションスキャン

理由: パーティションキーに対して計算を行うと、PostgreSQLはパーティション範囲を判断できなくなる。

ケース6: 広すぎる範囲指定

EXPLAIN ANALYZE
SELECT * FROM sales 
WHERE sale_date >= '2024-01-01';

実行計画

Append  (cost=0.00..88.50 rows=3000 width=48) (actual time=0.025..0.399 rows=3000 loops=1)
  ->  Seq Scan on sales_2024_01 sales_1  (cost=0.00..12.25 rows=500 width=49) (actual time=0.024..0.084 rows=500 loops=1)
        Filter: (sale_date >= '2024-01-01'::date)
  ->  Seq Scan on sales_2024_02 sales_2  (cost=0.00..12.25 rows=500 width=48) (actual time=0.006..0.042 rows=500 loops=1)
        Filter: (sale_date >= '2024-01-01'::date)
  ->  Seq Scan on sales_2024_03 sales_3  (cost=0.00..12.25 rows=500 width=49) (actual time=0.004..0.040 rows=500 loops=1)
        Filter: (sale_date >= '2024-01-01'::date)
  ->  Seq Scan on sales_2024_04 sales_4  (cost=0.00..12.25 rows=500 width=49) (actual time=0.004..0.037 rows=500 loops=1)
        Filter: (sale_date >= '2024-01-01'::date)
  ->  Seq Scan on sales_2024_05 sales_5  (cost=0.00..12.25 rows=500 width=48) (actual time=0.004..0.038 rows=500 loops=1)
        Filter: (sale_date >= '2024-01-01'::date)
  ->  Seq Scan on sales_2024_06 sales_6  (cost=0.00..12.25 rows=500 width=48) (actual time=0.004..0.037 rows=500 loops=1)
        Filter: (sale_date >= '2024-01-01'::date)
Planning Time: 0.283 ms
Execution Time: 0.492 ms
  • 全6パーティションがスキャンされている(sales_2024_01 ~ sales_2024_06)
  • 合計3,000件すべてを返却(全パーティションの全データ)
  • 各パーティションで500件ずつ読み込み、すべて条件に一致
  • 条件(>= '2024-01-01')が全パーティションの範囲に該当
  • これはプルーニングの失敗ではなく、条件が広すぎることが原因
  • 実行時間0.492msは比較的長め

結果: 全6パーティションスキャン

理由: 条件が全パーティションに該当するため、結果的に全パーティションがスキャンされる。これはプルーニングの失敗ではなく、条件が広すぎることが原因。

パーティションプルーニングの確認方法

EXPLAIN (VERBOSE) を使う

EXPLAIN (VERBOSE, COSTS OFF)
SELECT * FROM sales 
WHERE sale_date BETWEEN '2024-03-01' AND '2024-03-31';

実行計画に以下が表示される

Seq Scan on public.sales_2024_03 sales
  Output: sales.id, sales.sale_date, sales.customer_id, sales.product_id, sales.amount, sales.region, sales.status, sales.created_at
  Filter: ((sales.sale_date >= '2024-03-01'::date) AND (sales.sale_date <= '2024-03-31'::date))

スキャンされるパーティションが明示的に表示される。

Subplans Removed の確認

複数のパーティションが定義されている場合、プルーニングされたパーティション数が表示されることがある。

Subplans Removed: 5

これは、6パーティション中5パーティションがプルーニングされ、1パーティションのみスキャンされたことを示す。

まとめ: プルーニングが効く/効かないケース

プルーニングが効かないケース(全パーティションスキャン)

ケースNG例理由
関数適用EXTRACT(MONTH FROM sale_date) = 3実行時まで値が確定しない
型変換sale_date::TEXT LIKE '2024-03%'パーティション範囲が判断できない
否定条件sale_date != '2024-03-15'全パーティションに該当データがある可能性
動的な値(サブクエリ)sale_date = (SELECT MAX(sale_date) ...)プランニング時に値が不明
左辺に計算式sale_date + INTERVAL '1 day' > '2024-03-01'パーティションキーが変換される
広すぎる範囲sale_date >= '2024-01-01'全パーティションが条件に該当

プルーニングを効かせるためにどうするか

推奨事項OK例
等号、不等号、BETWEEN を使うsale_date BETWEEN '2024-03-01' AND '2024-03-31'
範囲を明示的に指定するsale_date >= '2024-03-01' AND sale_date < '2024-04-01'
右辺に関数や計算を配置する(左辺はシンプルに)sale_date > '2024-03-01' - INTERVAL '1 day'

実行計画の見方

プルーニングが効いている例

Append
  ->  Seq Scan on sales_2024_03  (1つのパーティションのみ)

プルーニングが効いていない例

Append
  ->  Seq Scan on sales_2024_01
  ->  Seq Scan on sales_2024_02
  ->  Seq Scan on sales_2024_03
  ->  Seq Scan on sales_2024_04
  ->  Seq Scan on sales_2024_05
  ->  Seq Scan on sales_2024_06  (全6パーティション)

参考

おわりに

PostgreSQLのパーティションテーブルは、適切に使用すればパフォーマンスを向上できることがわかった。
ただし、クエリの書き方によってはパーティションプルーニングが効かず、全パーティションスキャンが発生してしまう。

今回の検証で、以下の点に注意する必要があることがわかった。

  • パーティションキーに関数や型変換を適用しない
  • 左辺はシンプルに、計算は右辺で行う
  • 否定条件やサブクエリは避ける

実際に発行するSQLに対して、EXPLAIN ANALYZEを使って実行計画を確認し、意図したパーティションのみがスキャンされているかを確認したほうがよさそうだ。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。