Rails 8.1, Ruby 3.4, данные в MySQL, GoodJob на отдельном PostgreSQL. Перед праздниками запустили рассылку итогов года на 300 тысяч подписчиков:
class HolidayDigestJob < ApplicationJob
def perform
User.where(subscribed: true).each do |user|
DigestMailer.with(user: user).holiday.deliver_now
end
end
end
Через сорок минут и 80 тысяч писем контейнер прибили по памяти. Джобу перезапустили, и часть людей получила письмо дважды. Как это должно было выглядеть?
Тут две проблемы, и вторая хуже первой. each по relation загружает все 300 тысяч разом, отсюда память, это лечится find_each. Но даже с ним одна джоба на сорок минут - плохая единица работы: упала посередине, и непонятно, кому уже ушло. Я бы разрезала: одна джоба раздаёт пачки, каждая пачка своя джоба, а факт отправки пишется в базу:
class HolidayDigestJob < ApplicationJob
def perform(campaign_id)
User.where(subscribed: true).in_batches(of: 1_000) do |batch|
DigestBatchJob.perform_later(campaign_id, batch.ids)
end
end
end
class DigestBatchJob < ApplicationJob
def perform(campaign_id, user_ids)
sent = Delivery.where(campaign_id: campaign_id, user_id: user_ids).pluck(:user_id)
User.where(id: user_ids - sent).find_each do |user|
DigestMailer.with(user: user).holiday.deliver_now
Delivery.create!(campaign_id: campaign_id, user_id: user.id)
end
end
end
Тогда перезапуск безопасен: кому уже отправлено, тот отфильтруется.
Для понимания масштаба: объект User с десятком колонок вместе со строками занимает в Ruby порядка нескольких килобайт. 300 тысяч таких - уже гигабайт с лишним, а мейлер добавляет своих объектов на каждое письмо. find_each держит в памяти тысячу записей за раз. Если хочется убедиться, прогони на стейдже memory_profiler на 10 тысячах пользователей, рост будет почти линейный.
И уникальный индекс на deliveries (campaign_id, user_id). Без него проверка sent не спасёт, если одна пачка вдруг выполнится дважды параллельно.
Переделал на пачки по тысяче с таблицей deliveries и уникальным индексом. На стейдже прогнал 300 тысяч фейковых адресов на заглушку почты: 300 джоб, воркеры не выходят за 400 МБ, весь прогон 25 минут на четырёх потоках. Специально убил воркер посередине, после перезапуска дублей ноль. К Восьмому марта будет готово)