読者です 読者をやめる 読者になる 読者になる

Rails のクエリキャッシュの仕組みを調べた

rails ruby code reading

はじめに

Rails のログファイルに CACHE (0.0ms) という行が出力されることがある。

CACHE (0.0ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = '1321459' LIMIT 1  [["id", "1321459"]]

ここから以下のことがわかる。

  1. このリクエストでこのクエリが発行されるのは2回目またそれ以上である
  2. 結果をキャッシュから返したのでDBには問い合わせていない

クエリキャッシュ

この仕組みはクエリキャッシュというもので、Rails 2.0 で導入された(らしい。よく知らない)。

Rails ガイドだと Caching with Rails: An overview — Ruby on Rails Guides に記載されている。

1.5 SQL Caching Query caching is a Rails feature that caches the result set returned by each query so that if Rails encounters the same query again for that request, it will use the cached result set as opposed to running the query against the database again.

  • Rails がクエリの結果をキャッシュする
  • リクエスト処理中に同じクエリが来たらDBに問い合わせずにキャッシュから結果を返す

疑問

ここで以下の疑問が出てくる。

  • ログは誰が出しているのか
  • どうやってキャッシュしてるのか

コードを読んでこの疑問を明らかにしてみたい。 Rails 4.2.1 のコードを読みます。

ログは誰が出しているのか

これはコードを検索すればすぐにわかって、ActiveRecord::ConnectionAdapters::QueryCache というモジュールの cache_sql というメソッドで出している。

 76       def cache_sql(sql, binds)
 77         result =
 78           if @query_cache[sql].key?(binds)
 79             ActiveSupport::Notifications.instrument("sql.active_record",
 80               :sql => sql, :binds => binds, :name => "CACHE", :connection_id => object_id)
 81             @query_cache[sql][binds]
 82           else
 83             @query_cache[sql][binds] = yield
 84           end
 85         result.dup
 86       end

「出している」というか ActiveSupport::Notifications を使ってるので「通知している」としたほうが正確だ。ここで通知しているのは sql.active_record というイベントで、これを ActiveRecord::LogSubscriber が subscribe してログファイルに出力している。

これで疑問1が解決した。次。

どうやってキャッシュしてるのか

これも同じく cache_sql メソッドでキャッシュしているのがわかるが、もうちょっと深追いしてみたい。処理が cache_sql にたどり着くまでの流れを追ってみる。

まず、cache_sql は同じモジュール内の select_all から呼ばれているのがすぐにわかる。

 64       def select_all(arel, name = nil, binds = [])
 65         if @query_cache_enabled && !locked?(arel)
 66           arel, binds = binds_from_relation arel, binds
 67           sql = to_sql(arel, binds)
 68           cache_sql(sql, binds) { super(sql, name, binds) }
 69         else
 70           super
 71         end
 72       end

このメソッドは何をしているのだろうか?

  • 条件を満たしていれば cache_sql を呼ぶ
  • super を呼ぶ

cache_sql のほうはいま見たばかりだからよい。super のほうを追いたい。そのためにはこのモジュールと、それを include しているクラスも含めた継承関係を把握する必要がありそうだ。

継承関係

QueryCache モジュールを include しているクラスは ActiveRecord::ConnectionAdapters::AbstractAdapter なので、これを中心に読むと以下の継承関係が見えてくる。

f:id:takatoshiono:20150620002752p:plain

モジュールたちのルールはちょっとわかりにくいので書いておく。

  • include されたモジュールは、それをincludeしたクラスとその親クラスの間に差し込まれる
  • あとでincludeした方が先に呼ばれる
  • 1行で複数のモジュールをincludeしたときは先に書いた方が先に呼ばれる

ちなみに実際にデバッガで ancestors を確認したらこうなっていた。

[1] pry(#<ActiveRecord::ConnectionAdapters::Mysql2Adapter>)> self.class.ancestors
=> [ActiveRecord::ConnectionAdapters::Mysql2Adapter,
 ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter,
 ActiveRecord::ConnectionAdapters::Savepoints,
 ActiveRecord::ConnectionAdapters::AbstractAdapter,
 Octopus::AbstractAdapter::OctopusShard,
 ActiveRecord::ConnectionAdapters::ColumnDumper,
 MonitorMixin,
 ActiveSupport::Callbacks,
 ActiveRecord::ConnectionAdapters::QueryCache,
 ActiveRecord::ConnectionAdapters::DatabaseLimits,
 ActiveRecord::ConnectionAdapters::Quoting,
 ActiveRecord::ConnectionAdapters::DatabaseStatements,
 ActiveRecord::ConnectionAdapters::SchemaStatements,
 ActiveRecord::ConnectionAdapters::TimestampDefaultDeprecation,
 ActiveRecord::Migration::JoinTable,
 Object,
 Tapp::ObjectExtension,
 PP::ObjectMixin,
 ActiveSupport::Dependencies::Loadable,
 JSON::Ext::Generator::GeneratorMethods::Object,
 Kernel,
 BasicObject]

select_all

クラスの継承関係における QueryCache の位置がわかったので、QueryCache#select_all について見ていきたい。

まず select_all はなにかというとSQLを実行して結果を返すメソッドだ。

これは DatabaseStatements モジュール に定義されている。戻り値として ActiveRecord::Resultインスタンスを返すので、キャッシュされているのはそれだというのがわかった。

また select_all が呼ぶメソッドをたどっていくとこのようになっていた。

  • select_all (DatabaseStatements)
  • select (DatabaseStatements)
  • exec_query (DatabaseStatements の実装は空、Mysql2Adapterに実装)
  • execute (mysql2)

納得。

また select_all はActiveRecord::Base.connection から直接呼べるほかに、ActiveRecord::Querying#find_by_sql などから呼ばれている。きっと SELECT クエリはここを必ず通るんだろう。

まとめ

ちょっとした疑問を深追いしてみました。Rails のコードを追ってみるといろいろ知ることができて楽しいですし、知識が深まるのでオススメです。

パーフェクトRuby (PERFECT SERIES 6)

パーフェクトRuby (PERFECT SERIES 6)