Как мы в коробках рассылки разгоняли
Привет, меня зовут Степан Золотухин, я разработчик в Битрикс24. Моя команда работает над такими продуктами, как Почта, Маркетинг, Структура компании, Подписание, CRM-Формы и Бизнес-процессы.
Так как наш продукт необходимо рекламировать, ну а наших пользователей своевременно оповещать о новинках и различных ивентах, наша команда маркетологов отправляет каждый день около 300-400кк различных писем.
Коротко: в продукте есть штатная отправка писем «в один поток» через стандартный mail() — и сейчас это занимает 8–10 писем/с. Для быстрого решения проблемы мы добавили многопоточную стратегию отправки писем(без внешних очередей и спец. сервисов) и довели скорость отправки до 80–90 писем/с
В дефолтной конфигурации отправка идёт нативным PHP через функцию mail() (если у отправителя не задан SMTP). Дальше всё идет в инфраструктуру пользователя (exim, sendmail, postfix И так далее)
Это просто и надёжно, но скорость отправки упирается в ограничения при отправке в один поток.
Дальше я расскажу, как мы доработали модуль sender, он же — "Email-Маркетинг", чтобы добиться нужных результатов.
Ограничения, которые существуют как данность, так как мы не можем нагружать наших коробочных клиентов дополнительным программным обеспечением:
Среда: Стандартные требования для коробочной версии продукта PHP 8.1+, MySQL 8+ / PostgreSQL.
Запуск рассылок: На агентах/На cron
Шаблоны: пользователи собирают их в интерфейсе Email - рассылок. Переменные для подстановки берутся из b_sender_posting_recipient — там уже лежит персонализация в подготовленном виде, так как получатели для рассылок заготавливаются заранее.
Транспорт: по умолчанию mail(); если у отправителя настроен SMTP — используем наш внутренний \Bitrix\Main\Mail\Smtp\Mailer::sendMailBySmtp.
Задача: ускорить отправку чтобы было быстро и без изменения инфраструктуры у клиентов, без больших переделок.
Идея для решения проблемы
Вместо одного потока отправки, попробовать запустить 10, чтобы каждый обрабатывал свою долю адресатов. Потоки не спорят между собой, не берут одни и те же записи и не выходят за общий лимит параллельности.
Оформлено это было в виде стратегий потоков:
SingleThreadStrategy— базовый режим (один поток, удобно для отладки).TenThreadsStrategy— боевой режим на 10 параллельных потоков.
На текущий момент мы решили ограничиться таким набором стратегий, чтобы посмотреть на сколько производительным выйдет подход.
Схема отправки

