LabHub

ブログ

PostgreSQL パーティショニング実践ガイド

한국어English日本語

PostgreSQL Partitioning Guide

0. この記事の範囲と基準バージョン

この記事が扱うのは パーティションを作成し、アタッチし、デタッチする DDL と、その DDL が取得するロック です。Range・List・Hash のどれを選ぶか、パーティションキーをどの列にするかといった戦略の判断は PostgreSQL パーティショニング完全ガイド で別途扱います。以下の 2〜4 節の構文例は、各戦略が実際にどんな DDL で表現されるかを示すための最小限の分量であり、この記事の重心は 8 節のあとに続く運用 DDL のほうにあります。

基準エンジンは PostgreSQL 18 です。この記事に出てくるロックレベルと既定値はすべて PostgreSQL 18 のドキュメントで確認した値であり、ドキュメントの URL は最後の参考資料の節にまとめてあります。パーティショニングはメジャーバージョンごとに挙動が変わってきた領域なので、運用中のサーバーが 18 でないなら同じ文がそのまま成り立つと仮定してはいけません。特に DETACH PARTITION CONCURRENTLY のようにあとから入った構文は、使用中のバージョンのドキュメントでサポート状況を先に確認してください。

ロックの話を先に出す理由は単純です。パーティショニングで人を傷つけるのは構文ではなくロックです。構文は間違えればすぐエラーになります。ロックは間違えてもエラーになりません。ただその時間だけサービスが止まるだけです。

1. パーティショニングとは?

パーティショニングは、1つの大きなテーブルを複数の物理的なパーティションに分割する技法です。テーブルのデータが数億件以上の場合、クエリパフォーマンスを大幅に向上させることができます。

パーティショニングの利点

2. Range パーティショニング

最もよく使用される方式で、日付や数値の範囲で分割します。

-- 親テーブルの作成
CREATE TABLE orders (
    id          BIGSERIAL,
    customer_id INTEGER NOT NULL,
    order_date  DATE NOT NULL,
    amount      DECIMAL(10, 2),
    status      VARCHAR(20),
    created_at  TIMESTAMP DEFAULT NOW()
) 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 INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_status ON orders (status, order_date);

-- デフォルトパーティション(どの範囲にも合わないデータを収容)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

3. List パーティショニング

特定の値リストで分割します。地域やカテゴリなどに適しています。

CREATE TABLE events (
    id          BIGSERIAL,
    event_type  VARCHAR(50) NOT NULL,
    payload     JSONB,
    created_at  TIMESTAMP DEFAULT NOW()
) PARTITION BY LIST (event_type);

CREATE TABLE events_user PARTITION OF events
    FOR VALUES IN ('user_signup', 'user_login', 'user_logout');

CREATE TABLE events_order PARTITION OF events
    FOR VALUES IN ('order_created', 'order_paid', 'order_cancelled');

CREATE TABLE events_system PARTITION OF events
    FOR VALUES IN ('health_check', 'deploy', 'config_change');

CREATE TABLE events_default PARTITION OF events DEFAULT;

4. Hash パーティショニング

ハッシュ関数で均等に分配します。特定のキーの分布が均一な場合に適しています。

CREATE TABLE user_sessions (
    id         BIGSERIAL,
    user_id    INTEGER NOT NULL,
    session_id UUID NOT NULL,
    data       JSONB,
    expires_at TIMESTAMP
) PARTITION BY HASH (user_id);

-- 4つのパーティションに均等分配
CREATE TABLE user_sessions_0 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_sessions_1 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_sessions_2 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_sessions_3 PARTITION OF user_sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

5. Partition Pruning の確認

-- Partition pruning の有効化確認
SHOW enable_partition_pruning;  -- on

-- EXPLAIN で pruning を確認
EXPLAIN (ANALYZE, COSTS, BUFFERS)
SELECT * FROM orders
WHERE order_date >= '2026-03-01'
  AND order_date < '2026-04-01';

-- 結果例:
-- Append (actual rows=50000)
--   -> Seq Scan on orders_2026_03 (actual rows=50000)
--        Filter: (order_date >= '2026-03-01' AND order_date < '2026-04-01')
-- orders_2026_01、orders_2026_02 はスキャンされない!

6. パーティション自動作成(pg_partman)

-- pg_partman のインストール
CREATE EXTENSION pg_partman;

