今日は、キャッシュの設定基準について説明します。個人的な実務経験に基づいて作成した投稿なので、あくまで参考として見ていただけたら嬉しいです。 ㅎㅎ
Cacheとは?
キャッシュは、後で要求される結果を事前に保存しておき、迅速にサービスを提供することを意味します。つまり、事前に結果を保存しておき、後でリクエストが来たら、DBやAPIを参照せずにキャッシュにアクセスしてリクエストを処理する手法です。このようなキャッシュが登場した背景には、パレートの法則があります。
パレートの法則とは、80%の結果は20%の原因によって発生するというもので、下の図を参考にしていただけたらと思います!
つまり、キャッシュはすべてをキャッシュする必要はなく、サービス時に多く使用される20%のみをキャッシュすることで、全体的な効率を向上させることができます。
どのようなデータをキャッシュすべきか?
パレートの法則に従い、どのようなデータでもキャッシュするのではなく、本当に必要なデータのみをキャッシュする必要があります。では、どのようなデータをキャッシュすべきでしょうか?
頻繁に読み込まれるが、書き込みはほとんど行われないデータ
まさに、「頻繁に読み込まれるが、書き込みはほとんど行われないデータに対してキャッシュするべきだ」と理論的にはよく言われますが、「頻繁に読み込まれる」という基準と「書き込みはほとんど行われない」という基準はかなり曖昧でした。
そこで、私は以下のようなステップでキャッシュするデータを調査します。
- DataDogなどのAPMを使用して、RDBのクエリ呼び出し履歴トップ5を確認します。
- その中で、検索クエリを見つけ、どのテーブルから検索しているのかを確認します。
- 該当テーブルの更新クエリがどれだけ呼び出されているかを確認します。
このようなプロセスを経て、検索が多く発生する一方で更新クエリが少なく発生しているかどうかを確認します。私が実務で確認したテーブルは、検索クエリが1日に174万回発生する一方で、更新クエリは多くても500回程度でした。これなら、誰がどう見てもキャッシュに適した条件と言えるでしょう。 ㅎㅎ
最新性に敏感なデータ
最新性に敏感なデータとは、RDBとキャッシュ間の不一致が短くなくてはならないということです。例えば、決済関連の情報は最新性に非常に敏感であるため、上記のキャッシュ条件に合致していても、適用を検討する必要があります。
私は上記の2つの特性に合致する決済関連テーブルに対してキャッシュを実装する必要がありました。そのため、該当の決済関連テーブルを使用するすべてのロジックにキャッシュを適用したわけではなく、実質的に決済が行われない比較的安全なロジックに部分的にキャッシュすることにしました。
ローカルキャッシュ vs グローバルキャッシュ
さて、キャッシュするデータとキャッシュする範囲は、ある程度決まりました。では、「どこ」にキャッシュデータを保存するかを検討する必要があります。一般的には、ローカルメモリに保存するか、Redisなどの別途サーバーに保存することができます。
ローカルキャッシュ
ローカルキャッシュは、アプリケーションサーバーのメモリにキャッシュするデータを保存する方法であり、一般的にGuava cacheやCaffeine cacheがよく使用されます。
- アプリケーションロジックを実行中に、同じサーバー内のメモリからキャッシュを検索するため、速度が速いです。
- 実装が容易です。
- インスタンスが複数ある場合、いくつかの問題が発生します。
グローバルキャッシュ
グローバルキャッシュは、Redisなどの別途キャッシュデータを保存するサーバーを置く方式です。
- インスタンス間でキャッシュを共有するため、あるインスタンスでキャッシュを変更しても、すべてのインスタンスが同じキャッシュ値を取得できます。
- 新しいインスタンスが起動しても、すでに存在するキャッシュストレージを参照すればよいため、キャッシュを投入する処理を行う必要はありません。
- ネットワークトラフィックを経由する必要があるため、ローカルキャッシュに比べて速度が遅くなります。
- 別途キャッシュサーバーを置く必要があるため、インフラ管理コストが発生します。
筆者はどちらを選択したのか?
現在の会社のアプリケーションサーバーは、複数のインスタンスを起動する構造ですが、ローカルキャッシュを選択しました。
- RDBに保存されているキャッシュするデータが4万件ほどで、これをすべてメモリに載せても4MB以下です。
- 決済関連のデータについて、検索性能を向上させる必要がありました。
- Redisはすでに存在しますが、新しいキャッシュをRedisに保存すると、インフラコストが発生します。
キャッシュをどのように最新化するのか?
アプリケーションサーバーが複数あり、そこにローカルキャッシュを適用した場合、各アプリケーションサーバーに保存されているキャッシュ値が異なる可能性があります。例えば、Aサーバーに保存されているキャッシュデータは「1」ですが、Bサーバーに保存されているキャッシュデータはBサーバーで変更されて「2」になる可能性があります。この状況で、ユーザーがロードバランサーにリクエストを送信すると、AサーバーとBサーバーから異なる値を受け取ることになります。
したがって、各インスタンスごとにキャッシュを自動的に削除して、RDBから検索するように構成する必要がありますが、その際にTTLがよく使用されます。
TTLはどのくらいに設定すべきか?
TTLはTime To Liveの略で、特定の時間が経過するとキャッシュを削除する設定です。例えば、TTLを5秒に設定した場合、キャッシュデータは5秒後に自動的に削除されます。その後、キャッシュミスが発生すると、RDBからデータを取得して保存します。
**read/writeが1つのキャッシュサーバーで発生**
read/writeがRedisなどのグローバルキャッシュサーバー1台で発生する場合、またはローカルキャッシュが適用された1台のアプリケーションサーバーで発生する場合、TTLの値は時間単位以上に上げても問題ありません。いずれにしてもwrite時に既存のキャッシュを修正することになり、該当のキャッシュからデータを取得するサーバーは常に最新化されたデータを見ることになります。
この場合、TTLを設定せず、キャッシュサーバーがいっぱいになるとLRUアルゴリズムで自動的にキャッシュを少しずつ空けるように構成することもできます。
**read/writeが複数のキャッシュサーバーで発生**
read/writeが複数化されたグローバルキャッシュサーバーで発生する場合、またはローカルキャッシュが適用された複数のアプリケーションサーバーで発生する場合、TTLは秒~分単位にするのが良いでしょう。なぜなら、修正されたデータをまだ反映していないキャッシュサーバーの古いデータを読み込む可能性があるからです。
この時、TTLは様々な文脈で決定されますが、最新化が重要で値が変更される可能性が高いほどTTLを短くする必要があり、最新化がそれほど重要ではなく、値が変更される可能性が低いほどTTLを少し長くしても問題ありません。
筆者はTTLをどのように設定したのか?
私がキャッシュするデータは決済関連のデータであり、実質的に決済が発生する厳密なロジックにはキャッシュを適用しなくても、いずれにしても決済の特性上、最新化が重要です。ただし、更新の可能性は低いため、TTLは5秒程度に安全に設定しました。
結論
まとめると、私が選択したキャッシュ方式は以下の通りです。
- 決済関連データ
- 検索が非常に頻繁に発生するが、修正はほとんど発生しない。
- 実質的に決済は発生しないが、検索が発生するロジックに限定してキャッシュを適用する。
- ローカルキャッシュを適用し、TTLは5秒に設定する。
今後は、実際に適用したキャッシュ方式に限定して性能テストを実施する予定です。まだ具体的にどのように性能テストを実施するかは検討中なので、今後の投稿で記述したいと思います!