そーだいなるらくがき帳

そーだいが自由気侭に更新します。

キャッシュを活用するために必要な知識と勘所

 どうもキャッシュバスターズ、 id:Soudai です。 Cache(以下、キャッシュ)は特定の場面に置いて劇的な効果を発揮し、様々な問題を解決する反面、新たなコンポートやミドルウェアが追加され、複雑性が上がり、運用のレベルが上がるため、扱いに注意する必要があります。

 キャッシュを活用することで、パフォーマンスの改善や負荷軽減が行われ、コンピュータリソースの最適化によるサーバコストの削減や、レスポンスの改善によるユーザエクスペリエンスの改善がされます。

 反面、その劇的な効果に毒され安易に多用すると、サービスが強くキャッシュに依存してしまい、非常に壊れやすくなり、運用が難しくなってしまいます。これをWeb界隈では「キャッシュは麻薬」と比喩されて、戒められてきました。

 そのためキャッシュを使わずにサービスが運用できるのであれば使わないに越したことはないのですが、ある一定以上の規模になった際にコンピュータリソースの最適化、コストの改善などの面においてキャッシュの活用は避けては通れません。

 そこで今日はキャッシュを活用するために必要なこと、注意点などを纏めます。

キャッシュを利用するために必要なこと

 『失敗から学ぶRDBの正しい歩き方』 第16章 キャッシュ中毒 でもキャッシュを活用する際の注意点を記載されていますが、以下の3点を決める必要があります。

  1. キャッシュする対象*1
  2. キャッシュのアルゴリズム
  3. キャッシュの生存期間

 それぞれについて説明します。

キャッシュの対象

 まずキャッシュの対象を決める必要があります。 このとき、キャッシュを利用してパフォーマンス改善がどの程度されるかが非常に重要になります*2。それはレスポンスタイムの短縮やシステムの負荷軽減が必要な箇所を洗い出すことでもあります。

 そのようなパフォーマンスを効果的に改善する場所を検討するためには以下の点を注意すると良いです。

  • 頻繁にアクセスされるデータを特定する
  • コストの高い計算結果をキャッシュする
  • データの変更頻度を評価する

頻繁にアクセスされるデータを特定する

 アクセス頻度が高く、広いスコープで参照されるようなデータはキャッシュと相性がよいです。例えば全ユーザーが表示するような商品情報やWebアプリケーションにおける静的コンテンツなどが該当します。

 アクセス頻度が高ければ高いほど、キャッシュヒット率に影響します。 当たり前ではありますが、1回しかアクセスされないようなデータや処理、または特定の条件でしかアクセスされないレアケースのデータや処理などの場合はキャッシュを作成しても利用されないため、キャッシュを生成するコストが純粋なオーバーヘッドになり、逆効果になります。

 使われないキャッシュについては意味がありません。 そのため、同じデータを何度も高速に取得する必要がある場合にキャッシュは有効です。

コストの高い計算結果をキャッシュする

 データの生成や取得に時間がかかる、またはリソースを大量に消費する計算結果はキャッシュする価値があります。科学技術計算、データ分析、大規模なグラフィック処理など、一度計算に時間がかかるが、入力結果が同様の場合に出力結果も同じになるのであれば、一度計算した結果をキャッシュしておけば、同じ入力に対する計算を省略し、即座に結果を返すことができます。

 また類似の例に複雑で長大なSQLでのデータベースに対するアクセスや、ネットワークに負荷をかけるような大きな動画データ、Webページのレンダリングなどがあります。

 これらの1度の処理コストが大きければ大きいほど、計算結果やデータを保持することで再利用時に効果があります。

 つまり、軽量な処理やデータの場合、利用頻度が高くても後述する変更コストやキャッシュを保持するデータストア次第ではむしろオーバーヘッドが大きくなる場合があり、パフォーマンスがむしろ劣化する可能性もあります。