-- 自動パーティション管理の設定
SELECT partman.create_parent(
    p_parent_table   => 'public.orders',
    p_control        => 'order_date',
    p_type           => 'native',
    p_interval       => 'monthly',
    p_premake        => 3       -- 3ヶ月先まで事前作成
);

-- メンテナンス関数(cron で毎日実行)
SELECT partman.run_maintenance();

cron 設定

# pg_partman メンテナンス(毎日午前2時)
0 2 * * * psql -U postgres -d mydb \
  -c "SELECT partman.run_maintenance();" \
  >> /var/log/pg_partman.log 2>&1

手動自動化スクリプト

-- pg_partman なしで直接自動化
CREATE OR REPLACE FUNCTION create_monthly_partition(
    p_table TEXT,
    p_year INTEGER,
    p_month INTEGER
) RETURNS VOID AS $$
DECLARE
    partition_name TEXT;
    start_date DATE;
    end_date DATE;
BEGIN
    partition_name := format('%s_%s_%s',
        p_table,
        p_year,
        LPAD(p_month::TEXT, 2, '0')
    );
    start_date := make_date(p_year, p_month, 1);
    end_date := start_date + INTERVAL '1 month';

    EXECUTE format(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I
         FOR VALUES FROM (%L) TO (%L)',
        partition_name, p_table, start_date, end_date
    );

    RAISE NOTICE 'Created partition: %', partition_name;
END;
$$ LANGUAGE plpgsql;

-- 使用例
SELECT create_monthly_partition('orders', 2026, 4);
SELECT create_monthly_partition('orders', 2026, 5);

7. 古いパーティションの削除

-- パーティションの分離(データ保存、クエリから除外)
ALTER TABLE orders DETACH PARTITION orders_2025_01;

-- 分離されたパーティションを別テーブルとして維持または削除
DROP TABLE orders_2025_01;  -- 即時削除(数億件でも一瞬)

-- 比較:DELETE は非常に遅い
-- DELETE FROM orders WHERE order_date < '2025-02-01';  ← これはやめましょう!

8. 運用のコツ

パーティション状態のモニタリング

-- パーティション別行数の確認
SELECT
    schemaname || '.' || relname AS partition,
    n_live_tup AS row_count,
    pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE relname LIKE 'orders_%'
ORDER BY relname;

-- パーティション一覧の照会
SELECT
    parent.relname AS parent,
    child.relname AS partition,
    pg_get_expr(child.relpartbound, child.oid) AS bounds
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;

注意事項

1. PRIMARY KEY にパーティションキーを含める必要あり
   CREATE TABLE orders (...) PARTITION BY RANGE (order_date);
   → PK は (id, order_date) の形式でなければならない

2. UNIQUE 制約にもパーティションキーを含める必要あり

3. パーティション数が多すぎるとプランニングオーバーヘッドが増加
   → 1000以下を推奨

4. クロスパーティション UPDATE は PostgreSQL 11+ でのみサポート

パーティションをアタッチ・デタッチする DDL とそれぞれのロック

本番で実際に使うパーティション DDL は 4 つだけです。問題は、その 4 つが互いに違うロックを取ることです。

DDL親テーブルに取るロック対象パーティションに取るロック
CREATE TABLE ... PARTITION OFACCESS EXCLUSIVE新規作成する空テーブル
DROP TABLE(パーティション)ACCESS EXCLUSIVEACCESS EXCLUSIVE
ALTER TABLE ... ATTACH PARTITIONSHARE UPDATE EXCLUSIVEACCESS EXCLUSIVE
ALTER TABLE ... DETACH PARTITION CONCURRENTLYSHARE UPDATE EXCLUSIVEACCESS EXCLUSIVE(第 2 段階で)

PostgreSQL のドキュメントはこの違いを非常に直接的に書いています。PARTITION OF でパーティションを作ると親のパーティションテーブルに ACCESS EXCLUSIVE ロックが必要で、DROP TABLE でパーティションを削除するときも同様であり、同じことを ATTACHDETACH で行えばより弱いロックで済み、同時実行される操作との干渉が減る、という記述です。

