📖

N+1を検知するBulletと局所的スキップの手法について

2022/12/03に公開

はじめに

Bulletの導入HOW TO記事は他にもたくさんありますので、あくまでこの記事は「らくしふ」における段階的導入の紹介になります。

導入モチベーション

N+1は気をつけていても人間ですし、どうしても漏れてしまうことがあります。完璧な人間なんていません。レビューでも見逃してしまうこともあるでしょう。「なんで漏れるんだ!気合いで頑張ろう!」よりも、仕組みで解決する方が賢明です。そこで、仕組みで検知できるよう「らくしふ」でもBulletの導入をしました。
https://github.com/flyerhzm/bullet

ついでに、Unnecessary Eager Loadsも検出してくれるのはありがたいです。

設定

初期設定周りに関しては省略しますが、「らくしふ」では以下のように定義しています。

config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.bullet_logger = true
  # ここがポイント↓↓将来的にはtrueを設定したい
  Bullet.raise = ActiveModel::Type::Boolean.new.cast(ENV.fetch('BULLET_RAISE', 'false'))
end

現時点では、一旦環境変数を参照することで、開発者の設定に委ねています。全てのN+1やUnnecessary Eager Loadsを駆逐してから導入するのは、現実的ではないので一旦エラーRaiseはコントロールできるようにしています。ちょっとした対応の度にBulletエラーに遭遇して本来の対応に進めないケースを避けたいというのがポイントです。もちろん基本的にはtrue推奨ではあります。

局所的スキップ

さて、次に既存のBulletエラー発生箇所に対して、遭遇する度に修正するのが望ましいですが、意外と根が深かったり、対応中の内容と観点が外れてしまうので、切り分けて対応したいなどありえるかと思います。その場合に、局所的にBulletエラーの発生をスキップしたいケースが出てきます。

その場合は以下の2通りが提案されています。

  1. クラス単位でsafe_listに定義する方法
  2. コントローラーのアクション単位でBulletをdisableにする方法

https://github.com/flyerhzm/bullet#safe-list

どちらも簡単で良いのですが、望ましくない点もあります。
1の場合は、safe_listに追加したクラスにおいて、全く別の箇所でN+1などが発生していたとしても同様に検知がスキップされてしまうという点、2の場合は、同一アクション内において、他の箇所で検出すべきものまでスキップされてしまう点があります。

つまりBulletエラーが出ている特定箇所限定で、なるべく小さくスキップ範囲を指定したいという観点です。これに対応するために「らくしふ」では、上記のクラス単位やコントローラーのアクション単位に縛られず、どこからでも扱えるように汎用的なクラスメソッドを準備しました。

lib/utils/skip_bullet.rb
class SkipBullet
  if defined?(Bullet) && Bullet.enable?
    def self.around
      Bullet.enable = false
      yield
      Bullet.enable = true
    end
  else
    def self.around
      yield
    end
  end
end

これによりどこからでも以下のように使うことができるようになりました。

a = SkipBullet.around { ... } 

# or 

SkipBullet.around do 
  ...
end

さて、ここでSkipBullet.aroundを利用する際の、次の悩みポイントにぶち当たります。
それは以下のどちらの箇所で適用するかについてです。

  1. 実際にBulletが 「N+1 or Unnecessary Eager Load を見つけた!(Raise!)」とするコード (loop内やassociationアクセスする箇所)
  2. 上記問題の根本的なeager_loadincludesしている箇所
sample.rb

list = ModelA.where(...) # ←ここも囲む? ← 2
...
list.map do |item| # ← ここだけ? ← 1
  ...
end

根本的解決するために、2の箇所まで含んで適用するのが理想ではあるのですが、かなり広範囲をSkipBullet.aroundで囲む可能性があります。1と2の間にさまざまなロジックコードが存在する場合があるからです。これは「スキップ適用によるコード差分が多くなる」という点に加え、「Bulletの検知スキップが意図せず広範囲に及ぶ」ことにもなります。つまりここでも、意図せずBullet検知すべき箇所をスキップしてしまう懸念があるのです。

ですので、この点を考えて、一旦スキップ範囲は狭くして、実際にRaiseしてくれる箇所のみに適用して運用しようということにしました。つまり1の実際にBulletがraiseしてくれる箇所のみをスキップで囲むようにしました。

まとめ

このように既存のものに対しては対応or一時的にスキップしつつ、新たなコードに関しては問題を追加させないようにしています。

将来的には既存のものもスキップでの回避ではなく、根本修正対応をしていき、全面的にBullet.raise = trueにしていきたいと思います。

最後に

株式会社クロスビットでは、デスクレスワーカーのためのHR管理プラットフォームを開発しています。一緒に開発を行ってくれる各ポジションのエンジニアを募集中です。

https://x-bit.co.jp/recruit/
https://herp.careers/v1/xbit
https://note.com/xbit_recruit/

クロスビットテックブログ

Discussion