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
JavaのiBatisだと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 を追加定義することで、モデルを再現しています。
本当にこういう使い方でいいのかな?