メインコンテンツまでスキップ

Redis キャッシュ整合性

· 約10分

データベースの前にキャッシュを 1 層挟んだ時点で、「キャッシュのデータと DB のデータが食い違う」という問題は避けて通れなくなります。この記事では、キャッシュ整合性について自分の理解と、よく使われるいくつかの方式それぞれの落とし穴を整理します。

キャッシュ整合性

まず、キャッシュ整合性問題とは何か、なぜ解決する必要があるのか、そしてどんな方式があるのかを説明します。

キャッシュ整合性問題とは、キャッシュを使用する際に、キャッシュのデータとデータベースのデータが一致しないことで、データの誤りやデータ損失などの問題が発生しうることを指します。この問題は高並行(高トラフィック)シナリオで特によく起こります。複数のスレッドが同じデータを同時に読み書きする場合、データの整合性を保証するのは非常に難しいからです。Redis のメモリ上のデータと DB のデータをどう一致させ、(高並行シナリオで)不整合を発生させないようにするか。不整合が起きると、ケースによっては大きなトラブルにつながりかねないため、キャッシュ整合性の解決策はとても重要です。

問題の根本原因はこうです。キャッシュとデータベースは 2 つの独立したストレージであり、両者への書き込みはアトミックな操作ではありません。どちらを先に書いても、2 つのステップの間には時間的な窓が存在し、その窓の間に他のスレッドが読み取ると、「半分新しく半分古い」状態を目にする可能性があります。そこにスレッドスケジューリングの不確定性、データベーストランザクションのコミットタイミング、ネットワーク遅延が重なると、あらゆる交錯パターンが発生します。つまり整合性の方式を議論することは、本質的には「この窓を消せるのか、消せないなら汚れたデータはどれくらい生き残るのか、それをビジネスは許容できるのか」を議論することなのです。

以下に、よく使われる方式と、それぞれが高並行シナリオで抱える問題を挙げていきます。

キャッシュへの直接書き込み(非推奨)

発想は最も直感的です。データベースを更新した直後に最新値を Redis に書き込み、キャッシュに常に値がある状態を保てば、読み取りリクエストがデータベースまで突き抜けることもありません。

ステップ 3、4 の実行時、高並行シナリオでは Redis にデータを書き込む Java スレッドが順序どおりに実行される保証がありません(CPU のタイムスライスの問題に加え、分散・マイクロサービス環境ではさらに顕著になります)。その結果、最新データが他のスレッドの古いデータで上書きされる可能性があり、その後に他のスレッドの実行がなければ、キャッシュのデータはずっと古いままになってしまうかもしれません。

言い換えると、スレッド A が先に DB に書き、スレッド B が後に DB に書いたとしても、Redis への書き込み順序は逆転して B が先、A が後になりうるということです。最終的にキャッシュに残るのは A の古い値です。しかもこの上書きには自己修復の仕組みが一切ありません。後続の新しい書き込みがこの Key を「洗い流して」くれない限り、汚れたデータはキャッシュに残り続け、繰り返し読まれてしまいます。

ここで分散ロック(単一アプリケーションなら JVM ロックで十分)をそのまま使う人もいるでしょう。ロックをかける場合は DB 書き込みから Redis 書き込みまでのフロー全体を制御する必要があり、各 Key を操作する関数の範囲も自分で管理しなければならず、効率は間違いなく大きく低下します。ただしロックの粒度は自分で調整できます。分散ロックを使う方式は強整合性が求められるシナリオに向いており、整合性のために性能を犠牲にする形です(強整合性シナリオでは推奨)。

データベース書き込み後にキャッシュを削除(単純削除戦略 - 非推奨)

「キャッシュへの書き込み」に上書き問題があるなら、発想を変えましょう。データベース更新後はキャッシュを削除するだけにして、次の読み取りリクエストに自らデータベースへ戻って新しい値をロードし直させるのです。削除は冪等なので、新しい値が古い値に上書きされる問題はありません。

単純削除戦略は書き込みの上書き問題をうまく回避できるように見えますが、データを照会する他のスレッドがロジックに加わると問題が発生します。照会スレッドがデータベースからデータを取得するとき、ちょうど他のスレッドがデータベーストランザクションをコミット中かもしれません。このとき照会スレッドはトランザクションコミット前の古いデータを取得してキャッシュに保存してしまい、データ不整合の問題を引き起こします。

