キャッシュのガイドライン
この文書では、GitLabで使われている様々なキャッシュ戦略、それらを効果的に実装する方法、そして様々なゴチャについて説明します。この資料は、優れたキャッシング・ワークショップから抜粋したものです。
キャッシュとは?
データをより高速に保存するもの:
- コンピュータの多くの分野で使用されています。
- プロセッサにはキャッシュがあり、ハードディスクにはキャッシュがあります!
- 多くの場合、最終的にデータを保存したい場所に近いところにあります。
- よりシンプルなデータ保存
- 一時的なもの。
速いとは?
すべてのウェブページの目標は、100ミリ秒以内に戻ることです:
- これは達成可能ですが、最新のアプリケーションではキャッシュが必要です。
- より大きなレスポンスはビルドに時間がかかり、キャッシュは一定の速度を維持するために重要になります。
- キャッシュの読み込みは通常1ミリ秒以下です。これで改善されないことはほとんどありません。
- 最初の体験も重要なので、これは完全な解決策ではありません。
- ユーザー固有のデータがこれを難しくしており、このスピード目標を達成するために既存のアプリケーションをリファクタリングする際の最大の課題となっています。
- ユーザー固有のキャッシュは依然として効果的ですが、ユーザー間で共有される汎用キャッシュよりもキャッシュ・ヒット数が少なくなります。
- 私たちは、ページロードの大部分を常にキャッシュから取得することを目指しています。
なぜキャッシュを使うのですか?
- 高速化のためです!
- IOを避けるために
- ディスクの読み取り。
- データベースクエリ
- ネットワークリクエスト
- 同じ結果を何度も再計算するのを避けるため:
- レンダリングを表示します。
- JSONレンダリング
- Markdownレンダリング。
- 冗長性の提供。CloudFlareの “Always Online “機能のように、キャッシュを利用することで、他の場所での障害を目立たなくすることができる場合があります。
- メモリ消費を減らすため。Rubyでの処理は少ないが、大きな文字列をフェッチするだけ
- お金を節約するため。RAMに比べてプロセッサが高価なクラウドコンピューティングでは特にそうです。
キャッシュへの疑問
- エンジニアの中には、キャッシュはハックであり、本当の解決策は根本的なコードをより高速に改善することだと考え、最後の手段を除いてキャッシュに反対する人もいます。
- これはキャッシュの有効期限切れを恐れてのことかもしれません。
- しかし、キャッシュの方が_速い_ことには変わりありません。
- 真のパフォーマンスを得るためには、両方のテクニックを使う必要があります:
- 例えば、最初のコールドライトに時間がかかってタイムアウトするようでは、キャッシュの意味がありません。
- しかし、キャッシュがパフォーマンスを向上させないケースはほとんどありません。
- しかし、キャッシュをクイックハックとして使うこともできます。本当の “修正 “には何ヶ月もかかるのに、キャッシュはたった1日で実装できることもあります。
GitLabでのキャッシュ
Redisキャッシュにはデメリットもありますが、GitLabアプリケーション内やGitLab.comのキャッシュ設定を自由に活用してください。キャッシュの使用率を予測すると、十分な余裕があることがわかります。
ワークフロー
方法論
- できるだけ最終ユーザーの近くで、できるだけ頻繁にキャッシュします。
- ビューのレンダリングをキャッシュすることが、圧倒的にパフォーマンスを向上させます。
- できるだけ多くのユーザーのデータをキャッシュするようにしましょう:
- 一般的なデータは誰でもキャッシュできます。
- 新しい機能を構築する際には、この点に留意する必要があります。
- キャッシュデータはできるだけ保存するようにしてください:
- ネストされたキャッシュを使用して、期限切れ後もできるだけ多くのキャッシュデータを維持します。
- キャッシュへのリクエストはできるだけ少なくします:
- これにより、ネットワークの問題によって引き起こされる変数待ち時間を減らすことができます。
- キャッシュの各読み取りのオーバーヘッドを低減します。
キャッシュの利点
キャッシュは “価値ある “ものですか?これを測定するのは難しいかもしれませんが、検討することはできます:
- キャッシュされるデータのサイズは?
- 例えば、大きな HTML レスポンスを RAM ではなくディスクに保存するなどです。
- データをキャッシュすることで、どれだけのI/O、CPU、応答時間を節約できますか?
- キャッシュされるデータが大きくても、レンダリングにかかる時間が短い場合、例えば大きなテキストの塊をページにダンプするような場合、これはキャッシュする最適な場所を示しているかもしれません。
- このデータはどのくらいの頻度でアクセスされますか?
- 通常、頻繁にアクセスされるデータをキャッシュすると、より大きな効果が得られます。
- そのデータはどのくらいの頻度で変更されますか?
- キャッシュが再び読み込まれる前にキャッシュがローテートされる場合、このキャッシュは実際に有用ですか?
ツール
調査
- パフォーマンス・バーは、ローカルおよびプロダクションで調査する際の最初のステップです。高価なクエリ、過剰なRedisコールなどを探してください。
- フレームグラフを作成する: URL に
?performance_bar=flamegraph
を追加して、時間が費やされているメソッドを見つけやすくします。 - Railsのログに飛び込みましょう:
- パーシャルのレンダリング時間もよく見てください。
- レスポンスタイムだけを測定するには、
jq
を使ってJSONログを解析できます:tail -f log/development_json.log | jq ".duration_s"
tail -f log/api_json.log | jq ".duration_s"
-
development.log
:tail -f log/development.log | grep "cache hits"
tail -f log/development.log | grep "Rendered "
- 正しい場所を探してから:
- 原因が見つかるまで、コードの一部を削除するかコメントアウトしてください。
-
binding.pry
を使ってライブリクエストを調べます。これにはフォアグラウンドのウェブプロセスが必要です。
検証
- Grafana、特に以下のダッシュボード:
- ログ
- Grafana チャートでカバーできない場合は、代わりに Kibana を使用してください。
- 機能フラグ:
- キャッシュを追加するとき、機能フラグを使うことはほとんど常に価値があります。
- オンとオフを切り替えて、Grafanaのくねくねした線を見ましょう。
- キャッシュが温まるにつれ、最初はレスポンスタイムが上がることが予想されます。
- その影響は、フラグを100%で実行するまでは明らかではありません。
- パフォーマンスバー:
- ローカルでこれを使用し、Redisリストでキャッシュ呼び出しを探します。
- また、本番環境でもこれを使用して、キャッシュキーが期待通りのものであることを確認します。
- Flamegraphs:
- ページに
?performance_bar=flamegraph
。
- ページに
キャッシュレベル
高レベル
- HTTPキャッシュ:
- ETagsと有効期限を使用して、ブラウザにキャッシュされたバージョンを提供するように指示します。
- _これでも_Railsはヒットしますが、ビューレイヤはスキップされます。
- リバースプロキシキャッシュでのHTTPキャッシュ:
- 上記と同じですが、
public
。 - ブラウザの代わりに、リバースプロキシ (NGINX、HAProxy、Varnish など) にキャッシュされたバージョンを提供するように指示します。
- その後のリクエストがRailsにヒットすることはありません。
- 上記と同じですが、
- HTMLページのキャッシュ:
- HTMLファイルをディスクに書き込む
- Webサーバー(NGINX、Apache、Caddyなど)は、RailsをスキップしてHTMLファイル自体を提供します。
- ビューまたはアクションのキャッシュ
- Railsはレンダリングされたビュー全体をキャッシュストアに書き込み、それを返します。
- フラグメントキャッシュ:
- Railsキャッシュストアにビューの一部をキャッシュします。
- キャッシュされた部分は、レンダリング時にビューに挿入されます。
低レベル
- メソッドキャッシング:
- 同じメソッドを複数回呼び出しても、値を計算するのは1回だけです。
- Rubyメモリに格納。
@article ||= Article.find(params[:id])
strong_memoize_attr :method_name
- リクエストキャッシュ:
- ウェブリクエストの間、キーに対して同じ値を返します。
Gitlab::SafeRequestStore.fetch
- リードスルーまたはライトスルー SQL キャッシング:
- データベースの前にあるキャッシュ。
- Railsはこれを同じクエリのリクエスト内で行います。
- 斬新なキャッシュ。
- あるユースケースのための超特殊なキャッシュ。
Railsの組み込みキャッシュヘルパー
これはRailsガイドに詳しく書かれています。
- HTMLページキャッシュとアクションキャッシュはデフォルトでは含まれなくなりましたが、今でも便利です。
- RailsのガイドではHTTPキャッシングをConditional GETと呼んでいます。
- Railsのキャッシュストアについては、2つの非常に重要な(そしてほとんど同じ)メソッドを覚えておいてください:
-
cache
のエイリアスです: -
Rails.cache.fetch
のエイリアスに近いものです。
-
-
cache
には、ビューファイルを変更したときに変更される「テンプレートツリーダイジェスト」が含まれています。
Railsキャッシュオプション
expires_in
これはキャッシュエントリのTime To Live(TTL) を設定するもので、最も便利な(そして最もよく使われる)キャッシュオプションです。これはほとんどのRailsキャッシュヘルパーでサポートされています。
race_condition_ttl
このオプションは、キャッシュされていない複数のキーが同時にヒットするのを防ぎます。キーの有効期限が切れたことを最初に発見したプロセスは、TTLをこの値だけ増やし、新しいキャッシュ値を設定します。
キャッシュキーの負荷が非常に高い場合に使用し、 複数の同時書き込みを防ぎますが、10 秒などの低い値に設定すべきです。
HTTP キャッシュを使うタイミング
レスポンス全体がキャッシュ可能な場合、条件付きGETキャッシュを使います:
- 公開キャッシュを使用しない場合、プライバシーリスクはありません。ユーザーがブラウザで見るものだけをキャッシュします。
- ポーリングされるエンドポイントでは特に便利です。
- 良い例です:
- 更新のためにポーリングするディスカッションのリスト。最後に作成されたエントリの
updated_at
の値をetag
に使用します。 - APIエンドポイント。
- 更新のためにポーリングするディスカッションのリスト。最後に作成されたエントリの
デメリット
- ユーザーやAPIライブラリはキャッシュを無視できます。
- 時々、Chromeはキャッシュで変なことをします。
- 開発モードではキャッシュの存在を忘れ、変更が反映されないと怒ります。
- 理論的には、条件付きGETキャッシュを使うことはどこでも理にかなっていますが、実際には奇妙な問題を引き起こすことがあります。
ビューやアクションのキャッシュを使う場合
これはもはやRailsの世界ではあまり使われていません:
- Railsコアからサポートが削除されました。
- 通常はリバースプロキシのキャッシュや条件付きGETレスポンスに目を向ける方がよいでしょう。
- しかし、ディスクに書き込むことなくHTMLページのキャッシュをエミュレートするややシンプルな方法を提供しており、クラウド環境では有用です。
- かなり大きなマークアップの塊をキャッシュストアに保存します。
- このカスタム実装はAPI上で利用可能で、
cache_action
。
フラグメントキャッシュを使うタイミング
常に!
- おそらくRailsで最も便利なキャッシュタイプで、ビューのセクション、パーシャル全体、パーシャルのコレクションをキャッシュできます。
- レンダリングされたパーシャルのコレクションは、
cached: true
。 - パーシャルの内部よりもパーシャルのレンダリングコール周辺のキャッシュのほうが高速ですが、テンプレートツリーのダイジェストを失うことになります。
- ループの内部にキャッシュコールを配置するなど、多くのキャッシュコールを導入することに注意してください。避けられないこともありますが、パーシャルコレクションのキャッシュのように、これを回避するオプションがあります。
- ビューのレンダリングとJSONの生成は遅いので、可能な限りキャッシュすべきです。
メソッドキャッシュを使うタイミング
- インスタンス変数を使うか、
StrongMemoize
。 - リクエストの中で同じ値が複数回必要な場合に便利です。
- 同じキーに対する複数のキャッシュコールを防ぐために使用できます。
- ActiveRecord オブジェクトでは、reload を呼び出すまで値が変更されないというイシューが発生する可能性があります。
リクエストキャッシュを使うタイミング
- メソッドキャッシングと似たような使いかたですが、複数のメソッドにまたがって使うことができます。
- リクエストの間、何かを保存する標準的な方法です。
- (GitLabの実装では)ルックアップはキャッシュのルックアップに似ているので、両方に同じキーを使うことができます。これが
Gitlab::Cache.fetch_once
の仕組みです。
デメリット
-
Gitlab::Cache::JsonCache
やGitlab::SafeRequestStore
などを使用して、キャッシュされたオブジェクトに新しい属性を追加すると、キャッシュデータに新しい属性に対応する適切な値がない場合に、古いデータになってしまうというイシューが発生する可能性があります(過去の事例を参照)。
SQLキャッシュを使用するタイミング
Railsはリクエスト内の同一のクエリに対して自動的にこれを使用するので、そのような使用ケースではアクションは必要ありません。
- しかし、
identity_cache
のようなgemを使うと、複数のリクエストにまたがるクエリのキャッシュという別の目的があります。 -
Article.find(params[:id])
のように、単一のオブジェクトの検索に使用することは避けてください。 - 読み取り専用のオブジェクトを提供するため、結果を使用できないことがあります。
- また、リレーションシップをキャッシュすることも可能で、 物事のリストを返したいけれども、フィルタリングや順序づけはどうでもいいというような場合に便利です。
ノベルティキャッシュの使用例
他の選択肢を使い果たし、本当に厄介なものをキャッシュしなければならない場合は、カスタムソリューションを検討する時です:
- GitLabでの例としては、
RepositorySetCache
,RepositoryHashCache
andAvatarCache
があります。 - 可能な限り、カスタムキャッシュの実装を作成することは避けるべきです。
- 非常に効果的です。例えば、
merged_branch_names
、RepositoryHashCacheを使用したキャッシュです。
キャッシュの有効期限
Redisがキーを失効させる方法
要するに、古いものは新しいものに置き換えられます:
- RedisをLRUキャッシュとして設定するのに役立つ記事です。
- さまざまなキャッシュ消去戦略のオプションがあります。
- 機能的には Memcached に似ている
allkeys-lru
がいいでしょう。 - Redis 4.0 以降では、allkeys-lfu が利用可能です。
- すべての明示的な削除をDELではなくUNLINKを使って処理するようになり、Redisが即座にではなく、独自のタイミングでメモリを取り戻すことができるようになりました。
- これにより、Redisは即座にメモリを取り戻すのではなく、独自の時間でメモリを取り戻すことができます。これにより、キーが削除されたとマークされ、成功した値がすぐに返されますが、実際には後で削除されます。
Railsがキーを失効させる方法
- Railsは明示的な削除を使用するよりも、TTLとキャッシュキーの有効期限を使用する方を好みます。
- ビューでフラグメントキャッシュを行う場合、キャッシュキーにはデフォルトでテンプレートツリーのダイジェストが含まれます。
- 警告として、これはヘルパーには当てはまりません。
- RailsにはActiveRecordオブジェクトのキャッシュキーメソッドが2つあります:
cache_key_with_version
とcache_key
。最初のものはバージョン5.2以降でデフォルトで使用され、以前からの標準的な動作です。updated_at
のタイムスタンプをキーに含めます。
キャッシュ・キーの構成要素
application.log
に例があります:
cache(@project, :tag_list)
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29/projects/16-2021031614242546945
2/tag_list
- ビュー名とテンプレートツリーのダイジェスト
views/projects/_home_panel:462ad2485d7d6957e03ceba2c6717c29
- モデル名、ID、
updated_at
の値projects/16-20210316142425469452
- 文字列に変換して渡されたシンボル
tag_list
探す
- ユーザー固有のデータ
- これが最も重要です!
- 特にビューでは、これは必ずしも明らかではありません。
- キャッシュしたい領域で使われているすべてのヘルパーメソッドを調べなければなりません。
- ビリーが8分前に投稿した」というような、時間固有のデータ。
- レコードが更新されても、
updated_at
フィールドが変更されない場合。 - Railsヘルパーはテンプレートダイジェストをビューのキーにロールインしますが、ヘルパーなど他の場所ではこの現象は起こりません。
-
Grape::Entity
の場合、APIレイヤでの効果的なキャッシュが非常に難しくなります。これについては後で詳しく説明します。 -
break
やreturn
をビューのフラグメントキャッシュヘルパー内部で使わないでください。 - 古いデータを返す可能性のあるキャッシュキーの項目を並べ替えます:
- 例えば、
nil
を返す可能性のある2つの値を持ち、それらを入れ替えるなど。 - 代わりに
{ project: nil }
のようなハッシュを使ってください。
- 例えば、
- Railsは配列のメンバに対して
#cache_key
を呼び出してキーを探しますが、ハッシュの値に対しては呼び出しません。