その他
    ホーム 技術発信 DoRuby ActiveRecord::Relationの落とし穴
    ActiveRecord::Relationの落とし穴
     

    ActiveRecord::Relationの落とし穴

    この記事はアピリッツの技術ブログ「DoRuby」から移行した記事です。情報が古い可能性がありますのでご注意ください。

    モデルをwhere句等で取ってくると、ActiveRecord::Relationが返ってくる。それを利用した操作はとても遅くなる時があった。

    近況+直面した問題

     おやつをこんにゃくゼリーにしたら便通が良くなったHelloWorld?です。
     Ruby, Railsを学び始めて合計2ヶ月程(事前研修+入社してから1か月半)が過ぎ、新機能の実装をちょっとずつやり始めるようになりました。
     その機能の実装の一つで、普通に実装したらそのメソッドを実行するのに5秒程掛かっていてこれじゃあ運用できないよ、となっていたのが、ボトルネックを調べてそこ辺りの解消を試みてみたら、実行に掛かる時間が0.5秒以下まで抑えられたと言う事がありました。
     その理由を突き詰めてみました。
    (バージョンは以下の通りです。
    Rails: 4.1.9
    Ruby: 2.1.5p273
    Mysql: 5.6.36
    )

    原因、解決

     実際の実装でボトルネックを調べると、
     実装する為に分割した一つの、以下のようなメソッドに問題がありました。

        find_suitables(w_models)
          ret_models = []
          w_models.each do |model|
            if ...
               ...
              ret_models << model
            end
          end
          ret_models
        end
    

     w_models: 別のモデルからwhere句で取ってきたActiveRecord::Relation

     この中で、each文の中身が一番のボトルネックかな、と思っていたのですが、一向に軽くならないので、each文の中身全て削除して時間を計測してみたら、そのeach文そのものに時間をかなり取られていた事が分かりました。
     さて、これはどう解決すれば良いんだろう、と思って。
     試しに一旦そのw_modelsの型をActiveRecord::Relationからハッシュや配列に変換してからeach文に入れてみたら、劇的な動作速度の向上が出ました。
     でも、ActiveRecord::Relationから変換するにも、取ってきてから変換しているんじゃ意味が無いよな、と思って色々調べてみたら、以下のページが見つかりました。
    http://qiita.com/yut_h1979/items/4cb3d9a3b3fc87ca0435
     ActiveRecord::Base.connectionから生SQLでDBにアクセスできる! そして返り値を配列やらハッシュやらで取ってこれる!
     これは使うしか無いな、ともう、自分のコードの中でActiveRecordを使っていた部分の大半をこれで生SQLを打ち込んで、コードもActiveRecord::Relationからハッシュや配列を扱うようにしたら、実行に掛かる時間がとても短くなりました。

    検証

     実装した後、(テストコードも実装して)ちょっとその理由を様々な観点から確かめてみる事にしました。
     以下のように十分なレコード数がある適当なモデルからデータをActiveRecord::Relation、hash、arrayの形式で取得してきて、

        def take
          ar = ModelX.where("id <= #{NUMBER}") #ActiveRecord::Relation...takeで取ってくると配列になる。
          hash = ActiveRecord::Base.connection.select_all("SELECT * FROM model_xes LIMIT #{NUMBER}").to_hash #hash
          array = ActiveRecord::Base.connection.select_rows("SELECT * FROM model_xes LIMIT #{NUMBER}") #array
        #NUMBER: 1000, 2000, 3000, 4000,idに抜け落ちはありません。
        end
    

     時間の計測に関しては、測りたいコードの前後に

        time = Time.now
        code
        time = Time.now - time
        print "#{time}"
    

     を書けば小数点以下の時間も計測出来るとのことです(後で、benchmarkの存在も知りました)。
     eachで回したり、findと線形探索を比較してみたり、他にも様々な検証をrailsのコンソール上でしてみました。

     以下のメソッドをtakeからそれぞれ呼び出してみて、時間を計測しました。

        def get_each_time(ar, hash, array)
          time = Time.now
          ar.each do |n|
            if n.id % 10 == 0
            end
          end
          time = Time.now - time
          print "#{time}"
    
          time = Time.now
          hash.each do |n|
            if n["id"] % 10 == 0
            end
          end
          time = Time.now - time
          print "#{time}"
    
          time = Time.now
          array.each do |n|
            if n[0] % 10 == 0
            end
          end
          time = Time.now - time
          print "#{time}"
        end
    
        def get_find_time(ar, hash, array)
          id = ar.count * 3/4 #どのような探索でもちょっと探索をするような値を。
          time = Time.now
          ar.find(id)
          time = Time.now - time
          print "#{time}"
    
          time = Time.now
          hash.each do |n|
            if n["id"] == id
            break
            end
          end
          time = Time.now - time
          print "#{time}"
    
          time = Time.now
          array.each do |n|
            if n[0] = id
            break
            end
          end
          time = Time.now - time
          print "#{time}"
        end
    
        def get_find_by_time(ar, hash, array)
        #絶対に条件に引っかからないものを検索
          time = Time.now
          ar.find_by(A: 'AAA', B: 'BBB', C: 'CCC')
          time = Time.now - time
          print "#{time}\n"
    
          time = Time.now
          hash.each do |n|
            if n["A"] == "AAA" && n["B"] = 'BBB' && n["C"] == "CCC"
              break
            end
          end
          time = Time.now - time
          print "#{time}\n"
    
          time = Time.now
          array.each do |n|
            if n[1] == "AAA" && n[2] == "BBB" && n[3] = "CCC"
              break
            end
          end
          time = Time.now - time
          print "#{time}\n"
        end
    

     その結果は以下です。



     eachにおいては時間の軸を対数にしないと、全部表示できないようなレベルの差が出ました。これは、 SQL呼び出しの時間を鑑みても、無視できないレベルです。
     また、それぞれをメソッドとして呼ばずに、takeのメソッドの中でそのまま呼び出した場合をグラフにしてみると。


     find_byで値に変化が生じました。これはキャッシュ関連の問題だと思います。

    結論

     each, findの操作をする時は、ActiveRecord::Relationを使用するべきではない、というよりActiveRecord::Relationは極力使うべきでは無い、と自分は思いました。
     1000以上のレコード数のときではありますが、大抵の場合ハッシュや配列よりも時間が掛かっていますし、また、一気に処理時間が多くなるような事も容易に起こり得るようなものだと自分は危険視し始めています。

     実際の実装の時は、ハッシュを利用しました。
     配列の方が速いときもありますが、そう大きな差ではありませんし、また、可読性の面からも見て、ハッシュの方が良いと思ったからです。

    展望

     現在配属されているプロジェクトにおいて、似たような事でちょっと遅くなっているメソッドとかがあれば、同様にして処理の時間を減らせたらな、と思っています。

    後日

     なぜ、ActiveRecord::Relationが特にeach文では遅いかという理由を、この記事を読んだ同じ会社の同期や先輩からご教授頂きました。
     その理由は、ActiveRecord::Relationは、where句で取ってきたタイミングではなく、each文の中で初めてSQLを叩いているからだ、という結論で。
     なので、一番最初に行ったeachの比較でActiveRecord::Relationだけ、SQL文を叩いた時間を含める事となったようです。
     という訳で、どうやら、検証自体が余りフェアなものでは無かったのかもしれません。
     ただ、find, find_byに対しては多分大丈夫だと思います。後日、再度検討してみます。

    https://doruby.jp/users/mkobayashi/entries/ActiveRecord–Relationとは一体なんなのか
     同期がそれに関して詳しく調べた記事を書いたので、そのリンクも載せておきます。

    https://doruby.jp/users/hello_rails/entries/ActiveRecord–Relationの落とし穴-解決編
     後日、再度検討しました。