Active Record で N+1 問題

最近会社でHibernateのN+1問題事例を調べてたんですが、ActiveRecordでも当然のように起こりますね。

BOOKSテーブルが、1対NでREVIEWSテーブルと関連を持っています。(BOOKSが1、REVEWSがN)

以下のコードでは、BOOKSテーブルを全件検索して、それに関連するREVIEWSテーブルのレコードを取得して、REVIEWSテーブルのBODYカラムを出力する。

Book.all.each { |book|
  book.reviews.each { |review|
    puts review.body
  }
}

このコードではBOOKSテーブルに対して1回のSQLが発行され、REVIEWSテーブルに対してはBOOKSテーブルのレコード数分のSQLが発行されます。N+1問題です。BOOKSテーブルとREVIEWSテーブルの多重度に関係なく、親テーブルを複数検索して、子テーブルのレコードも取得しようとするとき、O-Rマッピングの通常のやり方だとこうなります。これはHibernateとかも同じ。

  Review Load (0.3ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 1)
  Review Load (0.2ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 2)
  Review Load (0.2ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 3)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 4)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 5)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 6)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 7)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 8)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 9)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 10)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 12)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 13)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 14)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 15)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 16)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 17)
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE ("reviews".book_id = 18)


対策はどうするかというと、馴染みのあるJOIN SQLを呼べばいい。

books = Book.joins('LEFT JOIN reviews ON books.id = reviews.book_id').
  select('books.id bid, reviews.id rid, reviews.book_id, reviews.body')

books.each { |book|
  puts book.body if book.body != nil
}

発行されるSQLは一発。

SELECT books.id bid, reviews.id rid, reviews.book_id, reviews.body FROM "books" LEFT JOIN reviews ON books.id = reviews.book_id

大手SIerなんかだと、こっちの方を好みます。性能問題が何よりも怖いから。
でも、このやり方だと、SQLで取得する結果がとても扱いにくい。

最初のN+1問題がでるやり方では取得したモデルbookはちゃんとreviewsをフィールドとして持った忠実なモデルになっている。
例えば、BOOKS.ID=1と関連するREVEIWSテーブルのレコードが2つあれば、一つのモデルbookの中に2つのreviewモデルを保持するreviewsコレクションが存在することになる。

一方下のJOIN SQLのやり方では、BOOKS.ID=1と関連するREVIEWSレコードが2つあってもあっても、階層構造ではなく、フラットな構造でデータを返してくる。例えば、BOOKS.ID=1,REVIEWS.ID=1 のレコードと、BOOKS.ID=1, REVIEWS.ID=2 という形。SQLの結果そのままで、オブジェクト指向のモデルにはなっていない。

このサンプルのようにただ子テーブル側データを表示するだけなら問題ないが、帳票のように階層的なデータを表示する場合に、こういうデータ構造は扱いにくい。

これを解消するために、JOIN SQLで取得したフラットなデータ構造を自分でモデル化してみる。

def build_book
  books = Book.joins('LEFT JOIN reviews ON books.id = reviews.book_id').
      select('books.id bid, reviews.id rid, reviews.book_id, reviews.body')
  res = {}
  books.each { |rec|
    book = res[rec.bid]
     
    if book == nil
      book = Book.new
      class << book
        attr_accessor :reviews
      end
      res[rec.bid] = book
      book.id = rec.bid
      book.reviews = Array.new
    end

    review = Review.new
    next if rec.rid == nil
    review.id = rec.rid
    review.book_id = rec.book_id
    review.body = rec.body
    book.reviews.push(review)
  }
  
  res.values
end

JavaiBatisだとSQLがなんだろうが、こういうマッピングをしてくれるんだけどね。(groupbyの機能)
JPAだとできない気がする。Active Recordではできるのかな。

特異クラスの使い方

前の記事の最後に出したコードで特異クラスを使ってます。

def build_book
  books = Book.joins('LEFT JOIN reviews ON books.id = reviews.book_id').
      select('books.id bid, reviews.id rid, reviews.book_id, reviews.body')
  res = {}
  books.each { |rec|
    book = res[rec.bid]
     
    if book == nil
      book = Book.new
      class << book
        attr_accessor :reviews
      end
      res[rec.bid] = book
      book.id = rec.bid
      book.reviews = Array.new
    end
  〜略〜
  }
  
  res.values
end

通常のO-Rマッピングの操作(findなど)でActive Recordが返してくるモデルには、関連先テーブルのデータもフィールドとして含めて返してくれます。

例えば、Bookモデルだったら、BOOKSテーブルと関連するREVIEWSテーブルのデータは、Book.reviews としてモデルを返してくれます。

しかし、今回はBookモデルを自分でnewして、Book.reviewsを持つモデルをつくろうとしてます。
ところが、Bookモデルのクラス定義は以下。

class Book < ActiveRecord::Base
  has_many :reviews
end

自分で普通にnewしたBookモデルでは、reviewsフィールドに値をセットできません。

だからといて、わざわざそのためにクラス定義するのも面倒だし。

そこで特異クラスを使ってみました。 attr_Accessor :reviews を追加定義することで、モデルを再現しています。

本当にこういう使い方でいいのかな?