Тут стоит отметить, что персонализация и подстановка необходимых данных в шаблон не тормозит, так как на момент отправки все данные для этого заранее подготавливаются в табличке b_sender_posting_recipient.
Как потоки делят работу
Разделение потоков было реализовано следующим образом: каждый поток берёт адресатов со своим признаком. За основу работы в режиме отправки в 10 потоков мы решили, что подойдет алгоритм получения последнего символа идентификатора и сравнение его с потоком который за эту пачку отвечает(фильтр выглядит примерно так: LAST_DIGIT = threadId). Дополнительно мы исключаем получателей, которые от наших рассылок отписались (в целом они отсекаются на этапе формирования списка, но мы должны действовать наверняка).
$filter = [
'@STATUS' => [
PostingRecipientTable::SEND_RESULT_NONE,
PostingRecipientTable::SEND_RESULT_WAIT_ACCEPT,
],
'=LAST_DIGIT' => $this->threadId,// ... + join к подпискам/consent, чтобы отсечь отписавшихся
];
Дополнительно мы ввели опцию sender.max_parallel_threads. которая позволяет контролировать максимальное количество запущенных процессов на кроне, чтобы при параллельном запуске нескольких рассылок не забивать процессы сервера. Контролируется это следующим образом: считаются активные потоки в PostingThreadTable и сравниваются со значением опции.
class TenThreadsStrategy extends AbstractThreadStrategy
{
public const THREADS_COUNT = 10;
public function isProcessLimited(): bool
{
$max = (int) Option::get('sender', 'max_parallel_threads', self::THREADS_COUNT);
$active = PostingThreadTable::getCount(['=STATUS' => PostingThreadTable::STATUS_IN_PROGRESS]);
return $active > $max;
}
}
Алгоритм отправки
В общих чертах отправка рассылок выглядит следующим образом: подготовить потоки → захватить свободный → взять батч адресатов → отправить → повторить.
Если больше получателей по фильтру нет, поток помечается как завершенный. Финальный поток проверяет, что все остальные были завершены и помечает рассылку отправленной:
$this->threadStrategy->setPostingId($this->postingId);
$this->threadStrategy->fillThreads();
$this->threadStrategy->lockThread();
$threadId = $this->threadStrategy->getThreadId();
if ($threadId === null)
{
// нет свободных потоков — выйти
return;
}
$recipients = $this->threadStrategy->getRecipients($batchLimit); // опция контролируется через админ панель, ее можно изменять в зависимости от потребностей
$this->message->getTransport()->setSendCount(count($recipients));
if ($this->message->getTransport()->start())
{
$this->sendToRecipients($recipients);
$this->message->getTransport()->end();
}
$this->threadStrategy->updateStatus(PostingThreadTable::STATUS_DONE);
// далее логика отслеживания завершения отправки всех потоков и финализации рассылки
Цифры «до/после»
Режим |
Инфраструктура |
Скорость |
|---|---|---|
Один поток |
cron + PHP, |
8–10 писем/с |
10 потоков |
те же cron + PHP |
80–90 писем/с |
Без внешних очередей, микросервисов и миграций - просто параллельная обработка того, что уже есть.
Грабли, на которые я наступил
Залипшие потоки. Если не чистить служебные записи, новые рассылки стартуют с задержкой. Исправилось финализацией PostingThreadTable.
Гонки за один поток. Без атомарного lockThread() два процесса могут захватить одно и то же. Тут решают блокировки.
Стабилизация отправки. В процессе тестирования всплыло достаточно большое количество моментов которые потребовали тюнинга со стороны кода (в детали вдаваться не буду).
Как запустить свои рассылки в таком режиме?
Нужно пойти в настройки модуля "Email-маркетинг" /bitrix/admin/settings.php?lang=ru&mid=sender&mid_menu=1.
Выбрать в пункте "Метод автоматической отправки рассылки" - "Крон"
Выбрать в пункте "Количество потоков при отправке" - "В 10 потоков"
Для запуска нескольких процессов отправки можем попробовать накидать следующий скрипт, назовите его /usr/local/bin/runner.sh который будет вызываться через cron:
#!/usr/bin/env bash
set -euo pipefail
if [[ "${DAEMONIZED:-}" != "1" ]]; then
setsid bash -c "DAEMONIZED=1 \"$0\" \"$@\"" >/dev/null 2>&1 & exit 0
fi
umask 027
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
PHP_BIN="$(command -v php)"
READLINK_BIN="$(command -v readlink)"
if [[ -z "${PHP_BIN}" || -z "${READLINK_BIN}" ]]; then
echo "Error: php or readlink not found in PATH." >&2
exit 127
fi
if [[ $# -lt 1 ]]; then
echo "Usage: $0 /path/to/script.php [concurrency]" >&2
exit 1
fi
SCRIPT_PATH="$1"
CONCURRENCY="${2:-10}"
LOCK_DIR="/tmp/safe_runner-$(id -u)"
mkdir -p -m 700 "${LOCK_DIR}"
LOCK_FILE="${LOCK_DIR}/$(basename "$0" .sh).lock"
exec 200>"${LOCK_FILE}"
if ! flock -n 200; then
echo "Error: Script is already running. Lock file: ${LOCK_FILE}" >&2
exit 1
fi
if [[ "${SCRIPT_PATH}" != /* ]]; then
echo "Error: Script path must be absolute: ${SCRIPT_PATH}" >&2
exit 1
fi
if [[ "${SCRIPT_PATH}" == *"://"* ]]; then
echo "Error: Script path must be a regular file; URI wrappers are forbidden: ${SCRIPT_PATH}" >&2
exit 1
fi
SCRIPT="$("${READLINK_BIN}" -f -- "${SCRIPT_PATH}")"
if [[ ! -f "$SCRIPT" ]] || [[ ! -r "$SCRIPT" ]]; then
echo "Error: File not found or is not readable: $SCRIPT" >&2
exit 1
fi
shopt -s nocasematch
if [[ ! "$SCRIPT" =~ \.php$ ]]; then
echo "Error: Script must have a .php extension: $SCRIPT" >&2
exit 1
fi
shopt -u nocasematch
get_perms() {
if stat -c '%a' "$1" 2>/dev/null; then return; fi
if stat -f '%Lp' "$1" 2>/dev/null; then return; fi
echo ""
}
perms="$(get_perms -- "$SCRIPT")"
if [[ -n "$perms" ]] && (( (10#$perms % 10) & 2 )); then
echo "Error: Refusing to run script that is world-writable: $SCRIPT (mode $perms)" >&2
exit 1
fi
if ! "$PHP_BIN" -l -- "$SCRIPT" >/dev/null 2>&1; then
echo "Error: Syntax check (lint) failed for $SCRIPT." >&2
"$PHP_BIN" -l -- "$SCRIPT" >&2 || true
exit 1
fi
if ! [[ "$CONCURRENCY" =~ ^[0-9]+$ ]]; then
echo "Error: Concurrency must be a positive integer." >&2
exit 1
fi
MAX_CONCURRENCY=10
if (( CONCURRENCY < 1 || CONCURRENCY > MAX_CONCURRENCY )); then
echo "Error: Concurrency must be between 1 and $MAX_CONCURRENCY." >&2
exit 1
fi
pids=()
cleanup() {
rm -f "${LOCK_FILE}"
echo -e "\nTermination signal received. Cleaning up child processes..."
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null || true
fi
done
for _ in {1..3}; do
alive=0
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then alive=1; break; fi
done
if (( alive == 0 )); then return 0; fi
sleep 1
done
for pid in "${pids[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid" 2>/dev/null || true
fi
done
}
trap cleanup INT TERM HUP QUIT PIPE EXIT ERR
echo "Starting $CONCURRENCY processes for script: $SCRIPT"
for ((i=1; i<=CONCURRENCY; i++)); do
"$PHP_BIN" -- "$SCRIPT" "$i" &
pids+=("$!")
done
# --- Waiting for completion ---
status=0
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
echo "Warning: Process $pid terminated with an error." >&2
status=1
fi
done
echo "All processes have finished."
exit "$status"
Добавьте файл /etc/cron.d/bitrix-sender
Добавьте контент в этот файл
*/5 * * * * bitrix /usr/local/bin/bitrix_runner.sh /home/bitrix/www/bitrix/tools/sender/cron_sender.php 5 >> /var/log/bitrix/sender_cron.log 2>&1
Настройте logrotate, чтобы избежать переполнение файла с логом
/var/log/bitrix/sender_cron.log {
weekly
rotate 4
compress
delaycompress
notifempty
create 0644 bitrix bitrix
missingok
}
После всех этих манипуляций можно идти в раздел “Марткетинг”, пытаться отправить 100500 писем своим дорогим клиентам и наблюдать за тем, насколько выше стал прирост в скорости отправки.
P.S. Понятное дело, что всё будет зависеть от серверных мощностей и гибких настроек, но и на базовой установке всё должно заработать достаточно шустро.
Выводы и планы
Мы понимаем, что можно ткнуть пальцем и сказать, что сейчас достаточно много инструментов, которые легко могут ускорить результат: использование очередей, консьюмеров, редиса и прочих вспомогательных вещей. Но простому обывателю довольно сложно устанавливать дополнительное ПО в свою инфраструктуру. Поэтому наша задача — дать пользователю результат и максимально снять с него обременения и заморочки.
В целом мы достигли своей цели и получили результат, который устраивает нас на данный момент — мы ускорили отправку рассылок. Но в дальнейшем мы будем выводить рассылки на новый уровень производительности и как вариант рассмотрим возможность установки дополнительных сервисов на коробочных установках.