メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

ActiveRecord クエリキャッシュのメモリ使用量と無効化

こんにちは。サーバーエンジニアの佐藤太一(@teach_kaiju)です。
本記事では、クエリキャッシュのメモリ使用量と有効/無効の切り替え方法について紹介します。

クエリキャッシュとは

Active Recordのクエリキャッシュは、1つのリクエストまたはジョブの実行中に同じSQLクエリが複数回実行された場合、2回目以降のクエリの実行を省略し、最初の結果をメモリ上にキャッシュして再利用する機能です。

# 1回目のクエリ実行時
Book.first
# Book Load (2.9ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1

# 2回目のクエリ実行時
Book.first
# CACHE Book Load (0.1ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1

※ 本記事で用いるモデル名(Book)は執筆にあたって差し替えたものであり、実際に使用したモデル名とは異なります。

キャッシュが使用される場合には発行されたクエリのログの先頭にCACHEと付いています。

2024/11 時点で、Sidekiq および SolidQueue ではクエリキャッシュがデフォルトで有効になっていること、 Rails コンソールでは無効になっていることを確認しました。

クエリキャッシュのメモリ消費量

クエリキャッシュはその性質上、大量のレコードを扱うジョブではメモリ使用量が膨大になりえます。

では、クエリキャッシュで実際どの程度メモリが圧迫されるのでしょうか? memory_profiler を用いて計測しました。 ※ モデル名は実際のものから差し替えています。

計測用コード

# 渡されたブロック内のメモリ消費および時間を出力
def report(&block)
  start_time = Time.current
  result = MemoryProfiler.report(&block)
  elapsed_time = Time.current - start_time

  puts "\n\n===== Profiler Report ====="
  puts "Total allocated: #{bytes_to_mb(result.total_allocated_memsize)} MB (#{result.total_allocated} objects)"
  puts "Total retained: #{bytes_to_mb(result.total_retained_memsize)} MB (#{result.total_retained} objects)"
  puts "Elapsed time: #{elapsed_time.round(2)} sec"
  puts "Query cache: #{Book.connection.query_cache.size} queries"
end

# bytes to MB 小数点第二位まで
def bytes_to_mb(bytes)
  (bytes / 1024.0 / 1024.0).round(2)
end

結果

データ数: 50 万
取得カラム: 数値と日付、合計 6 つ
実装: batch_size 1 万で上記のデータを取得する
※ find_each 等クエリキャッシュをスキップするメソッドは使用しません(後述)

クエリキャッシュ無効 クエリキャッシュ有効
Total allocated 281.03 MB (3510676 objects) 272.02 MB (3512618 objects)
Total retained 3.83 MB (71 objects) 99.24 MB (1500823 objects)
Query cache: 56 queries
Elapsed time 17.46 sec 20.83 sec

考察

allocated の差は誤差です。クエリキャッシュの有効/無効でアロケーション数はそんなに変わらないでしょう。
retained (使用中のメモリ) はキャッシュ分大幅に増加しています。

状況によって大きく差が出るため参考程度ですが、 50 万のデータでおよそ 100MB 程度のメモリを確保することがわかりました。
時間は誤差かもしれませんが、クエリキャッシュが無効なほうが少し高速なようです。

もし batch_size が 1 万ではなく 1000 であれば実行するクエリの数は 500 を超えます。Rails 7.1 以上であればクエリキャッシュの数の制限 (default 100) を超えるため、その分 retained は大幅に減少するでしょう。

find_each 等ではクエリキャッシュが無効になる

find_eachfind_in_batchesそしてin_batchesではクエリキャッシュが無効になります。

def batch_on_unloaded_relation(relation:, start:, finish:, load:, cursor:, order:, use_ranges:, remaining:, batch_limit:)
...
  relation.skip_query_cache! # Retaining the results in the query cache would undermine the point of batching

batches.rb

したがって、バッチ処理で上記メソッドを使用する分にはクエリキャッシュを気にする必要はあまりありません。 最適化のために上記メソッドを使わずにバッチ処理を行うときに気をつける必要があります

クエリキャッシュを無効化する方法

ActiveRecordのモデル.uncachedを使うのがおすすめです。ドキュメント

Model.uncached do
  # この中ではクエリキャッシュが無効になる
end

ActiveRecordのモデル.uncachedを使うと、リードレプリカ等の別DBを参照した場合でもクエリキャッシュを無効化することができます。

切り替え検証

※ Rails コンソールではキャッシュがデフォルト無効なため、無効 -> 有効の切り替えを行なっています

# 通常 (Rails コンソールのためキャッシュがデフォルト無効)
Book.first
Book.first
#  Book Load (2.2ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1
#  Book Load (0.4ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1

# キャッシュを有効化
ActiveRecord::Base.cache do
  Book.first
  Book.first
end
#  Book Load (2.9ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1
#  CACHE Book Load (0.1ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1

# 別DBに接続 (キャッシュが有効にならない)
ApplicationRecord.connected_to(role: :primary_replica) do
  ActiveRecord::Base.cache do
    Book.first
    Book.first
  end
end
#  Book Load (3.9ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1
#  Book Load (1.9ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1

# 別DBでキャッシュを有効化する
ApplicationRecord.connected_to(role: :primary_replica) do
  Book.cache do
    Book.first
    Book.first
  end
end
#  Book Load (2.8ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1
#  CACHE Book Load (0.2ms)  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1

本検証ではログにCACHEと付くかどうかを見ていますが、以下でも確認可能です

Book.connection.query_cache_enabled

おわりに

本記事では ActiveRecord のクエリキャッシュについて紹介しました。メモリ使用量が気になる方は、ぜひクエリキャッシュの無効化を検討してみてください。その際に本記事の内容が参考になれば幸いです。

参考文献

Sidekiq: Problems and Troubleshooting

ShakaCode: Rails 7.1 makes ActiveRecord query cache an LRU


是非読者になってください!


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら medpeer.co.jp

■エンジニア紹介ページはこちら engineer.medpeer.co.jp

■メドピア公式YouTube  www.youtube.com

■メドピア公式note
style.medpeer.co.jp