データの変更頻度を評価する:

 対象を決める際に最後に重要なことは対象元になるデータの更新頻度です。 変更される頻度が低いデータ、例えば画像のように生成されたら変更されないような静的データや、郵便番号や国名、地名のような変更頻度がほとんど無いような更新されないデータはキャッシュと相性が良いデータです。

 先程の計算コストが高い処理をキャッシュする際も同様で、入力結果が変更されないような処理はデータの変更頻度が少ない場合と同様に効果が高くなります。そのため計算結果のコストを改善する場合は、入力データの更新頻度や入力パターンに注目することが重要です。

 例えば月次処理のように過去の売上データが一度確定すれば、元のデータが変更されないような処理はキャッシュと相性の良い、データの変更頻度が低い処理と言えます。

 一方、頻繁に更新されるデータはキャッシュのデータが古くなる頻度も高いということになります。キャッシュのデータが古いと一貫性の問題を引き起こす可能性があるため、慎重に検討する必要があります。

 キャッシュが古い状態で利用すると問題が出るような即時性が高い処理の場合はデータの一貫性が問題になるため、仮にパフォーマンスに大きな改善が見込めても、キャッシュの更新の遅延などの更新処理に問題が発生した場合に不適切なデータを利用することになり、障害の理由になります。

 逆に元データの更新頻度は一定あるものの、データ反映の即時性は低く、古いデータを利用しても問題の無いような場合は更新をまとめることができます。このようにキャッシュそのもの更新頻度が低くなる場合は更新頻度の低いデータと同様の効果を期待することができます。

 類似の例で、更新が常に追加しかないようなデータ構造の場合はキャッシュの効果を期待することができます。これは元データに対して削除、変更が行われない場合には生成済みのキャッシュも維持することができるためです。

 例えばX*3のようなソーシャルメディアの投稿、ニュースフィード、ブログエントリなど、新しいコンテンツが定期的に追加されますが、既存のコンテンツはそのまま保持されるケースがあります。これらはユーザーが一度取得したデータまではクライアントなどでキャッシュしておき、ニュースフィードの更新時などは追加分のデータだけを取得すれば良いため、キャッシュを利用することができます。

 このように変更頻度はキャッシュの対象を決める際には重要な検討事項であり、キャッシュされたデータが古くなるリスクと、アプリケーションにとって許容可能なデータの鮮度や一貫性のレベルを考慮して、期待するパフォーマンス改善と天秤にかけながら検討する必要があります。

キャッシュのアルゴリズム

 対象のデータや処理がキャッシュと相性が良いとなった場合には次にどのようなタイミングでキャッシュを生成、保存するかのアルゴリズムにするか決める必要があります。

 キャッシュを実装する際には、いくつかの一般的なアルゴリズムや考え方があります。ここでは、主要なキャッシュアルゴリズムとその考え方について説明します。

データの更新パターンのアルゴリズム

  1. Write-Through
    考え方: データをキャッシュとバックエンドストレージの両方に同時に書き込みます。これにより、データの整合性が確保されますが、書き込みのレイテンシが増加する可能性があります。

  2. Write-Back Caching
    考え方: データは最初にキャッシュに書き込まれ、後でメインのバックエンドストレージに書き戻されます。これにより書き込みのパフォーマンスが向上しますが、データの損失リスクが増加します。

  3. Write-Around:
    考え方: この方法では、新しいまたは更新されたデータは直接データストアに書き込まれ、キャッシュは更新されません。データが後で読み出されるときに、キャッシュにロードされます。この方法は、書き込みが頻繁に行われるが、読み出しが比較的少ないシステムで有効で、キャッシュの不要な汚染を防ぐことができます。

  4. Preloading/Warming Up
    考え方: システムが休止状態の時に、将来的に必要になりそうなデータを予めキャッシュにロードしておく手法です。アプリケーションの起動時に変更されない情報を全て取得し、プロセス内に持っておく場合やDeploy時に作り直すパターンなども該当します。

  5. Lazy Loading (遅延ロード)
    考え方: データが実際に必要になったときにのみキャッシュにロードします。データが要求されると、キャッシュに存在しない場合はデータストアから読み込み、キャッシュに保存します。これにより、必要のないデータをキャッシュすることを避けることができますが、初回アクセスは遅くなります。

  6. Cache-Aside (サイドキャッシュ)
    考え方: アプリケーションまたはデータアクセス層が、キャッシュにデータが存在しないかを確認し、存在しない場合にはデータストアからデータを取得してキャッシュに保存します。データの更新はデータストアに直接行い、関連するキャッシュは無効化または更新されます。これはLazy Loadingの一形態ですが、より積極的な無効化や更新が行われます。

