Skip to content
Published on

データベースキャッシュ戦略完全ガイド:結局は無効化がすべてだ

シェア
Authors

はじめに

このブログにはすでに Redis キャッシング戦略完全ガイド があります。Cache-Aside、Read-Through、Write-Through、Write-Behind といったパターンカタログを扱う記事です。

この記事はキャッシュをデータベース側から積み上げて見ていきます。順番が違います。外部キャッシュを追加する決定を下す前に、すでに存在するキャッシュ層がきちんと使われているかをまず確認する必要があるからです。PostgreSQLにはすでに shared_buffers があり、OSのページキャッシュがあり、マテリアライズドビューという計算キャッシュがあります。これらが遊んでいる状態でRedisを載せると、問題は解決されず、整合性の問題だけが新たに生まれます

そしてこの記事の半分は無効化に充てられています。キャッシュ導入の難しい部分はキャッシュを満たすことではなく、正確な瞬間に空にすることだからです。パターン名を暗記しても、この部分を間違えるとユーザーに古いデータが見えてしまいます。

基準となるエンジンは PostgreSQL 18 であり、パラメータの既定値とロック動作はすべてPostgreSQL 18のドキュメントで確認しています。

1. キャッシュを追加する前に確認すること

「参照が遅いのでキャッシュを追加しよう」という提案が出たとき、まず投げかけるべき質問が3つあります。

質問1 — 遅い理由は計算かI/Oか。同じ結果を繰り返し作っているために遅いのであれば、キャッシュが答えです。インデックスがなく毎回テーブル全体を走査しているために遅いのであれば、キャッシュは問題を先送りするだけです。キャッシュが期限切れになるたびに、同じ遅いクエリが再び走ります。

質問2 — 読み書き比率はどうか。読み取りが圧倒的に多ければヒット率は高く、無効化の頻度も低くなります。書き込みが頻繁だとキャッシュは絶えず無効化され、効果は薄れ、増えるのは整合性のリスクだけです。

質問3 — 古いデータをどれくらいの時間まで見せてよいか。この質問への答えが秒単位か分単位かによって、設計はまったく変わります。そして、その答えが絶対に許されないなら、キャッシュではなく別の解決策を探す必要があります。

3つの質問に答える前にキャッシュを追加すると、後で「なぜときどき古い値が見えるのか」というバグ報告を受け取り、根本原因を見つけられなくなります。

2. すでにあるキャッシュ — shared_buffersとOSキャッシュ

PostgreSQLは自前のバッファプールとOSのページキャッシュを併用しています。この二層構造を理解することが、最初の本当のキャッシュ最適化になります。

shared_buffers既定値は128MBです。ドキュメントの調整指針は明確です。「専用のデータベースサーバーに1GB以上のRAMがあるなら、shared_buffers の妥当な初期値はシステムメモリの25%である。より大きな設定が効果的なワークロードもあるが、PostgreSQLはOSキャッシュにも依存するため、RAMの40%を超えて割り当てても、それより少ない割り当てより良い結果になる可能性は低い。」

ここでよくある誤解があります。effective_cache_size はメモリを確保するものではありません。プランナーの前提を変えるだけです。OSキャッシュを含め、どれだけのデータがキャッシュされているかの推定値であり、既定値は4GBです。メモリが256GBのサーバーでこの値が既定値のままだと、プランナーはインデックススキャンがディスクを大きく叩くと見なし、シーケンシャルスキャンに傾きます。

ヒット率はこのように測定します。

-- テーブルごとのバッファヒット率(低い順)
SELECT schemaname, relname,
       heap_blks_read, heap_blks_hit,
       CASE WHEN heap_blks_hit + heap_blks_read = 0 THEN NULL
            ELSE round(heap_blks_hit * 100.0 / (heap_blks_hit + heap_blks_read), 2)
       END AS heap_hit_pct,
       CASE WHEN idx_blks_hit + idx_blks_read = 0 THEN NULL
            ELSE round(idx_blks_hit * 100.0 / (idx_blks_hit + idx_blks_read), 2)
       END AS idx_hit_pct
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 20;

一つ注意点があります。ここでいう「read」とは、PostgreSQLのバッファプールになくOSへ要求されたブロックのことであり、必ずしもディスクを実際に叩いたわけではありません。OSのページキャッシュにあれば、実際のディスクI/Oは発生していません。そのため、この数値だけでディスク負荷を判断すると過大評価になります。

