📌

bullet(Gem)はどのようにN+1問題を検知しているのか?について調査してみた

2023/06/25に公開

はじめに

今回は、Railsを使用しているプロジェクトのパフォーマンスチューニングを行い際によく活用される以下の「bullet」というGemのソースコードを実際に読み、どのようなロジックでN+1問題を検知しているのか?について調査してみようと思います。
https://github.com/flyerhzm/bullet

そもそもbulletのGemとは?

お馴染みの N+1問題counter_cacheを使用する必要がある状態でありながら、counter_cacheが使用されていない問題 を検知するためのGemになります。

READMEの一部を引用(https://github.com/flyerhzm/bullet)

The Bullet gem is designed to help you increase your application's performance by reducing the number of queries it makes. It will watch your queries while you develop your application and notify you when you should add eager loading (N+1 queries), when you're using eager loading that isn't necessary and when you should use counter cache.

今回の本題であるN+1問題についてはとても有名である為、本記事に目を通して頂いた方であれば既にご存知か思いますが、 とcounter_cache についてはもしかしたら初耳という方もいらっしゃるかと思いましたので、一応概要だけ紹介させて頂きますと以下になります。

counter_cacheとは?

RailsのActiveRecordでRDB関連の設定の1つです。
counter_cacheを設定することにより、親子関係のテーブルにおいて、親テーブルが子テーブルの
件数をキャッシュすることができるようになります。

counter_cacheの詳細についてもっと知りたい方は以下の記事などを参考にしてみてください。
https://spirits.appirits.com/doruby/10410/#:~:text=counter_cacheとは、Railsの,できるようになります。

解説

bulletには、N+1問題とcounter_cache不使用を検知する機能がありますが、今回はN+1問題の検知の方に絞って実際のコードを読み解き、その中でも特に重要だと思われる箇所に絞って解説をさせて頂きたいと思います。
実際にソースコードを確認してみると、lib/bullet/detector/n_plus_one_query.rbというファイルがN+1問題を検知するロジックと深く関係していることが分かりました。

備考
※今回はあくまで個人の趣味で調査した内容となっている為、あくまで全体のイメージを掴む感覚として捉えて頂けましたら幸いです。

では実際にN+1を検知の役割を担っているコード部分のご紹介です。
lib/bullet/detector/n_plus_one_query.rb

# frozen_string_literal: true

module Bullet
  module Detector
    class NPlusOneQuery < Association
      extend Dependency
      extend StackTraceFilter

      class << self
        # executed when object.associations is called.
        # first, it keeps this method call for object.association.
        # then, it checks if this associations call is unpreload.
        #   if it is, keeps this unpreload associations and caller.
        def call_association(object, associations)
	    # bulletが有効化されていない場合はスキップ
          return unless Bullet.start?
	  # N+1問題の検知が無効化されている場合はスキップ
          return unless Bullet.n_plus_one_query_enable?
	  # objectの主キーがnilの場合はスキップ
          return unless object.bullet_primary_key_value
	  # objectのリレーション先が存在しない場合はスキップ
          return if inversed_objects.include?(object.bullet_key, associations)
          # objectとの関連性を記録
          add_call_object_associations(object, associations)

          # 以下の内容でデバッグ情報を出力
          Bullet.debug(
            'Detector::NPlusOneQuery#call_association',
	    # object.bullet_keyには、"#{モデル名}:#{主キー}"の値が入 る
            "object: #{object.bullet_key}, associations: #{associations}"
          )
	  # excluded_stacktrace_path?がfalseかつconditions_met?(object, associations)がtrueを返す場合は、N+1が検出されたことを示す通知処理を呼び出す
          if !excluded_stacktrace_path? && conditions_met?(object, associations)
            Bullet.debug('detect n + 1 query', "object: #{object.bullet_key}, associations: #{associations}")
            create_notification caller_in_project(object.bullet_key), object.class.to_s, associations
          end
        end
	
	# object.associationsが既に記録されているかを判定
        def conditions_met?(object, associations)
	  # メソッドの返り値は以下がそれぞれ期待するフラグ
	  #  possible? => true, impossible? => false, association? => false
          possible?(object) && !impossible?(object) && !association?(object, associations)
        end

        def possible?(object)
	    # possible_objectsには外部メソッドでActiveRecordの処理の結果が複数の場合の主キーリストが格納されている
          possible_objects.include? object.bullet_key
        end

        def impossible?(object)
	  # impossible_objectsには外部メソッドでActiveRecordの処理の結果が単体の場合の主キーリストが格納されている
          impossible_objects.include? object.bullet_key
        endi
	
        # objectとassociationsの関連情報が記録されているかどうかを判定
	# 既に関連情報が記録されている場合はtrueを返し、そうでない場合はfalseを返す
        def association?(object, associations)
	  # object_associationsに入る値としては、最終的には、ActiveRecord::Associations::Preloader配下のClassから、objectの関連オブジェクトの中でキャッシュされているオブジェクトの主キー一覧が配列として入っている
	  # https://www.rubydoc.info/docs/rails/ActiveRecord/Associations/Preloader
          value = object_associations[object.bullet_key]
          value&.each do |v|
            # associations == v comparison order is important here because
            # v variable might be a squeel node where :== method is redefined,
            # so it does not compare values at all and return unexpected results
            result = v.is_a?(Hash) ? v.key?(associations) : associations == v
            return true if result
          end

          false
        end

	private
        ・・・
	# N+1が検知されたことを通知するための処理
        def create_notification(callers, klazz, associations)
          notify_associations = Array.wrap(associations) - Bullet.get_safelist_associations(:n_plus_one_query, klazz)
          if notify_associations.present?
            notice = Bullet::Notification::NPlusOneQuery.new(callers, klazz, notify_associations)
            Bullet.notification_collector.add(notice)
          end
        end
      end
    end
  end
end

色々と書いてしまい、ややこしくなってしまいましたが、bulletでN+1問題を検知しているロジックとしては、以下になるのではと思いました。

調査結果

「N+1問題を検知する設定が行われている状態で、ActiveRecord::Associations::Preloader配下のClassを使用し、関連先のobjectの情報がキャッシュされているかどうかを確認している。」

おわりに

この先を把握するには、RailsのリポジトリからActiveRecord::Associations::Preloaderなどの中身を解読する必要がありそうですが、その辺りはまたの機会とさせて頂きたいと思います。
本記事を最後まで拝読頂きましてありがとうございました。

Discussion