Railsのバッチ処理のコツ

はじめに

Railsでギョーミーな仕事を行う上で欠かせないのがバッチ処理です。 日々上位システムから送られてくる膨大なデータを迅速に取り込み、集計処理を行いDBに格納する。上位システムは何層も構成されており、我々が集計処理に使える時間はエンドユーザーが出社してくるまでの数時間... みたいなシチュエーション無いですか?

今回はバッチ処理を行うコツについて書いてみようと思います。

  • 想定される処理

    1. CSVファイルの取り込み
    2. 集計処理
    3. 集計結果をDBに格納

    普通にrake taskを書いて処理できてれば今回の記事は必要ありません。そっとブラウザを閉じて下さい。そうでない場合、多くの人が直面する問題は次のようなものが考えられます。

    1. IDのオーバーフロー
    2. メモリが食いつぶされてバッチ処理が停止

    それでは順番に説明します。

IDのオーバーフロー

大量のデータを日々のバッチで取り込んでいる場合、IDがオーバーフローしてしまう可能性があります。IDの最大値はINT型で2147483647もあるので通常利用している範囲ではオーバーフローすることは考えなくても(※1)大丈夫です。しかし、データの取り込みは追記だけとは限りません。何かの条件でDELETE & INSERTする場合、ID列の寿命は思っていたより早く尽きてしまうでしょう。

※1 毎日10万件のデータを取り込んだとして58年持つ計算。

そんな場合は思い切ってID列を削除してしまいましょう。

# migrationの定義
create_table :sales, :id => false do |t|
  t.references :customer
  t.date :sales_date
  t.decimal :total_sales
end
add_index :sales, [:customer_id, :sales_date], unique: true

IDを削除することの弊害

IDを削除することで、IDのオーバーフローに怯える必要は無くなりました。ActiveRecord::Baseのwhereメソッドによる検索も普通に使えます。findメソッドによるID検索が使えないだけだと思っていると、大きな落とし穴が待ち構えています。それはUpdateができないことです。 この問題を解決するためにはcomposite_primary_keysというgemを利用しましょう。 Modelに次のようにキーを定義することでUpdateが可能になります。

class Sales < ActiveRecord::Base
    self.primary_keys = :customer_id, :sales_date
end

メモリが食いつぶされてバッチ処理が停止

初めは快調に処理が進んでいたのに、だんだん遅くなって停止してしまう。こんな経験はないでしょうか?

f:id:tech-kazuhisa:20140609210754j:plain

▲こうなるともうお手上げ

メモリの消費をチェック

Linuxであればfreeコマンドでメモリの消費をチェックしてみましょう。

$ watch free -m

             total       used       free     shared    buffers     cached
Mem:          8000       6998       1001          0        340       2346
-/+ buffers/cache:       4311       3688
Swap:        10063        414       9649

バッチ実行中にbuffers/cacheのfreeの値がどんどん減っていくようであれば、あなたのバッチ処理は何かがおかしいです。

大きなselectを行わない

Sale.all.each do |sale|
  # 略
end

全てのデータを一気に取得して処理するのではなく、可能であれば小さい単位に分割しましょう。

Shop.all.each do |shop|
  Sale.where(shop_id: shop.id).each do |sale|
    #略
  end
end

適切な粒度でバルクインサートを行う

このような処理はメモリを食いつぶす原因となります。

Sale.all.each do |sale|
  OtherSale.create!(total_sales: sale.total_sales, ...)
end

ActiveRecord.importを利用してバルクインサートを行いましょう。その際に、全てのレコードをインサートするのではなく適切な粒度になるよう気をつけましょう。

Sale.find_in_batches do |sales| # 1,000件ずつ取り出す
  list = []
  sales.each do |sale|
    list << OtherSale.new(total_sales: sale.total_sales, ...)
  end
  OtherSale.import list
end

最後に

バッチ実行中にだんだん遅くなっていく現象は、テスト段階では気づきにくいものです。本番運用が始まる前にかならずダミーデータを作成して検証を行うようにしましょう。