Medley Developer Blog

株式会社メドレーのエンジニア・デザイナーによるブログです

HTTP Cacheで求人サイトのスピード改善を試してみた話

こんにちは、開発本部の楊です。メドレーの社内勉強会「TechLunch」で、前回は、Reactの基本を紹介しましたが、今回はHTTP Cacheで、医療介護求人サイト「ジョブメドレー」のスピード改善ができないか検討した話について、共有しました。

なぜHTTP Cacheについて話すことにしたのか

ジョブメドレーには、医療機関や保育園、介護施設などさまざまな事業所の求人が掲載されています。現在、14万を超える事業部の求人が掲載されており、かつ事業所側で求人原稿を修正することもできるため、求職者側が閲覧するスピードについては、いつも意識して改善に取り組んでいます。

その試行錯誤の中で、HTTP Cacheを使って改善する方法について、実現性など含めて検証することにしました。

今回は、あるページをモデルケースに試してみました。このページは、PCは平均250ms、モバイルは180msになっています。

f:id:medley_inc:20180824171410p:plain

ユーザが該当のページを見たときに「内容に更新がないときはブラウザ側のキャッシュを利用してスピードを最適化する」「ページが更新されていたら、最新の内容をすぐユーザへ反映する」という要件でスピード改善されることを目指すことにしました。

HTTP Cacheとは?

Google Developersでは以下のように説明されています。

ネットワーク経由で情報を取得するには時間もコストもかかります。レスポンスが大きいと、クライアントとサーバ間のラウンドトリップを何度も繰り返す必要があるため、レスポンスが利用可能となってブラウザで処理できるようになるまで時間がかかります。さらに、ユーザ側ではデータの通信コストが発生します。そのため、前に取得したリソースをキャッシュに保存して再使用できることは、パフォーマンスを最適化する上で非常に重要です。

この機能はほぼ全てのブラウザに対応しており、HTTPヘッダーでCacheControlETAGLast-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ソースの内容が毎回同じであれば、ブラウザは前回キャッシュした内容を読み込むようになります。

サーバレスポンスタイムの短縮

今回の対象ページの機能としてサーバ側では主に二つの部分で時間がかかります。

  1. DB、Redisなどで画面表示が必要なデータ取得、ロジック処理
  2. クライアントに返す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を作成しています。

Railsの該当コード

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でどうなっているか追ってみました。

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に渡すのを忘れたとか
  • 問題なく更新されることを保証する仕組みの実装

まとめ

HTTP Cacheを利用して、ジョブメドレーのスピード改善を検討してみた調査過程を紹介しました。

expire_inETAGの作成方法など色々調べて、最終的にfresh_whenで実現できましたが、運用していく上での課題については、引き続き検討して行きたいと思います。