PostgreSQL 16以降は pg_stat_io ビューがあり、より正確な状況を把握できます。backend_typeobjectcontext 別に readshitsevictionsfsyncs を分けて表示します。contextbulkreadvacuum である項目が多い場合、それはキャッシュ不足ではなく正常な一括処理です。

3. マテリアライズドビュー — データベース内部の計算キャッシュ

集計結果のように「作るのに時間がかかるが頻繁には変わらない」データには、外部キャッシュよりマテリアライズドビューのほうが適していることが多くあります。理由は3つです。SQLで結合でき、インデックスを張ることができ、無効化ロジックをアプリケーションコードではなく更新スケジュールとして表現できるからです。

CREATE MATERIALIZED VIEW mv_daily_sales AS
SELECT tenant_id,
       date_trunc('day', created_at) AS sales_day,
       count(*)         AS order_count,
       sum(total_amount) AS total_amount
FROM orders
WHERE status = 'PAID'
GROUP BY 1, 2;

-- CONCURRENTLY での更新には一意インデックスが必須
CREATE UNIQUE INDEX uq_mv_daily_sales
  ON mv_daily_sales (tenant_id, sales_day);

警告: CONCURRENTLY を付けない REFRESH MATERIALIZED VIEWACCESS EXCLUSIVE ロックを取得します。ドキュメントの表現を借りれば、「多くの行に影響する更新は、使用するリソースが少なく、より速く完了するが、マテリアライズドビューから読み取ろうとする他の接続をロックアウトする可能性がある」とされています。つまり更新にかかる数分間、そのビューを参照するすべてのリクエストが完全に止まります。本番サービスが読み取るビューであれば、必ず CONCURRENTLY を使ってください。

CONCURRENTLY の条件はドキュメントに明記されています。「このオプションを使用できるのは、列名のみを使用し全行を含む UNIQUE インデックスが少なくとも1つある場合に限られる。つまり式インデックスであってはならず、WHERE 句を含んでもいけない。」また、「すでにデータが投入されているマテリアライズドビューにしか使用できない」こと、そしてこのオプションを使っても「1つのマテリアライズドビューに対して同時に実行できる REFRESH は1つだけである」ことも合わせて覚えておく必要があります。

-- 読み取りをブロックしない更新
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

ドキュメントは性能面のトレードオフにも触れています。CONCURRENTLY は「影響を受ける行数が少ない場合により速くなることがある」とされています。つまり全体を再計算する状況では、むしろ遅くなります。毎日全量を再計算する夜間バッチであれば、サービスが静かな時間帯に CONCURRENTLY なしで実行するほうが良い場合があります。判断基準はその時間帯にそのビューを読むトラフィックがあるかどうかです。

マテリアライズドビューが適さない場合も明確にあります。更新周期が秒単位でなければならない、ユーザーごとに結果がすべて異なる、あるいは結果がSQLで表現できない計算である場合は、外部キャッシュか他の構造が必要です。

4. 外部キャッシュを導入する判断基準

2節と3節をすべて試してもなお不足するとき、外部キャッシュが登場します。導入判断に使える基準です。

導入が妥当なシグナル

  • 同じキーへの参照が圧倒的に繰り返される(高いヒット率が見込める)
  • 元の計算コストが高く、結果は小さい
  • 秒単位の遅延整合性がドメイン上許容される
  • データベースが実際にCPUやI/Oの限界に達している

導入を見送るべきシグナル

  • 参照キーが毎回異なりヒット率が低いと見込まれる
  • 書き込みが頻繁で無効化が絶えず発生する
  • 古いデータの露出が金銭や安全に関わる
  • インデックスチューニングやクエリの書き換えをまだ試していない

特に最後の項目を強調します。キャッシュは性能問題を解決するのではなく、移動させるだけです。キャッシュが空になった瞬間(デプロイ直後、キャッシュノードの再起動直後、大規模な無効化の直後)には、すべての負荷がそのままデータベースへ向かいます。元のクエリが耐えられる水準でなければ、その瞬間にサービスが崩壊します。キャッシュは平常時の最適化であって、障害対策ではありません

5. 無効化の4つの失敗モード

