Railsで大量データを削除する方法と懸念点

# はじめに
皆様、こんにちは！
佐久間まゆちゃんのプロデューサーの@hiroki_tanakaです。

私はRailsアプリケーションの保守に関わっているのですが先日、本番環境に大量の不要データが存在していることが判明しました。
それがキッカケでRailsでの大量データの削除方法を検討したので、調べたことをまとめました。

# Railsにおけるdestroyとdeleteの違い
まず、Railsには2つのデータ削除メソッドのdestroyとdeleteがあります。
それぞれの違いを簡単にまとめたいと思います。

### destroy/destroy!
ActiveRecordを介して指定した1レコードを削除します。
ActiveRecordを介するためcallbackメソッド(`before_destroy`や`after_destroy`など)やvalidationが機能します。
また、削除対象のModelに`dependent: :destroy`が設定されている関連Modelが存在している場合は、設定されているModelも一緒に削除します。
destroyは実行時にエラーが発生し削除出来なかった場合はfalseを返すだけで例外を返却しません。
対して、destroy!は例外を返却します。
そのため、削除時のエラーを明示的にキャッチしたいという場合はdestroy!を使用するのが良いと思います。

### delete
ActiveRecordを介さずにDBに対して直接SQL(DELETE文)を発行して対象の1レコードを削除します。
ActiveRecordを介さないため、callbackメソッドやvalidationは機能しません。
また、削除対象のModelに`dependent: :destroy`の関連付けされているModelがあったとしても削除しません。
失敗時の挙動はdestroy同様にfalseを返すだけで例外を返却しません。
deleteにはdelete!が存在しないので、「データを削除して、失敗した場合はエラーを返却したい」という場合はdestroy!を素直に使用するのが良いと思います。

### destory_all
destroy/destroy!は1レコードしか削除対象に取れませんが、destory_allは複数レコードが指定可能で指定されたレコードを全て削除します。
destroyと同様にdestory_allもActiveRecordを介するためcallbackメソッドやvalidation・`dependent: :destroy`が機能します。
destory_allはdestroyと同様に実行時にエラーが発生し、途中で削除処理が失敗した場合はfalseを返すだけで例外を返却しません。
ただし、destory_all!というメソッドは存在しないため、「大量データを削除したい。でも、失敗した場合はちゃんとエラーを返して欲しい」という場合は方法を考える必要があります。(その方法は後述します。)

### delete_all
指定された複数レコードをActiveRecordを介さずに削除します。
delete同様にcallbackメソッドやvalidation・`dependent: :destroy`は機能しません。
delete_allもdeleteと同様に実行時にエラーが発生し、途中で削除処理が失敗した場合はfalseを返すだけで例外を返却しません。
個人的にはそんなに使用する場面がないと思うのですが、ActiveRecordを介さない分処理がdestroyやdestory_allより速いです。
そのため、大量データを例外やコールバック・関連を気にせず一気に削除したいという場面では使用できるかと思います。

# 大量データを削除する方法
本題の大量データを削除する方法について見ていきたいと思います。

## 前提
- Ruby 2.6.6
- Rails 6.0.2
- Model:Animal
    - 削除対象データ：10万件(Modelの全量ではない。)
    - callbackメソッド：あり
    - `dependent: :destroy`の関連付けModel：あり

今回削除対象のModelにはcallbackも`dependent: :destroy`で関連付いたModelもあります。
要件として関連Modelも削除する必要があり、処理途中にエラーがあった場合明示的にキャッチしたいです。
この時点でdelete/delete_all/destroy_allは使用できません。
そして、本番稼働中のアプリケーションが動いている裏側で削除処理を実行する必要があります。
なので、極端にDBに負荷の掛かる方法はあまり取りたくありません。

## やり方①：トランザクションを張らずに1件1件にdestroy!を行う
```ruby
  animals = Animal.where(type: 'dog') # 削除対象のデータ抽出
  animals.each do |animal|
    animal.destroy!
  end
```

最もシンプルに考えるとこのようなコードになると思います。
ただし、2点問題点があります。

- 10万回destroyが走り続けるので負荷が大きい。
- 削除処理の途中でエラーが発生し処理が落ちた場合、それまでに削除されたデータはロールバックせずに削除されたままとなる。(やり直しが効かない。)

そのため、DBに余力がある場合やアプリケーションに明確な閉局時間が決まっている場合かつ、削除が途中で失敗際にロールバックしなくても問題ない・データ整合性を問わない場合はこの方法で良いかと思います。

## やり方②：トランザクションを張った上で1件1件にdestroy!を行う
```ruby
  animals = Animal.where(type: 'dog') # 削除対象のデータ抽出
  ActiveRecord::Base.transaction do
    animals.each do |animal|
      animal.destroy!
    end
  end
```

①のやり方の削除処理を1トランザクションとしました。
1トランザクションとすることで削除処理の途中でエラーが発生した場合、削除処理は全てロールバックしやり直しが効きます。
そのため、この方法を使えばデータ整合性が求められるデータを安全に一括削除することが出来ます。

1点注意点として、トランザクションを明示的に張る場合は必ずdestroy!を使用しなければなりません。
destroyはエラーが発生しても例外を発生させずにfalseを返すだけなので、処理が止まらずトランザクションから抜けないためです。

## やり方③：トランザクションを張った上で1000件毎にデータを抽出し、destroy!を行う。削除処理の合間に0.1秒のsleepを入れる。
```ruby
  animals = Animal.where(type: 'dog') # 削除対象のデータ抽出
  ActiveRecord::Base.transaction do
    animals.in_batches.each do |delete_target_animals|
      delete_target_animals.map(&:destroy!)
      sleep(0.1)
    end
  end
```

上記のやり方が今回採用した方法です。
[ActiveRecord::Relation#in_batchesメソッド](https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-in_batches)を使用して、10万件のレコードを1000件単位でまとめて取得し、その塊の1件ずつに対してdestroy!を行います。
そして、1000件毎の削除処理が完了すると`sleep(0.1)`で0.1秒処理を停止し、DBへの負荷を軽減させます。
また、外側に大きなトランザクションを張っているので、削除処理の途中でエラーが発生しても全てロールバックされやり直しが効くので安全です。

※補足：in_batchesメソッドの件数変更方法
in_batchesメソッドは`of`オプションを指定することで、取得する件数を変更できます。
デフォルトでは1000件です。
下記のように記載すれば10000件毎に処理を行います。

```ruby
  animals = Animal.where(type: 'dog') # 削除対象のデータ抽出
  ActiveRecord::Base.transaction do
    animals.in_batches(of: 10000).each do |delete_target_animals|
      delete_target_animals.map(&:destroy!)
      sleep(0.1)
    end
  end
```

## おわりに
大量データの削除に関しては、要件次第で様々な方法があると思います。
そのため、ベストプラクティスがあれば是非伺いたいです(o｡_｡)oﾍﾟｺｯ