LabHub

ブログ

PostgreSQL 17パーティショニングとパラレルクエリガイド

한국어English日本語

PostgreSQL 17 パーティショニング戦略とパラレルクエリ最適化の完全ガイド

PostgreSQL 17 でパーティショニングとパラレルクエリが重要な理由

大規模な運用環境では、単一テーブルの行数が数億件を超えると、インデックスのサイズ、vacuum の所要時間、クエリの応答速度がいずれも急激に悪化する。PostgreSQL 17 は宣言的パーティショニング (Declarative Partitioning) とパラレルクエリ (Parallel Query) を一段階引き上げた。パーティション化されたテーブルで identity column と exclusion constraint を直接サポートし、ALTER TABLE ... MERGE PARTITIONSSPLIT PARTITION 構文でパーティション境界を動的に変更できるようになった。パラレルクエリ側では FULL OUTER JOIN と集約関数に対する並列処理が拡大され、GIN インデックスの Parallel Create Index、相関サブクエリの並列化などが追加された。

この記事では、パーティショニングのタイプ別の設計戦略、パーティションプルーニングの原理、パラレルクエリ実行計画の分析、大規模テーブルの移行手順、そして実際の運用で出会うトラブルシューティングまでをコードとともに扱う。

パーティショニングのタイプ概要

PostgreSQL は 3 つの基本的なパーティショニング戦略と、それらを組み合わせた複合パーティショニングをサポートする。

Range パーティショニング

時系列データや連続値の範囲に適している。注文テーブルを月別に分けるのが代表例だ。

-- Range パーティショニング: 月別の注文テーブル
CREATE TABLE orders (
    order_id    BIGSERIAL,
    customer_id BIGINT NOT NULL,
    order_date  DATE NOT NULL,
    total_amount NUMERIC(12,2),
    status      VARCHAR(20) DEFAULT 'pending'
) PARTITION BY RANGE (order_date);

-- 月別パーティションの作成
CREATE TABLE orders_2026_01 PARTITION OF orders
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE orders_2026_02 PARTITION OF orders
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE orders_2026_03 PARTITION OF orders
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- デフォルトパーティション (範囲に該当しないデータを受け入れる)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

-- 各パーティションにローカルインデックスが自動作成される
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_status ON orders (status, order_date);

List パーティショニング

離散的なカテゴリ値 (地域、ステータスコードなど) で分割するときに使う。

-- List パーティショニング: 地域別のユーザーテーブル
CREATE TABLE users (
    user_id     BIGSERIAL,
    username    VARCHAR(100) NOT NULL,
    email       VARCHAR(255) NOT NULL,
    region      VARCHAR(10) NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT now()
) PARTITION BY LIST (region);

CREATE TABLE users_kr PARTITION OF users
    FOR VALUES IN ('KR');

CREATE TABLE users_us PARTITION OF users
    FOR VALUES IN ('US');

CREATE TABLE users_eu PARTITION OF users
    FOR VALUES IN ('DE', 'FR', 'GB', 'IT', 'ES');

CREATE TABLE users_apac PARTITION OF users
    FOR VALUES IN ('JP', 'SG', 'AU', 'IN');

CREATE TABLE users_others PARTITION OF users DEFAULT;

Hash パーティショニング

特定カラムのハッシュ値で均等に分配する。データに自然な範囲やカテゴリがないときに有用だ。

-- Hash パーティショニング: セッションテーブルを 4 パーティションに均等分配
CREATE TABLE sessions (
    session_id  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     BIGINT NOT NULL,
    payload     JSONB,
    created_at  TIMESTAMPTZ DEFAULT now(),
    expires_at  TIMESTAMPTZ
) PARTITION BY HASH (session_id);

CREATE TABLE sessions_p0 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_p2 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_p3 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

複合パーティショニング (Sub-Partitioning)

Range と List を組み合わせて 2 段階のパーティショニングを構成できる。

-- 複合パーティショニング: 年別 Range -> 地域別 List
CREATE TABLE events (
    event_id    BIGSERIAL,
    event_type  VARCHAR(50),
    region      VARCHAR(10),
    event_date  DATE NOT NULL,
    payload     JSONB
) PARTITION BY RANGE (event_date);