ここがこの記事の核心です。キャッシュの整合性が崩れる方式は類型化できます。

失敗モード1 — 更新と削除のあいだの窓。データベースを更新してからキャッシュを削除するまでのあいだに別のリクエストがキャッシュを読むと、古い値が見えます。窓は短いものの、ゼロではありません。トラフィックが多ければ必ず発生します。

失敗モード2 — 読み取りと書き込みの競合。さらに厄介なケースです。リクエストAがキャッシュミスによりデータベースから値V1を読み取ります。その直後にリクエストBが値をV2に更新し、キャッシュを削除します。続いてAが遅れてV1をキャッシュに書き込みます。結果として、古い値V1は期限切れになるまでキャッシュに残り続けます。削除が書き込みより先に起きたため、削除でも消えません。

失敗モード3 — トランザクションのロールバック。キャッシュを先に削除してからデータベースを更新し、そのトランザクションがロールバックすると、キャッシュは空なのにデータは古い状態のままになります。次の参照が古い値を再びキャッシュに満たすため結果的には整合しますが、そのあいだに新しい値を見たユーザーがいれば混乱を招きます。

失敗モード4 — 部分的な失敗。データベースの更新は成功したのに、キャッシュの削除がネットワークエラーで失敗します。リトライしなければ、期限切れになるまで古い値が残ります。

この4つに対する実務的な対応は次のとおりです。

  • 削除はするが満たさない。書き込みパスではキャッシュに新しい値を入れず、削除だけを行います。新しい値を入れた瞬間、失敗モード2の競合に参加することになります。
  • 削除をコミット後まで遅らせる。トランザクションの内側でキャッシュを削除すると、ロールバック時に不整合が生じます。コミットの成功を確認してから削除します。アウトボックステーブルに無効化イベントをトランザクションと一緒に記録し、別のワーカーが処理すれば、失敗モード3と4を同時に防げます。
  • 有効期限を必ず設定する。無効化が失敗しても、有効期限が最終防衛線になります。有効期限のないキャッシュエントリは、いつか必ず事故を起こします。
  • 遅延二重削除を検討する。更新直後に一度削除し、短い遅延のあとにもう一度削除する方式です。失敗モード2の遅れて届く書き込みを片付けます。完璧ではありませんが、費用対効果は良好です。

6. 書き込みパスの順序問題

5節を順序の問題として整理し直すと、選択肢は4つあり、それぞれリスクが異なります。

順序リスク
キャッシュ更新 → DB更新DB更新が失敗すると、実在しない値がキャッシュに残る。使ってはならない
DB更新 → キャッシュ更新2つの書き込みの順序が入れ替わると、古い値がキャッシュに残る
キャッシュ削除 → DB更新削除と更新のあいだの参照が古い値を再び満たす
DB更新(コミット) → キャッシュ削除最も安全。残るリスクは削除の失敗と短い競合の窓

4番目が標準です。残りは特別な理由がない限り使いません。

そして必ず守るべきことが一つあります。キャッシュ操作をデータベーストランザクションの中に入れないでください。理由は2つあります。第一に、トランザクションがロールバックしてもキャッシュ操作は元に戻りません。第二に、キャッシュサーバーの応答を待つあいだデータベーストランザクションが開いたままになり、そのトランザクションがVACUUMによる死んだ行の回収を妨げます。ネットワーク遅延がわずか数秒でも、データベース全体に影響します。

-- トランザクションと原子的に無効化イベントを記録するアウトボックス
CREATE TABLE cache_invalidation_outbox (
  id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cache_key   text        NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

CREATE INDEX idx_cache_outbox_pending
  ON cache_invalidation_outbox (created_at)
  WHERE processed_at IS NULL;

ワーカーは4節のキューパターン、つまり FOR UPDATE SKIP LOCKED でこのテーブルを消費すればよいです。こうすることで、「データベースのコミットが実際に成功した場合に限り、かつ必ず一度は」無効化が起こります。

7. キャッシュスタンピードと対策

人気のあるキーのキャッシュが期限切れになった瞬間、そのキーを参照していたすべてのリクエストが同時にキャッシュミスを起こし、すべてデータベースへ向かいます。1秒間に数千件もの同一クエリが一度に到着します。これがキャッシュスタンピードです。

対策は3つあります。

第一に、有効期限にランダムなオフセットを与えます。同じ時刻に作られたエントリが同じ時刻に一斉に期限切れにならないよう分散させます。最も安価で最も効果的な方法です。

第二に、再計算を1つのリクエストだけに行わせるようロックします。ミスが発生したリクエストのうち1つだけが元の計算を行い、残りは待つか古い値を返します。アプリケーションプロセスが複数ある場合は分散ロックが必要で、PostgreSQLのトランザクションレベルのアドバイザリーロックがこの用途に使えます。

-- 再計算の権限を1つのリクエストだけに持たせる
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('recompute:daily_sales:42'));
-- trueを受け取ったリクエストだけが重い集計を実行し、キャッシュを満たす
COMMIT;

