こんにちは @sonots です。

rails4 で バルクインサートおよび、バルクアップデート(MySQL only) クエリを ActiveRecord から投げるには activerecord-import gem を使えます。rails4-rc2 でも動きましたのでご報告。

前準備

Gemfile に
gem 'activerecord-import'
を追加して
$ bundle
だけですね。

バルクインサート

ActiveRecord を使って以下のように create すると
10.times do |i|
  Book.create! :name => "book #{i}", :author => "author #{i}"
end
こんなかんじに個別に INSERT クエリが走って遅いですよね。
 INSERT INTO `books` (`author`, `created_at`, `name`, `updated_at`) VALUES ('author 0', '2013-06-22 08:47:44', 'book 0', '2013-06-22 08:47:44')
 INSERT INTO `books` (`author`, `created_at`, `name`, `updated_at`) VALUES ('author 1', '2013-06-22 08:47:44', 'book 1', '2013-06-22 08:47:44')
以下10個分 ….

そこでバルクインサートですよ。10個まとめてバルクインサートするにはこんなかんじで。
books = []
10.times do |i|
  books << Book.new(:name => "book #{i}", :author => "author #{i}")
end
Book.import books
実際のクエリはこんなかんじに1つの INSERT クエリになりますね。
  INSERT INTO `books` (`id`,`name`,`author`,`created_at`,`updated_at`) VALUES (NULL,'book 0','author 0','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 1','author 1','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 2','author 2','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 3','author 3','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 4','author 4','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 5','author 5','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 6','author 6','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 7','author 7','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 8','author 8','2013-06-22 09:23:10','2013-06-22 09:23:10'),(NULL,'book 9','author 9','2013-06-22 09:23:10','2013-06-22 09:23:10')

10個だと対した問題ではないかもしれませんが、1000個ぐらいになったらバルクインサートしたい所ですね。Benchmark 結果を見るには、InnoDB Table の場合、ActiveRecord の validation が有効な状態で4倍、validation が無効な状態で20倍スピードアップとのこと。

バルクアップデート

で、MySQL で使えるバルクアップデートですが、以下のように使います。name カラムを一括更新してみます。注意点は、Book.import の第一引数は ActiveRecord::Relation ではなく Array なので #to_a してあげないといけない所ですかね。
books = Book.all
books.each do |book|
  book.name = "updated #{book.name}"
end
Book.import books.to_a, :on_duplicate_key_update => [:name]
ON DUPLICATE KEY UPDATE を使ったバルクアップデートクエリを投げてくれます。
 INSERT INTO `books` (`id`,`name`,`author`,`created_at`,`updated_at`) VALUES (1,'updated book 0','author 0','2013-06-22 09:24:16','2013-06-22 09:24:16'),(2,'updated book 1','author 1','2013-06-22 09:24:16','2013-06-22 09:24:16'),(3,'updated book 2','author 2','2013-06-22 09:24:16','2013-06-22 09:24:16'),(4,'updated book 3','author 3','2013-06-22 09:24:16','2013-06-22 09:24:16'),(5,'updated book 4','author 4','2013-06-22 09:24:16','2013-06-22 09:24:16'),(6,'updated book 5','author 5','2013-06-22 09:24:16','2013-06-22 09:24:16'),(7,'updated book 6','author 6','2013-06-22 09:24:16','2013-06-22 09:24:16'),(8,'updated book 7','author 7','2013-06-22 09:24:16','2013-06-22 09:24:16'),(9,'updated book 8','author 8','2013-06-22 09:24:16','2013-06-22 09:24:16'),(10,'updated book 9','author 9','2013-06-22 09:24:16','2013-06-22 09:24:16') ON DUPLICATE KEY UPDATE `books`.`name`=VALUES(`name`),`books`.`updated_at`=VALUES(`updated_at`)

1度のバルクアップデートの数を絞る

さっきの例だと Book.all としていたので、全てのレコードを1度にバルクアップデートすることになります。あまりにもレコード数が多いとクエリサイズが巨大になってしまうので 1000 個ぐらいに抑えたいですよね。そういうときは ActiveRecord の #find_in_batches を使って細かくわけてクエリを投げましょう。今回はサンプルなので 2個ずつバルクアップデートしてみます。
Book.all.find_in_batches(:batch_size => 2) do |books|
  books.each do |book|
    book.name = "updated #{book.name}"
  end
  Book.import books.to_a, :on_duplicate_key_update => [:name]
end
実際のクエリはこんなかんじになりますね。
  SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 2
  INSERT INTO `books` (`id`,`name`,`created_at`,`updated_at`) VALUES (1,'updated updated book 0','2013-06-22 09:13:04','2013-06-22 09:13:04'),(2,'updated updated book 1','2013-06-22 09:13:04','2013-06-22 09:13:04') ON DUPLICATE KEY UPDATE `books`.`name`=VALUES(`name`),`books`.`updated_at`=VALUES(`updated_at`)
  SELECT `books`.* FROM `books` WHERE (`books`.`id` > 2) ORDER BY `books`.`id` ASC LIMIT 2
  INSERT INTO `books` (`id`,`name`,`created_at`,`updated_at`) VALUES (3,'updated updated book 2','2013-06-22 09:13:04','2013-06-22 09:13:04'),(4,'updated updated book 3','2013-06-22 09:13:04','2013-06-22 09:13:04') ON DUPLICATE KEY UPDATE `books`.`name`=VALUES(`name`),`books`.`updated_at`=VALUES(`updated_at`)
以下10個分 ….

カラムを絞る

ActiveRecord オブジェクトの配列を渡すと、全てのカラムのパラメータがバルクアップデートのクエリに載ってしまうようです。今回は :name カラムだけを更新したいのだからそこだけクエリに載せたいですよね。カラムを絞るには次のようにします。
books = Book.all
books.each do |book|
  book.name = "updated #{book.name}"
end
columns = [:id, :name] # 注意: レコード指定のためにプライマリーキー :id も指定する
values = books.map {|book| [book.id, book.name] }
Book.import columns, values, :on_duplicate_key_update => [:name]
クエリはこんなかんじです。:author 属性が減っていて少し節約ですね :D
  INSERT INTO `books` (`id`,`name`,`created_at`,`updated_at`) VALUES (1,'updated book 0','2013-06-22 09:29:48','2013-06-22 09:29:48'),(2,'updated book 1','2013-06-22 09:29:48','2013-06-22 09:29:48'),(3,'updated book 2','2013-06-22 09:29:48','2013-06-22 09:29:48'),(4,'updated book 3','2013-06-22 09:29:48','2013-06-22 09:29:48'),(5,'updated book 4','2013-06-22 09:29:48','2013-06-22 09:29:48'),(6,'updated book 5','2013-06-22 09:29:48','2013-06-22 09:29:48'),(7,'updated book 6','2013-06-22 09:29:48','2013-06-22 09:29:48'),(8,'updated book 7','2013-06-22 09:29:48','2013-06-22 09:29:48'),(9,'updated book 8','2013-06-22 09:29:48','2013-06-22 09:29:48'),(10,'updated book 9','2013-06-22 09:29:48','2013-06-22 09:29:48') ON DUPLICATE KEY UPDATE `books`.`name`=VALUES(`name`),`books`.`updated_at`=VALUES(`updated_at`)

updated_at を更新しない

updated_at も更新する必要がないとか、updated_at 分のクエリサイズも減らしたいとかいう時は、:timestamps => false オプションを使えます。
Book.import columns, values, :on_duplicate_key_update => [:name], :timestamps => false
大分スッキリしてうれしいですね :D
  INSERT INTO `books` (`id`,`name`) VALUES (1,'updated book 1'),(2,'updated book 2'),(3,'updated book 3'),(4,'updated book 4'),(5,'updated book 5'),(6,'updated book 6'),(7,'updated book 7'),(8,'updated book 8'),(9,'updated book 9'),(10,'updated book 10') ON DUPLICATE KEY UPDATE `books`.`name`=VALUES(`name`)

validation をオフ

デフォルトでは ActiveRecord モデルの validation を使いますが、validation を切ってしまえば Benchmark 結果のいうように、さらなるスピードアップが図れます。:validate => false オプションを使用します。
Book.import columns, values, :on_duplicate_key_update => [:name], :validate => false

まとめ

まとめのテンプレート的にはこんなかんじになりますかね。
columns = [:id, :name]
Book.all.find_in_batches(:batch_size => 1000) do |books|
  books.each do |book|
    book.name = "updated #{book.name}"
  end
  values = books.map {|book| [book.id, book.name] }
  Book.import columns, values, :on_duplicate_key_update => [:name], :timestamps => false, :validate => false
end

こんなかんじで MySQL のバルクアップデートを効率的に使えば20倍スピードアップできるので積極的に使っていきましょう!参考: Benchmark

あれ、rails4 関係なかった。まぁ、動きましたよ、ということで ^^;

それでは!