CREATE TABLE events_2026 PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')
    PARTITION BY LIST (region);

CREATE TABLE events_2026_kr PARTITION OF events_2026
    FOR VALUES IN ('KR');

CREATE TABLE events_2026_us PARTITION OF events_2026
    FOR VALUES IN ('US');

CREATE TABLE events_2026_eu PARTITION OF events_2026
    FOR VALUES IN ('DE', 'FR', 'GB');

Range・List・Hash パーティショニングの選択基準の比較

項目RangeListHash
適したデータ時系列、連続範囲離散カテゴリ均等分配が必要
パーティションキーの例order_date, created_atregion, statususer_id, session_id
パーティションプルーニング範囲条件で有効等号条件で有効ハッシュキーの等号のみ動作
データ偏りのリスク特定期間に集中しうる特定値に集中しうる低い (均等分配)
新規パーティションの追加容易 (将来の範囲を追加)容易 (新しい値を追加)不可 (再ハッシュが必要)
パーティションの削除DROP で即時削除DROP で即時削除個別削除は不可
保管・アーカイブ優秀 (古いパーティションを分離)普通難しい
複合パーティショニング対応対応2 段階は不可
PostgreSQL 17 の改善MERGE/SPLIT 対応MERGE/SPLIT 対応限定的

選択基準のまとめ: 時系列ログや注文データなら Range、地域やステータスコードが軸なら List、均等分配が最も重要でプルーニングの重要度が低ければ Hash を選ぶ。実務では Range + List の複合パーティショニングが最も多い。

パーティションプルーニングの原理と最適化

パーティションプルーニング (Partition Pruning) は、クエリの WHERE 句を解析して不要なパーティションを実行計画から除外する仕組みだ。PostgreSQL 17 ではコンパイル時プルーニングとランタイムプルーニングの両方が動作する。

コンパイル時プルーニング

クエリの計画段階で定数条件を評価し、パーティションを除外する。

-- コンパイル時プルーニングの確認
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE order_date >= '2026-03-01' AND order_date < '2026-04-01';

/*
結果:
  Append
    -> Seq Scan on orders_2026_03
          Filter: ((order_date >= '2026-03-01') AND (order_date < '2026-04-01'))
-- orders_2026_01、orders_2026_02 はプルーニングされスキャンされない
*/

ランタイムプルーニング

パラメータ化されたクエリやサブクエリの結果に応じて、実行時点でプルーニングが働く。

-- ランタイムプルーニング: Prepared Statement で動作する
PREPARE get_orders(date, date) AS
SELECT * FROM orders WHERE order_date >= $1 AND order_date < $2;

EXPLAIN ANALYZE EXECUTE get_orders('2026-02-01', '2026-03-01');
-- 実行時点で orders_2026_02 だけをスキャンする

プルーニングが動作しないケース

次の状況ではプルーニングが働かないため注意が必要だ。

-- プルーニング動作の確認: enable_partition_pruning の設定
SHOW enable_partition_pruning;  -- 必ず 'on' を確認する

-- アンチパターン: パーティションキーに関数を適用 (プルーニング不可)
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE EXTRACT(YEAR FROM order_date) = 2026
  AND EXTRACT(MONTH FROM order_date) = 3;
-- 全パーティションをスキャンすることになる

-- 正しいパターン: 範囲条件を使う (プルーニングが働く)
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE order_date >= '2026-03-01' AND order_date < '2026-04-01';
-- orders_2026_03 だけをスキャンする

パラレルクエリ実行計画の分析

PostgreSQL 17 はパラレルクエリの実行をより積極的に活用する。パーティション化されたテーブルでは各パーティションを並列ワーカーが同時にスキャンでき、FULL OUTER JOIN と集約演算に対する並列処理も拡張された。

主要なパラレルクエリパラメータ