ACCESS EXCLUSIVE は最も強いロックです。そのテーブルを読むだけの SELECT でさえ待たされます。つまり CREATE TABLE orders_2026_09 PARTITION OF orders ... の一行は、実行されたその瞬間に orders に触れるすべてのセッションを止めます。空のテーブルを作る作業なので処理そのものはミリ秒で終わりますが、ロックを取得するまでの待ち時間はミリ秒ではありません。先行する長いトランザクションが orders に弱いロックでも掛けていれば、こちらの DDL はその後ろに並びます。そして ACCESS EXCLUSIVE の要求がキューの先頭に立った瞬間、その後ろに入ってくるすべての SELECT まで一緒に止まります。「パーティションを 1 つ追加しただけで障害になった」の正確なメカニズムがこれです。

安全な順序 — 別に作り、検証し、それからアタッチする

-- 1) 親と無関係な独立テーブルとして作る。親には一切ロックが掛からない。
CREATE TABLE orders_2026_09 (
    LIKE orders INCLUDING DEFAULTS INCLUDING STORAGE
);

-- 2) 必要ならここで先にデータを投入する。
--    まだ親と何の関係もないので、どれだけ時間が掛かってもサービスに影響はない。

-- 3) パーティション境界と同じ CHECK 制約を NOT VALID で付ける。
--    NOT VALID なので、この時点では既存行をスキャンしない。
ALTER TABLE orders_2026_09
    ADD CONSTRAINT orders_2026_09_bound
    CHECK (order_date >= DATE '2026-09-01' AND order_date < DATE '2026-10-01')
    NOT VALID;

-- 4) 検証だけを別に走らせる。この段階は SHARE UPDATE EXCLUSIVE しか取らないので
--    同時実行の INSERT/UPDATE をブロックしない。
ALTER TABLE orders_2026_09 VALIDATE CONSTRAINT orders_2026_09_bound;

-- 5) アタッチする。有効な CHECK が既にあるのでフルスキャンが省略される。
ALTER TABLE orders ATTACH PARTITION orders_2026_09
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- 6) 冗長になった CHECK 制約を外す。
ALTER TABLE orders_2026_09 DROP CONSTRAINT orders_2026_09_bound;

各段階がなぜ必要かは、ドキュメントにそのまま書かれています。

ATTACH PARTITION は、アタッチしようとするテーブルがパーティション制約に違反する行を持っていないかを確認するためにフルスキャンを行います。そしてそのスキャンは、対象パーティションに ACCESS EXCLUSIVE ロックを保持したまま実行されます。ドキュメントはこのスキャンを避ける方法として「望むパーティション制約を満たす行だけを許可する有効な CHECK 制約をあらかじめ付けておく」ことを明記しています。3〜4 の手順がそれです。

そして VALIDATE CONSTRAINT は、新しい行については既に制約が強制されているため、既存の行だけを検査すればよく、同時更新をブロックする必要がありません。ドキュメントの表現では「検証は変更対象のテーブルに SHARE UPDATE EXCLUSIVE ロックのみを取得する」です。つまりこのレシピは、時間の掛かるスキャンを弱いロックの側で処理し、強いロックが必要な ATTACH はスキャンなしで一瞬で終わるように順序を入れ替えただけのものです。

DEFAULT パーティションがあると手順がもう 1 つ増える

ドキュメントは DEFAULT パーティションがある場合を別に警告しています。新しいパーティションをアタッチするとき、DEFAULT パーティションの中に「本来は新しいパーティションに入るべきだった行」がないことを確認する必要があり、その確認は DEFAULT パーティションに ACCESS EXCLUSIVE ロックを保持したまま行われます。DEFAULT パーティションが肥大化していると、このスキャン 1 つでアタッチ作業が数十分を消費し、その間 DEFAULT パーティションは読むこともできません。

回避方法も同じです。DEFAULT パーティションに「これからアタッチする範囲を除外する」CHECK 制約をあらかじめ掛けておきます。

ALTER TABLE orders_default
    ADD CONSTRAINT orders_default_excl_2026_09
    CHECK (order_date < DATE '2026-09-01' OR order_date >= DATE '2026-10-01')
    NOT VALID;

ALTER TABLE orders_default VALIDATE CONSTRAINT orders_default_excl_2026_09;
-- これで ATTACH は DEFAULT パーティションをスキャンしない。

DEFAULT パーティションをそもそも置かないという選択肢もあります。範囲外の INSERT がエラーとして即座に表に出るほうが、静かに DEFAULT に溜まって数か月後のパーティション追加を妨げるより良い、と判断するチームは多いです。どちらにせよトレードオフであって正解ではありません。DEFAULT パーティションはセーフティネットであると同時に、あとで請求書が届く負債でもあります。

