ホーム 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でキャッシュに書き込んで、そちらから取得する、等々色んな方法があります。

記事を共有

最近人気な記事