Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

複雑なデータモデルを高速にキャッシュする方法 — Rails APIのレスポンス改善テクニック

データモデルが複雑になってくると、APIのレスポンスタイムが悲しいことに1秒近くかかるようになることがあります。そんなときの手軽な解決策が :includes です。モデルのアソシエーションを事前に読み込んでおけば(プリロード)、SQLの発行回数を大幅に減らせます。これだけでかなりの時間を節約できます。

しかし、サイトが再び遅くなり、今度はレスポンスのキャッシュを考え始めます。すると新たな問題が発生します。キャッシュからレスポンスを取得したい場合:

results = {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}
cached_objects = Rails.cache.fetch_multi(results.keys) do |key|
  Lawyer.find(results[key]).as_json
end

このコードでは、せっかくの :includes が使えなくなってしまいます。両立させることはできるのでしょうか?キャッシュされたオブジェクトへの高速なレスポンスを実現しつつ、キャッシュに存在しないオブジェクトも素早く読み込むには、どうすればいいのでしょう?

やるべきことが多いと考えるのが大変ですが、問題を小さなピースに分解して、シンプルな「次のステップ」を見つければ格段に取り組みやすくなります。

では、最初に何をすべきでしょうか?何をするにも、まずどのオブジェクトがキャッシュにあり、どれをまだ検索する必要があるのかを把握しなければなりません。

キャッシュ済みと未キャッシュを分離する

たとえば、次のようなキャッシュキーのリストがあるとします:

cache_keys = [:key_1, :key_2, :key_3]

このうち、どれがキャッシュに存在するのか、どうやって判別すればいいのでしょうか?

ActiveSupport::Cache には read_multi という便利なメソッドがあります

# lawyer_1だけがキャッシュされている場合

cache_keys = [:lawyer_1, :lawyer_2, :lawyer_3]
Rails.cache.read_multi(cache_keys) # => {:lawyer_1 => {"id": 1, "name": "Bob the Lawyer"} }

read_multi は、キャッシュ内で見つかった各キーについて {key: value} 形式のハッシュを返します。では、キャッシュに存在しないキーはどうやって見つけるのでしょうか?素直なやり方としては、すべてのキャッシュキーをループし、read_multi が返したハッシュに含まれていないものを特定します

cache_keys = [:lawyer_1, :lawyer_2, :lawyer_3]
uncached_keys = []

cached_keys_with_values = Rails.cache.read_multi(cache_keys)

cache_keys.each do |key|
  uncached_keys << key unless cached_keys_with_values.has_key?(key)
end

ここまでで、何が手に入ったでしょうか?

  • オブジェクトを取得したいすべてのキャッシュキーの配列
  • キャッシュで見つかった各オブジェクトの {key: value} ペアのハッシュ
  • キャッシュになかったキーのリスト

そして、次に必要なのは?

  • キャッシュになかったキーに対応する。できれば一括で取得したいところです。

それが次のステップです。

未キャッシュの値をプリロードする

まもなく、キャッシュキーをもとにオブジェクトを検索することになります。作業を簡単にするために、コードを次のように変更しましょう:

cache_identifiers = {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}
cache_keys = cache_identifiers.keys
uncached_keys = []

cached_keys_with_values = Rails.cache.read_multi(cache_keys)

cache_keys.each do |key|
  uncached_keys << key unless cached_keys_with_values.has_key?(key)
end

こうすることで、cache_identifiers がキャッシュキーと取得対象のオブジェクトIDの両方を管理してくれるようになります。

さて、未キャッシュのキーは:

uncached_keys # => [:lawyer_2, :lawyer_3]

そして cache_identifiers ハッシュは:

cache_identifiers # => {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}

これで、該当するオブジェクトを一括で取得・プリロード・シリアライズできます:

uncached_ids = uncached_keys.map { |key| cache_identifiers[key] }
uncached_lawyers = Lawyer.where(id: uncached_ids)
                         .includes([:address, :practice_areas, :awards, ...])
                         .map(&:as_json)

ここまでで、何が揃ったでしょうか?

  • 最初にオブジェクトを取得したかったすべてのキャッシュキーの配列
  • キャッシュで見つかった各オブジェクトの {key: value} ペアのハッシュ
  • キャッシュになかったキーのリスト
  • キャッシュで見つからなかったすべての値

そして、次に必要なのは?

  • 今取得したばかりの値をキャッシュに保存すること。そうすれば次回からこの一連の処理を繰り返さずに済みます。
  • キャッシュ由来かどうかにかかわらず、すべてのオブジェクトを含む最終的なリスト。

未キャッシュの値をキャッシュに書き込む

現在、「未キャッシュのキーのリスト」と「未キャッシュの値のリスト」という2つのリストがあります。しかしキャッシュに保存するなら、[key, value] のペアを1つのリストにまとめて、value を対応する key のすぐ隣に置けたほうが扱いやすいでしょう。これは私のお気に入りのメソッドのひとつ、zip を使う絶好の機会です:

