Массовые уведомления клиентам: очередь вместо залпа
Юрист согласовал текст, маркетинг нажал «отправить», и через десять минут у вас 50 тысяч писем или SMS, которые нужно доставить всей базе клиентов одновременно: меняются условия обслуживания, отзывается функция, меняется тариф. Это не всплеск, вызванный внешними обстоятельствами, — это ваша собственная кнопка «отправить всем», и именно поэтому её проще спланировать заранее. Разберём, почему цикл for client in clients: send(client) кладёт почтовый шлюз или SMS-провайдера за минуты, и как вместо залпа выстроить очередь, которая растянет отправку по времени, не потеряв актуальности сообщения.
Содержание
- Почему прямой цикл отправки не работает на масштабе
- Архитектура: очередь вместо синхронного цикла
- Пул воркеров и контроль скорости отправки
- Защита от rate-limit провайдера: что учитывать помимо общего лимита
- Как растянуть отправку по времени, не потеряв актуальности сообщения
- Мониторинг прогресса и отчётность о рассылке
Почему прямой цикл отправки не работает на масштабе
Самый очевидный способ разослать уведомление — взять список получателей и в цикле дёргать API отправки: SMTP, HTTP-эндпоинт SMS-агрегатора, push-шлюз. На тестовой базе в сто записей это работает мгновенно и создаёт иллюзию, что решение готово. Проблема начинается на масштабе в тысячи и десятки тысяч получателей, и у неё несколько независимых причин.
Синхронный HTTP-запрос держит воркер до ответа. Если рассылка идёт из обработчика веб-запроса или из процесса, который дожидается ответа каждого вызова API, время выполнения линейно растёт с числом получателей. При среднем времени ответа внешнего API в 200-300 мс (это ориентир — у вашего провайдера может быть иначе, проверяйте по факту) 50 тысяч писем в один поток — это несколько часов работы одного процесса. Процесс либо упрётся в таймаут HTTP-запроса, который его запустил, либо будет держать соединение с базой открытым всё это время, либо просто упадёт при первом сетевом сбое где-то на 30-й тысяче — и непонятно, кому уже ушло, а кому нет.
Провайдер отправки режет вас по rate-limit. У любого почтового или SMS-шлюза есть лимит запросов в секунду или в минуту на аккаунт — это защита самого провайдера от его собственной перегрузки и от репутационных рисков. Прямой цикл без пауз упирается в этот лимит за секунды: провайдер начинает отвечать 429 Too Many Requests или молча выбрасывать часть запросов. О механике rate limiting и о том, почему наивная реализация лимитов бьёт по честному трафику сильнее, чем по злоумышленникам, — в статье про rate limiting; тот же принцип действует и на стороне провайдера, к которому обращаетесь вы.
Нет точки восстановления при сбое. Если процесс упал на середине списка, у вас нет надёжного способа понять, кому отправка ушла успешно, а кого нужно повторить, — если только вы не вели отдельный журнал прогресса вручную. Повторный запуск с начала списка означает дублирование уведомлений тем, кто уже получил письмо, а для многих типов уведомлений (например, про изменение цены или условий) дубль хуже, чем задержка.
Нагрузка приходит залпом на всю инфраструктуру разом. Даже если вы шлёте не напрямую во внешний API, а сначала пишете задачи в базу или очередь, само формирование 50 тысяч сообщений — это нагрузка на базу данных, на рендеринг шаблона письма для каждого получателя, на сеть. Если это происходит синхронно в одном запросе, вы рискуете уложить не только отправку, но и остальной прод, который делит с этим процессом ресурсы сервера.
Важно отделить эту ситуацию от похожей, но по сути другой: если у вас растёт очередь писем из-за внешнего всплеска регистраций (пришло много новых пользователей одновременно и всем нужно письмо с подтверждением) — там источник нагрузки внешний и непредсказуемый по времени. Здесь источник — вы сами, вы точно знаете момент и объём заранее, и это меняет весь подход: можно спланировать окно отправки, темп и приоритеты сильно заранее, а не тушить пожар постфактум.
Архитектура: очередь вместо синхронного цикла
Правильная схема разбивает задачу на два независимых шага: формирование списка задач на отправку и сама отправка, которая происходит из очереди, а не из прямого цикла.
- Продюсер формирует список получателей по критерию (все клиенты, клиенты определённого тарифа, клиенты определённого региона) и кладёт в очередь по одной задаче на каждого получателя — не одно гигантское сообщение «разошли всем», а атомарные единицы работы.
- Брокер (RabbitMQ, Redis, встроенная очередь задач вашего фреймворка) хранит эти задачи и отдаёт их воркерам по готовности, а не всем разом.
- Пул воркеров — ограниченное число процессов или потоков, которые забирают задачи из очереди с контролируемой скоростью и вызывают API отправки.
Общий принцип и механику работы очереди сообщений — продюсер, брокер, консьюмер, at-least-once доставка и идемпотентность — подробно разбирает статья как устроена очередь сообщений. Для рассылки уведомлений это не абстракция ради абстракции, а конкретный способ решить обе проблемы разом: и защититься от rate-limit провайдера, и получить надёжный прогресс с возможностью докрутить недоставленное после сбоя.
Минимальный пример на Python с Redis Streams в качестве брокера — постановка задач в очередь:
import redis
import json
r = redis.Redis(host="localhost", port=6379, db=0)
def enqueue_notifications(client_ids: list[str], template: str):
for client_id in client_ids:
r.xadd("notifications:queue", {
"client_id": client_id,
"template": template,
})
# Формирование задачи занимает секунды, а не часы —
# сама отправка происходит отдельно, воркерами
enqueue_notifications(all_client_ids, template="terms_update_2026")
Здесь важно, что enqueue_notifications не отправляет ни одного письма — она только записывает намерение в очередь. Это быстрая операция (запись в Redis для 50 тысяч записей занимает секунды, а не часы), и именно поэтому её можно смело вызывать из обработчика админ-панели без риска положить веб-сервер.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПул воркеров и контроль скорости отправки
Воркер — отдельный процесс, который читает задачи из очереди и вызывает внешний API. Ключевой параметр здесь — не «сколько воркеров запустить», а «с какой суммарной скоростью они бьют по провайдеру».
import redis
import time
r = redis.Redis(host="localhost", port=6379, db=0)
RATE_LIMIT_PER_SEC = 10 # ориентир — уточните реальный лимит у вашего провайдера
def worker():
last_group = "0"
while True:
messages = r.xread({"notifications:queue": last_group}, count=1, block=5000)
if not messages:
continue
for stream, entries in messages:
for msg_id, data in entries:
send_notification(data[b"client_id"], data[b"template"])
last_group = msg_id
time.sleep(1 / RATE_LIMIT_PER_SEC)
Это самая простая реализация — один воркер с паузой между отправками, ограничивающей темп до RATE_LIMIT_PER_SEC сообщений в секунду. На практике удобнее держать несколько воркеров с общим лимитом, а не паузу в каждом: например, токен-бакет в Redis, который выдаёт «разрешения на отправку» с фиксированной скоростью, и все воркеры конкурентно их забирают. Это точнее держит суммарный темп независимо от числа воркеров и позволяет масштабировать пропускную способность горизонтально — добавили воркер, общий лимит остался прежним, просто задачи разбираются равномернее.
Готовые библиотеки очередей задач (Celery, RQ, BullMQ, Sidekiq) обычно уже умеют ограничивать темп на уровне очереди или задачи — не изобретайте токен-бакет с нуля, если у вас уже есть один из этих инструментов в стеке; используйте встроенный rate limit конкретно для очереди уведомлений, не трогая остальные.
Защита от rate-limit провайдера: что учитывать помимо общего лимита
Лимит запросов в секунду — не единственное ограничение, о которое разбивается массовая рассылка.
Лимиты бывают многоуровневыми. У почтовых и SMS-провайдеров часто есть отдельный лимит в секунду, отдельный в минуту и отдельный в сутки (или в тарифном периоде) — упереться можно в любой из них независимо. Прежде чем проектировать темп отправки, посмотрите документацию конкретного провайдера на все уровни лимитов, а не только на «запросов в секунду», которое чаще всего указано на главной странице тарифов.
Лимит часто общий на аккаунт, а не на IP. В отличие от rate limiting на вашем сервере, где ограничение обычно привязано к IP клиента, лимит у почтового или SMS-провайдера чаще привязан к API-ключу или аккаунту целиком. Это значит, что параллельные воркеры не «размазывают» нагрузку — они складываются в один и тот же счётчик, и превышение общего темпа наступит так же, как если бы отправка шла из одного потока.
Ответ 429 нужно обрабатывать retry с backoff, а не считать провалом. Если провайдер всё же отдал 429 (кратковременный всплеск, временная просадка лимита с его стороны), правильная реакция воркера — вернуть задачу в очередь с экспоненциальной задержкой перед повтором, а не помечать получателя как «не доставлено» окончательно. Большинство брокеров задач поддерживают это из коробки через retry-политику на уровне задачи.
Для email отдельно действуют правила доставляемости, а не только скорость. Резкий всплеск исходящих писем даже в пределах формального rate-limit API может насторожить принимающие почтовые серверы (Gmail, Mail.ru) и привести к попаданию в спам — это отдельный от лимита API механизм репутации. Если рассылка идёт через собственный SMTP, а не через транзакционный email-сервис, важны SPF/DKIM/DMARC и постепенный темп отправки на стороне вашего Postfix — это подробно разобрано в статье про настройку массовой рассылки с сервера. Массовое уведомление всей базы — это тот случай, когда даже прогретый IP может получить повышенное внимание фильтров просто из-за резкого объёма, поэтому темп из предыдущего раздела нужен даже при отсутствии формального 429 от провайдера.
Как растянуть отправку по времени, не потеряв актуальности сообщения
Главное возражение против очереди — «а если сообщение срочное, нам что, ждать час, пока разошлётся всем?». Ответ зависит от типа уведомления, и здесь стоит разделить два случая.
Сообщение с точным дедлайном (например, «сервис отключается в 18:00»). Здесь важно, чтобы весь темп отправки укладывался в разумный запас до дедлайна. Посчитайте заранее: если у вас 50 тысяч получателей и провайдер держит 20 запросов в секунду, полная рассылка займёт около 42 минут (50000 / 20 / 60) — это ориентир на конкретных цифрах вашей задачи, у вас лимит и объём базы будут другими, посчитайте свои. Запускайте отправку с запасом, а не впритык к дедлайну, и мониторьте прогресс очереди, а не полагайтесь, что «должно успеть».
Сообщение без жёсткого дедлайна (изменение условий, которое вступает в силу через неделю). Здесь можно не гнаться за минимальным временем отправки вообще, а сознательно растянуть рассылку на часы или дни — это снижает нагрузку и на вашу инфраструктуру, и на репутацию у почтовых провайдеров. Разумный компромисс — сегментировать базу и слать волнами: сначала небольшой процент (проверить, что шаблон рендерится верно, ссылки рабочие, отписки не сыплются массово), затем остальное с постепенным ростом объёма.
Приоритизация внутри очереди. Если часть получателей критичнее остальных (например, клиенты на активном тарифе против тех, кто давно не платил), заведите две очереди или используйте приоритет сообщения, если брокер это поддерживает (в RabbitMQ — priority queue, в Kafka — отдельный топик с более частым опросом). Это позволяет гарантировать, что важные получатели получат уведомление в первую очередь, даже если общий объём рассылки большой.
Не пересчитывайте список получателей на лету во время долгой отправки. Если формирование очереди заняло час, а список клиентов формировался запросом «выбрать всех активных клиентов» в момент старта, а не был зафиксирован снапшотом, к концу рассылки список реальных активных клиентов уже другой — кто-то ушёл, кто-то появился. Зафиксируйте список получателей одним снапшотом в момент постановки в очередь, а не пересчитывайте условие на каждой итерации.
Мониторинг прогресса и отчётность о рассылке
Синхронный цикл в этом смысле обманчиво прост: он либо закончился, либо упал, и вы это видите сразу. Очередь скрывает прогресс, если не добавить наблюдаемость намеренно — и это нужно сделать до старта рассылки, а не после того, как маркетинг спросил «ну как там, всем ушло?».
Минимальный набор метрик:
- Длина очереди — сколько задач ещё не забрано воркерами. Растущая или не убывающая длина сигнализирует, что воркеры не успевают или упали.
- Число успешных и неуспешных отправок — отдельные счётчики, а не общий «обработано». Неуспешные нужно разбирать: временная ошибка провайдера (retry поможет) или постоянная (неверный адрес, отписка, заблокированный номер — retry не поможет, нужно исключить из списка).
- Задачи в dead letter — сообщения, которые исчерпали лимит повторов и не были доставлены. Их нужно явно просматривать после рассылки, а не оставлять в очереди навсегда.
- Оценочное время до завершения — исходя из текущей скорости обработки и оставшейся длины очереди, чтобы отвечать на вопрос «когда закончится» не гаданием, а расчётом.
Простой способ получить эти метрики без внешней системы мониторинга — писать статус каждой задачи (отправлено, ошибка, в retry) в отдельную таблицу или ключи Redis с TTL и раз в минуту снимать агрегат оттуда в дашборд или просто в лог, который проверяете вручную во время активной рассылки.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько ресурсов нужно серверу под такую очередь?
Для очереди на 50-100 тысяч задач Redis или RabbitMQ комфортно работают на паре гигабайт оперативной памяти — сообщения небольшие (id получателя, шаблон, метаданные). Основная нагрузка не на брокер, а на сеть при обращении к внешнему API отправки — закладывайтесь на стабильный исходящий канал.
Можно ли просто добавить time.sleep() в прямой цикл вместо очереди?
Для совсем небольшой базы (несколько сотен получателей) — да, рабочий компромисс без лишней инфраструктуры. Но у него нет устойчивости к сбою процесса: упал на середине — не знаете, кому ушло, а кому нет, без ручного журнала. Очередь даёт эту устойчивость почти бесплатно, если Redis уже есть в стеке.
Что делать, если провайдер всё равно вернул массовую блокировку по подозрению в спаме?
Это репутационная проблема, а не про скорость. Останавливайте отправку немедленно, разбирайтесь с провайдером напрямую через поддержку, и на будущее закладывайте больший запас по времени и меньший темп на старте — резкий выход на полную скорость чаще триггерит защиту провайдера, чем растянутая по часам отправка.
Нужна ли очередь, если уведомление отправляется push-сервисом (FCM, APNs), а не почтой или SMS?
Да, механика та же: у push-провайдеров тоже есть лимиты на батч и на частоту, хотя обычно выше, чем у email/SMS. Разница в том, что push-сервисы часто принимают пакетную отправку (батчи токенов за один вызов), так что задача в очереди — это батч, а не один получатель, но принцип тот же.
Как быть, если часть базы вообще не должна получить уведомление (отписались, заблокированы, недействующий адрес)?
Фильтруйте получателей на этапе формирования очереди, а не отправки — так вы не тратите слоты rate-limit на заведомо неуспешные попытки и не получаете жалобу от того, кто явно отписался. Держите список исключений отдельной таблицей, которую проверяете перед постановкой задачи в очередь.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →