DEV Community

Kaziu
Kaziu

Posted on

Rails の `dependent: :destroy` が激重になる話を簡単な例で

何が起きるか

親レコードを消すと、子レコードも一緒に消えて便利な dependent: :destroy(いわゆる cascade 削除)。
でも子がコールバックを持っていると、削除のたびにそれが発火します。

class Author < ApplicationRecord
  has_many :books, dependent: :destroy
end

class Book < ApplicationRecord
  belongs_to :author, touch: true   # 消すと親の更新日時を更新(いわゆる touch)
  after_destroy :reindex_search     # 1件消すたびに Elasticsearch を再インデックス(同期・重い)
end
Enter fullscreen mode Exit fullscreen mode

author.destroy すると…

Author を1件destroy
 └─ books が1000件あれば 1件ずつ destroy(cascade)
      └─ そのたびに親を touch し、after_destroy で
         Elasticsearch 更新が「直列」で1000回走る 😱
Enter fullscreen mode Exit fullscreen mode

件数が少ないうちは気づかず、本番のデータ量で突然タイムアウトします。

対策:重い子だけ「コールバック無し」で先に消す

delete_all はコールバック(cascade も touch も)を発火させず、SQL 1発で消します。

ActiveRecord::Base.transaction do
  Book.where(author_id: author.id).delete_all  # ① 重い子を静かに一掃
  author.destroy                               # ② もう子はいないので連鎖しない
end

# Elasticsearch の更新は捨てずに、あとで非同期・一括で1回だけ
ReindexJob.perform_later(author.id)            # ③ トランザクションの外で
Enter fullscreen mode Exit fullscreen mode

ポイント3つ

やること 理由
重い子を delete_all で先に消す コールバック(cascade / touch)を発火させない(destroy_all は発火する)
本体はそのあと消す 連鎖する子が残っていないので重い処理が走らない
重い処理(ES再インデックス)は非同期ジョブで一括 「同期で1件ずつ」→「非同期でまとめて1回」に置き換え

用語メモ

  • cascade(カスケード) … 親を消すと子も連鎖して消える挙動。dependent: :destroy がこれ
  • touch … 中身は変えず「更新された印」として関連レコードの更新日時だけ更新すること
  • destroy = 1件ずつ読み込み、コールバックを呼ぶ(安全だが遅い)
  • delete = SQLで直接、コールバックを呼ばない(速いが自己責任)

Top comments (0)