[1, 2, 3].zip(["a", "b", "c"]) # => [[1, "a"], [2, "b"], [3, "c"]]

zip を使えば、取得した値を簡単にキャッシュできます:

uncached_keys.zip(uncached_lawyers).each do |key, value|
  Rails.cache.write(key, value)
end

ここまでで、何が揃ったでしょうか?

  • 最初にオブジェクトを取得したかったすべてのキャッシュキーの配列
  • キャッシュで見つかった各オブジェクトの {key: value} ペアのハッシュ
  • 直前にキャッシュへ書き込んだ、元々未キャッシュだった値のリスト

そして、まだ必要なのは?

  • キャッシュ由来かどうかにかかわらず、すべてのオブジェクトを含む1つの大きなリスト。

すべてを組み合わせる

まず、順序を保ったキャッシュキーのリストがあります:

cache_keys = cache_identifiers.keys

キャッシュから取得したオブジェクトのリスト:

cached_keys_with_values = Rails.cache.read_multi(cache_keys)

そして、先ほどデータベースから取得したオブジェクトのリスト:

uncached_ids = uncached_keys.map { |key| cache_identifiers[key] }
uncached_lawyers = Lawyer.where(id: uncached_ids)
                         .includes([:address, :practice_areas, :awards, ...])
                         .map(&:as_json)

あとは最後のループをひとつ回して、すべてを組み合わせるだけです:

results = []
cache_keys.each do |key|
  results << cached_keys_with_values[key] || uncached_lawyers.shift
end

つまり、各キャッシュキーについて、そのキーに対応するオブジェクトがキャッシュにあればそれを取り出し、なければデータベースから取得した次のオブジェクトを取り出します。

これで完成です!

全体のコードは以下のようになります:

cache_identifiers = {lawyer_1: 1, lawyer_2: 2, lawyer_3: 3}
cache_keys = cache_identifiers.keys
uncached_keys = []

# キャッシュからキャッシュ済みの値を取得
cached_keys_with_values = Rails.cache.read_multi(cache_keys)

# キャッシュになかったキーのリストを作成
cache_keys.each do |key|
  uncached_keys << key unless cached_keys_with_values.has_key?(key)
end

# 未キャッシュの値を一括で取得
uncached_ids = uncached_keys.map { |key| cache_identifiers[key] }
uncached_lawyers = Lawyer.where(id: uncached_ids)
                         .includes([:address, :practice_areas, :awards, ...])
                         .map(&:as_json)

# 未キャッシュだった値をキャッシュに書き戻す
uncached_keys.zip(uncached_lawyers).each do |key, value|
  Rails.cache.write(key, value)
end

# キャッシュ済み・未キャッシュの値から最終的な結果セットを作成
results = []
cache_keys.each do |key|
  results << cached_keys_with_values[key] || uncached_lawyers.shift
end
results

その価値はあるのでしょうか?場合によります。コード量はかなり増えます。しかし、多数のアソシエーションを持つオブジェクトをキャッシュしているなら、数十〜数百回のSQL呼び出しを節約できる可能性があります。それにより、APIレスポンスの時間を大幅に短縮できます。

Avvoでは、このパターンが非常に役立っています。私たちのJSON APIの多くがこのパターンを採用し、キャッシュされたレスポンスを驚くほど高速に返しています。

このパターンがあまりに便利だったため、私は bulk_cache_fetcher というgemを作ってカプセル化しました。巨大で複雑なデータモデルをキャッシュしようとしているなら、ぜひ試してみてください!

  1. Windows 10のChromeでCookieとキャッシュを最速で削除する2つの方法

    Webサイトにアクセスすると、サイトの設定やプロフィール情報といった閲覧情報を保存するためにCookieが作成されます。また、ブラウザのキャッシュにはWebページ、画像、CSS、音声、動画などのダウンロード済みコンテンツが保存され、ページの読み込みを高速化して快適な閲覧をサポートしてくれます。 しかし、すべてのCookieが有益とは限りません。こうしたデータをハードディスク上に溜め込んでおくと、プライバシー漏洩のリスクにつながったり、ディスク容量を圧迫してPCの動作を遅くしたりする原因になります。 そのため、ブラウザのCookieとキャッシュは定期的に削除することが重要です。この記事では、Wi

  2. ステガノグラフィとは?画像や動画に潜むマルウェアの脅威と検出・対策方法

    ゼロデイ脅威や悪名高いエクスプロイト、新型コロナウイルスへの対策に追われる私たちの裏で、ハッカーたちは新たな手法を使ってマルウェアを拡散し続けています。その最新の武器となっているのが、古代から存在し1499年に紹介された「ステガノグラフィ(隠蔽書法)」という概念です。ギリシャ語の「steganos(隠された)」と「graphy(書くこと)」を組み合わせた言葉に由来するこの手法は、データを読み取れない形で隠して送信するもので、今や危険な新トレンドとなりつつあります。本記事では、この新たな脅威の実態と、身を守るための方法について詳しく解説します。ステガノグラフィとは?前述のとおり、ステガノグラフィ