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 を使うときは、メソッド名だけで動きを判断せず、次のバッチがどのような条件で取得されているのかまで確認する。

大量データを扱うバッチ処理では、特に意識しておきたいポイントです。