📦

【Rails】大量のデータを登録するスクリプトで気をつけたいこと

2024/09/26に公開

こんにちは!
ラブグラフエンジニアのひろです。

今回は大量のデータを流し込みたいときに意識するポイントを挙げていこうと思います。
メモリやデータベースへの負荷をなるべく減らし、効率的なデータ登録ができるように工夫できることを考えていきましょう。

1. バッチ処理

大量データの処理では、「一度に処理するデータ量」を適切に設定することが重要です。
ActiveRecord の find_in_batchesin_batchesfind_each を使うと、大量のデータをバッチ単位で処理できます。

find_in_batches は指定したバッチサイズ(デフォルトは1000件)でデータを Array で取得し、それぞれのバッチごとに処理を行います。

def perform
  Book.find_in_batches(batch_size: 1000) do |array|
    array.each do |book|
      some_method(book)
    end
  end
end

in_batchesfind_in_batches とほぼ同じですが、 ActiveRecord::Relation がブロックに渡されます。

def perform
  Book.in_batches(batch_size: 1000) do |relation|
    relation.each do |book|
      some_method(book)
    end
  end
end

また、find_each を使うと、指定したバッチサイズ(デフォルトは1000件)でデータを1件ずつ取得し、メモリに負担をかけずに処理することができます。

def perform
  Book.find_each(batch_size: 1000) do |book|
    some_method(book)
  end
end

find_each を使うことで、メモリの使用量を抑えつつ、効率よく大量データを処理できます。

2. activerecord-import によるバルクインサート

大量のデータを登録する際、1件ずつ INSERT を実行するのは非効率です。
そこで、activerecord-import という Gem を使って、一度に複数のレコードをデータベースに挿入する「バルクインサート」をおこなうことで、パフォーマンスを大幅に向上させることができます。

Gemfile
gem 'activerecord-import'
def perform
  books = []
  (1..1000).each do |num|
    books << Book.new(title: "Book #{num}")
  end

  Book.import books # バルクインサート
end

activerecord-import を使うことで、通常のループ処理に比べて圧倒的に高速で、負荷の少ないデータ登録が可能になります。

Rails 6 から一括INSERTをおこなうための insert_all メソッドが追加されています。
バリデーションやコールバックを無視する仕様ですが、こちらの使用も検討してみてください。
https://railsdoc.com/page/insert_all

3. エラーハンドリングとリトライ

ここからは Sidekiq を例に、非同期処理としてジョブを実行する場合の注意点について説明します。
特に、リトライ機能に関連したエラーハンドリングについて詳しく見ていきます。

リトライの問題

大量のデータを処理していると、途中で予期しないエラーが発生する可能性が高まります。
たとえば、データベースの接続エラーやタイムアウト、外部APIのレスポンスエラーなど、さまざまな原因でジョブが失敗することがあります。

Sidekiq では、ジョブが失敗した際に自動でリトライがおこなわれるため、処理が途中で失敗しても再試行されます。
この自動リトライは非常に便利な機能ですが、適切にエラーハンドリングをしないと、次のような問題が発生する可能性があります。

よくある問題

同じデータを何度も処理してしまう: リトライ時に、エラーが発生した箇所までの処理が繰り返し実行されるため、例えば同じデータの登録が何度もおこなわれることがあります。
これにより、データが重複したり、意図しない結果になることがあります。

無限リトライ: ジョブが何度も失敗し続ける場合、リトライが無限に続いてしまい、システムリソースを消費し続けるリスクがあります。特に、クリティカルなエラーが発生している場合にこれを防止することが重要です。
(Sidekiq の場合は25回がリトライ上限となっているので無限リトライは避けられます)

対策

自動リトライを活用する場合、以下の対策を講じておくことで、安定したジョブ実行が可能になります。

1. リトライ回数の制御
自動リトライの回数を制限することが可能です。
例えば、5回リトライしても失敗する場合、ジョブを再試行するのを止める。という設定にできます。

class MyWorker
  include Sidekiq::Worker
  sidekiq_options retry: 5 # 最大5回リトライ
end

上記のように retry: 5 を指定することで、リトライ回数を最大5回に制限し、それ以上リトライしないようにします。

2. エラー発生時のリトライ防止
場合によっては、特定のエラーが発生した際にリトライを防止したいことがあります。
sidekiq_retry_in メソッドを使用することで、ジョブが失敗したときにリトライの挙動を細かく制御できます。

class MyWorker
  include Sidekiq::Worker

  # リトライ時の挙動をカスタマイズ
  sidekiq_retry_in do |count, exception, jobhash|
    case exception
    when ActiveRecord::RecordNotFound
      :discard # レコードが見つからない場合、リトライせずジョブを破棄
    when StandardError
      :kill # その他のクリティカルなエラーはデッドセットに送る
    end
  end

  def perform
    ...
  end
end

エラーハンドリングについてはこちらが詳しいです
https://github.com/sidekiq/sidekiq/wiki/Error-Handling#configuration

これらの設定をおこなうことにより、不要なリトライを避けることができ、起きうる問題に対して柔軟に対応することができるようになります。

リトライを停止するだけでなく、間隔を制御する方法もあり、こちらで紹介しています。
https://zenn.dev/lovegraph/articles/09dec0f2727f50

4. 実行中の監視

大量のデータを処理するジョブは、実行時間が長くなりがちです。
そのため、ジョブの進捗やステータスをリアルタイムで監視する仕組みがあるとより安心でしょう。
Slack などのツールを使い、定期的に進行状況を確認することで、問題が発生した際に迅速に対応できます。

例えば、1000件処理するごとに Slack に通知を送るスクリプトは次のように実装できます。

def perform
  Book.find_in_batches(batch_size: 1000).with_index do |batch, index|
    batch.each do |book|
      some_method(book)
    end

    notify_slack("処理が #{(index + 1) * 1000} 件まで完了しました")
  end
end

def notify_slack(message)
  SlackNotifier.notify(message)
end

このように、進捗状況をリアルタイムで可視化することで、ジョブがどこまで進んでいるのか、問題が発生していないかをすぐに把握できます。

まとめ

以上、大量のデータを扱う際に意識したいポイントについて紹介しました。
処理を効率化するためには、適切なバッチ処理やバルクインサート、エラーハンドリング、そしてジョブの進行状況をリアルタイムで把握する仕組みが重要です。

これらの工夫を取り入れることで、大量のデータを扱うスクリプトやジョブでもシステムへの負荷を抑えつつ、安定してデータ処理を進めることができるでしょう。
ぜひ、日々の開発に取り入れてみてください!

ラブグラフのエンジニアブログ

Discussion