「テーブルが数億行になって遅い」「古いログの削除に何時間もかかる」——それはパーティショニングの出番かもしれません。1つの巨大テーブルを、ルールに従って物理的に複数の子テーブルに分割し、プランナーが必要な分割だけを読み、古い分割をまるごと一瞬で捨てられるようにします。
ただし、パーティショニングは「とりあえず入れる」最適化ではありません。設計制約(特に主キー)と運用の手間が増えます。この記事は、「いつ・どう分割し、いつ分割すべきでないか」を、PostgreSQL 公式ドキュメントに忠実に、本番運用の手順とともに解説します。パフォーマンス総論 の §9 で挙げた「物理設計」レイヤーの深掘りです。
この記事のルール:パーティショニングの仕様・構文・設定の既定値・限界・PostgreSQL 18 の変更点は、すべて PostgreSQL 18 公式ドキュメント(2026年6月時点) に基づきます。マネージド環境では並列設定等が異なることがあるため、
EXPLAINで実際のプルーニングを確認してください。
1. いつ分割すべきか(と、すべきでないか)
公式が挙げるパーティショニングの利点:
- クエリ性能の劇的改善——よく使う行が少数のパーティションに集中するとき。索引上位ツリーの代替になり、よく使う部分がメモリに乗りやすくなる。
- 大量更新/集計の高速化——1パーティションの大部分を読むなら、テーブル全体のランダムアクセスよりシーケンシャルスキャンが効く。
- バルク投入・削除がメタ操作になる——
DROP TABLEやDETACH PARTITIONは、一括DELETEより桁違いに速く、しかも**DELETEが生む VACUUM 負荷を完全に回避**する。 - コールドデータを安いストレージへ移せる。
そしていつ効くかの目安(公式):
これらの利点が割に合うのは、通常テーブルが非常に大きい場合のみ。テーブルがパーティショニングの恩恵を受け始める正確な点はアプリ次第だが、目安はテーブルサイズがデータベースサーバーの物理メモリを超えること。
重要な判断:数百万行程度なら、まず索引設計とVACUUMを尽くす。パーティショニングは複雑さ(特に主キー制約)と運用コストを増やすので、「物理メモリを超える規模」「明確な時系列の破棄パターンがある」場合に絞って導入します。YAGNI——必要になってから。
2. 3つの方式:RANGE / LIST / HASH
公式の定義と構文。
| 方式 | 分割の仕方 | 典型用途 |
|---|---|---|
| RANGE | キーの値の範囲(重複なし)で分割 | 時系列(日付・連番) |
| LIST | キーの値を明示列挙して分割 | 地域・テナント・カテゴリ |
| HASH | モジュラスと剰余で分割 | 自然な区分がないが均等に分散したい |
RANGE:時系列の定番
-- 親テーブル(データは入らない。ルーティングするだけ)
CREATE TABLE measurement (
city_id int NOT NULL,
logdate date NOT NULL,
peaktemp int
) PARTITION BY RANGE (logdate);
-- 月次パーティション。下限は含む・上限は含まない('2026-07-01' は7月側)
CREATE TABLE measurement_y2026m06 PARTITION OF measurement
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- どの範囲にも入らない行の受け皿(任意・hash では不可)
CREATE TABLE measurement_default PARTITION OF measurement DEFAULT;
境界の罠(公式が明記):「下限 FROM は含む、上限 TO は含まない」。例:範囲が 1〜10 と 10〜20 のとき、値 10 は2つ目に入ります。隣接パーティションで上限=次の下限にすれば、隙間も重複もありません。上下限なしは MINVALUE/MAXVALUE。
LIST:明示列挙
CREATE TABLE customers (id bigint, region text, name text)
PARTITION BY LIST (region);
CREATE TABLE customers_apac PARTITION OF customers FOR VALUES IN ('JP','KR','SG');
CREATE TABLE customers_emea PARTITION OF customers FOR VALUES IN ('DE','FR','UK');
CREATE TABLE customers_other PARTITION OF customers DEFAULT;
HASH:均等分散
CREATE TABLE events (id bigint, payload jsonb) PARTITION BY HASH (id);
-- 4分割(モジュラス4・剰余0〜3)
CREATE TABLE events_p0 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE events_p1 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE events_p2 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE events_p3 PARTITION OF events FOR VALUES WITH (MODULUS 4, REMAINDER 3);
3. 最大の設計制約:主キーはパーティションキーを含む
これがパーティショニング導入で最も人を驚かせる点です。公式の規則:
パーティション化テーブルにユニークまたは主キー制約を作るには、パーティションキーに式や関数呼び出しを含めず、制約の列にすべてのパーティションキー列を含めなければならない。
理由は明快——各パーティションの索引は自分の中の一意性しか保証できないので、「異なるパーティション間で重複しない」ことはパーティション構造自体で担保するしかないからです。
-- ❌ これは失敗する:主キー (id) がパーティションキー logdate を含まない
-- CREATE TABLE m (id bigint PRIMARY KEY, logdate date) PARTITION BY RANGE (logdate);
-- ✅ パーティションキーを主キーに含める(複合主キー)
CREATE TABLE m (
id bigint GENERATED ALWAYS AS IDENTITY,
logdate date NOT NULL,
PRIMARY KEY (id, logdate) -- パーティションキー logdate を含む
) PARTITION BY RANGE (logdate);
設計への影響:
id単独でグローバル一意にしたい場合、パーティショニングと相性が悪い(外部キーの参照先としても(id, logdate)複合になる)。これは「分割するか否か」を決める最重要のトレードオフです。uuidv7()(PG18)のような時刻順 UUID を使い、論理的な一意性は UUID で担保しつつ主キーに区分列を含める、といった設計で折り合いをつけます。
その他の限界(公式 §5.12.2.3):BEFORE ROW トリガーで行き先パーティションは変えられない/一時表と永続表をパーティションツリーに混在不可/通常テーブルとパーティション化テーブルを後から相互変換できない(最初から設計する)/パーティションキーを更新すると行が別パーティションへ移動する。
4. パーティションプルーニング:効いているか必ず確認
パーティショニングの性能の源泉がプルーニング——「クエリに関係ないパーティションを読み飛ばす」最適化です(公式定義どおり)。enable_partition_pruning は既定 ON。
プルーニングには2種類あります。
- プラン時プルーニング:
WHERE logdate >= '2026-06-01'のように計画段階で値が分かる場合。 - 実行時プルーニング:
PREPAREのパラメータやサブクエリ結果、ネステッドループ内側のパラメータなど、計画時には値が不明でも実行時に絞る。
-- プルーニングが効いていれば、関係するパーティションしか計画に出ない
EXPLAIN (ANALYZE)
SELECT count(*) FROM measurement WHERE logdate >= DATE '2026-06-01';
-- → measurement_y2026m06 など該当パーティションだけが現れ、他は読まれない
プラン時にプルーニングされたパーティションは
EXPLAIN/EXPLAIN ANALYZEに現れません。逆に「全パーティションが計画に出る」なら、WHEREがパーティションキーに触れていない=プルーニングが効いていないサインです。必ずパーティションキーで絞るクエリ設計にします。
(補足:旧来の継承ベース分割は constraint_exclusion を使いプラン時のみ。宣言的パーティショニングのプルーニングはプラン時+実行時の両方で効く、より強力な仕組みです。)
5. ローリングウィンドウ運用:時系列の本命
RANGE パーティショニングの真価は運用にあります。「直近Nヶ月だけ保持し、古い月は捨てる」というローリングウィンドウは、DELETE ではなくパーティションの付け外しで行います。
-- 古い月をまるごと削除(数百万行でも一瞬。DELETE のような VACUUM 負荷なし)
DROP TABLE measurement_y2025m06;
-- 捨てずにアーカイブしたいなら、独立テーブルとして切り離す
ALTER TABLE measurement DETACH PARTITION measurement_y2025m06;
-- DETACH CONCURRENTLY は親への重いロックを避ける(トランザクション外・DEFAULTパーティションがあると不可)
新しい月のパーティションを安全に追加する
毎月、来月分のパーティションを事前に用意します。空のテーブルを CREATE TABLE ... PARTITION OF で作るのが基本(空なのでスキャン不要)。既存テーブルを ATTACH する場合は、事前に一致する CHECK 制約を付けておくと、公式の言うとおり取り込み時の全行スキャン(と長いロック)を回避できます。
-- 既存テーブルを取り込む場合:先に CHECK を付けると ATTACH 時の全行スキャンを省ける
ALTER TABLE measurement_new
ADD CONSTRAINT ck CHECK (logdate >= '2026-07-01' AND logdate < '2026-08-01');
ALTER TABLE measurement ATTACH PARTITION measurement_new
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
ALTER TABLE measurement_new DROP CONSTRAINT ck; -- 取り込み後は冗長なので外してよい
運用は冪等な関数にして、cron(pg_cron 等)で月次実行するのが堅牢です。
-- 指定月のパーティションを「無ければ作る」冪等関数。二重実行しても安全
CREATE OR REPLACE FUNCTION ensure_measurement_partition(target date)
RETURNS void LANGUAGE plpgsql AS $$
DECLARE
start_d date := date_trunc('month', target)::date;
end_d date := (date_trunc('month', target) + interval '1 month')::date;
part text := format('measurement_y%sm%s', to_char(start_d,'YYYY'), to_char(start_d,'MM'));
BEGIN
IF to_regclass(part) IS NULL THEN -- 既に在れば何もしない=冪等
EXECUTE format(
'CREATE TABLE %I PARTITION OF measurement FOR VALUES FROM (%L) TO (%L)',
part, start_d, end_d);
END IF;
END;
$$;
-- 毎月、今月と来月分を確保(実行漏れに強い)
SELECT ensure_measurement_partition(current_date);
SELECT ensure_measurement_partition((current_date + interval '1 month')::date);
可観測性のために:来月分の作成が漏れると、
DEFAULTパーティションに落ちるか(用意していれば)、無ければ INSERT がエラーになります。「次の2ヶ月分が存在するか」を監視し、欠けていればアラートする運用にしておくと事故を防げます。インデックスは親に作れば全パーティションへ自動波及し、後から作るパーティションにも引き継がれます。
6. partitionwise join / aggregate:既定OFFの理由
パーティション同士の結合・集約を「対応するパーティションごと」に行う最適化が partitionwise join / aggregate。両方とも既定 OFFです。
理由は公式が明記するメモリコスト:これらを有効にすると「work_mem で制限されるノード数がパーティション数に比例して線形に増え得る。実行時の総メモリ消費が大きく増える可能性があり、計画自体も CPU・メモリ的に大幅に高コストになる」。
-- パーティション数が少なく、パーティション同士の結合/集約が重いワークロードでのみ検討
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;
「速くなるなら常時ON」ではありません。パーティション数が多いと逆効果になり得るので、対象ワークロードで
EXPLAIN ANALYZEしてから、必要ならセッション/特定クエリ単位で有効化します。
7. PostgreSQL 18 の改善
PG18 はパーティショニングの運用と計画を実務的に改善しました(公式リリースノート)。
- 多数パーティションのクエリ計画を効率化——パーティションが多い設計のプランニングが速くなった。
- partitionwise join を適用できるケースを拡大し、メモリ使用も削減——§6 のトレードオフが緩和。
- パーティションクエリのコスト見積もりを改善。
- VACUUM/ANALYZE が子を処理せず親のパーティション化テーブルだけを処理可能に。
- パーティション化テーブルに
NOT VALID外部キー制約を許可(段階的な制約追加が可能に)。 - 非btree のユニークインデックスをパーティションキーに利用可能に。
- unlogged なパーティション化テーブルは不可に(一貫性のため)。
アップグレード観点では、パーティション数を多く取る設計(多テナント等)ほど PG18 の計画効率化の恩恵を受けます。
8. 導入判断チェックリスト
- 規模は十分か? テーブルが物理メモリを超える/明確な破棄パターン(時系列)があるか。なければ索引・VACUUM が先。
- パーティションキーは決まるか? ほとんどのクエリの
WHEREがそのキーで絞れるか(プルーニングの前提)。 - 主キー制約を許容できるか? 主キー/ユニークにパーティションキー列を含める設計で問題ないか。
- 方式は適切か? 時系列=RANGE、明示区分=LIST、均等分散=HASH。
- 運用を自動化したか? 来月分の事前作成・古い月の DETACH/DROP を冪等関数+スケジューラで。
- プルーニングを確認したか? 代表クエリを
EXPLAINし、必要なパーティションだけが読まれるか。
9. まとめ
- パーティショニングは**「物理メモリを超える規模」+「明確な破棄パターン」**で初めて割に合う。小さい表ではまず索引・VACUUM。
- RANGE/LIST/HASH。RANGE の境界は下限含む・上限含まない。古いデータは
DROP/DETACH PARTITIONで一瞬(VACUUM 負荷もなし)。 - 主キーはパーティションキーを含める——これが最大の設計制約。導入前に必ず確認。
- **プルーニング(既定ON)**が性能の源泉。
WHEREをパーティションキーで絞り、EXPLAINで効果を確認。 - ローリングウィンドウは冪等関数+スケジューラで自動化。ATTACH は事前 CHECK で全行スキャン回避。
- partitionwise join/agg は既定OFF(メモリコスト)。PG18 で多数パーティションの計画・partitionwise join・コスト見積もりが改善。
これで PostgreSQL 本体の性能設計シリーズは一巡です。パフォーマンス総論 を起点に、インデックス・EXPLAIN・MVCC/VACUUM・JSONB と合わせて、専用DBを増やさずとも本番の要求に応える PostgreSQL を設計してください。