DETACH はテーブルを削除しない

DETACH PARTITION は親との接続を切るだけです。テーブル自体はそのまま残り、ディスクを占有し続けます。これを知らないと「古いパーティションを整理したのにディスクが減らない」になります。

-- 同時アクセスをブロックしない方法でデタッチする
ALTER TABLE orders DETACH PARTITION orders_2025_01 CONCURRENTLY;

-- 確認: もう親の子ではないが、テーブルは生きている
SELECT c.relname,
       pg_size_pretty(pg_total_relation_size(c.oid)) AS size
FROM pg_class c
WHERE c.relname = 'orders_2025_01';

-- 本当に消すなら、ここで明示的に削除する
DROP TABLE orders_2025_01;

CONCURRENTLY を付けると、PostgreSQL は内部でトランザクションを 2 つに分割します。最初のトランザクションで親とパーティションの両方に SHARE UPDATE EXCLUSIVE ロックを取り、パーティションを「デタッチ中」と印を付けてコミットし、そのパーティションテーブルを使っていた既存のトランザクションがすべて終わるのを待ちます。次に第 2 のトランザクションが親に SHARE UPDATE EXCLUSIVE を、切り離すパーティションに ACCESS EXCLUSIVE を取ってデタッチを完了させます。親テーブル側に強いロックが一度も掛からない、というのが要点です。

代償もあります。内部で 2 回コミットするため、この構文はトランザクションブロックの中では使えません。マイグレーションツールがすべての DDL を 1 つのトランザクションで包む方式なら、そのままでは使えません。その 1 つのマイグレーションだけをトランザクションの外に出す設定が必要で、その方法はツールごとに異なります。

パーティションテーブルに無停止でインデックスを作る

通常のテーブルなら答えは簡単です。ドキュメントの表現どおり、通常の CREATE INDEX はインデックスが完成するまでそのテーブルへの書き込みをブロックし(読み取りはブロックしません)、CONCURRENTLY は同時実行の INSERT・UPDATE・DELETE を妨げるロックを一切取らない代わりに、テーブルを 2 回スキャンし関連トランザクションの終了を待ちます。

問題は、パーティションテーブルには CONCURRENTLY を使えないことです。ドキュメントが「パーティションテーブルのインデックスの並行ビルドはサポートされていない」と明示している制約です。そのためパーティションの親に素の CREATE INDEX を打つと、すべてのパーティションにインデックスが作られ終わるまでそのテーブル全体の書き込みがブロックされます。パーティションが 36 個なら、36 個すべてが終わるまでです。

ドキュメントが勧める回避策は 3 段階です。

-- 1) 親にだけインデックス定義を作る。ONLY が肝。
--    この時点で親インデックスは invalid 状態で、実データには触れない。
CREATE INDEX idx_orders_customer ON ONLY orders (customer_id);

-- 2) パーティションごとに CONCURRENTLY で個別に作る。ここでは書き込みが止まらない。
CREATE INDEX CONCURRENTLY idx_orders_2026_09_customer
    ON orders_2026_09 (customer_id);
CREATE INDEX CONCURRENTLY idx_orders_2026_10_customer
    ON orders_2026_10 (customer_id);

-- 3) 作ったインデックスを親インデックスにアタッチする。
ALTER INDEX idx_orders_customer
    ATTACH PARTITION idx_orders_2026_09_customer;
ALTER INDEX idx_orders_customer
    ATTACH PARTITION idx_orders_2026_10_customer;

すべてのパーティションのインデックスがアタッチされた瞬間、親インデックスは自動的に valid になります。1 つでも漏らすと親インデックスは invalid のまま残り、invalid なインデックスは検索に使われないまま更新コストだけを払い続けます。最悪の組み合わせです。

ですからこの作業のあとには必ず確認クエリを走らせるべきです。

SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;

カタログのドキュメントによれば、indisvalid が false であるということは「インデックスが不完全な可能性があり検索には安全に使えないが、INSERT・UPDATE は依然としてこのインデックスを更新しなければならない」という意味です。1 行も返らなければ正常です。行が返るなら原因は 2 つのどちらかです。3 の手順の ATTACH を漏らしたか、CREATE INDEX CONCURRENTLY が途中で失敗したかです。

