- PostgreSQL 17 でパーティショニングとパラレルクエリが重要な理由
- パーティショニングのタイプ概要
- Range・List・Hash パーティショニングの選択基準の比較
- パーティションプルーニングの原理と最適化
- パラレルクエリ実行計画の分析
- PostgreSQL 17 のパーティショニング新機能
- 大規模テーブルのパーティショニング移行手順
- 運用時の注意点
- トラブルシューティング: 失敗事例と復旧
- パフォーマンス最適化チェックリスト
- 参考資料

PostgreSQL 17 でパーティショニングとパラレルクエリが重要な理由
大規模な運用環境では、単一テーブルの行数が数億件を超えると、インデックスのサイズ、vacuum の所要時間、クエリの応答速度がいずれも急激に悪化する。PostgreSQL 17 は宣言的パーティショニング (Declarative Partitioning) とパラレルクエリ (Parallel Query) を一段階引き上げた。パーティション化されたテーブルで identity column と exclusion constraint を直接サポートし、ALTER TABLE ... MERGE PARTITIONS と SPLIT 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 パーティショニングの選択基準の比較
| 項目 | Range | List | Hash |
|---|---|---|---|
| 適したデータ | 時系列、連続範囲 | 離散カテゴリ | 均等分配が必要 |
| パーティションキーの例 | order_date, created_at | region, status | user_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 だけをスキャンする
プルーニングが動作しないケース
次の状況ではプルーニングが働かないため注意が必要だ。
- 関数の適用:
WHERE EXTRACT(MONTH FROM order_date) = 3のようにパーティションキーへ関数を適用するとプルーニングは不可 - 型の不一致: パーティションキーが
DATEなのにTIMESTAMP値と比較すると、暗黙のキャストによりプルーニングが失敗しうる - OR 条件に非パーティションキーが混ざる:
WHERE order_date = '2026-03-01' OR customer_id = 100のようにパーティションキーと非パーティションキーを OR で結ぶと全パーティションをスキャンする - enable_partition_pruning = off: この GUC パラメータがオフだとプルーニングは無効になる
-- プルーニング動作の確認: 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_workers | 8 | インスタンス全体の並列ワーカー最大数 |
max_parallel_workers_per_gather | 2 | Gather ノードあたりの並列ワーカー最大数 |
min_parallel_table_scan_size | 8MB | 並列 Seq Scan の最小テーブルサイズ |
min_parallel_index_scan_size | 512kB | 並列 Index Scan の最小インデックスサイズ |
parallel_tuple_cost | 0.1 | 並列ワーカーからのタプル転送コスト |
parallel_setup_cost | 1000 | 並列ワーカーの起動コスト |
max_worker_processes | 8 | バックグラウンドワーカープロセス全体の上限 |
work_mem | 4MB | ワーカーごとに個別適用される作業メモリ |
並列実行計画の確認
-- パラレルクエリパラメータの設定 (セッションレベル)
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
*/
重要なポイント:
Workers PlannedとWorkers Launchedが一致するかを確認する。一致しない場合はmax_worker_processesかシステムリソースが不足している。Parallel Appendは、複数のパーティションを並列ワーカーが分担してスキャンするという意味だ。Partial GroupAggregateとFinalize GroupAggregateにより、集約が 2 段階 (ワーカーでの部分集約 + リーダーでの最終集約) に分かれる。
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) の活用
ダウンタイムが許されない環境では論理レプリケーションを活用する。
- 新しいパーティションテーブルを別スキーマに作成する
- 論理レプリケーションのサブスクリプション (Subscription) でデータをリアルタイム同期する
- 差分を確認したうえで、短い停止時間のあいだにアプリケーションを切り替える
- サブスクリプションを解除し、旧テーブルを整理する
運用時の注意点
インデックス管理
パーティションテーブルでは、インデックスは各パーティションにローカルで作成される。親テーブルにインデックスを作成すると、既存および将来のパーティションへ自動的に適用される。グローバルな一意インデックスはパーティションキーを含める必要がある。
-- パーティションテーブルの一意制約: パーティションキーを含めることが必須
-- 以下はエラーになる (パーティションキーを含まない)
-- 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
);
制約とトリガー
CHECK制約は各パーティションに独立して定義できる- パーティションテーブルを参照する
FOREIGN KEYは可能であり、パーティションテーブルから他テーブルへ FK を張ることも PostgreSQL 12 以降サポートされている - BEFORE ROW トリガーは各パーティションに定義する必要がある
トラブルシューティング: 失敗事例と復旧
事例 1: 並列ワーカーがゼロで実行される問題
症状: EXPLAIN ANALYZE で Workers 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;
パフォーマンス最適化チェックリスト
実運用のためのチェックリストをまとめる。
パーティショニング設計:
- パーティション数を 100 個以下に保つ。数千個のパーティションはプランナのオーバーヘッドを生む。
- DEFAULT パーティションを必ず作成し、範囲外データの INSERT 失敗を防ぐ。
- パーティションキーを PRIMARY KEY と UNIQUE 制約に含める。
パラレルクエリのチューニング:
min_parallel_table_scan_sizeとmin_parallel_index_scan_sizeをワークロードに合わせて調整する。parallel_tuple_costとparallel_setup_costを下げると、プランナが並列計画をより積極的に選ぶようになる。jit = onと並列クエリを併用すると、OLAP ワークロードでさらなる性能向上が期待できる。
監視項目:
pg_stat_user_tablesで各パーティションのn_live_tup、n_dead_tup、last_autovacuumを確認するpg_stat_activityで並列ワーカーの使用状況を監視するEXPLAIN (ANALYZE, BUFFERS)でパーティションプルーニングと並列実行を検証する
参考資料
- PostgreSQL 公式ドキュメント - Table Partitioning
- PostgreSQL 公式ドキュメント - How Parallel Query Works
- PostgreSQL 17 リリースノート - パーティショニングおよびパラレルクエリの改善
- Crunchy Data - Postgres Parallel Query Troubleshooting
- Mydbops - PostgreSQL 17 Partitioning Best Practices: MERGE/SPLIT Commands
- Microsoft Tech Community - Postgres 17 Query Performance Improvements
- AWS Blog - Improve query performance with parallel queries in PostgreSQL
- pgMustard - Increasing max_parallel_workers_per_gather