💵

一覧表示画面にキャッシュを導入する上で考えたこと

2024/12/05に公開

本記事は SimpleForm Advent Calendar 2024 の 5 日目の記事です。

シンプルフォーム株式会社で SRE をしている守屋です。
本記事ではデータの一覧を表示する画面にキャッシュを導入する際に考えたことについて書いています。当社プロダクトでの独自事例を元に書いていますが、一覧画面の要件としては比較的世にありふれた構成となっているため、他社の方にも参考になる部分があるかと思います。

背景

当社の主要プロダクトである SimpleCheck は、ユーザーに法人情報を提供するための Web アプリケーションを UI として提供しています。ユーザーが法人名を入力すると、Web 上のリアルタイム情報と当社が独自に収集した情報を組み合わせた「レポート」が作成され、Web UI 上に表示されます。過去に取得したレポートは一覧画面に表示され、法人名等で検索する機能も提供しています。ありがたいことに本プロダクトの利用者は順調にスケールしており、顧客毎に取得したレポートの数も日々増加しています。その一方でデータ量の増加に伴い一覧画面の表示に伴う DB 負荷が増加しており、取得しているレポート量が多い場合はユーザー目線でも表示速度の悪化を感じるレベルになりつつありました。そのために SQL のチューニングなど様々な改善を行なっており、その一環でキャッシュの導入も検討することになりました。

本記事におけるキャッシュの扱い

CDN、ブラウザでのキャッシュ、RDB におけるクエリキャッシュなど様々なキャッシュが存在しますが、本記事ではアプリケーションレイヤーで作成・参照するキャッシュについて取り上げます。RDB に対する SQL 実行結果を Redis のようなインメモリ DB にキャッシュすることで、処理の高速化や RDB への負荷を分散させることを目的とします。一応、CDN レイヤー(CloudFront)でのキャッシュも選択肢としてはありますが、後述の通り比較的複雑なキャッシュ制御を実現する必要があり、アプリケーションレイヤーでのキャッシュを選んでいます。

レポート周りのデータ構造について

キャッシュの話をする前に、レポート周りのデータ構造について簡単に説明します。当社は基本的に Aurora MySQL 上にデータベースを構築しており、レポートとユーザー周りのデータ構造はざっくり以下のような構成になっています。

  • ユーザーが Web UI 上で調査したい法人を入力すると Reports というテーブルにレコードが一件挿入される
  • 全てのユーザーは UserGroups テーブルで定義された何かしらのグループに所属する(法人顧客の中における部署のようなイメージ)
  • ユーザーが取得したレポートはそのユーザーが所属するグループ内の別ユーザーも閲覧できる

当社でのキャッシュ戦略を考える上では上述の UserGroups というテーブルが鍵となってきます。要は同じグループに所属するユーザーには同じレポート一覧が表示されるということですので、キャッシュもこのグループ単位で作成できると効率が良さそうです!

キャッシュを導入するAPIについて

上述のレポートの一覧を RDB から取得して返却する GET API があり、フロントエンドからリクエストして結果を画面に表示しています。今回はその API にキャッシュを導入することでパフォーマンスの改善を狙いたいと思います。API は Ruby on Rails で実装されており、ざっくり以下のようなコードになっています。

class ReportsController < ApplicationController
  def index
    search_result = Form::ReportSearch.new(search_params).result
    render json: {
      reports: build_reports_hash(search_result) # レスポンス用にデータを整形する処理
    }
  end
end

Controller はフロントエンドからクエリパラメータとして渡された検索条件(search_params)を Reports を検索するための form クラス(Form::ReportSearch) に渡します。form クラスの中では検索条件に応じて Reports に別テーブルの JOIN や、where 句での絞り込みを追加して検索用の SQL を構築します。検索条件によって絞り込みに使われるカラムや JOIN するテーブルが異なるので Reports テーブルに付与したインデックスが十分に活用できないことが多く、ボトルネックになっています。その後、result メソッドで SQL が実行され、Report オブジェクトの一覧が返却されます。ボトルネックの解消のために一旦以下のように雑にキャッシュを導入してみます。

class ReportsController < ApplicationController
  def index
    search_result = Rails.cache.fetch( # formが返す結果を一時間キャッシュ
      "reports/#{current_user.user_group_id}/#{request.query_string}",
      expires_in: 1.hour,
      race_condition_ttl: 5.seconds,
    ) do
      Form::ReportSearch.new(search_params).result
    end
    render json: {
      reports: build_reports_hash(search_result)
    }
  end
end

