その他
    ホーム 技術発信 DoRuby 高速にステーキを作ろう!!(ActiveRecordの速さを追い求める.2)
    高速にステーキを作ろう!!(ActiveRecordの速さを追い求める.2)
     

    高速にステーキを作ろう!!(ActiveRecordの速さを追い求める.2)

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

    3はある、かもしれないような気がする。

    ステーキを作るコード

     美味しいステーキを(ステーキに)作りたいが為に、以下のようなコードがありました(疑似コードも書いたので面倒ならそれを読んでください)。

    class Cow < ActiveRecord::Base
      DEAD = 0.freeze
      ALIVE = 1.freeze
      SICK = 2.freeze
    
      def create_steak!(part, solt_vol, pepper_vol, bake_time, rest_time)
        self.slaughter! if self.is_alive?
        beef = Beef.find_by(cow_id: self.id, part: part)
        return if beef.nil?
        beef.steak!(solt_vol, pepper_vol, bake_time, rest_time)
      end
    =begin
    def ステーキ作る!(部位、塩の量、胡椒の量、焼く時間、休ませる時間)
      牛が生きていたら屠畜する
      肉が得られなかったら作れないのでさよなら
      肉をステーキにする
    end
    =end
    
      def slaughter!
        return if !self.is_alive?
        before_status = self.life_status
        self.life_status = DEAD
        self.dead_time = Time.now
        self.save
        if before_status != SICK
          Beef::PART.each do |part|
            Beef.create(cow_id: self.id, part: part)
          end
        end
      end
    =begin
    def 屠畜!
      もう死んでたらおしまい
      屠畜して時間を記録
      牛が病気じゃなかったら部位毎に肉を取得
    end
    =end
    
      def is_alive?
        self.life_status != DEAD
      end
    end
    =begin
    def 生きてる?
      生きてるかどうか返す
    end
    =end
    
    class Beef < ActiveRecord::Base
      PART = ["Loin", "Libeye", "Sirloin", "Spencer Roll", "Tenderloin", "Top Sirloin Butt", ...]
      RECIPE_NAME = ["YAKINIKU", "STEAK", "BEEF STEW", "BEEF CURRY", "BEEF STROGANOF"]
      validate :recipe_include?, :part_include?
    
      def steak!(solt_vol, pepper_vol, bake_time, rest_time)
        self.solt_volume += solt_vol
        self.pepper_volume += pepper_vol
        self.bake_time += bake_time
        self.is_cooked = true
        self.rest_time = rest_time
        self.recipe_name = "STEAK"
        self.save
        self
      end
    =begin
    def ステーキにする!(塩の量、胡椒の量、焼く時間、休ませる時間)
      塩を振る
      胡椒を振る
      焼く
      調理済ステータスにする
      休ませる
      レシピ名を与える
      保存
    end
    =end
    
      def recipe_include?
        if self.is_cooked
          unless RECIPE_NAME.include?(self.recipe_name)
            error.add(:recipe_name, "Recipe not found.")
          end
        end
      end
    =begin
    def そのレシピある?
      調理済なら
        レシピ名調べて無かったらエラー
    end
    =end
    
      def part_include?
        unless PART.include?(self.part)
          error.add(:part, "Part not found")
        end
      end
    =begin
    def その部位ある?
      部位名調べて無かったらエラー
    end
    =end
    end
    

    Cow Model(簡略化してます)

    id          : integer
    life_status : integer
    dead_time   : datetime
    

    Beef Model(簡略化してます)

    id            : integer
    cow_id        : integer
    part          : string
    is_cooked     : boolean
    solt_volume   : float(g)
    pepper_volume : float(g)
    rest_time     : integer(sec)
    bake_time     : integer(sec)
    recipe_name   : string
    

     さて、このコードでCowインスタンスが存在すれば(cowとする)、

    cow.create_steak!("Sirloin", 2.0, 1.0, 90, 90)
    

    の一文で塩を2g、胡椒を1g、それから90秒焼いて90秒休ませたサーロインステーキが作れてしまいます(これ以上凝るとコード長が大変な事になるので妥協しました)。

    沢山サーロインステーキを作りたい

     さて、沢山サーロインステーキを作りたいときにはどうしたら良いでしょう。
     まず、沢山の牛を用意しなければいけません。新鮮な肉が良いので、生きている牛を1000匹用意しましょう(もちろん病気の牛は除外します)。

    cows = Cow.where(life_status: Cow::ALIVE).take(1000)
    

     それから1000個のステーキを作りましょう。

    cows.each do |cow|
      cow.create_steak!("Sirloin", 2.0, 1.0, 90, 90)
    end
    

     ……遅い! とても遅い!
     何故?
     そりゃあ、一匹一匹、屠畜して、塩胡椒を振って、焼いて休ませて、を繰り返してるから(一匹毎にSQLを発行、1000 * n回のSQLの発行が起きているから)ですよ。
     1000匹一気に屠畜して、1000個のサーロインに一気に塩胡椒を振って、1000個のサーロインを焼いて休ませてあげた方が速いに決まってます。
     その為には、やはり、それ用の沢山ステーキを作るコードを書きましょう。
     ただ、一つgemが必要です。
     activerecord-importというgemで、これを使えば、

    models = []
    1000.times do |n|
      models << Model.new(...)
    end
    Model.import(models)
    

     という感じで、一括insertが出来るようになります(gemを使わなくとも、一括insertするSQL文をコード上で生成して生SQLでダイレクトに挿入するという力技で出来る事は出来ます)。
     さて、大量にステーキを高速に作るために、Cowモデルにメソッドを作りましょう。

    def self.create_steaks!(num, part, solt_vol, pepper_vol, bake_time, rest_time)
      last_insert_id = Beef.maximum(:id) + 1 #確実では無いかも
      now = Time.now
      cow_ids = Cow.where(life_status: Cow::ALIVE).limit(num).pluck(:id)
      return if cow_ids.length < num
      Cow.where(id: cow_ids).update_all(life_sttus: Cow::DEAD, dead_time: now)
      beefs = []
      steak_beef_ids = []
      cow_ids.each do |cow_id|
        BEEF::PART.each do |p|
          beef = BEEF.new(id: last_insert_id, cow_id: self.id, part: p)
          last_insert_id += 1
          beefs << beef
          steak_beef_ids << beef.id if p == part
        end
      end
      Beef.import(beefs)
      Beef.where(id: steak_beef_ids).update_all(solt_volume: solt_vol, pepper_volume: pepper_vol, bake_time: bake_time, is_cook: true, rest_time: rest_time, recipe_name: "STEAK")
    =begin
    def ステーキを沢山作る!(牛の数, 部位, 塩の量, 胡椒の量, 焼く時間, 休ませる時間)
      最後の肉のIDを取得
      現在時間を取得
      生きている牛のIDを、数だけ取得
      生きている牛が数だけ居なかったらおしまい
      そのIDの牛全てを屠畜
      そのIDの牛全てに対して牛肉を作成。指定された部位のIDは別に分けておく
      指定された部位の肉を焼いて更新
    end
    =end
    end
    

     このコードだと、下の1行で1000個のステーキが作れます!(生きている牛が1000体以上居たらに限りますが)

    Cow.create_steaks!(1000, 2.0, 1.0, 90, 90)
    

     そして、このコードはどれだけステーキを焼こうともSQLの呼び出し回数は固定です(newはSQLの呼び出しになりません)。
     直感的にも屠畜場から調理場へ行って肉を焼いて屠畜場に戻って……を何回と繰り返すのではなく、屠畜場で一気に処理して、それから調理場へ全ての肉を運んで全ての肉を焼いて……とした方が早いとは分かると思います。
     一つ一つのSQLは重いですが、単純に行うよりはずっと早いです。
     ただ、注意点が3つほどあります。

    IDを自分で振る必要がある

     importではオートインクリメントの値を計算してくれません。

    last_insert_id = Beef.maximum(:id) + 1 #確実では無いかも
    

     この行はその為ですね。#確実では無いかも、というのは、オートインクリメントの値を取ってきていないからです。オートインクリメントの値を取る方法を少し調べてみたりはしましたが、中々見つからなかったので、今回はこれにしました。

    バリデーションをしてくれない

     importは、DBに直接働きかける為、トリガなどを一切無視しまう事が原因です。なので、このメソッドは、予想外の値が入らない事が前提条件となるでしょう。それか、バリデーションを自分でimportの前に入れておくか、とか。

    他のActiveRecord関連のgemと相性が悪い

     バリデーションをしてくれない、という事と同じで、DBに直接働きかけるという点が原因です。
     例えば、水平分散DBを実現するactiverecord-turntableというgemの、uniqueなID管理を統括してくれるsequenceと相性が良くないです。
     import処理を行った後も、その連番の値を管理してくれている数値が更新されないので、次に普通にcreateとかしようとすると、idが重複しているよ! と怒られてしまいます。
     なので、sequenceを使っているmodelに関しては、import処理を行った後に、以下のように、sequenceの値を進めてあげる必要が出てきます。

    Beef.connection.next_sequence_value("beefs_id_seq", num)
    #加算した分(num)だけ、シーケンスの値を進める
    

    最後に

     Railsの処理速度で一番ボトルネックになりやすいのは、SQLの呼び出しでしょう。
     そのSQLの処理時間を減らす為には主に、
    ・出来るだけモデルそのままではなく、必要な情報だけを取得するようにする
    ・呼び出し回数そのものを減らす
    という事になってくると思います。
     特に呼び出し回数を減らす、という点においては、今回の高速化においての主な方法だった、一回のSQLで一括読み込み/書き出しをする、という他に、メモ化する、Redisでキャッシュに書き込んで、そちらから取得する、等々色んな方法があります。