後者はよくあります。ドキュメントは、並行ビルドがデッドロックや一意性違反で失敗した場合「invalid なインデックスを残したままコマンドが失敗する」と書いており、推奨される復旧方法としてそのインデックスを DROP して CREATE INDEX CONCURRENTLY をやり直すか、REINDEX INDEX CONCURRENTLY で再構築することを挙げています。失敗したまま放置された invalid インデックスは静かに書き込みコストだけを食い続けるので、上のクエリはインデックス作業の直後だけでなく定期的にも回す価値があります。

既存テーブルをパーティションテーブルに変える

すでにデータが入っている通常のテーブルをその場でパーティションテーブルに「変える」コマンドはありません。新しいパーティションテーブルを作り、既存テーブルをその中へ移すしかありません。実務で使う方法は 2 つです。

方法 A は既存テーブルをまるごと最初のパーティションとしてアタッチするもの です。1 行もコピーしないので圧倒的に速いです。ただし既存テーブルのすべての行が 1 つのパーティション境界の中に収まる必要があります。過去データをまとめて「2026-09-01 より前」という 1 パーティションに押し込み、そこから先を月別に分ける形なら、この条件を満たします。

-- 1) 新しいパーティション親を作る。パーティションキーが PK に含まれる必要があるため
--    既存の PK をそのままコピーできないことが多い。だから INCLUDING ALL は避ける。
CREATE TABLE orders_new (
    LIKE orders INCLUDING DEFAULTS INCLUDING STORAGE
) PARTITION BY RANGE (order_date);

-- 2) 既存テーブルに境界と同じ CHECK を付けて別に検証する(前のレシピと同じ)
ALTER TABLE orders
    ADD CONSTRAINT orders_bound CHECK (order_date < DATE '2026-09-01') NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_bound;

-- 3) 既存テーブルを最初のパーティションとしてアタッチする。CHECK のおかげでスキャンは省略。
ALTER TABLE orders_new ATTACH PARTITION orders
    FOR VALUES FROM (MINVALUE) TO ('2026-09-01');

-- 4) これから入ってくるデータを受けるパーティションを作る
CREATE TABLE orders_2026_09 PARTITION OF orders_new
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

-- 5) 名前を入れ替える。この段階だけ短く ACCESS EXCLUSIVE が必要になる。
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE orders RENAME TO orders_legacy;
ALTER TABLE orders_new RENAME TO orders;
COMMIT;

3 の手順で MINVALUE を使ったのはドキュメントの説明どおりの理由です。MINVALUEMAXVALUE は実際に格納される値ではなく「下限がない」「上限がない」ことを表す表現です。また RANGE パーティションでは FROM が含む側、TO が含まない側です。TO ('2026-09-01') は 9 月 1 日 0 時を含まないので、2 の手順の CHECK 条件とちょうど同じ境界になります。この不等号を取り違えると、ATTACH が失敗するか、境界にまたがる 1 日分が消えます。

5 の手順の lock_timeout が、このレシピで最も重要な一行です。ドキュメントによれば既定値は 0 で、これはタイムアウトなし、つまり無制限に待つことを意味します。名前の入れ替え自体は一瞬で終わりますが、ACCESS EXCLUSIVE ロックを取得できなければ無制限に待ち続け、その待機が後続のすべてのクエリを一緒にブロックします。lock_timeout を短く設定しておけば、ロックが取れなかったときに死ぬのはサービスではなくこちらの DDL です。失敗して数分後にやり直すほうが、成功するまでサービスを止め続けるより良いです。

方法 B は新しいパーティションテーブルへバッチコピーしてから切り替えるもの です。既存の行が複数のパーティションに散る必要があるときや、スキーマも同時に変えるときに使います。過去データを期間ごとに INSERT ... SELECT で移し、その間に入ってくる変更分はトリガーや論理レプリケーションで追いつかせ、最後に短いロックで名前を入れ替えます。方法 A よりはるかに時間が掛かり追いつきロジックも必要ですが、境界条件は自由です。

どちらにせよ、リハーサルなしに本番で初めて実行してはいけません。特に方法 A は 3 の手順まで順調に進んで 5 の手順でロックが取れず止まることが多いです。そのとき既に ordersorders_new のパーティションになっている、という点を覚えておく必要があります。ロールバック計画は「3 の手順を DETACH で戻す」ところまで先に書いておくべきです。

失敗事例と診断の順序