具体的な交錯の順序はこうです。読み取りスレッドがキャッシュミスに気づき、データベースを照会します。このとき取得できるのは書き込みトランザクションのコミット前の古い値です。その後、書き込みスレッドがトランザクションをコミットしてキャッシュを削除します。最後に読み取りスレッドが手元の古い値をキャッシュに書き戻します。削除が古い値の書き戻しの前に発生しているため、削除は無駄になり、キャッシュには再び汚れたデータが残ります。しかもこちらも自己修復の手段がありません。

遅延二重削除戦略(推奨)

遅延キューを導入し、一定時間待ってからその Key をもう一度削除する戦略です。単純削除が残した問題を解決し、ロックも使わないため、このキャッシュ二重削除戦略は、一部のデータが短時間不整合になることを許容できるアプリケーションシナリオに、個人的にはより適していると考えています。

2 回目の削除の意義はセーフティネットにあります。先ほどの「古い値が削除後に書き戻される」交錯パターンは、遅延時間経過後の 2 回目の削除によって掃除されます。遅延時間は「読み取りスレッドが DB を照会してからキャッシュに書き戻すまで」の一連の所要時間をカバーするように選ぶ必要があり、実務ではインターフェースの実際の所要時間をもとに、やや大きめの値を見積もるのが一般的です。代償も明確で、遅延の窓の間は依然として古いデータを読む可能性があります。つまりこの方式が保証するのは結果整合性であって、強整合性ではありません。

以上の方式のほかにも、選択肢はたくさんあります。

  1. 読み書きロック。書き込み操作時に書き込みロックを取得し、書き込みが完了するまで他のスレッドはキャッシュの読み書きができないようにします。この方式はデータの整合性を保証できますが、読み取りの遅延やロック競合の問題が増える可能性があります。
  2. バージョン番号による制御。キャッシュにデータのバージョン番号を保存し、データ更新のたびにバージョン番号も更新します。読み取り時にバージョン番号が一致するかを比較し、一致しなければデータを再ロードします。この方式はデータの整合性を保証できますが、データの保存領域と読み取りのオーバーヘッドが増えます。
  3. 有効期限付きキャッシュ。キャッシュにデータを保存する際に有効期限を設定し、期限を過ぎるとキャッシュは自動的に無効になり、データの再ロードが必要になります。この方式はキャッシュとデータベースの不整合の可能性を減らせますが、データの保存領域と読み取りのオーバーヘッドが増える可能性があります。
  4. データベースの非同期更新。データベースへの書き込み時にキャッシュを直接更新せず、更新操作を非同期でメッセージキューに送信し、コンシューマーがキャッシュの更新を担当します。この方式はデータベースの負荷を減らせますが、メッセージキューの複雑さと遅延が増える可能性があります。

ハマりどころと注意点

  1. どの削除系の方式を選ぶにせよ、キャッシュにはセーフティネットとして有効期限を設定することをおすすめします。ある交錯パターンで汚れたデータが生まれても、期限切れ後には自動的に DB へ戻って修正され、不整合の生存時間を制御可能な範囲に抑えられます。

  2. 遅延二重削除の 2 回目の削除をメモリ内で行う(スレッドを sleep させてから削除するなど)と、アプリケーションの再起動で消えてしまいます。信頼性が求められるなら、メッセージキューや遅延キューで削除タスクを投げるようにすれば、失敗してもリトライできます。

  3. キャッシュの削除自体も失敗する可能性があります(ネットワークの揺らぎ、Redis の一時的な不通など)。削除失敗にはリトライの仕組みを用意しないと、汚れたデータが同じように長期間残り続けます。

  4. 通常の業務で「キャッシュとデータベースが任意の瞬間に完全一致する」ことを追い求めないでください。キャッシュとデータベースは別々のストレージであり、ロックやトランザクションレベルの協調を導入しない限り、強整合性は実現できません。まずビジネスがどれくらいの不整合を許容できるかを明確にしてから、方式を選びましょう。

まとめ

キャッシュ整合性に銀の弾丸はなく、本質は整合性・性能・実装の複雑さの間のトレードオフです。キャッシュへの直接書き込みは並行処理で上書きされ、自己修復もできません。単純削除は上書き問題を塞ぎましたが、古い値の書き戻しは防げません。遅延二重削除は 1 回の遅延削除をセーフティネットにして結果整合性を手に入れる方式で、短時間のダーティリードを許容できる大多数のビジネスに向いています。本当に強整合性が必要なシナリオなら、素直にロックをかけて、性能と引き換えに正しさを取りましょう。方式を選ぶ前に、まずこの問いに答えてください——このデータが数百ミリ秒汚れていたとき、ビジネスは本当に困るのか?

COMMENTS