N+1クエリの検出ツールとして有名なBulletのコードリーディングをしました。バージョンは5.9.0です。

コードリーディングの過程で、False Negative, False Positiveなケースを発見したのでそれも併せて紹介します。

※この記事はRuby on Rails Advent Calendar 2018の19日目の記事です。

処理の概要

  • BulletのRackミドルウェアを差し込む
  • 使っているORMに対してモジュールをprependやらextendやらで拡張。
    • これによってORMのメソッド呼び出しをフックしてBulletの処理をすることができる
  • モジュールのメソッドでN+1クエリの集計をして、Rackミドルウェアを使って結果を表示する

使い方ざっくり

config/environments/development.rb とかに以下のような記述を入れればOK

RailsだとRailtieで自動的にRackミドルウェアが読み込まれます

コードリーディング

Railsのコントローラで以下のようなN+1クエリを検知したときのパターンを追っていきます。

lib/bullet.rbでRails::Railtieが定義されている場合はBullet::BulletRailtieが定義されます。

BulletRailtieはイニシャライザでBullet::RackのRackミドルウェアをアプリケーションのミドルウェアに差し込みます。

Bullet::Rackは以下のように定義されています。

Bullet.enable?がtrueの場合はBullet.start_requestした上でapp.callします。Bullet.notification?がtrueの場合にレスポンスBodyにBulletの通知を入れてRackレスポンスを返します。

Bullet.start_requestはThread.currentに値を設定しています。Bullet::RackのensureでBullet.end_requestを呼び出していますが、このメソッドで設定したThread.currentの値をnilでリセットしています。

lib/bullet.rbの読み込みで、::ActiveRecordが定義されている場合はActiveRecordのautoloadが設定されます。

configで書いた Bullet.enable = trueは以下のようなセッターメソッドになっています。

ActiveRecordを使っている場合はBullet::ActiveRecord.enableが呼ばれます。ActiveRecordのバージョンによってパッチの当て方が変わるため、バージョンによって読み込むファイルが変わります。以下、ActiveRecordのバージョン5.2のパターンを追っていきます。

Bullet::ActiveRecord.enableは以下のように定義されており、ActiveRecord::BaseやActiveRecord::Relationに対してprependやextendでパッチを当てまくってます。

まず User.all とクエリメソッドが呼ばれると .records => .find_by_sql が呼ばれます。

User.allが複数件の場合はresult.size > 1なのでadd_possible_objectsでpossible_objectsとしてマーキングされます。

キーは"#{モデル名}:#{主キーの値}"でセットされます。Userでid: 1なら”User:1″ という感じです。

user.posts.to_aのアソシエーションのメソッド呼び出しではfind_by_sqlload_targetが呼ばれます。load_targetではBullet::Detector::NPlusOneQuery.call_associationが呼ばれます。

conditions_met?がtrueの場合 create_notificationで通知が生成されます。

conditions_met?は以下のように定義されています。

User.allで各ユーザレコードはpossible_objectsとしてマーキングされていて、かつ impossible_objectsとしてマーキングされていないためconditions_met?はtrueになります。

create_notificationはBullet.notification_collector(Thread.currentに設定されているBullet::NotificationCollectorオブジェクト)のaddメソッドで通知としてセットされます。

あとはここでセットされた通知がBullet::RackでHTMLとしてセットされることでポップアップなどが表示されることになります。

BulletのFalse Positive, False Negative

impossible, possibleで判別しているためいくつかのケースで誤検知が発生します。

False Positiveの例だと以下のようなケースで誤検知されます。

これはUser.all.to_aが複数件のときにpossible_objects扱いになってしまうため、Array#firstで絞り込んだとしてもconditions_met?がtrueになってしまうことに起因します。もちろんこんなSQL的にも非効率でメモリも食いそうなコードは書かないと思いますがw

False Negativeの例だと以下のようなケースで誤検知されます。

これはUser.find(XX)でimpossible_objects扱いになってしまうため、conditions_met?がfalseになってしまうことに起因します。

いずれの例も、こんな書き方はしないと思うので問題ないと思いますが、万能ではないことは覚えておいて良いかもしれません。

ということで、N+1クエリを回避する一番良い方法はRailsのログをちゃんと読むことだなーと改めて思いました…w