バッチ処理などで大量のレコードを処理するときはカーソルベースページネーションを使う

バッチ処理などで大量のレコードを処理するときは、少しづつ取得して処理するためにページネーションを用いるのが一般的だが、このときオフセットベースページネーション(一般的なページネーション)を使ってはいけない。

たとえば以下のように有効期限の切れたファイルの情報を格納するレコードを削除するときを考える。

i = 0
n = 100
now = Time.zone.now
loop do
  records = UploadedFile.where('expired_at <= ?', now)
  records = records.limit(n).offset(n * i)
  records = records.to_a
  break if records.empty?
  records.each do |record|
    record.file.delete # S3等にアップロードしてあるファイルを削除する
    record.destroy # レコードの削除
  end
  i += 1
end

ページネーションにはオフセットベースページネーションを使用しているが、レコードを削除しているため次回ループ時にズレが生じる。

これを防ぐためには、カーソルベースページネーションを用いる。カーソルベースページネーションは以下のようなものである。

  • 一意かつソート可能な識別子をカーソルとする
  • カーソルで昇順(降順)ソート
  • 指定したカーソルより大きい(小さい)もののみをN件抽出

ページネーションにオフセットを用いないため、レコードの削除を行なってもズレが生じない。

cursor = nil
n = 100
now = Time.zone.now
loop do
  records = UploadedFile.where('expired_at <= ?', now)
  records = records.limit(n).sort(id: :asc) # 一意かつソート可能な識別子でソート(ここではauto incrementを用いたID)
  records = records.where('id > ?', cursor) if cursor.present? # 現在のカーソルより大きいもののみに絞る
  records = records.to_a
  break if records.empty?
  records.each do |record|
    record.file.delete # S3等にアップロードしてあるファイルを削除する
    record.destroy # レコードの削除
  end
  cursor = records.last.id # カーソルを更新
end

なお、ActiveRecord の場合は find_in_batches などがこのカーソルベースページネーションを使うので、ActiveRecord を使っている場合はこれを用いるのがよい。

このように、カーソルベースページネーションは一般的にはSNS等のタイムラインなどに使われることが多いが、ページネーション中に対象レコードの増減が起きても歯抜け・重複が起きないという特性がバッチ処理でも有効となる。