症状: パーティションを 1 つ追加しただけでサービス全体が止まった

ロック待ちです。順に確認します。

-- 1) いまロックを待っているセッションは何個か
SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock';

-- 2) 何を待っていて、誰がブロックしているのか
SELECT pid,
       now() - query_start AS waiting_for,
       left(query, 60)     AS query,
       pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY query_start;
出力例

  pid  | waiting_for |                query                 | blocked_by
-------+-------------+--------------------------------------+------------
 24815 | 00:04:12    | CREATE TABLE orders_2026_09 PARTITI  | {24102}
 24903 | 00:04:07    | SELECT id, amount FROM orders WHERE  | {24815}
 24911 | 00:04:06    | SELECT count(*) FROM orders WHERE o  | {24815}

読み方が重要です。pg_blocking_pids はドキュメントの説明どおり「指定したプロセスがロックを取得するのを妨げているセッションの PID の配列」を返します。上の出力では 2 つの SELECT が DDL(24815)にブロックされ、その DDL はさらに 24102 にブロックされています。つまり本当の原因は DDL ではなく 24102 が握っている古いトランザクション であり、DDL はその後ろで ACCESS EXCLUSIVE を要求したまま、後続の読み取り全部を道連れにして止めているだけです。

だから対応の順序も決まります。24102 が生きたまま DDL だけ殺しても、次の試行で同じことが繰り返されます。前にいる古いトランザクションを先に処理し、次回は lock_timeout を設定したうえで CREATE TABLE ... PARTITION OF ではなく CREATECHECKVALIDATEATTACH の順序に置き換えます。

症状: ATTACH PARTITION が 30 分経っても終わらない

ほぼ必ずスキャンです。確認するのは 2 点です。

  1. アタッチしようとするテーブルに、パーティション境界と一致する 有効な CHECK 制約があるか。\d+ orders_2026_09 で制約が見えるか、そして NOT VALID の表示が残っていないかを見ます。NOT VALID 状態の制約は、ATTACH がスキャン省略の根拠として使いません。VALIDATE CONSTRAINT を忘れたケースが最も多いです。
  2. DEFAULT パーティションがあるか。あるなら、いまスキャンされているのはアタッチ対象のテーブルではなく DEFAULT パーティションかもしれません。前節の除外 CHECK が必要です。

症状: インデックスを 1 つ追加したら書き込みが全部止まった

パーティションの親に CONCURRENTLY なしで CREATE INDEX を実行した場合です。パーティションテーブルには CONCURRENTLY が使えないため、うっかりこうなります。キャンセルして前述の ON ONLY の 3 段階レシピでやり直しますが、キャンセルしたあとは必ず invalid なインデックスが残っていないかを pg_index の照会で確認してください。

症状: パーティションはちゃんと作られるのに実行計画がおかしい

親テーブルの統計情報がない可能性が高いです。ドキュメントはこう書いています。パーティションテーブルはタプルを直接格納しないため autovacuum の処理対象にならず、したがって autovacuum はパーティションテーブルに ANALYZE を実行せず、これがパーティションテーブルの統計情報を参照するクエリに最適でない計画をもたらしうる、と。個々のパーティションは通常のテーブルと同じく autovacuum が処理しますが、親だけは誰も面倒を見ません。

解決策もドキュメントにそのまま書かれています。パーティションテーブルに最初にデータが投入されたとき、そしてパーティション間のデータ分布が大きく変わるたびに、手動で ANALYZE を実行することです。

-- 親に対して明示的に実行する
ANALYZE orders;

毎月パーティションを追加するシステムなら、パーティション作成スクリプトの末尾にこの一行を足しておくのが最も確実です。

症状: パーティションを消したのにディスク使用量が減らない

DETACH だけして DROP TABLE をしていない場合です。切り離されたテーブルの一覧はこう探します。

-- 親に付いているパーティションの一覧
SELECT c.relname
FROM pg_inherits i
JOIN pg_class p ON p.oid = i.inhparent
JOIN pg_class c ON c.oid = i.inhrelid
WHERE p.relname = 'orders';

-- 名前が orders_ で始まるのに上の一覧に出てこないテーブルが、
-- 昔 DETACH されたまま忘れられたものである

ここまでやらないほうがよい場合

ここまで読めば、パーティション運用がかなり手の掛かる仕事だと分かるはずです。実際そうであり、だからやらないほうが正しい場合も多くあります。

