- はじめに
- 1. パーティショニングが実際に解決する問題
- 2. 三つのパーティショニング方式
- 3. パーティションプルーニング — 唯一の本当の利点
- 4. パーティションキーが課す制約
- 5. パーティション数とプランナーコスト
- 6. パーティションの運用 — 付けると外す
- 7. パーティショニングの限界線 — いつシャーディングか
- 8. シャーディングすると壊れるもの
- クイズ: 実力を確認してみましょう
- おわりに
- 参考資料
- 続けて読む
はじめに
このブログにはPostgreSQLのパーティショニングに関する記事がすでに複数あります。PostgreSQLパーティショニング完全ガイドはRange、List、Hashそれぞれの戦略の文法と性能を扱い、PostgreSQLパーティショニング戦略と並列クエリは並列実行との相互作用を扱います。
この記事の切り口は異なります。パーティショニングを目的地ではなく、経路上の一地点として捉えます。データが大きくなるとき私たちがたどる道は、インデックス → パーティショニング → リードレプリカ → シャーディングです。各段階は一つ前の段階が通用しなくなったときにだけ意味を持ち、各段階は一つ前の段階になかった新しい問題を連れてきます。この記事は「パーティショニングをいつ始め、いつ止め、いつ次に進むか」を扱います。特にシャーディングに移る瞬間に何が壊れるかを前もって知ることが目的です。
基準となるエンジンはPostgreSQL 18であり、パーティショニングの制約とデフォルト値はすべてPostgreSQL 18のドキュメントで確認しました。シャーディングの部分は特定の製品に依存しない設計原則を中心に扱いつつ、製品固有の挙動は各製品のドキュメントを確認するようにと明記しました。
1. パーティショニングが実際に解決する問題
まず誤解を取り除きます。パーティショニングはディスクを増やしてくれるわけではなく、書き込みのスループットを増やしてくれるわけでもなく、ほとんどのクエリを自動的に速くしてくれるわけでもありません。パーティションはすべて同じサーバーの同じストレージ上にあるからです。
パーティショニングが実際に解決する問題は四つあります。
一つ目は、スキャン範囲の縮小です。クエリが特定のパーティションだけに触れれば、残りはまったく読みません。これがパーティションプルーニングであり、パーティショニングが持つ唯一の根本的な性能上の利点です。
二つ目は、大量削除の定数化です。3年分のログから最も古い1年を消す作業は、DELETEで行うと数時間かかり、大量の死んだ行とWALを生み出します。月単位のパーティションならDROP TABLEを12回実行するだけです。この違いが、パーティショニングを導入する最も一般的な理由です。
三つ目は、メンテナンス単位の縮小です。VACUUM、ANALYZE、インデックスの再構築がテーブル全体ではなくパーティション単位で回ります。更新されない過去のパーティションは事実上メンテナンスが不要になります。
四つ目は、インデックスサイズの縮小です。パーティションごとに別々のインデックスを持つため、各インデックスが小さくなりキャッシュに収まりやすくなります。
逆に言えば、上の四つが必要でないならパーティショニングは損です。計画立案の時間が伸び、一意制約に制限が生まれ、運用作業が増えます。「テーブルが大きいからパーティショニングしよう」という判断は、それ自体では根拠になりません。
2. 三つのパーティショニング方式
PostgreSQLの宣言的パーティショニングは三つの方式を提供します。ドキュメントの定義そのままです。
RANGE — キー列の範囲で分割します。ドキュメントによれば「各範囲の境界は下限が含まれ(inclusive)、上限が除外される(exclusive)」とされています。このルールを誤解すると、境界のデータが消えたり重なったりします。
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
tenant_id bigint NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL
) PARTITION BY RANGE (occurred_at);
-- 2026年8月: 8月1日00:00:00を含み、9月1日00:00:00を除く
CREATE TABLE events_2026_08 PARTITION OF events
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE events_2026_09 PARTITION OF events
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
LIST — ドキュメントの表現では、各パーティションに「どのキー値が入るかを明示的に列挙」します。地域コード、国、ステータス値のように値の種類が有限で安定しているときに使います。
HASH — ドキュメントの表現では、各パーティションに「モジュラスと剰余を指定し、パーティションキーのハッシュ値をモジュラスで割った余りが指定した値になる行を格納」します。値の分布を均等に分けることが目的で、範囲によるプルーニングはできません。
-- テナントを八つに均等分散
CREATE TABLE events_h (LIKE events INCLUDING ALL) PARTITION BY HASH (tenant_id);
CREATE TABLE events_h_0 PARTITION OF events_h FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE events_h_1 PARTITION OF events_h FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... 残り6個
RANGEとLISTにはDEFAULTパーティションを置くことができます。どこにも属さない行を受け取る場所です。ただし5節と6節で見る理由から、DEFAULTパーティションは空のまま維持するのが原則です。
3. パーティションプルーニング — 唯一の本当の利点
プルーニングは、パーティションの定義を見て条件を満たせないパーティションを計画から除外する最適化です。enable_partition_pruningが制御し、ドキュメントの例にも「the default」と表示されているとおり、デフォルト値はonです。
プルーニングは二つの時点で起こります。
計画時点のプルーニング — WHERE条件が定数のとき、計画を作りながら除外します。EXPLAINの出力には残らず、そのまま消えます。
実行時点のプルーニング — パラメータの値が実行中になって初めて決まる場合(プリペアド文のバインド値、サブクエリの結果、Nested Loopの内側)に、実行しながら除外します。このときはEXPLAINの出力にSubplans Removedが表示されます。
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';
Aggregate (cost=4210.55..4210.56 rows=1 width=8)
(actual time=31.204..31.205 rows=1 loops=1)
Buffers: shared hit=2048
-> Seq Scan on events_2026_08 events (cost=0.00..3901.20 rows=123740 width=0)
(actual time=0.011..21.882 rows=123740 loops=1)
Filter: ((occurred_at >= '2026-08-01 00:00:00+09'::timestamptz)
AND (occurred_at < '2026-09-01 00:00:00+09'::timestamptz))
Buffers: shared hit=2048
Planning Time: 0.502 ms
Execution Time: 31.240 ms
ここで確認すべきは計画にパーティションが一つだけ登場するかです。36個のパーティションが全部並んでいれば、プルーニングは失敗しており、パーティショニングの利点を一つも得られていない状態です。
プルーニングが失敗する代表的な原因を二つ覚えておいてください。一つ目は、パーティションキーに関数をかぶせるとプルーニングが働きません。WHERE date_trunc('month', occurred_at) = ...は条件をキーの範囲に還元できません。二つ目は、パーティションキーが条件にまったく現れないと当然すべてのパーティションを走査します。これがパーティションキーの選択が決定的である理由です。
4. パーティションキーが課す制約
パーティションキーは性能だけを決めるのではありません。スキーマが表現できる制約の種類まで決めます。
ドキュメントが明記する制限の中で最も重要なものです。パーティションされたテーブルに一意制約や主キーを作るには、「パーティションキーに式や関数呼び出しが含まれてはならず、制約の列がパーティションキーのすべての列を含んでいなければならない」とされています。
この一文が設計全体を変えます。
-- occurred_atでパーティショニングしたテーブルで
-- これは作れない: パーティションキー(occurred_at)が欠けている
ALTER TABLE events ADD CONSTRAINT uq_events_id UNIQUE (id);
-- これだけが可能: パーティションキーを含める必要がある
ALTER TABLE events ADD CONSTRAINT uq_events_id_time UNIQUE (id, occurred_at);
つまり、時間でパーティショニングすると「idはグローバルに一意である」をデータベースの制約として表現できません。代わりにアプリケーションやシーケンスがそれを保証しなければなりません。UUIDやUUIDv7のようなグローバル一意識別子を使う理由もここにあります。
排他制約(exclusion constraint)にも同じ制限があります。ドキュメントによれば「パーティションキーの列をすべて含まなければならず、それらの列を等価比較しなければならない」とされています。
そのほかにドキュメントが明記している制限です。
INSERTに対するBEFORE ROWトリガーは新しい行の最終的な行き先パーティションを変更できません。- 同じパーティションツリーの中に一時テーブルと永久テーブルを混在させることはできません。
パーティションキーを選ぶ実用的な手順はこうです。第一に、ほとんどのクエリが常に付けてくる条件が何かを見ます。第二に、データ保持ポリシーがどの軸で切り出すかを見ます。第三に、その軸が一意制約の要件と衝突しないかを確認します。三つとも満たす軸がなければ、パーティショニングはまだ早いという合図かもしれません。
5. パーティション数とプランナーコスト
「パーティションを細かく分けるほど良い」という直感は間違っています。ドキュメントが直接反論しています。「パーティションが少ないより多いほうが良いと、あるいはその逆だと、安易に仮定してはならない」。
数字についてのドキュメントの案内はこうです。「クエリプランナーは一般的に数千個までのパーティション階層をかなりうまく処理する。ただし典型的なクエリにおいて、プランナーが少数のパーティションだけを残してすべて除去できる必要がある」。
コストは二か所で発生します。ドキュメントの表現そのままに「プランナーがパーティションプルーニングを実行した後も残るパーティションが多いと、計画立案の時間が長くなりメモリ消費が増える」のであり、より恐ろしいのはメモリのほうです。「特に多くのセッションが多数のパーティションに触れると、サーバーのメモリ消費が時間とともに大きく増える可能性がある。各パーティションのメタデータが、それに触れる各セッションのローカルメモリに読み込まれなければならないためである」。
ワークロードに応じた案内もあります。「データウェアハウス型のワークロードでは、OLTP型より多くのパーティションを使うことが合理的な場合がある。データウェアハウスでは通常、処理時間の大部分が実行に使われるため、計画立案の時間の重要性は下がる」。
実務上の基準をまとめるとこうです。OLTPではパーティション数を数十から数百単位に保ち、それ以上が必要ならパーティションの間隔を広げるか(日単位を月単位に)、古いパーティションを切り離してアーカイブに出します。そしてmax_locks_per_transactionを忘れないでください。デフォルト値は64で、ドキュメント自体が「子テーブルの多い親テーブルへのクエリ」を値を上げるべき事例として挙げています。数百個のパーティションを一つのトランザクションで触れると、この上限にぶつかります。
6. パーティションの運用 — 付けると外す
パーティションテーブルの運用は、大部分が「将来のパーティションを前もって作り、過去のパーティションを切り離す」の繰り返しです。
前もって作る。パーティションのない範囲に行が入ってくるとエラーになるか、DEFAULTパーティションに入ります。どちらも好ましくありません。最低でも二、三期間分を前もって作っておくバッチをスケジューラに登録しておきましょう。
付ける。
警告:
ALTER TABLE ... ATTACH PARTITIONは親テーブルにはSHARE UPDATE EXCLUSIVEロックしかかけませんが、アタッチされるテーブル自体とDEFAULTパーティション(存在する場合)にはACCESS EXCLUSIVEロックをかけます。DEFAULTパーティションに大量のデータがあると、新しい範囲と重なる行がないかを確認するスキャンが長くかかり、その間DEFAULTパーティションへのアクセスがすべてブロックされます。ドキュメントは、DEFAULTパーティションがある場合には「アタッチするパーティションの制約を排除するCHECK制約をあらかじめ作っておく」ことを推奨しています。より良い答えは、DEFAULTパーティションをそもそも置かないか、常に空のまま維持することです。
既存のテーブルをパーティションとしてアタッチするときは、CHECK制約を前もってかけておくと検証スキャンを飛ばせます。
-- アタッチする前に範囲を証明するCHECKを前もって作る
ALTER TABLE events_2026_10_staging
ADD CONSTRAINT chk_range
CHECK (occurred_at >= '2026-10-01' AND occurred_at < '2026-11-01');
ALTER TABLE events ATTACH PARTITION events_2026_10_staging
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
外す。DETACH PARTITIONには並行モードがあります。ドキュメントの表現では、「CONCURRENTLYを指定すると、パーティションされたテーブルにアクセスする他のセッションを妨げないよう、引き下げられたロックレベルで実行」されます。保持ポリシーのバッチではこのオプションを標準で使ってください。
-- ロックの影響を最小化しながら切り離す
ALTER TABLE events DETACH PARTITION events_2023_08 CONCURRENTLY;
-- 切り離された後は普通の独立したテーブルなので自由に扱える
-- アーカイブにダンプするか
DROP TABLE events_2023_08;
インデックス。パーティションテーブルにはCREATE INDEX CONCURRENTLYを直接使うことができません。ドキュメントは「パーティションされたテーブルの同時インデックス作成は現在サポートされていない」と明記し、各パーティションに個別に同時作成したうえで、最後に親テーブルに非同時で作成するよう案内しています。
パーティション単位のジョインと集計。enable_partitionwise_joinとenable_partitionwise_aggregateは、パーティション境界が同じテーブル同士をパーティション単位でジョインまたは集計できるようにします。二つのパラメータともデフォルト値はoffです。計画立案のコストが増えるためです。パーティション境界がそろった大型テーブルのジョインが頻繁な分析ワークロードなら、オンにしてみる価値があります。
7. パーティショニングの限界線 — いつシャーディングか
パーティショニングは一台のサーバーの中の話です。次の三つのうちいずれかに当てはまると、パーティショニングではこれ以上先に進めません。
一つ目は、書き込みのスループットが一つのノードの限界に達したときです。パーティションをどれだけ分けても、WALは一つのストリームであり、コミットは一つのディスクに向かいます。読み取りはレプリカに分散できますが、書き込みはそうはいきません。
二つ目は、データが一つのノードのストレージやバックアップウィンドウを超えるときです。バックアップと復旧の時間が事業が耐えられるRTOを超えると、物理的に分割しなければなりません。
三つ目は、地域や規制によってデータを物理的に分離しなければならないときです。これは性能の問題ではなく要件です。
ここでは順序を守ることが重要です。シャーディングは最後の手段です。その前に試すことがまだ残っている場合がほとんどです。インデックスとクエリのチューニング、リードレプリカへの読み取り分散、パーティショニングによるスキャンの縮小、古いデータを別の分析用ストレージへ分離すること、キャッシュ層の導入、そして垂直スケーリング。ハードウェアの値段とエンジニアの時間を比べると、垂直スケーリングが依然として最も安い答えであることが多いです。
PostgreSQLのエコシステムでシャーディングを実装する経路は三つあります。
- アプリケーションレベルのシャーディング — アプリケーションがシャードキーを見てどのデータベースに行くかを決めます。最も単純で最も制御可能ですが、ルーティングとリバランシングを自分で作らなければなりません。
postgres_fdwベースのフェデレーション — 外部テーブルをパーティションとしてアタッチし、他のノードのデータを一つのテーブルのように問い合わせます。条件のプッシュダウンがどこまで効くかが性能を左右します。- 分散拡張や分散SQLエンジン — Citusのような拡張や、別の分散SQL製品を使います。各製品の挙動と制約はその製品のドキュメントで確認してください。バージョンごとにサポート範囲が大きく変わる領域です。
8. シャーディングすると壊れるもの
シャーディングを決める前に、何を諦めるのかを正確に知っておく必要があります。四つあります。
一つ目は、クロスシャードジョインです。シャードキーが異なる二つのテーブルをジョインするには、複数ノードのデータを一か所に集めなければなりません。対応は二つです。頻繁にジョインするテーブル同士を同じシャードキーに配置してジョインが常に一つのシャードの中で完結するようにするか(コロケーション)、サイズが小さく変更が稀なテーブルはすべてのシャードに複製しておくか(参照テーブル)です。この二つでカバーされないジョインが多いなら、シャードキーの選択が間違っています。
二つ目は、グローバル一意性とシーケンスです。各シャードのbigserialは互いに重複します。対応は、シャードごとにシーケンスの開始値と増分を変える、UUID系の識別子を使う、あるいは上位ビットにシャード番号を入れる方式です。PostgreSQL 18には時間順序を保持するuuidv7()関数が追加されたため、ソートの局所性が必要な場合の候補になります。
三つ目は、トランザクションです。複数のシャードにまたがる原子的な更新は二相コミットを要求し、二相コミットはコーディネーターが死んだときにロックが残る問題を連れてきます。実務の答えは大抵、シャードをまたがないトランザクションだけを許すようにドメインを設計することです。またがざるを得ない場合は、サガパターンのように補償トランザクションで整合性を合わせます。
四つ目は、リバランスです。シャードを8個から16個に増やす作業はデータの移動を伴います。単純なモジュラーハッシュを使うと、ほぼすべてのデータが移動します。一貫性ハッシュや仮想シャード(論理シャードを物理ノードにマッピングする間接層)を最初から導入しておかないと、後で苦労します。
ここに運用コストが加わります。シャード数の分だけバックアップ、モニタリング、バージョンアップグレード、障害対応が掛け算されます。シャーディングは技術の決定ではなく組織の決定です。
クイズ: 実力を確認してみましょう
クイズ1: 月単位でパーティショニングしたテーブルなのに、このクエリがすべてのパーティションを走査します。なぜでしょうか。
SELECT count(*) FROM events
WHERE date_trunc('month', occurred_at) = '2026-08-01'::timestamptz;
正解: パーティションキーに関数をかぶせているため、プルーニングが働きません。
解説: プルーニングは、WHERE条件をパーティションの境界と比較できるときにだけ動作します。date_trunc('month', occurred_at)は列そのものではなく列の関数の結果なので、プランナーはそれをoccurred_atの範囲に還元できません。書き直すとこうなります。
SELECT count(*) FROM events
WHERE occurred_at >= '2026-08-01' AND occurred_at < '2026-09-01';
RANGEパーティションの境界は下限が含まれ上限が除外されるので、条件も同じ形に合わせるのが自然です。EXPLAINでパーティションが一つだけ登場するか必ず確認してください。
クイズ2: occurred_atでパーティショニングしたeventsテーブルにPRIMARY KEY (id)を作ろうとするとエラーになります。
正解: パーティションされたテーブルの一意制約は、パーティションキーのすべての列を含まなければならないためです。
解説: ドキュメントは「制約の列がパーティションキーのすべての列を含んでいなければならない」と明記しています。パーティションごとに別々のインデックスが存在するため、パーティションキーが欠けた一意インデックスにはパーティション間の一意性を保証する方法がありません。選択肢は三つです。第一に、PRIMARY KEY (id, occurred_at)のようにパーティションキーを含めます。第二に、グローバルな一意性をデータベースではなく識別子の生成方式で保証します(UUID、uuidv7()、スノーフレーク系)。第三に、一意性が本当に重要でパーティショニングの利点がさほど大きくないなら、パーティショニング自体を再検討します。この制約を後になって発見し、設計を作り直す羽目になることはよくあるので、パーティションキーを決めるときに一緒に検討すべきです。
クイズ3: 日単位のパーティションで3年分を運用しています(約1,100個)。最近、計画立案の時間が実行時間より長くなりました。
正解: パーティション数が多くプランナーの負担が大きくなっています。間隔を広げるか、古いパーティションを切り離す必要があります。
解説: ドキュメントはプランナーが「数千個までのパーティション階層をかなりうまく処理する」としながらも、「典型的なクエリにおいて少数のパーティションだけを残してすべて除去できる必要がある」という条件を付けています。プルーニングの後も多くのパーティションが残ると、計画立案の時間とメモリ消費がともに増えます。特に各セッションが触れたパーティションのメタデータをローカルメモリに読み込むため、接続数の多いサーバーではメモリの圧迫が積み重なります。対応は三つです。直近のデータだけ日単位に置き、過去は月単位に統合すること、保持期間を過ぎたパーティションをDETACH PARTITION ... CONCURRENTLYで切り離すこと、そしてmax_locks_per_transaction(デフォルト値64)に余裕があるか確認することです。
クイズ4: チームが「書き込みが遅いからパーティショニングしよう」と言っています。妥当でしょうか。
正解: 大抵は妥当ではありません。パーティショニングは書き込みのスループットを増やしません。
解説: パーティションはすべて同じサーバー、同じWALストリーム、同じストレージを使います。パーティショニングで書き込みが速くなる場合は間接的なものです。インデックスが小さくなってインデックス更新のコストが下がる、あるいは大量削除がDROP TABLEに置き換わって死んだ行の発生がなくなる程度です。書き込みが遅い本当の原因はたいてい別の場所にあります。インデックスが多すぎる、コミットのたびに同期的なディスク書き込みが発生する(synchronous_commitのデフォルトはon)、チェックポイントが頻繁すぎる(max_wal_sizeのデフォルトは1GB)、ロック競合がある、トリガーが重い、といったことです。この順序でまず確認し、それでも一つのノードの限界であるなら、そのときはパーティショニングではなくシャーディングや垂直スケーリングを検討すべきです。
クイズ5: シャーディングを導入することにしました。シャードキーを選ぶときに真っ先に確認すべきことは何でしょうか。
正解: トランザクションとジョインがシャードの境界を越えないかどうかです。
解説: シャードキー選択の第一基準は、データ分布の均等性ではなく境界を越える頻度です。マルチテナントサービスでtenant_idがしばしば良いシャードキーになる理由は、ほとんどのトランザクションとジョインが一つのテナントの中で完結するからです。逆に時間をシャードキーに使うと、最新のシャードにすべての書き込みが集中するホットスポットが生まれますし、ユーザーIDでシャーディングしたあとに「注文と商品をジョインする」と、毎回のクエリがクロスシャードになってしまいます。確認する順序はこうです。第一に、上位十個のクエリがすべてシャードキーを条件として持っているか。第二に、トランザクションの境界がシャードの中に収まっているか。第三に、特定のシャードにデータやトラフィックが偏っていないか。三つのうち一つでも外れていれば、そのキーは候補から外すべきです。
おわりに
パーティショニングとシャーディングは名前も図も似ていますが、性格はまったく異なります。パーティショニングは一つのデータベースの中の物理設計であり、元に戻すのが比較的簡単です。シャーディングはシステムアーキテクチャの変更であり、元に戻すのは事実上不可能です。
だからこそ順序が重要です。インデックスとクエリで解決するならそこで止め、スキャン範囲や保持ポリシーが理由ならパーティショニングをし、読み取り負荷ならレプリカを増やし、それでも一つのノードの限界であるなら、そのときシャーディングを検討します。各段階で「次の段階に進む根拠」を数字で書き残しておくと、組織の議論が短くなります。
パーティションプルーニングを自分で確認してみたいならPostgreSQLプレイグラウンドで、大容量のテストデータが必要ならモックデータ生成を活用してください。
参考資料
- PostgreSQL 18, Table Partitioning: https://www.postgresql.org/docs/18/ddl-partitioning.html (2026-08-15 確認)
- PostgreSQL 18, ALTER TABLE: https://www.postgresql.org/docs/18/sql-altertable.html (2026-08-15 確認)
- PostgreSQL 18, CREATE INDEX: https://www.postgresql.org/docs/18/sql-createindex.html (2026-08-15 確認)
- PostgreSQL 18, Query Planning: https://www.postgresql.org/docs/18/runtime-config-query.html (2026-08-15 確認)
- PostgreSQL 18, Lock Management: https://www.postgresql.org/docs/18/runtime-config-locks.html (2026-08-15 確認)
- PostgreSQL 18, Write Ahead Log: https://www.postgresql.org/docs/18/runtime-config-wal.html (2026-08-15 確認)
続けて読む
- 前編: 無停止スキーマ変更完全ガイド — DDLが取得するロックレベル
- 次編: コネクションプール完全ガイド — プーリングモードが結ぶ契約
- PostgreSQLパーティショニング完全ガイド — Range、List、Hashの文法と性能
- PostgreSQLパーティショニング戦略と並列クエリ — 並列実行との相互作用
- MongoDB Sharding 完全ガイド — 別のエンジンのシャーディングモデル
- PostgreSQLプレイグラウンド — パーティションプルーニングの実験
- モックデータ生成 — 大容量のテストデータを作る
현재 단락 (1/133)
このブログにはPostgreSQLのパーティショニングに関する記事がすでに複数あります。[PostgreSQLパーティショニング完全ガイド](/blog/database/2026-03-03-pos...