Как мы в коробках рассылки разгоняли

Привет, меня зовут Степан Золотухин, я разработчик в Битрикс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, mail()

8–10 писем/с

10 потоков

те же cron + PHP

80–90 писем/с

Без внешних очередей, микросервисов и миграций - просто параллельная обработка того, что уже есть.

Грабли, на которые я наступил

  1. Залипшие потоки. Если не чистить служебные записи, новые рассылки стартуют с задержкой. Исправилось финализацией PostingThreadTable.

  2. Гонки за один поток. Без атомарного lockThread() два процесса могут захватить одно и то же. Тут решают блокировки.

  3. Стабилизация отправки. В процессе тестирования всплыло достаточно большое количество моментов которые потребовали тюнинга со стороны кода (в детали вдаваться не буду).

Как запустить свои рассылки в таком режиме? 

  • Нужно пойти в настройки модуля "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. Понятное дело, что всё будет зависеть от серверных мощностей и гибких настроек,  но и на базовой установке всё должно заработать достаточно шустро.

Выводы и планы

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

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

Комментарии (0)