graph LR

    %% 書き込みフロー
    subgraph Write["書き込みフロー"]
        subgraph WT["Write-Through"]
            A1[アプリケーション] -->|書き込み| B1[キャッシュ & ストレージ]
        end
        subgraph WB["Write-Back"]
            C1[アプリケーション] -->|書き込み| D1[キャッシュ]
            D1 -->|後で書き戻し| E1[ストレージ]
        end
        subgraph WA["Write-Around"]
            F1[アプリケーション] -->|直接書き込み| G1[ストレージ]
        end
    end
graph LR

    %% 読み込みフロー
    subgraph Read["読み込みフロー"]
        subgraph LL["Lazy Loading"]
            K1[要求時] -->|読み込み| L1[キャッシュ]
            L1 --> M1[データストアからの読み込み]
        end
        subgraph CA["Cache-Aside"]
            N1[データ要求] -->|確認| O1[キャッシュ]
            N1 -->|必要なら| P1[データストア]
            P1 --> O1
        end
        subgraph PL["Preloading"]
            Q1[アイドル時/予測] -->|読み込み| R1[キャッシュ]
        end
    end

これら、キャッシュにアーキテクチャやパターンの汎用的な知識はCPUやメモリに関する内容が参考になります。 キャッシュの更新や活用方法について学びたい場合はそれらの体系的な知識をキャッチアップすると良いでしょう。

下記の資料も参考になります。

onk.hatenablog.jp

speakerdeck.com

キャッシュの生存期間

 キャッシュの保存が決まれば、最後はキャッシュの生存期間と範囲を決める必要があります。 キャッシュの生存期間はキャッシュのキーを決め、そのキーに紐づいたキャッシュの有効な範囲、または失効する条件を決めることになります。

 有効な範囲が広いほどキャッシュヒットする率はあがりますが、キャッシュの元になるデータが更新される場合にそれに紐づくキャッシュのデータは更新が必要になります。

 また有効な期間が長いほど、キャッシュの生存期間が長くなりますが、キャッシュヒット率の低いキャッシュを長期間保持しているとメモリなどのコンピュータリソースを無駄に確保し続けることにも繋がります。そのため、データの生存期間を決めるアルゴリズムを対象に合わせて選ぶ必要があります。

データの生存期間のアルゴリズム

  1. Least Recently Used (LRU):
    考え方: 最後に利用してから最も時間が経過しているアイテムをキャッシュから削除します。これは「最近使われていないものは、これからも使われない可能性が高い」という考えに基づいています。

  2. Least Frequently Used (LFU):
    考え方: 利用された回数が最も少ないアイテムをキャッシュから削除します。これは「頻繁に使われないものは、これからも使われない可能性が高い」という考えに基づいています。

  3. First-In-First-Out (FIFO):
    考え方: 最初にキャッシュに入ったアイテムを最初に削除します。このアプローチはシンプルですが、使用頻度や最近の使用状況は考慮しません。キューを利用したアイテムの管理が該当します。

  4. Random Replacement (RR):
    考え方: ランダムにアイテムを選んでキャッシュから削除します。実装が非常にシンプルである一方で、パフォーマンスは最適ではないことが多いですが、例えば、短期的なセッションデータや一過性のイベント情報など、一定時間後には関連性が低くなるデータを定期的に削除するために使われたりします。

  5. Time-To-Live (TTL) :
    考え方: 各アイテムに有効期限を設定し、期限が切れたアイテムを削除します。これは特に変更頻度が予測可能なデータや、一定時間後に必ず無効になるべきデータに適しています。

  6. Refresh-Ahead:
    考え方:キャッシュされたデータが特定のイベントの発生時や一定時間の経過など、定期的に自動的に更新されるようにします。システムは、データが再度必要になる前に、バックグラウンドでキャッシュを更新します。この戦略は、データの更新パターンが予測可能である場合に有効です。

  7. Time-To-Refresh (定期更新):
    キャッシュされたデータに対して、一定の時間が経過すると自動的に更新を行います。これはRefresh-Aheadと似ていますが、こちらは特定のデータやパターンに基づいて更新するのではなく、時間ベースで一律に更新を行います。