パラメータ既定値説明
max_parallel_workers8インスタンス全体の並列ワーカー最大数
max_parallel_workers_per_gather2Gather ノードあたりの並列ワーカー最大数
min_parallel_table_scan_size8MB並列 Seq Scan の最小テーブルサイズ
min_parallel_index_scan_size512kB並列 Index Scan の最小インデックスサイズ
parallel_tuple_cost0.1並列ワーカーからのタプル転送コスト
parallel_setup_cost1000並列ワーカーの起動コスト
max_worker_processes8バックグラウンドワーカープロセス全体の上限
work_mem4MBワーカーごとに個別適用される作業メモリ

並列実行計画の確認

-- パラレルクエリパラメータの設定 (セッションレベル)
SET max_parallel_workers_per_gather = 4;
SET work_mem = '256MB';

-- 大規模パーティションテーブルの並列スキャン
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT customer_id, SUM(total_amount) AS total_spent
FROM orders
WHERE order_date >= '2026-01-01' AND order_date < '2026-04-01'
GROUP BY customer_id
ORDER BY total_spent DESC
LIMIT 100;

/*
実行計画の例:
  Limit  (cost=... rows=100)
    -> Sort  (cost=... rows=...)
          Sort Key: (sum(total_amount)) DESC
          -> Finalize GroupAggregate  (cost=... rows=...)
                Group Key: customer_id
                -> Gather Merge  (cost=... rows=...)
                      Workers Planned: 4
                      Workers Launched: 4
                      -> Partial GroupAggregate  (cost=... rows=...)
                            Group Key: customer_id
                            -> Parallel Append  (cost=... rows=...)
                                  -> Parallel Seq Scan on orders_2026_01
                                        Filter: (...)
                                  -> Parallel Seq Scan on orders_2026_02
                                        Filter: (...)
                                  -> Parallel Seq Scan on orders_2026_03
                                        Filter: (...)
  Planning Time: 2.1 ms
  Execution Time: 1,245 ms
*/

重要なポイント:

Parallel Hash Join の分析

-- 並列 Hash Join: 大規模ジョインの最適化
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.order_id, o.order_date, c.username, o.total_amount
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.order_date >= '2026-03-01' AND o.order_date < '2026-04-01'
  AND o.total_amount > 10000;

/*
  Gather  (cost=... rows=...)
    Workers Planned: 4
    Workers Launched: 4
    -> Parallel Hash Join  (cost=... rows=...)
          Hash Cond: (o.customer_id = c.customer_id)
          -> Parallel Seq Scan on orders_2026_03 o
                Filter: (total_amount > 10000)
          -> Parallel Hash  (cost=... rows=...)
                Buckets: 65536  Batches: 1  Memory Usage: 12MB
                -> Seq Scan on customers c
*/

Parallel Hash Join では、すべてのワーカーが共有ハッシュテーブルを一緒に構築するため、シングルスレッドの Hash Join より速くビルドされる。work_mem の設定に応じて in-memory または multi-batch で動作するが、ワーカー数が N のとき最大で (N+1) x work_mem のメモリが使われる点に注意する。

PostgreSQL 17 のパーティショニング新機能

MERGE PARTITIONS

複数のパーティションを 1 つに統合できる。古いデータを四半期別や年別にまとめるときに有用だ。

-- PostgreSQL 17: 月別パーティションを四半期別に統合
ALTER TABLE orders
    MERGE PARTITIONS (orders_2026_01, orders_2026_02, orders_2026_03)
    INTO orders_2026_q1;

-- 統合後の新しいパーティションを確認
SELECT
    parent.relname AS parent_table,
    child.relname  AS partition_name,
    pg_get_expr(child.relpartbound, child.oid) AS partition_bound
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child  ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;

SPLIT PARTITION

1 つのパーティションを複数に分割する。データが大きくなりすぎたパーティションを細分化するときに使う。

-- PostgreSQL 17: 四半期パーティションを再び月別に分割
ALTER TABLE orders
    SPLIT PARTITION orders_2026_q1 INTO (
        PARTITION orders_2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01'),
        PARTITION orders_2026_02 FOR VALUES FROM ('2026-02-01') TO ('2026-03-01'),
        PARTITION orders_2026_03 FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
    );