検索結果を返すメソッドを Rails.cache.fetch ブロックで囲んでいます。キャッシュキー("reports/#{current_user.user_group_id}/#{request.query_string}")にヒットした場合はキャッシュ用 DB (ElastiCache Redis) から結果を取得し、ヒットしない場合は従前の通り SQL が実行されます。キャッシュキーにはユーザーが所属するグループ ID とクエリパラメータを含めています。これにより同じグループ内の誰かが検索した結果を再利用できるようになります。言うまでもありませんが、キャッシュキーの設計を誤ると本来見えてはいけない別ユーザーのデータが表示される等のセキュリティインシデントに繋がるため、注意が必要です。一旦これでもキャッシュとしては動作しますが、他にも考慮すべき点がありました。

新規取得したレポートもリアルタイムに画面に表示する必要がある

レポート一覧画面では自分及び同じグループの他ユーザーが新規取得したレポートをリアルタイムに表示するために一定の間隔でレポート一覧取得 API をポーリングしています。キャッシュの有効期限が切れない限りいくらポーリングしても同じ結果が返ってしまうため、リアルタイムに画面に情報を反映することができません。よってデータの更新を検知してキャッシュを更新する仕組みが必要です。レポート取得のタイミングでキャッシュをパージすればよいのですが、レポートは画面からだけではなくバッチや別基盤の API にも取得トリガーがあるのが悩みどころでした。パージ処理を分散させたくはなかったので、代わりに以下のようなロジックを導入しました。

  • グループ内で閲覧できる全レポートのうち最新の ID をキャッシュしておく
  • レポート一覧 API を叩いた際に再度最新のレポート ID を RDB から取得し、キャッシュしている ID と比較する
  • 上記が異なる場合はデータが更新されたと判断し、レポート検索結果のキャッシュを更新する

実装としては以下のようなイメージです。

class ReportsController < ApplicationController
  def index
    search_result = Rails.cache.fetch(
      "reports/#{current_user.user_group_id}/#{request.query_string}",
      expires_in: 1.hour,
      race_condition_ttl: 5.seconds,
      force: current_user.use_report_cache?, # キャッシュを利用するかの判定
    ) do
      Form::ReportSearch.new(search_params).result
    end
    render json: {
      reports: build_reports_hash(search_result)
    }
  end
end

Rails.cache.fetch の force オプションに true を渡すとキャッシュを強制的に更新することができるので、これにキャッシュの利用判定メソッドの結果を渡しています。キャッシュの利用判定メソッドは以下のような実装イメージです。

## キャッシュ利用判定メソッド
def use_report_cache?
  # キャッシュされたレポートIDとRDBから取得した最新のレポートIDを比較
  # 一致した場合はキャッシュOKなのでtrueを返却
  return true if cached_last_report_id == Report.where(user_group_id: user_group_id).select(:id).last&.id

  # IDが一致しない場合はIDのキャッシュを削除してfalseを返却
  Rails.cache.delete("last_report_id/#{user_group_id}")
  false 
end

private

## その時点での最新のレポートIDをキャッシュするメソッド
def cached_last_report_id
  Rails.cache.fetch(
    "last_report_id/#{user_group_id}",
    expires_in: 1.hour,
    race_condition_ttl: 5.seconds,
  ) do
    Report.where(user_group_id: user_group_id).select(:id).last&.id
  end
end

キャッシュの利用判定のために毎回 SQL を叩いてしまうのは勿体無い気もしますが、上記のクエリは Reports テーブルに付与してあるインデックスが効くため低コストで実行できます。これでデータの更新を検知してキャッシュを更新できるようになりました。

[Tips] ちょっとした安全装置

上述のコードではキャッシュの有効期限(expires_in)を一時間とハードコーディングしていますが、こちらを環境変数にしておく手もあります。本番環境で何かしら不具合が発生してキャッシュ周りが怪しい場合、環境変数を書き換えて expires_in = 0 にしてしまえば実質的にキャッシュを無効化できるためです。

おわりに

以下はキャッシュを導入した API の NewRelic APM によるモニタリング結果なのですが、キャッシュ導入によりざっくり 2〜3 倍のレスポンスタイムの改善を確認することができました。フロントエンドから API ポーリングをしている関係上、ユーザーが一覧画面を開いたまま PC を放置すると、重い SQL が逐一実行されて RDB に負荷をかけてしまい、しばしばフェイルオーバーが発生する事態が以前はあったのですが、キャッシュ導入以後は RDB 負荷も安定するようになりました。可用性向上の観点でもキャッシュ導入は有効だったと言えるかと思います。


以上、一覧画面にキャッシュを導入する上で考えたことについて書いてきました。本記事が少しでも皆様のお役に立てば幸いです!

SimpleForm Tech Blog

Discussion