バックエンド
分散キャッシュ層としてのRedis
キャッシュは整合性と引き換えにレイテンシを得る仕組みであり、キャッシュに関するインシデントの多くはこの取引を忘れることから生じます。本ガイドでは、データベースの前段にRedisを置く前に私たちが検討するパターン、有効期限の戦略、障害モード、トポロジーの選択を取り上げます。
· 5分で読めます · Starone エンジニアリング
遅いクエリの前段にRedisを追加するのは、手軽に得られる性能改善の代表であり、同時にもっとも誤りやすいものの一つでもあります。最初のバージョンは動きます。問題は後から現れます。誰にも説明できない古いデータ、人気のキーが期限切れになった途端に倒れるデータベース、ピーク時にメモリを使い果たすクラスターです。
私たちの出発点は、キャッシュとはシステムがそれなしでも成り立たなければならない最適化である、という考え方です。Redisがフラッシュされたとき、アプリケーションは遅くなるべきであって、不正確になったり利用不能になったりしてはなりません。以下の判断はすべて、この一線を守ることから導かれます。
書き込みパターンは意図して選ぶ
私たちの標準はキャッシュアサイドです。アプリケーションはRedisから読み取り、ミスした場合はデータベースにフォールバックして、キーに値を格納します。書き込み時にはデータベースを更新し、その後でキーを削除します。新しい値をセットするより削除のほうが安全です。そうしないと、同時に書き込む2つのライターによって古いほうの値がキャッシュに残る可能性があるからです。それでも狭い競合は残ります。リーダーが削除の直前に古い行を読み込み、削除の直後にそれを書き戻すケースです。すべてのキーにTTLを持たせる理由の一つがここにあります。
ライトスルーは書き込み経路の一部としてキャッシュを更新するため、書き込み後の読み取りはウォームな状態で、アプリケーションが最後に書いた内容と整合します。その代わり、更新のたびに2つ目の書き込みが発生し、一度も読まれないかもしれないデータまでキャッシュします。ライトビハインドはキャッシュから書き込みの確認応答を返し、永続化は非同期に行います。書き込みレイテンシはもっとも低くなりますが、ノードが故障すると確認応答済みのデータを失う可能性があるため、カウンターやアクティビティのシグナルのように、失っても構わない、あるいは再構築できるデータに限定します。
TTL、ジッター、無効化
TTLは、値がどこまで古くてもよいかを表明するものであり、技術的な問いであると同時にプロダクト上の問いでもあります。商品カタログなら数分を許容できるかもしれませんが、権限チェックでは数秒、あるいはまったく許容できないこともあります。書き込み時の明示的な無効化はこの時間幅を縮めますが、有効期限の代わりではなく補完として使うべきです。無効化メッセージは常に失われうるからです。
一緒に書き込まれたキーは一緒に期限切れになります。デプロイやバッチジョブが1万個のキーを同じTTLでウォームアップすると、それらは同じ1秒のうちにすべてミスし、データベースは全負荷を一度に受けます。各TTLにその長さの数パーセント程度のランダムなジッターを加えれば、コストをかけずに期限切れを時間的に分散できます。
キャッシュスタンピードとその防止策
スタンピードは、計算コストが高く人気のあるキーが期限切れになり、多数のリクエストが同時にミスして、それぞれが同じ値を再計算するときに発生します。データベースは、キャッシュが保護しているはずだったまさにその瞬間に、同一の重いクエリのバーストを受けます。対策はよく知られており、組み合わせて使えます。
- シングルフライト:1つのリクエストが、たとえばNXと有効期限を指定したSETで短命なロックを取得して値を再計算し、他のリクエストは短時間待つか、以前の値を返します。
- stale-while-revalidate:論理的な有効期限を過ぎても値を保持し、バックグラウンドで更新します。これにより、ユーザーのリクエストが再計算のコストを負うことはなくなります。
- 確率的な早期リフレッシュ:期限が近づくにつれて各リーダーが更新を行う確率を小さな値から徐々に高めます。ロックなしで急峻な境界を避けられます。
- ネガティブキャッシュ:存在しないレコードに対して短命なマーカーを保存し、存在しない識別子への繰り返しの参照が毎回データベースに到達しないようにします。
ホットキーとビッグキー
Redisはシャードごとに単一スレッドでコマンドを実行します。これが振る舞いを予測可能にする一方、2種類のキーを危険なものにします。ホットキーは読み取りが非常に多く、クラスターの残りがアイドルのまま1つのシャードだけが飽和します。キーはただ1つのスロットにしか存在しないため、シャーディングは助けになりません。一般的な対処は、Redisの前段に1〜2秒のTTLを持つ小さなインプロセスキャッシュを置くか、わずかに古いデータが許容される箇所ではレプリカから読み取ることです。
ビッグキー、たとえば数百万のメンバーを持つハッシュやソート済みセットは、全体を読み取るときや削除するときにイベントループをブロックします。KEYSのようなコマンドや範囲に上限のない読み取りも同じ影響を及ぼすため、アプリケーションコードでは禁止し、SCANとページングによるアクセスに置き換えるべきです。大きな構造は複数のキーに分割し、削除にはUNLINKを使ってメモリの回収をメインスレッドの外で行います。
メモリ上限とエビクションポリシー
メモリ上限のないキャッシュは、いずれオペレーティングシステムによって停止させられます。私たちはmaxmemoryを明示的に設定し、レプリケーションバッファと断片化のための余裕を残したうえで、データに合ったエビクションポリシーを選びます。
- allkeys-lruまたはallkeys-lfu:インスタンス内のすべてがキャッシュエントリである場合。少数のキーが他よりはるかに人気である場合はLFUのほうがうまく対処できます。
- volatile-lruまたはvolatile-ttl:期限付きのキャッシュエントリと、エビクションされてはならないキーが同じインスタンスに混在する場合。期限のないキーだけが残ると書き込みが失敗することを受け入れる必要があります。
- noeviction:キュー、ロック、レート制限の状態など、気づかれないまま消えてはならないものをRedisが保持する場合。この場合、アプリケーション側で書き込みエラーを処理しなければなりません。
SentinelかClusterか
Sentinelは、レプリカを伴う単一のプライマリに高可用性を提供します。プライマリを監視し、障害時にはレプリカを昇格させます。データセットは1つのノードに収まる必要があり、書き込みはすべて1か所に集まります。多くのシステムではそれで十分であり、複数キーのコマンドやトランザクションもすべてそのまま使えます。
Redis Clusterは16,384個のハッシュスロットを使ってデータを複数のプライマリに分散し、書き込みとメモリを水平にスケールさせます。その代償として、複数キーの操作、トランザクション、Luaスクリプトは、すべてのキーが同じスロットにハッシュされる場合にしか動作しません。つまり、最初からハッシュタグを用いたキー名を設計する必要があり、クライアントはリシャーディング中のリダイレクトに従わなければなりません。私たちがClusterを選ぶのは、データセットや書き込みレートが1ノードに収まらなくなったときであり、既定の選択としてではありません。
キャッシュは正本のデータベースではない
RedisはRDBスナップショットと追記専用ファイル(AOF)によってディスクに永続化でき、レプリケーションも可能です。ただしレプリケーションは非同期であるため、フェイルオーバーによって旧プライマリがすでに確認応答した書き込みが破棄されることがあり、AOFの既定設定ではフラッシュは1秒に1回です。これらの特性はキャッシュとしては妥当ですが、他のどこにも存在しないデータにとっては不適切です。
逆の選択が正しい場合もあります。セッション状態、レート制限のカウンター、短命なロックは、失った場合の影響が理解されているのであればRedisだけに置いて構いません。ユーザーが再度サインインする、制限がリセットされる、ロックが期限切れになる、といった影響です。データを失うことが資金の損失や不変条件の違反につながる場合、正本は永続的で同期的なコミットを行うデータベースに置き、Redisには導出されたビューを持たせます。
