Отправить одно письмо просто. С сорока тысячами задача другая, и сложность в ней не в пропускной способности. Всё решает то, что произойдёт, когда прогон встанет на середине.
А он встанет. Тик крона упрётся в таймаут. 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-минутный звонок