find_in_batchesでoffsetを使うとどうなる?Railsのバッチ処理でハマったポイント
目次
はじめに
大量のデータを処理するとき、Railsでは find_in_batches を使うことがあります。
例えば、
User.find_in_batches(batch_size: 1000) do |users|
users.each do |user|
# 処理
end
end
のように書くことで、すべてのレコードを一度にメモリへ読み込まず、一定件数ずつ処理できます。
バッチ処理を実装している中で、
「最初の10,000件を飛ばして、その続きから
find_in_batchesすればいいのでは?」
と考え、offset を組み合わせたことがありました。
例えば、次のようなコードです。
User.offset(10_000).find_in_batches(batch_size: 1000) do |users| # 処理 end
一見すると、
最初の10,000件をスキップ ↓ 10,001件目から1,000件ずつ処理
してくれそうに見えます。
しかし、find_in_batches の仕組みを理解せずに offset を組み合わせると、意図せずレコードを飛ばしてしまう可能性があります。
この記事では、
find_in_batchesがどのように次のバッチを取得しているのかoffsetを組み合わせると何が起こるのか- 途中からバッチ処理を始めたい場合にどうすればよいのか
を整理します。
そもそも find_in_batches はどう動くのか
例えば、次のコードを実行するとします。
User.find_in_batches(batch_size: 5) do |users| # 処理 end
分かりやすく、ユーザーのIDが1〜30まで連続しているとします。
最初のバッチでは、
1 2 3 4 5
を取得します。
次の5件を取得するとき、単純に、
OFFSET 5
としているわけではありません。
概念的には、
WHERE id > 5 ORDER BY id ASC LIMIT 5
のように、前回取得した最後のIDを基準に次のレコードを取得していきます。
そのため、
1〜5 ↓ 6〜10 ↓ 11〜15 ↓ 16〜20
という形で処理が進みます。
大量データを扱う場合、OFFSETを増やしながら取得するよりも、このようにカーソルとなる値を基準に取得する方が扱いやすいケースがあります。
offset を付けるとどうなるのか
ここで、
User.offset(10).find_in_batches(batch_size: 5) do |users| # 処理 end
としてみます。
最初に想像しやすい動きは、
1〜10をスキップ 11〜15 16〜20 21〜25 26〜30
ではないでしょうか。
つまり、
最初にOFFSET 10を適用 ↓ そこから通常のバッチ処理
というイメージです。
しかし、実際には offset がRelationに残った状態で、次のバッチを取得するための条件が追加されていきます。
例えば、IDが1〜30まで存在するとします。
1回目の取得
最初は、
OFFSET 10 LIMIT 5
なので、11〜15 を取得します。
ここまでは想定通りです。
2回目の取得
次のバッチでは、前回最後に取得したIDを基準に、
id > 15
という条件が追加されます。
ただし、OFFSET 10 も残っています。
そのため、イメージとしては、
id > 15 ↓ 16〜30が対象 OFFSET 10 ↓ 16〜25をスキップ LIMIT 5 ↓ 26〜30を取得
となります。
結果として、
11〜15 26〜30
が処理され、
16〜25
が処理対象から抜けてしまいます。
つまり、
offset は最初の1回だけ適用されるわけではありません。
これが find_in_batches と offset を安易に組み合わせると危険な理由です。
order と同じ感覚で考えない
find_in_batches では、通常の order がそのまま利用されないケースがあります。
例えば、
User.order(created_at: :desc).find_in_batches
のようなコードを書いた場合、バッチ処理ではカーソルとして利用するカラムを基準にした順序が使われます。
そのため、
offsetもバッチ処理時に無視されるのでは?
と考えてしまうかもしれません。
しかし、order と offset は同じ扱いではありません。
offset を指定したRelationに対して find_in_batches を使うと、offsetが残ったままバッチ処理が進むため注意が必要です。
途中から処理したい場合は start を使う
では、
ID 10,000以降のデータから処理したい
場合はどうすればよいでしょうか。
find_in_batches には start を指定できます。
User.find_in_batches( start: 10_000, batch_size: 1000 ) do |users| # 処理 end
この場合、デフォルトで主キーをカーソルとして使っているなら、
id >= 10,000
を基準に処理が始まります。
イメージとしては、
ID 10,000以降を取得 ↓ 1,000件処理 ↓ 最後に取得したIDを基準に次の1,000件を取得 ↓ 繰り返す
となります。
終了位置を指定したい場合は finish も利用できます。
User.find_in_batches( start: 10_000, finish: 20_000, batch_size: 1000 ) do |users| # 処理 end
処理範囲を分割したい場合にも使えます。
start: 10_000 と offset(10_000) は意味が違う
ここは特に注意したいポイントです。
User.offset(10_000)
は、
検索結果の先頭から10,000件を飛ばす
という意味です。
一方で、
User.find_in_batches(start: 10_000)
は、デフォルトで主キーをカーソルにしている場合、
IDが10,000以上のレコードから処理を始める
という意味になります。
つまり、
offset(10_000)
start: 10_000
上記2つは同じではありません。
例えばIDに欠番があり、
1 2 3 10000 10001
というデータだった場合、
start: 10_000
は、
10,000件スキップする
という意味ではありません。
あくまで、
id >= 10,000
から処理を始めるという意味です。
「件数」と「カーソルの値」を混同しないことが大切です。
バッチ処理を途中から再開するなら、最後に処理したIDを持つ
例えば、
バッチ処理が途中で失敗したので、続きから再開したい
というケースを考えます。
この場合、
10,000件処理した
という件数だけを保存して、
offset(10_000)
から再開するよりも、
最後に正常に処理したID
を保存しておく方が扱いやすくなります。
例えば、
最後に処理したID = 12,543
だった場合、
User.find_in_batches( start: 12_544, batch_size: 1000 ) do |users| # 処理 end
のように再開できます。
つまり、
何件処理したか
ではなく、
どのレコードまで処理したか
を記録しておく考え方です。
大量データを扱うバッチ処理では、この違いが重要になります。
OFFSETはパフォーマンス面でも注意が必要
offset を大量データで使う場合は、処理の正しさだけでなくパフォーマンスにも注意が必要です。
例えば、
SELECT * FROM users ORDER BY id LIMIT 1000 OFFSET 10000000;
のように、非常に大きなOFFSETを指定した場合です。
データベースは目的の位置までレコードを読み進める必要があるため、OFFSETが大きくなるほど処理コストが増えることがあります。
大量データを順番に処理する場合は、
10,000,000件スキップ
するのではなく、
前回の最後のID
を基準に、
WHERE id > ?
として続きを取得する方が適しているケースがあります。
バッチ処理でも、
コードが動くかだけではなく、実際にどんなSQLが発行されるかを見ることが重要です。
Railsのログで実際のSQLを確認する
今回のように挙動が分かりにくい場合は、小さなデータを用意してRailsのログを確認すると理解しやすくなります。
例えば、
User.offset(10)
.find_in_batches(batch_size: 5) do |users|
puts users.map(&:id)
end
を実行して、
1回目はどんなSQLが発行された? 2回目ではどんな条件が追加された? OFFSETは残っている? 次のバッチはどの値を基準に取得している?
を確認します。
Active Recordのコードだけを見て、
たぶんこう動くだろう
と判断するのではなく、
実際に発行されたSQLを確認する。
バッチ処理の挙動を理解する上でも大切なポイントです。
まとめ
find_in_batches と offset を組み合わせる場合は注意が必要です。
一見すると、
OFFSETを一度適用 ↓ そこからバッチ処理
のように見えます。
しかし、offsetが残ったRelationに対してバッチ処理のカーソル条件が追加されていくため、
1回目 OFFSETを適用 ↓ 2回目 カーソル条件 + OFFSET ↓ 3回目 カーソル条件 + OFFSET
という動きになり、意図せず処理対象のレコードを飛ばす原因になります。
途中から処理を始めたい場合は、
find_in_batches(start: ...)
を使う方法があります。
また、処理を途中から再開する必要があるなら、
処理した件数
ではなく、
最後に処理したID
など、カーソルとして利用できる値を記録しておく方法も考えられます。
今回のポイントをまとめると、
offset ↓ 件数をスキップする start ↓ カーソルの開始位置を指定する
という違いです。
find_in_batches を使うときは、メソッド名だけで動きを判断せず、次のバッチがどのような条件で取得されているのかまで確認する。
大量データを扱うバッチ処理では、特に意識しておきたいポイントです。



