大規模テーブルのパーティショニング移行手順

既存の単一テーブル (数億件) をパーティションテーブルへ移行するのは、運用環境で最も難しい作業の 1 つだ。ダウンタイムを最小化する実践的な手順をまとめる。

方法 1: pg_partman を活用したオンライン移行

-- ステップ 1: 拡張のインストール
CREATE EXTENSION IF NOT EXISTS pg_partman;

-- ステップ 2: 新しいパーティションテーブルの作成
CREATE TABLE orders_partitioned (LIKE orders INCLUDING ALL)
    PARTITION BY RANGE (order_date);

-- ステップ 3: pg_partman でパーティションを自動生成
SELECT partman.create_parent(
    p_parent_table := 'public.orders_partitioned',
    p_control := 'order_date',
    p_type := 'native',
    p_interval := '1 month',
    p_premake := 3
);

-- ステップ 4: データ移行 (バッチ処理)
-- 一度に全件を INSERT すると WAL が急増するのでバッチに分ける
DO $$
DECLARE
    batch_start DATE := '2020-01-01';
    batch_end   DATE;
BEGIN
    WHILE batch_start < '2026-04-01' LOOP
        batch_end := batch_start + INTERVAL '1 month';
        INSERT INTO orders_partitioned
        SELECT * FROM orders
        WHERE order_date >= batch_start AND order_date < batch_end;
        RAISE NOTICE 'Migrated: % to %', batch_start, batch_end;
        batch_start := batch_end;
        PERFORM pg_sleep(0.5);  -- WAL 負荷の分散
    END LOOP;
END $$;

-- ステップ 5: テーブルの入れ替え (短いロック)
BEGIN;
ALTER TABLE orders RENAME TO orders_old;
ALTER TABLE orders_partitioned RENAME TO orders;
COMMIT;

-- ステップ 6: 検証後に旧テーブルを削除
-- SELECT count(*) FROM orders;
-- SELECT count(*) FROM orders_old;
-- DROP TABLE orders_old;

方法 2: 論理レプリケーション (Logical Replication) の活用

ダウンタイムが許されない環境では論理レプリケーションを活用する。

  1. 新しいパーティションテーブルを別スキーマに作成する
  2. 論理レプリケーションのサブスクリプション (Subscription) でデータをリアルタイム同期する
  3. 差分を確認したうえで、短い停止時間のあいだにアプリケーションを切り替える
  4. サブスクリプションを解除し、旧テーブルを整理する

運用時の注意点

インデックス管理

パーティションテーブルでは、インデックスは各パーティションにローカルで作成される。親テーブルにインデックスを作成すると、既存および将来のパーティションへ自動的に適用される。グローバルな一意インデックスはパーティションキーを含める必要がある。

-- パーティションテーブルの一意制約: パーティションキーを含めることが必須
-- 以下はエラーになる (パーティションキーを含まない)
-- ALTER TABLE orders ADD CONSTRAINT pk_orders PRIMARY KEY (order_id);

-- 正しい方法: パーティションキーを含める
ALTER TABLE orders ADD CONSTRAINT pk_orders
    PRIMARY KEY (order_id, order_date);

Vacuum 戦略

パーティションテーブルでは VACUUM が各パーティションに対して個別に実行される。PostgreSQL 17 では VACUUM コマンドの並列処理が可能だ。

-- 特定のパーティションだけを vacuum
VACUUM (VERBOSE, ANALYZE) orders_2026_03;

-- パーティションテーブル全体を vacuum (各パーティションを順に)
VACUUM (VERBOSE, ANALYZE) orders;

-- autovacuum のチューニング: パーティション別の設定
ALTER TABLE orders_2026_03 SET (
    autovacuum_vacuum_scale_factor = 0.01,
    autovacuum_analyze_scale_factor = 0.005,
    autovacuum_vacuum_cost_delay = 2
);

制約とトリガー

トラブルシューティング: 失敗事例と復旧

事例 1: 並列ワーカーがゼロで実行される問題