graph TD
    subgraph LRU["Least Recently Used (LRU)"]
        A1[Cache] -->|アイテムを使用| B1[最後の利用から時間経過順]
        B1 --> C1[最も古いアイテムを削除]
    end
graph TD

    subgraph LFU["Least Frequently Used (LFU)"]
        A2[Cache] -->|アイテムを使用| B2[使用頻度の記録]
        B2 --> C2[最も少なく使用されたアイテムを削除]
    end
graph TD

    subgraph FIFO["First-In-First-Out (FIFO)"]
        A3[Cache] -->|アイテムを追加| B3[キューへ]
        B3 --> C3[キューの最初のアイテムを削除]
    end
graph TD

    subgraph RR["Random Replacement (RR)"]
        A4[Cache] -->|削除時| B4[ランダムにアイテム選択]
        B4 --> C4[選ばれたアイテムを削除]
    end
graph TD

    subgraph TTL["Time-To-Live (TTL)"]
        A5[Cache] -->|アイテムを追加| B5[有効期限設定]
        B5 --> C5[期限切れアイテムを削除]
    end
graph TD

    subgraph RA["Refresh-Ahead"]
        A6[Cache] -->|バックグラウンドで更新| B6[対象アイテムの利用イベント]
        B6 --> C6[対象アイテム更新]
    end
graph TD

    subgraph TTR["Time-To-Refresh"]
        A7[Cache] -->|アイテム使用| B7[一定時間経過]
        B7 --> C7[全アイテム更新]
    end

 キャッシュの有効期間や失効条件は、必ず決めるようにしましょう。なぜなら永続するキャッシュは運用時に消していいかの判断が難しくなり、前述の通り、無駄なリソースを用意することにもなりますし、障害発生時などで、今の有効なキャッシュのデータの状態の把握が難しくなるため、よほどの理由がない限りは生存期間を決め、定期的に削除しましょう。

キャッシュを利用するときは単純な構造にする

 ここまで説明した方法を活用し、キャッシュを利用するとなった場合、単純に構造にできるように努めましょう。なぜなら以下のような問題がキャッシュの運用にはあります。

  • 参照されたタイミングのキャッシュがどのデータかの把握が難しい
  • どのデータがキャシュされたかの把握が難しい
  • どこまでキャッシュされているのかの把握が難しい
  • 意図しない結果となった場合、原因がキャッシュなのか元データの破損なのかの判断が難しい

 このキャッシュの難しさは機能追加や問題発生時に顕著に出てきます。デバッグが難しく、どのデータがキャッシュされているか解りにくいためです。だからこそ、認知負荷が少なくなるようなシンプルなルールで、単一な責務のキャッシュの構造にするように心がけましょう。

おわりに

 という話をYAPC::Hiroshimaの前夜祭とPHPerKaigi 2024で話をします。 ぜひ、遊びにきてください。

fortee.jp

*1:計算結果やRDBMSのレコードなどのデータ、オブジェクト

*2:キャッシュを導入して効果が高いところから対応するべき

*3:旧:Twitter