HTTP Cache で求人サイトのスピード改善を試してみた話
こんにちは、開発本部の楊です。メドレーの社内勉強会「TechLunch」で、前回は、React の基本を紹介しましたが、今回は HTTP Cache で、医療介護求人サイト「ジョブメドレー」のスピード改善ができないか検討した話について、共有しました。
なぜ HTTP Cache について話すことにしたのか
ジョブメドレーには、医療機関や保育園、介護施設などさまざまな事業所の求人が掲載されています。現在、14 万を超える事業部の求人が掲載されており、かつ事業所側で求人原稿を修正することもできるため、求職者側が閲覧するスピードについては、いつも意識して改善に取り組んでいます。
その試行錯誤の中で、HTTP Cache を使って改善する方法について、実現性など含めて検証することにしました。
今回は、あるページをモデルケースに試してみました。このページは、PC は平均 250ms、モバイルは 180ms になっています。
ユーザが該当のページを見たときに「内容に更新がないときはブラウザ側のキャッシュを利用してスピードを最適化する」「ページが更新されていたら、最新の内容をすぐユーザへ反映する」という要件でスピード改善されることを目指すことにしました。
HTTP Cache とは?
Google Developersでは以下のように説明されています。
ネットワーク経由で情報を取得するには時間もコストもかかります。レスポンスが大きいと、クライアントとサーバ間のラウンドトリップを何度も繰り返す必要があるため、レスポンスが利用可能となってブラウザで処理できるようになるまで時間がかかります。さらに、ユーザ側ではデータの通信コストが発生します。そのため、前に取得したリソースをキャッシュに保存して再使用できることは、パフォーマンスを最適化する上で非常に重要です。
この機能はほぼ全てのブラウザに対応しており、HTTP ヘッダーでCacheControl
、ETAG
、Last-Modified
などを利用して、リソース更新するタイミングなどを細かくコントロール可能です。
Rails での HTTP Cache
expire_in でキャッシュ
1 時間キャッシュしたい場合は、コントローラにコードを一行書けば大丈夫です。
expires_in(1.hour)
これで問題なく 1 時間キャッシュされるのですが、「ページが編集されたら即時にユーザへ反映する」という要件を満たしてはいません。
ETAG を利用
では「ページが更新されたら最新の内容をすぐユーザへ反映して、更新がない時はブラウザ側のキャッシュを利用してスピードを最適化する」を行いたい場合はどうすればよいでしょうか。
ここではETAG
を利用しようと思います。
ETAG
はページの内容によって、ユニークな文字列を作成して、ブラウザ側でページの更新あるかどうかを判定するものです。
ETAG はどう作成されているか
Rails のデフォルトでは HTTP Cache を有効になっていて、ETAG
を自動的に作成しています。
header['ETag'] = Digest::MD5.hexdigest(body)
上のコードのようなイメージで、レスポンス Body からETAG
を作成します。
つまり、サーバから返される HTML ソースの内容が毎回同じであれば、ブラウザは前回キャッシュした内容を読み込むようになります。
サーバレスポンスタイムの短縮
今回の対象ページの機能としてサーバ側では主に二つの部分で時間がかかります。
- DB、Redis などで画面表示が必要なデータ取得、ロジック処理
- クライアントに返す HTML のレンダリング
対象ページは「HTML のレンダリング」する時間が長かったので、それを改善できれば、サーバレスポンスタイムを一気に短くできます。
画面に表示する必要なデータに変換がなければ、HTML のレンダリングをせずにレスポンスを返す機能がないかをさらに調べました。
fresh_when を使う
名前の通り、いつ画面更新するかをコントロールするメソッドです。
データベース中の該当データが更新されたら、新しいETAG
を作成するコードは以下のようになります。
fresh_when(etag: [@job_offer, @job_offer.facility])
model から ETAG をどう作成するか
model の cache_key から ETAG を作ります。
該当ページで使用する job_offer モデルの cache_key は"job_offer/5-20071224150000"
のような感じになります。
model の id と updated_at の組み合わせでユニークな cache_key を作成しています。
def cache_key(*timestamp_names)
case
when new_record?
"#{model_name.cache_key}/new"
when timestamp_names.any?
timestamp = max_updated_column_timestamp(timestamp_names)
timestamp = timestamp.utc.to_s(cache_timestamp_format)
"#{model_name.cache_key}/#{id}-#{timestamp}"
when timestamp = max_updated_column_timestamp
timestamp = timestamp.utc.to_s(cache_timestamp_format)
"#{model_name.cache_key}/#{id}-#{timestamp}"
else
"#{model_name.cache_key}/#{id}"
end
end
自前の作成したクラスから ETAG をどう作成するか
model だけではなく、自前で作成したクラスでデータ管理をしてるところもあります。 そちらでの実現方法も試してみました。
そこでETAG
を作るコードが Rails でどうなっているか追ってみました。
def retrieve_cache_key(key)
case
when key.respond_to?(:cache_key) then key.cache_key
when key.is_a?(Array) then key.map { |element| retrieve_cache_key(element) }.to_param
when key.respond_to?(:to_a) then retrieve_cache_key(key.to_a)
else key.to_param
end.to_s
end
このように、自作クラスにcache_key
のメソッドを定義したら、その結果から ETAG が作成されるようになっています。このメソッドを使って、ユニークな値を返せば大丈夫です。
ここまで調査したことを踏まえて、該当ページを HTTP Cache で実装をしてみました。以下が該当の疑似コードになります。
class JobOfferBrowsingHistory
def cache_key
# return job offer ids
end
end
fresh_when に渡す
@user_history = JobOfferBrowsingHistory.new
fresh_when(etag: [@job_offer, @job_offer.facility, @user_history])
テスト
開発環境で該当ページを 2 回ロードしました。 1 回目は 1200ms、キャッシュが効いた 2 回目は 130ms になりました。すごく早いです! もちろん、ページ内のデータが更新されたら、最新の内容がページに反映されるようにもなっています。
これで「ページのデータが更新されたら、最新の内容をすぐユーザへ反映する」「更新がなければ HTTP Cache を返す」という状態が実現しました。
課題
- 実装ミスで、更新が必要なのに、更新されない問題が起こりえる
- 例えば、画面に新しい model を追加したが、
fresh_when
に渡すのを忘れたとか
- 例えば、画面に新しい model を追加したが、
- 問題なく更新されることを保証する仕組みの実装
まとめ
HTTP Cache を利用して、ジョブメドレーのスピード改善を検討してみた調査過程を紹介しました。
expire_in
、ETAG
の作成方法など色々調べて、最終的にfresh_when
で実現できましたが、運用していく上での課題については、引き続き検討して行きたいと思います。