← Назад в блог

Очередь надо выводить, а не хранить: возобновляемые рассылки

Опубликовано
4 мин чтения
--- просмотров

Отправить одно письмо просто. С сорока тысячами задача другая, и сложность в ней не в пропускной способности. Всё решает то, что произойдёт, когда прогон встанет на середине.

А он встанет. Тик крона упрётся в таймаут. PHP выберет лимит памяти. SMTP-релей начнёт отказывать в соединениях. Кто-нибудь выкатит релиз. И вот вы отправили неизвестно сколько писем и должны решить, что делать дальше.

Два привычных ответа, и оба плохи

Можно запустить сначала. Тогда все, кто уже получил письмо, получают его снова. На списке в сорок тысяч это массовый дубль, всплеск отписок и доля жалоб на спам, которая будет тянуться за доменом месяцами.

Можно вести курсор: хранить последнюю обработанную строку и продолжать с неё. Работает ровно до момента, когда аудитория меняется прямо во время прогона. Кто-то отписался, кто-то добавился в сегмент, и курсор указывает на позицию в списке, который под ним сдвинулся. Кого-то вы пропустите, кому-то продублируете, и понять, что именно случилось, будет нельзя.

Оба варианта проваливаются по одной причине. Они считают запоминаемой сущностью прогресс, а прогресс - это факт о прогоне. Прогоны умирают.

Вместо этого выводить

Долговечный факт звучит иначе: этому человеку эта кампания отправлена. Одна строка, записанная в момент отправки, переживает смерть того прогона, который её записал.

Как только такая запись есть, очередь можно вообще не хранить. Она становится запросом:

SELECT c.ID AS c_id, c.title, c.content, u.ID AS u_id, u.user_email
FROM   wp_users u
JOIN   mm_user_category     uc  ON u.ID = uc.user_id
JOIN   mm_campaign_category cc  ON uc.category_id = cc.category_id
JOIN   mm_campaigns         c   ON cc.campaign_id = c.ID
LEFT JOIN mm_user_campaign  d   ON u.ID = d.user_id AND c.ID = d.campaign_id
WHERE  d.status IS NULL
  AND  c.is_active = 1
LIMIT  19

Связка LEFT JOIN с условием IS NULL и есть вся идея: подписчики, у которых нет записи о доставке по этой кампании. Остальное следует из неё.

Восстановление после падения достаётся бесплатно. Следующий тик задаёт тот же вопрос и получает оставшихся, и никому не нужно знать, что прогон умер.

Двойная отправка становится невозможной, а не просто маловероятной. Человека с записью о доставке нельзя выбрать. Нет флага, который можно забыть проставить.

Изменения аудитории обрабатываются сами. Добавленный сегодня появится в завтрашнем запросе, удалённый исчезнет. Курсора, который надо инвалидировать, тут просто нет.

Состояние при этом можно посмотреть. «Кто ещё не получил» - обычный запрос, который выполнит кто угодно, а не внутренний счётчик, которому надо верить.

Темп живёт рядом

В том же цикле держится и соответствие лимитам хостера:

$send_per_time = (int) get_option('mm_send_amount_per_time');   // 19
$sleep         = (int) get_option('mm_send_sleep_time');        // 3 000 000 мкс

foreach (UserCampaignPendingDTO::load_pending($campaign_id, $send_per_time) as $pending) {
    usleep($sleep);
    UserCampaignPendingMail::send_pending($pending, true);
}

Фиксированная порция за тик с паузой между письмами. Код ничем не примечателен, и работает он только потому, что очередь выводится. С курсором сон внутри цикла лишь расширяет окно, в котором падение оставляет курсор врущим о том, где вы были.

Чем платим

Каждый тик выполняет джойн по пяти таблицам. На большом списке под это нужны индексы на связующих таблицах и на статусе доставки, и обходится тик дороже, чем чтение курсора.

Таблица доставок растёт как число подписчиков, помноженное на число кампаний. Это цена гарантии, и одновременно таблица отчётности: «в очереди», «отправлено» и «ошибка» по кампании достаются из тех же строк.

Отправка, которая прошла, но не записалась, повторится. Направление отказа тут верное, но из него следует, что запись должна идти сразу после отправки, а не пачкой в конце прогона.

Общая форма

Когда нужно понять, какая работа осталась, выводите это из уже выполненного вместо того, чтобы записывать, где вы остановились. Выполненная работа долговечна и монотонна, а позиция в прогоне не обладает ни одним из этих свойств.

Та же логика стоит за идемпотентными потребителями сообщений, за сверкой вместо переигрывания событий и за «желаемым состоянием против текущего» в инструментах развёртывания. Каждый раз система перестаёт зависеть от того, помнит ли она свои действия, и начинает зависеть от возможности посмотреть, что истинно.

Открыт для работы по контракту

Я доступен для работы по контракту. Если у вас есть интересная идея проекта, запишитесь на звонок через Calendly.

Записаться на 30-минутный звонок