セッションレベルではなく、トランザクションレベルの関数を使うことが重要です。コネクションプールの背後では、セッションレベルのロックは解放されないまま残ることがあります。

第三に、期限切れの前に先回りして更新します。残り寿命が一定の割合を下回ったら、バックグラウンドであらかじめ再計算します。ユーザーのリクエストは常にキャッシュから応答されます。トラフィックが予測可能なダッシュボード系のワークロードによく合います。

もう一つ。キャッシュ層が落ちたときの挙動をあらかじめ決めておいてください。キャッシュサーバーが応答しなくなったら元データにフォールバックするのか、フォールバックするならデータベースがその負荷に耐えられるのか、耐えられないなら一部のリクエストを速やかに拒否するのか。この判断を障害の最中に下すのでは遅すぎます。

8. 何を測定するか

キャッシュは指標なしで運用すると、利益も損失も見えなくなります。少なくとも次の4つは必要です。

ヒット率。低ければキャッシュが仕事をしていないということであり、逆に高すぎる場合(例えば99.99%)は、そもそも負荷がなかった可能性もあります。キーのグループ別に分けて見ることが重要です。全体平均はたいてい何も語りません。

元のクエリの実行頻度。キャッシュ導入の前後で pg_stat_statementscalls がどれだけ減ったかを見ます。

SELECT calls, round(total_exec_time::numeric, 1) AS total_ms,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       rows, left(query, 70) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

pg_stat_statementsshared_preload_libraries に登録し、拡張機能をインストールしないと動作しません。pg_stat_statements.max の既定値は5000、track の既定値は top です。

無効化の遅延。データが変わった時刻とキャッシュが実際に空になった時刻の差です。アウトボックステーブルを使っていれば、created_atprocessed_at の差としてそのまま測定できます。

キャッシュが空のときの元データへの負荷。定期的に(例えばデプロイのたびに)キャッシュを空にした直後のデータベース負荷を記録しておいてください。この値がデータベースの容量に近い場合、サービスはキャッシュなしでは成り立たない状態です。それ自体がリスクとして管理される必要があります。

クイズ:理解度を確認しましょう

クイズ1:以下のコードの問題点は何でしょうか。
BEGIN;
UPDATE products SET price = 19900 WHERE id = 42;
-- アプリケーションがここでRedisのDEL products:42を呼び出す
COMMIT;

回答: キャッシュの削除がトランザクションの内側にあります。2つのことが壊れます。

解説: 第一に、COMMIT が失敗してロールバックすると、データは古い値のままなのにキャッシュは空になります。次の参照が古い値を再び満たすので最終的には整合しますが、そのあいだ誤った状態が存在します。第二に、さらに深刻なことに、キャッシュサーバーの応答を待つあいだデータベーストランザクションが開いたままになります。開いたトランザクションはデータベース全体で死んだ行の回収を妨げます。キャッシュサーバーが遅くなれば、その遅延がそのままVACUUMの遅延になります。正しい順序はコミットを先に完了させ、そのあとでキャッシュを削除することであり、削除自体の失敗に備えてアウトボックステーブルに無効化イベントをトランザクションと一緒に記録する方式が最も安全です。

クイズ2:更新のたびにキャッシュへ新しい値を入れているのに、ときどき古い値が見えます。なぜでしょうか。

回答: 読み取りと書き込みの競合が原因です。キャッシュには値を満たすのではなく、削除だけを行うべきです。