テーブルが小さいならパーティショニングは純粋な損です。プランナがパーティションごとに判断する必要があるので計画コストが増え、毎月パーティションを作って消す運用負担が生まれ、得るものはありません。ドキュメントも「パーティションは多いほうが常に良い、あるいは常に悪い、と決めつけてはいけない」と釘を刺しています。

クエリの WHERE 句にパーティションキーが入ってこないワークロードなら、パーティショニングは損に近いです。プルーニングが効かなければ、1 つの大きなテーブルをスキャンしていたものが N 個の小さなテーブルのスキャンに変わるだけで、そこに計画コストが上乗せされます。パーティションキーを選ぶ問題は結局「うちのクエリは何でフィルタするのか」の問題であり、それは 戦略編 の主題です。

保持ポリシーのないテーブル、つまりデータを決して消さないテーブルなら、パーティショニング最大の利点である DROP TABLE を永遠に使いません。この場合パーティショニングの価値はプルーニングだけに縮み、それはたいていインデックスでも得られます。

最後に正直に言えば、パーティショニングを検討する前にインデックスとクエリを先に見るべきです。パーティショニングはスキーマを戻しにくい方向へ変える決定です。インデックスは間違えれば消せば済みますが、パーティションテーブルを戻すにはこの記事のマイグレーションを逆方向にもう一度やる必要があります。

参考資料

この記事の 6 節の pg_partman の例は p_type => 'native' を使っていますが、pg_partman 5.0 のドキュメントを基準にすると p_type が受け取る値は rangelist で、既定値は range です。5.x ではトリガーベースのパーティショニングが廃止され、すべてのパーティショニングが組み込みの宣言的パーティショニングで処理されるようになりました。p_premake の既定値もドキュメント上は 4 です。インストール済み拡張のバージョンを先に確認し、そのバージョンのドキュメントに合わせて引数を書いてください。

9. クイズ

Q1: Range パーティショニングにおける DEFAULT パーティションの役割は?

DEFAULT パーティションは、どのパーティションの範囲にも属さないデータを収容します。例えば、2026年のパーティションしかないのに2027年のデータが INSERT されると DEFAULT パーティションに保存されます。DEFAULT パーティションがない場合、範囲外のデータ INSERT 時にエラーが発生します。

Q2: DROP PARTITION が DELETE より速い理由は?

DELETE は各行を1つずつ削除しながら WAL ログを記録し、dead tuple が残り VACUUM が必要です。一方 DROP TABLE(パーティション削除)はテーブルのデータファイル自体を即座に削除するため、数億件のデータも瞬時に除去されます。行数に関係なくほぼ一定の時間で完了します。

Q3: PRIMARY KEY にパーティションキーを含めなければならない理由は?

PostgreSQL はパーティションテーブルのユニーク制約を各パーティションのローカルインデックスで実装します。パーティションキーが PK に含まれていないと、異なるパーティションに同じ id が存在する可能性があり、テーブル全体レベルのユニーク性を保証できません。そのため (id, order_date) のようにパーティションキーを PK に含める必要があります。

クイズ

Q1: 「PostgreSQL パーティショニング実践ガイド」の主なトピックは何ですか? PostgreSQL の Range/List/Hash パーティショニング方式、partition pruning、自動化戦略、運用のコツを実践例とともに解説します。

Q2: パーティショニングとは?とは何ですか? パーティショニングは、1つの大きなテーブルを複数の物理的なパーティションに分割する技法です。テーブルのデータが数億件以上の場合、クエリパフォーマンスを大幅に向上させることができます。 パーティショニングの利点 クエリパフォーマンスの向上: Partition pruning で必要なパーティションのみスキャン 大量データの削除: DROP PARTITION で即時削除(DELETE より数百倍高速) 並列処理: パーティション別の並列スキャンが可能 管理の容易さ: パーティション別のインデックス、VACUUM、バックアップが可能

Q3: Range パーティショニングの核心的な概念を説明してください。 最もよく使用される方式で、日付や数値の範囲で分割します。

Q4: List パーティショニングの主な特徴は何ですか? 特定の値リストで分割します。地域やカテゴリなどに適しています。

Q5: Hash パーティショニングはどのように機能しますか? ハッシュ関数で均等に分配します。特定のキーの分布が均一な場合に適しています。

コメント

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

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