41
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Rails の ActiveRecord で膨大なレコード数を高速かつ省メモリで処理する

Last updated at Posted at 2015-08-12

Rails の task で超巨大なテーブルに対して集計を行いたくて、
いろいろ調べてみたところ、以下の 2 つの方法をみつけました。

  • pluck
  • find_each

pluck は任意のカラムのデータを配列で取得するメソッドです。
find メソッドのようにインスタンスを取得することはできませんが、
その代わりにインスタンス生成にかかるオーバーヘッドがかからないため非常に高速です。
メモリ消費の点でも、インスタンスを生成しない分有利ですが、
取得するレコード数に比例してメモリ使用量は大きくなるため、
巨大なテーブルに対して使用するのは危険が伴います。

find_each は少しずつデータをメモリに展開して処理を行うメソッドです。
一度にメモリに展開するレコード数を制限できるため、
使用メモリ量をコントロールすることができます。
ただし、インスタンス生成のオーバーヘッドがかかるため、
データ量が膨大だと pluck に比べて速度が出ません。

そこで、これらの考えを組み合わせることで
高速かつ省メモリで大量レコードを処理するメソッドを書いてみました。
それが以下の pluck_each です。

# coding: utf-8
module ActiveRecord
  module Calculations

    def pluck_each(columns = [], batch_size = 100000)
      # primary key の位置特定 (なければ columns の最後に追加)
      pk_idx = columns.map(&:to_s).index(self.primary_key) || columns.count
      columns << self.primary_key if pk_idx == columns.count

      relation = self.reorder(self.primary_key).limit(batch_size)
      part = relation
      last_pk_val = nil
      loop do
        part = relation.where('? > ?', self.primary_key, last_pk_val) unless last_pk_val.nil?

        # 全レコードを yield したら終了
        c = part.count
        break if c == 0

        # LIMIT を残り件数よりも多く設定すると遅くなる問題への対処
        part = part.limit([batch_size, c].min)

        # 部分的に pluck を実行
        part.pluck(*columns).each do |ary|
          yield ary
          last_pk_val = ary[pk_idx]
        end
      end
    end

  end
end

使い方

Rails で使用する場合は lib/core_ext/active_record/calculations.rb に上記のコードを記述し、
config/initializers/extensions_loader.rb 等に以下のようなコードを書いておけば読み込めます。

  Dir.glob("#{Rails.root}/lib/core_ext/**/*.rb").each {|ext| require ext }

pluck_each を使用する際は、取得したいカラム名 (シンボル) の配列を渡します。第 2 引数にはメモリに展開するレコード数も渡せます (デフォルトは 10 万)。

以下のような感じです。おそらく pluck と find_each を使用したことがあれば、(汎用性には乏しいですが) それなりに違和感なく使えると思います。

  # 1000 レコードずつメモリに展開
  User.where(last_login: s..e).pluck_each([:id, :username, :email, :last_login], 1000) do |ary|
    # レコード 1 件毎の処理内容
    # (ary = [1, 'user1', 'user1@mail.com', '2015-04-01 13:00:00'], ...)
  end

注意点

  • Primary Key が複合キーのテーブルには使用できません
  • 第 1 引数に Primary Key が入っていない場合、ブロックパラメータの配列の最後に Primary Key の値が追加されます

最後に

MySQL だけなのかはよくわからないのですが、LIMIT を残り件数よりも多く設定するとかなり遅くなるため、
pluck_each にはその対策として毎ループで count も取得してます。
無駄な気もしますが、これしか思いつかなかったので、もっと良い方法があれば教えてください。
(それ以前に、LIMIT が残り件数よりも多いときになぜ遅くなるかもよくわかってないので、そっちも教えて欲しいです。)

ちなみに、find_each にはこの対策がされていなくて、最終ループだけ遅くなってしまうという問題があり、
それを pluck_each と似たような方法で対策すると find_each も速くなったりします。

41
44
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
44

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?