Open4

Rails の低レベルキャッシュ

naon708naon708

Railsでクエリの結果などをキャッシュしたい時に使用するのが低レベルキャッシュ
Rails.cache で低レベルキャッシュに関する情報にアクセスできる。

# fetch メソッドはキャッシュの取得と保存を行う
# アクセス時にキャッシュストアの中を見て、「key_name」に対応する値を返す。値がなければブロックの返り値を「key_name」として保存する。
# オプションで有効期限を設定できる
Rails.cache.fetch('key_name', expired_in: 60.minutes) do
  # キャッシュに保存したい値
end

# updated_at など最新の情報をキーに含めれば、キャッシュされている値が古いかどうか検証できる
product_cache_key = "price-#{id}-#{updated_at.strftime("%Y%m%d")}"
Rails.cache.fetch(product_cache_key) do
  price
end

# その他、キャッシュを操作するメソッド
Rails.cache.delete
Rails.cache.clear
Rails.cache.exist?
Rails.cache.write
Rails.cache.read
naon708naon708

キャッシュの保存先設定

各環境の設定ファイルに記載する。

config/environments/*.rb
# Redis を使う場合
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }

環境ごとに指定する必要が無い場合は config/application.rb でも良い。

Railsにおける保存先一覧

  • メモリストア
  • ファイルストア
  • Memcached
  • Redis

https://railsguides.jp/v7.2/caching_with_rails.html#キャッシュストア

naon708naon708

キャッシュスタンピードについて

キャッシュスタンピード (Cache Stampede) とは、大量アクセスがあるサービス等においてキャッシュが破棄されたタイミングでDBへ大量の問い合わせが発生してしまう現象のこと。

パフォーマンスに大きく影響する。

改善策としては

  • 期限が切れる前に、事前に別プロセス(バッチ処理など)で新しいキャッシュを作成しておく
  • 期限が切れる前に、該当する有効期限が近いデータをDBから再取得して期限を伸ばす
  • キャッシュストアの分散ロックを用いてAPIアクセスを絞る

参考

https://techblog.zozo.com/entry/zozotown-cache-stampede

分散ロックを用いた制御とは

分散ロックとは、不必要な複数回アクセスを防いだりやデータの整合性を担保するため特定のリソースへのアクセスを制限すること。

今回の例だとキャッシュの有効期限が切れたタイミングでDBへのアクセスが増加してしまう問題があり、それを対策するための解決策のひとつ。

  • リクエストが来る
    • キャッシュを探し、有効なデータが存在すればそれを返す。
    • キャッシュを探し、有効なデータが存在しなければDBからデータを再取得しキャッシュを生成する。

このときに、DBからデータを再取得する際にロックを利用する。

  • キャッシュが無いことを認識 → Redis に対してロック取得を試みる
    • ロックの取得成功
      • DBからデータを取得 → キャッシュに書き込む → ロックを解除 → データを返却
    • ロックの取得失敗
      • 他プロセスがキャッシュを更新中と判断する → 少し待つ → キャッシュを再確認 → キャッシュがあればデータを返却

DBからデータを取得するのを1プロセスに絞ることができ、負荷が軽減される

GPTが生成した実装サンプル(Ruby)
def fetch_product_data(product_id)
  cache_key = "product:#{product_id}"
  lock_key  = "lock:product:#{product_id}"
  
  # 1. キャッシュ確認
  cached_data = Rails.cache.read(cache_key)
  return cached_data if cached_data

  # 2. キャッシュが無いのでロック取得を試みる(SETNX のような動作)
  lock_acquired = Redis.current.setnx(lock_key, Time.now.to_i)
  
  if lock_acquired
    # ロック取得に成功したので、ロックの有効期限も設定しておく(例:10秒)
    Redis.current.expire(lock_key, 10)
    
    # 3. バックエンドから最新データを取得
    data = fetch_from_database(product_id)
    
    # 4. キャッシュ更新
    Rails.cache.write(cache_key, data, expires_in: 5.minutes)
    
    # 5. ロック解除(ロックキー削除)
    Redis.current.del(lock_key)
    
    return data
  else
    # ロックが取れなかった場合:他のプロセスが更新中なので、少し待って再度キャッシュを確認
    sleep(0.2)  # 200ミリ秒待機
    return Rails.cache.read(cache_key) || fetch_from_database(product_id)
  end
end

補足

ロックの取得後に何らかの問題が発生した場合ロックが残り続けてしまうので、ロックする際には期限をつける

naon708naon708

用語理解

  • 低レベルキャッシュ:特定の値やクエリ結果を保存するためのキャッシュ。
  • キャッシュヒット:サーバーがキャッシュを使用して問合せに答えを返すことができ、データベースにまったくアクセスしなかったことを表す。
  • キャッシュヒット率:リクエストされたオブジェクトがキャッシュから提供される割合。
  • キャッシュミス:要求されたデータがキャッシュに存在しなく、DBにアクセスしなければならない状態のこと。
  • キャッシュ・スタンピード (Cache Stampede):キャッシュの有効期限が切れたときに、複数リクエストが同時にデータベースにアクセスし、サーバーに負荷がかかる現象。
  • ロック(排他制御):特定のリソースに対するアクセスを制御すること。データの整合性を保つためにデータの読み書きを制限する。
  • 分散ロック:分散システムにおいてロックを用いてアクセスを制御すること。
    • 目的
      • データの整合性を担保するため。
      • 同じ作業を不必要に複数回行わないため。

参考記事

https://morizyun.github.io/ruby/rails-function-cache-fetch-write-delete.html#Rails-cache-fetch
https://techblog.zozo.com/entry/zozotown-cache-stampede
https://christina04.hatenablog.com/entry/redis-distributed-locking