監査ログのイベントテーブルにおけるPostgreSQLのパーティショニング
監査ログ向けの PostgreSQL パーティショニング:導入効果の判断、パーティションキーの選び方、管理画面のフィルタや保持に与える影響をわかりやすく解説します。

なぜイベント/監査テーブルが問題になるのか
イベントテーブルと監査テーブルは見た目は似ていますが、目的が異なります。
イベントテーブルは起きたことを記録します:ページビュー、送信されたメール、Webhook の呼び出し、ジョブの実行など。監査テーブルは誰がいつ何を変えたかを記録します:ステータス変更、権限更新、支払い承認など。多くの場合「変更前」と「変更後」の詳細が含まれます。
どちらも追記のみ(append-only)で急速に増えます。個々の行を頻繁に削除することは少なく、毎分新しい行が到着します。バックグラウンドジョブや外部連携を含めると、小さなプロダクトでも数週間で何百万行ものログが蓄積されることがあります。
日常作業で痛みが出るのはここです。管理画面では「昨日のエラー」や「このユーザーの操作」といった短いフィルタが必要ですが、テーブルが大きくなるとそのような基本画面が遅くなります。
まず目に付く症状は次のようなものです:
- 狭い日付範囲でもフィルタに数秒かかる(あるいはタイムアウトする)。
- インデックスが肥大化して挿入が遅くなり、ストレージコストが上がる。
- VACUUM や autovacuum に時間がかかり、保守が目立つようになる。
- 保持(retention)が危険になる:古い行の削除が遅く、テーブルにブロートが生じる。
パーティショニングはこれらに対処する一つの手段です。簡単に言えば、ひとつの大きなテーブルを多くの小さなテーブル(パーティション)に分けて、論理的には同じ名前で扱えるようにします。PostgreSQL は通常時間を基準にルールを設定し、新しい行を適切なパーティションに振り分けます。
そのためチームはイベントテーブルに対して PostgreSQL のパーティショニングを検討します:最新のデータが小さな塊にまとまっていれば、クエリが時間ウィンドウだけを必要とする場合に不要なパーティションをスキップできます。
ただしパーティショニングは魔法の性能向上スイッチではありません。「過去7日間」のようなクエリに大きく効き、保持作業も簡単にしますが、新たな問題を生むこともあります:
- パーティションキーを使わないクエリは多くのパーティションをチェックしなければならない。
- パーティションが増えると管理すべきオブジェクトが増え、設定ミスのリスクが高まる。
- 一部のユニーク制約やインデックスは全データにまたがって保証しにくくなる。
管理画面が日付フィルタと予測可能な保持ルールに大きく依存しているなら、パーティショニングは有効です。もしもほとんどのクエリが「ユーザーXの全履歴を探す」ようなものなら、UI とインデックスを慎重に設計しない限り問題を招くことがあります。
ログ/監査での典型的なアクセスパターン
イベント/監査テーブルは一方向に増えていきます:上方向へ。挿入が継続して発生し、更新はほとんどありません。多くの行は一度書き込まれ、その後サポート作業、インシデントレビュー、コンプライアンスチェックのときに参照されます。
この「追記のみ」という性質が重要です。書き込み性能は常に気になる点で、挿入は一日中行われます。一方で読み取りは一時的に重要度が増します(サポートや運用が素早く答えを必要とする場面)。
ほとんどの読み取りはフィルタです。管理画面ではユーザーはたいてい広い範囲(過去24時間など)から始めて、次にユーザーやエンティティ、アクションで絞り込みます。
一般的なフィルタは次の通りです:
- 時間範囲
- 実行者(ユーザーID、サービスアカウント、IP アドレス)
- 対象(エンティティ種別+エンティティID、例:Order #1234)
- アクション種別(作成、更新、削除、ログイン失敗など)
- ステータスや重要度(成功/エラー)
時間範囲は自然な「最初の切り口」で、ほぼ常に存在します。これがイベントテーブル向けパーティショニングの核心的な洞察です:多くのクエリは時間のスライスを求めており、その他の条件はそのスライス内の二次フィルタです。
保持もまた重要です。ログはめったに永続的に残しません。詳細なイベントを 30 日や 90 日保管してから削除やアーカイブを行うことが多いです。監査ログは要件によっては 365 日以上の保持が必要な場合もありますが、それでも古いデータをデータベースに負荷をかけずに削除する予測可能な方法が欲しいはずです。
監査には追加の期待もあります。通常、履歴は不変であるべきで、すべての記録は追跡可能(who/what/when とリクエストやセッションの文脈)で、アクセスは制御されるべきです(誰でもセキュリティ関連イベントを見られるべきではない)。
これらのパターンは UI 設計にそのまま反映されます。人々がデフォルトで期待するフィルタ(日付ピッカー、ユーザー選択、エンティティ検索、アクションのドロップダウン)は、テーブルとインデックスがサポートすべきものであり、そうでなければログ量が増えたときに管理体験が遅くなります。
パーティショニングが見合うかどうかの判断
パーティショニングは監査ログのためのデフォルトの最適解ではありません。日常的なクエリと定期的なメンテナンスが互いに干渉し始めるほどテーブルが大きくなったときに価値を発揮します。
単純なサイズの目安としては:イベントテーブルが数千万行規模に達したら計測を始める価値があります。テーブルとインデックスが数十ギガバイト級になると、単純な日付検索でも遅くなったり予測不能になったりします。ディスクから読み込むデータページが増え、インデックスの維持コストが高くなるためです。
最も明確なクエリ上のサインは、頻繁に小さい時間スライス(過去1日、過去1週)を要求するのに、PostgreSQL がテーブルの大部分に触れてしまっている場合です。最近のアクティビティ画面が遅い、あるいは日付+ユーザーやアクションでフィルタすると遅くなるなら要注意です。クエリプランで大きなスキャンやバッファ読み込みが常に多いなら、余計なデータまで読んでいる可能性があります。
メンテナンス面のサインも同じくらい重要です:
- VACUUM や autovacuum に以前より時間がかかる。
- autovacuum が遅れ、デッドタプル(ブロート)が増える。
- マルチカラムインデックスが予想以上に成長する。
- メンテナンスと通常トラフィックが重なるとロック競合が目立つようになる。
運用コストはチームをパーティショニングに駆り立てるゆっくりした圧力です。バックアップやリストアが遅くなり、ストレージが増え、保持ジョブが高コストになります。大きな DELETE はブロートと追加の VACUUM 作業を生むからです。
主要なゴールが「保持をシンプルにすること」と「最近の期間のクエリを高速にすること」なら、パーティショニングは真剣に検討する価値があります。テーブルが中程度で、良いインデックスでクエリが既に高速なら、パーティショニングは複雑さを増すだけかもしれません。
イベント/監査テーブルに合うパーティショニングの選択肢
ほとんどの監査/イベントデータでは、最も簡単で効果的なのは時間によるレンジパーティショニングです。ログは時間順に到着し、クエリは「過去24時間」「過去30日」といった時間窓に集中し、保持も時間ベースであることが多いためです。時間パーティションにすれば、古いデータを削除する代わりに古いパーティションを DROP するだけで済み、巨大な DELETE を避けられます。
時間レンジのパーティショニングはインデックスも小さく保てます。各パーティションが独自のインデックスを持つため、先週だけを探すクエリは何年分もの履歴をカバーする巨大なインデックスを辿る必要がありません。
ほかのスタイルもありますが、ログや監査には当てはまりにくいことが多いです:
- List(テナント別)は、少数の非常に大きなテナントがいて、クエリの多くが特定テナント内に収まる場合に有効です。テナント数が何百~何千になると扱いにくくなります。
- Hash(書き込み分散)は時間ウィンドウのクエリがない場合に書き込みを均等に分散する目的で有効ですが、保持や時系列の閲覧を難しくします。
- サブパーティショニング(時間+テナント)は強力ですが複雑さが急速に増します。非常に高いスループットや厳格なテナント分離が必要なシステム向けです。
時間を選ぶなら、閲覧と保持の仕方に合ったパーティションサイズを選んでください。大量のデータや厳格な保持がある場合は日次パーティションが適します。中程度のボリュームなら月次が管理しやすいです。
実用例:管理チームが毎朝の失敗ログインを確認し、過去7日で絞るなら、日次か週次パーティションにしておけばクエリは最新のパーティションだけを触ることが多く、PostgreSQL は残りを無視できます。
どの方法を選ぶにせよ、将来のパーティション作成、遅延到着イベントの処理、境界(日付の区切り)で何をするかといった「退屈な部分」を計画しておくことが重要です。これらの手順がシンプルならパーティショニングの効果は大きくなります。
適切なパーティションキーの選び方
良いパーティションキーは、図面上のデータの見た目ではなく、実際の読み方に合致します。
イベントや監査ログでは、まず管理画面を見てください:人々が最初に使うフィルタは何ですか? 多くのチームではそれが時間範囲(過去24時間、過去7日、カスタム期間)です。これが当てはまるなら、時間ベースのパーティショニングがもっとも大きく予測しやすい利点をもたらします。
キーは長期の約束と考えてください。何年も続けて実行するクエリ向けに最適化することになります。
人々が最初に使う「最初のフィルタ」から始める
ほとんどの管理画面はパターンがあります:時間範囲+オプションでユーザー、アクション、ステータス、リソースなど。結果を早く狭めるものをパーティションキーにしてください。
現実的なチェックポイント:
- デフォルトビューが「最近のイベント」なら、タイムスタンプでパーティションする。
- デフォルトビューが「あるテナント/アカウントのイベント」なら、
tenant_idが意味を持つことがあるが、テナントが十分大きい場合に限る。 - 最初のステップが常に「ユーザーを選ぶ」なら
user_idは魅力的に見えるが、通常は管理するパーティションが多くなりすぎる。
高カードinality のキーは避ける
パーティショニングは各パーティションが意味のあるデータ塊になる場合に最も効果的です。user_id、session_id、request_id、device_id のようなキーは何千・何百万ものパーティションを生み、メタデータのオーバーヘッドと運用の複雑さを招き、しばしばプランニングを遅くします。
時間ベースのパーティションはパーティション数が予測可能です。日次、週次、月次からボリュームに合わせて選んでください。少なすぎる(年次など)の場合は効果が小さく、多すぎる(時間毎など)の場合はオーバーヘッドが急増します。
どのタイムスタンプを使うか:created_at と occurred_at
時間の意味を明確にしましょう:
occurred_at:イベントがプロダクト内で実際に起きた時刻。created_at:データベースがそれを記録した時刻。
監査では「いつ起きたか(occurred)」が管理者にとって重要なことが多いです。ただし遅延到着(オフラインクライアント、再試行、キュー)が発生しやすい場合、occurred_at は遅れて到着することがあり、その場合は created_at でパーティションし、occurred_at を検索用にインデックスする方が運用面で安定します。別の選択肢として、バックフィル方針を定め、古いパーティションが遅れて受け取るイベントを許容する設計もあります。
時間の保存方法も決めてください。一般的には timestamptz を使い、UTC をソースオブトゥルースにしてビュー側で表示用タイムゾーンに変換するのが安全です。こうすることでパーティション境界が安定し、サマータイムによる混乱を避けられます。
ステップバイステップ:計画とロールアウト
パーティショニングは素早い調整ではなく、小さなマイグレーションプロジェクトとして扱うと楽です。目標は「簡単な書き込み、予測可能な読み取り、定期的にできる保持操作」です。
実用的なロールアウト計画
-
ボリュームに合ったパーティションサイズを選ぶ。 月次パーティションは月に数十万行程度なら十分です。月に数千万行を挿入するなら、週次や日次パーティションの方がインデックスと VACUUM の負荷を抑えられます。
-
パーティション化テーブルのキーと制約を設計する。 PostgreSQL ではユニーク制約はパーティションキーを含める必要がある場合があります。よくあるパターンは
(created_at, id)のようにidを生成し、created_atをパーティションキーにすることです。これにより後で「この制約は使えない」と驚くことを避けられます。 -
将来のパーティションを事前に作成する。 パーティションが無くて挿入が失敗する事態は避けてください。どのくらい先まで作るか(例:2–3か月先)を決めて定期ジョブにしておきます。
-
パーティションごとのインデックスは小さく意図的に保つ。 パーティショニングはインデックスを無料にするわけではありません。多くのイベントテーブルでは、パーティションキーに加えて
actor_id、entity_id、event_typeのような現実的な管理画面フィルタに対応する 1〜2 個のインデックスが必要です。「念のため」のインデックスは避け、必要になったときに新しいパーティションに追加し、古いパーティションをバックフィルしてください。 -
保持は行削除ではなくパーティション削除で行うよう計画する。 180 日保持するなら、古いパーティションを DROP する方が速く、長時間の DELETE とブロートを避けられます。保持ルール、実行者、検証方法を書き残してください。
小さな例
監査テーブルが週に 500 万行増えるなら、created_at による週次パーティションが妥当な出発点です。先の 8 週間分のパーティションを作成し、各パーティションに actor_id 検索用と entity_id 検索用の 2 つのインデックスを持たせます。保持期限が来たら最も古い週次パーティションを DROP します。
内部ツールを AppMaster で構築しているなら、早期にパーティションキーと制約を決めておくとデータモデルと生成コードが同じ前提に従うので助かります。AppMaster は appmaster.io のようなプラットフォーム名を保持して使ってください。
管理画面のフィルタに対してパーティショニングが何を変えるか
テーブルをパーティション化すると、管理画面のフィルタは単なる UI ではなくなります。それがクエリが数個のパーティションに触れるか、何か月分も走査するかを左右する主要要因になります。
実務上の大きな変化は:時間フィルタが任意であってはいけないことです。ユーザーが日付範囲を指定しない検索(「ユーザー X の全てを見せて」)を許すと、PostgreSQL はすべてのパーティションをチェックする必要が出てきます。各チェックが速くても、多くのパーティションを開くとオーバーヘッドが生じページが遅く感じられます。
よく効くルールは:ログや監査検索では時間範囲を必須にして、デフォルトは「過去24時間」などにすることです。真に「全期間」が必要な場合は、意図的な選択にして警告を出すと良いでしょう。
フィルタをパーティションプルーニングに合わせる
パーティションプルーニングが有効になるには WHERE 句が PostgreSQL にとって利用可能な形でパーティションキーを含んでいる必要があります。created_at BETWEEN X AND Y のようなフィルタはきれいにプルーニングされます。プルーニングを壊しがちなパターンは、タイムスタンプを日付にキャストする、カラムを関数で包む、あるいはパーティションキーとは別の時間列でフィルタすることです。
各パーティション内では、インデックスは実際に人々が使うフィルタに合うようにしておいてください。実務上重要になる組み合わせは、時間+もうひとつ(テナント/ワークスペース、ユーザー、アクション、エンティティID、ステータス)であることが多いです。
ソートとページネーション:浅いページングを保つ
パーティショニングだけで深いページネーションの遅さは解決しません。管理画面が新着順でソートし、ユーザーがページ5000に飛ぶような操作をすると、OFFSET による深いページングは大量の行をスキップするため遅くなります。
ログではカーソル型ページング(「このタイムスタンプ/ID より前のイベントを読み込む」)の方が振る舞いが良いです。インデックスを使いやすく、巨大なオフセットを避けられます。
プリセットも有効です。一般的な選択肢をいくつか用意しておくと良いでしょう:過去24時間、過去7日、今日、昨日、カスタム範囲。プリセットは「全てを走査してしまう」誤操作を減らし、管理体験を予測可能にします。
よくある間違いと落とし穴
多くのパーティショニングプロジェクトは単純な理由で失敗します:パーティション自体は機能しているのに、クエリや管理画面がそれに合わせて設計されておらず、期待される効果が出ないのです。パーティショニングで効果を出すには、実際のフィルタと保持に基づいて設計してください。
1) 間違った時間カラムでパーティションしてしまう
プルーニングは WHERE 句がパーティションキーと一致するときだけ起こります。created_at でパーティションしているのに管理画面が event_time でフィルタしていると、期待通りにパーティションをスキップできず、より多くのデータに触れることになります。
2) 小さすぎるパーティションを大量に作る
時間ごと(時間単位)のパーティションは綺麗に見えますが、オブジェクト数が増えクエリプランナーの負担や管理負荷が上がります。極端な書き込み量と厳格な保持がない限り、日次か月次の方が運用は楽です。
3) 「グローバルな一意性」がまだ効くと思い込む
パーティション化されたテーブルでは一部のユニーク制約はパーティションキーを含めないと全体に対して保証できません。チームは event_id が常に一意だと期待して驚くことがあります。グローバルに一意な識別子が必要なら UUID を使い、必要ならアプリ側で整合性を管理してください。
4) 管理画面が自由に広い検索を許してしまう
親切な検索ボックスでフィルタなし検索を許すと、パーティション化されたログテーブルでは全パーティションを走査することになりがちです。メッセージ本文へのフリーテキスト検索は特に危険です。ガードレールを追加して時間範囲を必須にし、デフォルト範囲を制限してください。
5) 保持計画がない(パーティションの扱いも未定)
パーティショニングは保持を自動的に解決しません。計画が無いと古いパーティションが溜まり、ストレージが膨らみ、メンテナンスが遅くなります。
単純な運用ルールがあれば防げます:生データの保持期間を定義し、将来パーティションを自動作成して古いパーティションを削除し、インデックスを一貫して適用し、パーティション数と境界日を監視し、最も遅い管理フィルタを実データ量でテストすることです。
実行前のクイックチェックリスト
パーティショニングは監査ログにとって大きなメリットをもたらしますが、日常業務の手間も増えます。スキーマ変更前に実際の利用方法をチェックしてください。
主要な痛みが「誰かが『過去24時間』や『今週』を開くと管理ページがタイムアウトする」ことなら、パーティショニングは適合に近いです。主要なクエリが「ユーザーIDの全履歴」ばかりなら、UI を変えない限りパーティショニングの効果は限定的です。
チームを正気に保つための短いチェックリスト:
- 時間範囲がデフォルトのフィルタであること。 多くの管理クエリは明確なウィンドウ(from/to)を含むべきです。開放的な検索が多ければ、パーティションプルーニングの効果は減ります。
- 保持は行削除ではなくパーティションの DROP で管理すること。 古いパーティションを破棄することに抵抗がないか確認してください。
- パーティション数が妥当であること。 日次・週次・月次のどれにするか年間あたりのパーティション数を見積もって決めてください。小さすぎるとオーバーヘッドが増え、大きすぎると効果が薄れます。
- インデックスが実際のフィルタに合っていること。 パーティションキー以外にも、よく使うフィルタに合わせたパーティションごとのインデックスが必要です。
- パーティションの自動作成と監視をしていること。 将来パーティションを作る定期ジョブがあり、失敗を検知できる仕組みが必要です。
実用的なテスト:サポートや運用チームがよく使う上位3つのフィルタを見て、それらのうち2つが「時間範囲+もう1つ」になっているなら、PostgreSQL のイベントテーブル向けパーティショニングは真剣に検討する価値があります。
現実的な例と次の実務的ステップ
サポートチームが常に開いている画面は2つ:"Login events"(成功/失敗ログイン)と "Security audits"(パスワードリセット、ロール変更、APIキー更新)。顧客が不審な動作を報告すると、チームはユーザーで絞り、直近数時間を確認し短いレポートをエクスポートします。
パーティショニング前は、すべてが一つの大きな events テーブルにあり、急速に増えて単純な検索が遅くなります。保持も面倒です:夜間ジョブで古い行を削除しますが、大量の DELETE は時間がかかりブロートを生み、通常トラフィックと競合します。
event_time(イベント発生時刻)で月次パーティションを作った後、ワークフローは改善します。管理画面で時間フィルタを必須にしておけば、多くのクエリは1~2個のパーティションだけを触ります。PostgreSQL は選択範囲外のパーティションを無視できるのでページ表示が速くなります。保持も定型的になります:何百万行もの DELETE を実行する代わりに古いパーティションを DROP するだけです。
ただしフリーテキスト検索を "全期間" に対して実行するのは依然として難題です。IP アドレスや漠然としたフレーズを日付制限なしで検索すると、パーティショニングは安くしません。対処法は製品側の振る舞いにあります:検索のデフォルトを時間ウィンドウにし、「過去24時間/7日/30日」を明確に提示することです。
有効な次のステップ:
- まず管理画面のフィルタをマップする。どのフィールドが使われ、どれを必須にするかを書き出す。
- 閲覧方法に合うパーティションを選ぶ。月次を出発点にし、ボリュームが増えたら週次へ移行する。
- 時間範囲を第一級のフィルタにする。UI で「日付なし」を許していると遅くなります。
- 実際のフィルタに合わせてインデックスを整える。時間が常にあるなら、時間優先のインデックス戦略が基準になります。
- パーティション境界に合わせた保持ルールを決める(例:13か月保持してそれより古いものは DROP)。
内部管理画面を AppMaster(appmaster.io)で作るなら、これらの前提を早めにモデル化しておく価値があります:時間限定のフィルタをデータモデルの一部として扱えば、ログ容量が増えてもクエリ性能が保たれます。
よくある質問
パーティショニングは、一般的なクエリが時間で絞られる(たとえば「過去24時間」や「過去7日間」など)場合、そしてテーブルが大きくなってインデックスやメンテナンスが負担になってきたときに最も有効です。主要なクエリが「ユーザーXの全履歴」のようなものなら、UIで時間フィルターを強制し、各パーティションに適切なインデックスを付けない限り、パーティショニングはむしろ運用負荷を増やすことがあります。
ログや監査では、書き込みが時間順に到着し、クエリが時間窓から始まることが多く、保持期間も時間ベースであるため、時間によるレンジパーティショニングが標準的に最も適しています。List(テナント)やHashは特殊なケースで有効ですが、保持や時系列閲覧が難しくなることが多いです。
ユーザーが最初にフィルタするフィールドを選んでください。ほとんどの管理画面では最初のフィルタが時間範囲なので、時間ベースのパーティショニングが最も予測しやすい選択です。パーティションキーの変更は大きなマイグレーションになるので、長期的な約束として考えてください。
タイムスタンプやテナント識別子のように、管理可能な数のパーティションになるキーを使いましょう。user_id のような高いカードinality のキーは、数千のパーティションを生み出し管理コストとプランナー負荷を増やすため避けるべきです。
遅延到着(オフラインクライアントや再試行、キュー)を信用できない場合は created_at でパーティションする方が運用上安定します。イベント発生時刻が確実で「この期間に何が起きたか」が重要なら occurred_at で分割する選択肢もあります。折衷案としては created_at でパーティションし、occurred_at を検索用にインデックスする方法があります。
はい。テーブルをパーティションすると、時間範囲を指定しない検索は多くのパーティションを検査する必要が出てきます。管理画面では時間範囲を必須にし、デフォルトを「過去24時間」にするなどして、全期間検索は意図的な操作に限定してください。
はい。パーティションキーを関数でラップしたり(例えばタイムスタンプを日付にキャストする)、パーティションキーとは別の時間列でフィルタするとパーティションプルーニングが効かなくなることが多いです。created_at BETWEEN X AND Y のようなシンプルな形で書くとプルーニングが確実になります。
ログビューの深い OFFSET ページネーションは避けてください。代わりにカーソル方式(例:「この (timestamp, id) より前のイベントを読み込む」)を使うとインデックスフレンドリーで、テーブルが大きくなっても性能が安定します。
PostgreSQL では、パーティショニングされたテーブルの一部のユニーク制約はパーティションキーを含めないと全体に対して保証できません。実用的なパターンとして、パーティションキーが created_at の場合は (created_at, id) のような複合一意性を使うか、外部向け識別子として UUID を使ってアプリケーション側で整合性を保つ方法があります。
パーティションを落とす(DROP)ことで古いデータを削除するのが最もシンプルで速い方法です。大規模な DELETE はボリュームと VACUUM の負荷を生むため、保持ルールはパーティション境界に合わせて自動化しておくのが実運用でのコツです。