解説: リクエストAがキャッシュミスによりデータベースからV1を読み取り、その直後にリクエストBがV2に更新してキャッシュにV2を書き込みます。ところがAが遅れて、自分が読み取ったV1をキャッシュに書き込むと、最終状態はV1になります。期限切れになるまで古い値が残ります。書き込みパスでキャッシュを満たさず削除だけを行えば、次の参照が最新の値を読み取って満たすため、この競合の窓は大きく縮まります。完全にはなくならないため、短い遅延のあとにもう一度削除する遅延二重削除と有効期限を併用します。

クイズ3:ダッシュボード用の集計ビューをREFRESH MATERIALIZED VIEWで更新しているが、更新中にダッシュボードが止まります。

回答: CONCURRENTLY が抜けています。

解説: CONCURRENTLY を付けない REFRESH MATERIALIZED VIEWACCESS EXCLUSIVE ロックを取得します。このロックは通常の SELECT が取得する ACCESS SHARE とも衝突するため、参照がすべて止まります。CONCURRENTLY を使うにはドキュメントが定める条件を満たす必要があります。列名のみを使用し全行を含む UNIQUE インデックスが少なくとも1つ必要で、式インデックスであったり WHERE 句を含んだりしてはいけません。また、すでにデータが投入されているビューにしか使えません。

CREATE UNIQUE INDEX uq_mv_daily_sales ON mv_daily_sales (tenant_id, sales_day);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales;

ただし、ドキュメントは CONCURRENTLY が「影響を受ける行数が少ない場合により速くなることがある」としているため、全量再計算でその時間帯に参照トラフィックがないのであれば、通常の更新のほうが良い選択です。

クイズ4:キャッシュのヒット率が98%なのに、データベースの負荷が下がりません。どこを見るべきでしょうか。

回答: ヒット率をキーのグループ別に分けて見て、pg_stat_statements で実際にどのクエリが負荷を生んでいるかを確認する必要があります。

解説: 全体平均のヒット率は誤解を招きやすいものです。軽いキーが大量にヒットして平均を押し上げる一方、本当に重いクエリはキャッシュを経由していないことがあります。確認の順序はこうです。まず pg_stat_statementstotal_exec_time の降順で見て、上位のクエリが何かを確認します。そのクエリがそもそもキャッシュ対象でなかったなら、キャッシュ対象の選定が誤っていたことになります。キャッシュ対象であるのに呼び出しが多いなら、無効化が頻繁すぎるということであり、更新頻度とキャッシュキーの設計を見直す必要があります。キーを細かく分けすぎて、1回の更新が数百個のキーを無効化しているケースがよくあります。

クイズ5:デプロイのたびに、デプロイ直後の数分間データベースのCPUが100%に張り付きます。

回答: キャッシュが空になった状態ですべての負荷が元データに集中する、コールドスタートの問題です。

解説: キャッシュは平常時の最適化であり、容量対策ではありません。キャッシュが空になった瞬間の負荷に耐えられないなら、サービスはキャッシュなしでは成り立たない状態であり、これはリスクとして管理される必要があります。対応は3つです。第一に、デプロイがキャッシュを空にしないようにします。キャッシュキーにデプロイのバージョンを含める慣行がよくある原因です。スキーマが実際に変わったキーにだけバージョンを付けてください。第二に、ウォームアップの段階をデプロイ手順に組み込みます。トラフィックを受ける前に上位のキーをあらかじめ満たしておきます。第三に、スタンピード対策を施します。有効期限のランダム化とトランザクションレベルのアドバイザリーロックにより同一キーの再計算を1件に制限すれば、瞬間的な負荷が大きく減ります。

まとめ

キャッシュ設計の難しさはキャッシュを満たす側にあるのではなく、空にする側にあります。そして空にする問題は分散システムの順序問題です。2つの異なるストアに対する2つの書き込みがあり、その順序を保証する方法がないというのが問題の本質です。パターン名を暗記することでは、この問題は解けません。

実務的な結論は3行にまとめられます。キャッシュを追加する前に、データベース内部のキャッシュ層をまず確認する。書き込みパスではコミット後に削除だけを行い、満たさない。無効化は失敗するものと前提し、有効期限とアウトボックスを併用する

集計クエリとマテリアライズドビューを自分の手で試してみたい場合は Postgres プレイグラウンド を、分析ワークロードの集計性能を比較したい場合は DuckDB プレイグラウンド を活用してください。

参考資料

続けて読む