症状: EXPLAIN ANALYZEWorkers Planned: 4 なのに Workers Launched: 0 になる

原因と解決:

-- 現在の設定を確認
SHOW max_worker_processes;          -- 既定 8
SHOW max_parallel_workers;          -- 既定 8
SHOW max_parallel_workers_per_gather;  -- 既定 2

-- 同時実行中のワーカー数を確認
SELECT count(*) FROM pg_stat_activity
WHERE backend_type = 'parallel worker';

-- 解決: max_worker_processes を増やす (再起動が必要)
-- postgresql.conf
-- max_worker_processes = 16
-- max_parallel_workers = 12
-- max_parallel_workers_per_gather = 4

ワーカープール全体が他のクエリですでに使い切られている状態で新しいクエリが走ると、ワーカーをゼロしか受け取れないことがある。ピーク時間帯に並列クエリが集中しないよう、コネクションプールの設定と合わせて調整する必要がある。

事例 2: パーティションプルーニングが効かず全体スキャンになる

症状: 特定の月のデータだけを照会しているのに全パーティションをスキャンする

原因: パーティションキーへの関数適用、または型の不一致

-- 問題のクエリ: 型の不一致
-- パーティションキーが DATE なのに TIMESTAMP で比較している
SELECT * FROM orders
WHERE order_date = '2026-03-15 00:00:00'::timestamp;

-- 解決: 正確な型で比較する
SELECT * FROM orders
WHERE order_date = '2026-03-15'::date;

-- プルーニング動作の検証
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE order_date = '2026-03-15'::date;
-- Append -> Seq Scan on orders_2026_03 (プルーニングは正常)

事例 3: パーティション追加時の LOCK 待ち

症状: CREATE TABLE ... PARTITION OF の実行が長時間待たされる

原因: 親テーブルに対する ACCESS EXCLUSIVE LOCK が必要で、同時実行中のトランザクションが当該テーブルを使用している

解決:

-- lock_timeout を設定して早く失敗させる
SET lock_timeout = '5s';

-- 非アクティブなトランザクションを確認
SELECT pid, state, query_start, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
   OR state = 'idle in transaction';

-- メンテナンスウィンドウで実行するか、CONCURRENTLY オプションを活用する
-- (パーティション追加自体は CONCURRENTLY 非対応なので、インデックスのみ)
CREATE INDEX CONCURRENTLY idx_new_part ON orders_2026_04 (customer_id);

事例 4: work_mem の膨張による OOM

症状: 並列クエリの実行中に PostgreSQL プロセスが OOM Killer によって終了させられる

原因: work_mem がワーカー数だけ乗算される。work_mem = 1GB でワーカーが 4 個なら、リーダーを含めて 5 x 1GB = 5GB まで使われうる

-- 安全な work_mem の計算
-- 総メモリ: 64GB、最大同時接続: 200、並列ワーカー最大: 4
-- work_mem = 64GB * 0.25 / 200 / 5 = 約 16MB
SET work_mem = '16MB';

-- 特定のクエリでのみ一時的に上げる
SET LOCAL work_mem = '256MB';
SELECT ... ;  -- 大規模な集約クエリ
RESET work_mem;

パフォーマンス最適化チェックリスト

実運用のためのチェックリストをまとめる。

パーティショニング設計:

パラレルクエリのチューニング:

監視項目:

参考資料

  1. PostgreSQL 公式ドキュメント - Table Partitioning
  2. PostgreSQL 公式ドキュメント - How Parallel Query Works
  3. PostgreSQL 17 リリースノート - パーティショニングおよびパラレルクエリの改善
  4. Crunchy Data - Postgres Parallel Query Troubleshooting
  5. Mydbops - PostgreSQL 17 Partitioning Best Practices: MERGE/SPLIT Commands
  6. Microsoft Tech Community - Postgres 17 Query Performance Improvements
  7. AWS Blog - Improve query performance with parallel queries in PostgreSQL
  8. pgMustard - Increasing max_parallel_workers_per_gather

コメント

まだコメントはありません。

ログインするとコメントできます