PostgreSQL パーティションテーブル × Prepared Statement の落とし穴と3つの対処パターン
目次
はじめに
Confit 開発担当のカズシです。
システム開発において、「当時はベストだと思って選択した設計が、数年後に思わぬ形で牙をむく」という経験をしたことはありませんか? 今回はまさにそんなお話です。私たちが開発している Confit というサービスではデータベースとして PostgreSQL(バージョン 16)を採用しています。サービスの成長に伴うデータ量の増加への対応は、多くの開発現場で共通の課題です。主な選択肢としては次のようなものがあります。
- アーカイブ方式:古いデータを別テーブルやストレージに移動する。構成はシンプルだが、定期的なデータ移行の運用コストが発生する。
- シャーディング:データを複数の DB インスタンスに分散する。スケールアウトはしやすいが、アプリケーション側の実装が複雑になる。
- パーティションテーブル:テーブルを論理的に分割しつつ、アプリケーションからは1つのテーブルとして扱える。PostgreSQL のパーティションプルーニングにより、クエリが自動的に該当パーティションだけにアクセスする。
Confit は学術大会における研究者の発表を大会後も継続的に公開するサービスであるため、アーカイブ方式は合わず、かと言ってシャーディングが必要なほどのサービス規模でもありません。また、アプリケーションからのデータの読み書きが大会単位(EC サイトに例えるなら店舗単位)で完結するケースがほとんどであるため、大会単位で論理的に分割する設計が自然に定まり、パーティションテーブルを選択しました。
しかし、データ増加への対応として選んだこの設計が、特定の条件のもとでは裏目に出て、思わぬ性能問題を引き起こすことになりました。本記事では PostgreSQL の実行計画キャッシュとその制御モードをおさらいしたうえで、パーティションテーブルとの組み合わせで生じる落とし穴と、3つの対処パターンを紹介します。
Prepared Statement と plan_cache_mode の基礎
本題に入る前に、問題の前提となる Prepared Statement のキャッシュ挙動と plan_cache_mode について整理します。
ORM のクエリは Prepared Statement として処理される
MyBatis や Prisma など、多くの ORM はパラメータをバインドしてクエリを送信します。PostgreSQL はこれをサーバーサイドの Prepared Statement として扱い、実行計画(クエリプラン)をキャッシュします。バインドパラメータの使用は SQL インジェクション対策としても適切な実装です。
Custom Plan と Generic Plan の違い
PostgreSQL の Prepared Statement には2種類の実行計画があります。
| Custom Plan | Generic Plan | |
|---|---|---|
| パラメータ | 実際の値を使って計画生成 | $1, $2… を unknown として計画生成 |
| 統計の利用 | カーディナリティを正確に推定できる | 平均的な推定になる |
| 実行計画の再利用 | しない(毎回生成) | 一度生成した計画を使い回す |
Generic Plan はプランニングコストを削減できるため、特にシンプルなクエリでは Custom Plan より高速になるケースが多くあります。一方、実際のパラメータ値を知らないため、後述する特定の状況では問題が生じます。
plan_cache_mode の設定値
plan_cache_mode パラメータで実行計画の選択挙動を制御できます。
auto(デフォルト):PostgreSQL が Custom / Generic を自動的に切り替えるforce_custom_plan:常に Custom Plan を使用するforce_generic_plan:常に Generic Plan を使用する
デフォルトの auto では PostgreSQL が自動でコストを判断しますが、次のセクションで挙動の詳細を確認します。
auto モードのキャッシュ挙動と再現方法
説明には以下のクエリを使います。products テーブルは seller_id(販売元)をパーティションキーに分割されており、特定の販売元の商品一覧を取得します。
|
1 2 3 |
SELECT id, name, price, created_at FROM products WHERE seller_id = $1; |
手元で再現する方法
上記の挙動は PREPARE 文と EXPLAIN (ANALYZE, BUFFERS) を組み合わせて確認できます。通常の EXPLAIN は Prepared Statement のキャッシュを経由しないため、必ず PREPARE → EXECUTE の形で実行することがポイントです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
-- 現在の設定を確認 SHOW plan_cache_mode; -- Prepared Statement を作成 PREPARE stmt(text) AS SELECT id, name, price, created_at FROM products WHERE seller_id = $1; -- 複数回実行して挙動を観察(6回目前後で変化が起きる) EXPLAIN (ANALYZE, BUFFERS) EXECUTE stmt('customerA'); -- Custom / Generic それぞれの使用回数を確認 SELECT name, generic_plans, custom_plans FROM pg_prepared_statements WHERE name = 'stmt'; |
なお、PostgreSQL 16 以降では EXPLAIN (GENERIC_PLAN) オプションが利用でき、PREPARE / EXECUTE を経由せずにパラメータ付き SQL の Generic Plan を直接確認できます。Custom / Generic の選択挙動そのものを観察するには PREPARE 経由の手順が必要ですが、Generic Plan の中身だけを確認したい用途であればこちらの方が簡便です。
|
1 2 3 4 |
EXPLAIN (GENERIC_PLAN) SELECT id, name, price, created_at FROM products WHERE seller_id = $1; |
PostgreSQL の公式ドキュメントでは、Prepared Statement は最初の5回が Custom Plan で実行され、6回目から Generic Plan のコストとの比較が行われる、と説明されています。実際に検証した環境でも同様の挙動を確認できました。以下、それぞれのフェーズの動作を順に見ていきます。
1〜5回目:Custom Plan で実行
実際のバインド値を使って実行計画を生成するため、seller_id = 'customerA' のように値が確定した状態で統計情報を活用でき、その値に応じた効率的な計画が作られます。
6回目:Generic Plan のコスト評価
このフェーズでは、PostgreSQL がこれまでの Custom Plan の平均コストと Generic Plan のコストを比較します。Generic Plan の生成時はパラメータが unknown として扱われるため、Custom Plan とは異なる計画が生成される可能性があります。
評価後:使用するプランを決定し、Generic Plan のコストはキャッシュされる
コスト比較の結果「Custom Plan の方が安い」と判断されれば、以降は Custom Plan が使われます。コスト比較自体は実行のたびに行われますが、Generic Plan 本体は1度しか構築されず、そのコストはキャッシュされて再利用されます。
キャッシュとパーティションの落とし穴
Prepared Statement の実行計画キャッシュは、プランニングコストを削減できる優れたパフォーマンス最適化です。ただし、パーティションテーブルと組み合わさるとプランニング自体が想定外のコストになる場合があります。
PostgreSQL の パーティションプルーニング には、計画段階で不要なパーティションを計画から除外する「プラン時プルーニング」と、実行段階で対象を絞り込む「実行時プルーニング」の2種類があります。Custom Plan と Generic Plan で機能の有無が異なります。
| プラン時プルーニング | 実行時プルーニング | |
|---|---|---|
| Custom Plan | 機能する | 不要 |
| Generic Plan | 機能しない | 機能する |
Generic Plan ではパラメータが unknown として扱われるため、プラン時プルーニングが効きません。計画には対象テーブルの全パーティションが含まれた状態で構築されることになり、計画構築自体のコストが想定外に大きくなります。具体的には全パーティションに対するカタログ参照と AccessShareLock の取得、プラン木の構築が発生し、これらがクエリレイテンシの増加(タイムアウトの直接原因)と DB 側の CPU 負荷上昇を引き起こします。実行時プルーニングによって実際にスキャンされるパーティションは1つに絞られますが、計画構築時点でのこれらのコストは避けられません。
Confit でもこの挙動により、パーティションテーブルを複数 JOIN する SQL を含む処理でタイムアウトが発生し、DB 負荷の上昇を招く事象が起きました。スロークエリとして対象 SQL は特定できたものの、原因の切り分けには時間を要しました。要因を以下に整理します。
- 症状がコネクション単位で発生:今回のようなケースでは Generic Plan の評価コストが大きいため、評価結果としても Custom Plan が選ばれます。同一コネクション内では Generic Plan が再構築されないため、6回目の実行で1度だけ問題が表面化し、以降は Custom Plan で高速に応答できます。今回問題が表面化したのは AWS Lambda 関数で実装している処理であり、実行特性上コネクションが頻繁に張り直されるため、結果としてこの「6回目の実行」が繰り返し発生し、断続的なタイムアウトとして観測されました。
- 通常の
EXPLAINで再現しない:スロークエリの調査では問題のクエリをEXPLAINで確認するのが定石ですが、通常のEXPLAINは Prepared Statement のキャッシュを経由しないため、本問題は表面化しません。クエリ自体は正常に見えるため、クエリチューニングを行っても根本的な解消にはなりません。 - JOIN を伴うクエリで特に顕著:プランニングコストはパーティション数に比例して増え、JOIN を伴うとテーブル数との組み合わせで掛け算的に拡大します。パーティション採用前にデータ量・パーティション数の観点での検証は行っていましたが、実際のアプリケーションで発行される複数 JOIN を伴うクエリでの検証が不足しており、影響を見落としていました。実行時ではなくプランニングフェーズで生じる負荷である点も、一般的なクエリ性能問題との違いです。
問題を回避する3つの対処パターン
① DB 全体に force_custom_plan を設定する
RDS のパラメータグループで plan_cache_mode = force_custom_plan を設定すると、全クエリで常に Custom Plan が使用されます。Dynamic パラメータのため、再起動なしで即時反映されます。ただし、Generic Plan の恩恵(プランニングコストの削減)がすべてのクエリで失われます。先述のとおりシンプルなクエリでは Generic Plan の方が高速になるケースもあるため、DB 全体への一律適用はトレードオフの検討が必要です。
なお、DB 全体ではなくアプリケーション用ロールに限定して ALTER ROLE app_user SET plan_cache_mode = force_custom_plan; を設定する方法もあります。バッチや分析用に別ロールを切っている場合は、片方だけ force にする運用が可能で、DB 全体への適用と後述するセッション単位の切り替えの中間的な選択肢になります。
また、先日リリースされた PostgreSQL 18 でもパーティションテーブル関連の改善が複数取り込まれており、引き続き改善が見込まれます。将来のバージョンでは force_custom_plan への切り替えが不要になるケースが増えていく可能性もあります。
② SQL を子テーブルへの直接アクセスに変更する
本記事で扱ってきた products は seller_id をパーティションキーとする分割テーブルです。クエリ発行時点でパーティションキーが確定している場合は、親テーブルではなく子テーブル(個別パーティション)を直接参照することで問題を回避できます。
|
1 2 3 4 5 6 7 8 |
-- 変更前:親テーブルを参照(パーティションプルーニングに依存) SELECT id, name, price, created_at FROM products WHERE seller_id = $1; -- 変更後:子テーブルを直接参照 SELECT id, name, price, created_at FROM products_customerA; |
子テーブルを指定すればそのテーブルにパーティションは存在しないため、Generic Plan の構築時にも計画には1つのテーブルしか含まれず、本記事で扱った計画構築コストの問題は発生しません。この方法は影響範囲が問題のあるクエリに限定できるため、DB 全体への設定変更を避けたい場合に適しています。
ただし、クエリ発行時点でパーティションキーが確定していることが前提です。日付ベースのパーティションなど、条件によっては複数パーティションにまたがる検索が必要な設計では適用できません。
③ セッション単位でモードを変更する
パーティションをまたいだ検索が必要な処理に対しては、セッション(コネクション)単位で plan_cache_mode を切り替える方法があります。
|
1 2 3 4 5 6 7 |
-- 問題のあるクエリ実行前に設定 SET plan_cache_mode = force_custom_plan; -- クエリ実行 ... -- 元に戻す SET plan_cache_mode = auto; |
DB 全体への影響を避けつつ、特定の処理だけに対処できます。設定の管理コストは増えますが、Generic Plan の恩恵を他のクエリで維持できます。
Confit ではパーティションキーでもある大会単位での読み書きが多いため②を採用し、どうしても大会をまたぐ処理が必要な場合のみ③により対処することにしました。
おわりに
今回は幸運にも対処が可能な設計だったため、影響を抑えながら対応できました。もしどのパターンも適用できなかったら、稼働中のサービスでの大掛かりな再設計を迫られていた可能性があります。データベースやデータ周りはアプリケーション処理と違って後から変更が難しい領域なので、設計時の選択がそのまま将来の柔軟性を決めてしまう面があると改めて感じました。AI で開発スピードが上がる時代だからこそ、データ設計に時間をかける価値はむしろ大きくなっているように思います。
また、今回の問題は plan_cache_mode の挙動もパーティションテーブルの仕組みも、それぞれは公式ドキュメントに記載されています。ただ、それらの組み合わせで起きる挙動までは、事前に知ることが難しく、検証や実際の稼働を経て初めて気づくような問題でした。
この記事が、同じような構成を採用しているサービスの設計の参考になり、同様の事象に遭遇する方が一人でも減るきっかけになれば幸いです。






