Битрикс: батчевый обход пользователей для рассылки купонов
Методы sendCouponsInBatches() и processBatch() для класса BirthdayCouponSender: постраничный обход активных пользователей через lastUserId вместо OFFSET, проверка дней до дня рождения, защита от дублей за год.
<?php
use Bitrix\Main\Loader;
use Bitrix\Main\UserTable;
// Константы класса BirthdayCouponSender
public const DEFAULT_BATCH_SIZE = 500;
private const DAYS_BEFORE_BIRTHDAY = 5;
private const COUPON_VALID_DAYS_AFTER_BIRTHDAY = 5;
public static function sendCouponsInBatches(
int $batchSize = self::DEFAULT_BATCH_SIZE,
int $maxBatches = 0,
bool $sendEmail = true
): array
{
if (!Loader::includeModule('sale') || !Loader::includeModule('catalog')) {
self::log('skip', ['reason' => 'required modules are not available']);
return [
'success' => false,
'message' => 'required modules are not available',
];
}
$discount = self::resolveDiscount();
if ($discount === null) {
self::log('skip', ['reason' => 'birthday discount was not found']);
return [
'success' => false,
'message' => self::$lastResolveDiscountError !== ''
? self::$lastResolveDiscountError
: 'birthday discount was not found',
];
}
$batchSize = max(1, $batchSize);
$maxBatches = max(0, $maxBatches);
$lastUserId = 0;
$batchNumber = 0;
$processed = 0;
$matched = 0;
$sent = 0;
while (true) {
$batch = self::processBatch($discount, $lastUserId, $batchSize, $sendEmail);
$batchNumber++;
$processed += (int)$batch['processed'];
$matched += (int)$batch['matched'];
$sent += (int)$batch['sent'];
$lastUserId = (int)$batch['last_user_id'];
if (($batch['done'] ?? true) === true) {
break;
}
if ($maxBatches > 0 && $batchNumber >= $maxBatches) {
break;
}
}
$result = [
'success' => true,
'message' => 'birthday coupons batch processing completed',
'processed_users' => $processed,
'matched_users' => $matched,
'sent_coupons' => $sent,
'email_mode' => $sendEmail ? 'send' : 'skip',
'batches' => $batchNumber,
'last_user_id' => $lastUserId,
'discount_id' => (int)$discount['ID'],
'discount_name' => (string)$discount['NAME'],
'batch_size' => $batchSize,
];
self::log('done', $result);
return $result;
}
private static function loadBirthdayUsersBatch(int $lastUserId, int $limit): array
{
return UserTable::getList([
'select' => ['ID', 'NAME', 'LAST_NAME', 'LOGIN', 'EMAIL', 'PERSONAL_BIRTHDAY'],
'filter' => [
'=ACTIVE' => 'Y',
'>ID' => $lastUserId,
'!=EMAIL' => false,
'!=PERSONAL_BIRTHDAY' => false,
],
'order' => ['ID' => 'ASC'],
'limit' => $limit,
])->fetchAll();
}
private static function processBatch(array $discount, int $lastUserId, int $batchSize, bool $sendEmail): array
{
$today = new \DateTimeImmutable('today');
$rows = self::loadBirthdayUsersBatch($lastUserId, $batchSize);
if (empty($rows)) {
return [
'processed' => 0,
'matched' => 0,
'sent' => 0,
'last_user_id' => $lastUserId,
'done' => true,
];
}
$processed = 0;
$matched = 0;
$sent = 0;
foreach ($rows as $user) {
$processed++;
$birthdayInfo = self::parseBirthday((string)($user['PERSONAL_BIRTHDAY'] ?? ''));
if ($birthdayInfo === null) {
continue;
}
$nextBirthday = self::resolveNextBirthdayDate(
(int)$birthdayInfo['month'],
(int)$birthdayInfo['day'],
$today
);
if ($nextBirthday === null) {
continue;
}
$daysLeft = (int)$today->diff($nextBirthday)->format('%a');
if ($daysLeft !== self::DAYS_BEFORE_BIRTHDAY) {
continue;
}
$matched++;
$userId = (int)($user['ID'] ?? 0);
if ($userId <= 0 || self::hasCouponForYear($userId, (int)$nextBirthday->format('Y'))) {
continue;
}
$coupon = self::createCoupon($userId, $discount, $nextBirthday);
if ($coupon === null) {
continue;
}
if ($sendEmail) {
self::sendCouponEmail($user, $coupon, $nextBirthday);
} else {
self::log('coupon_created_without_email', [
'user_id' => $userId,
'email' => (string)($user['EMAIL'] ?? ''),
'coupon' => $coupon,
'birthday_date' => $nextBirthday->format('d.m.Y'),
'active_to' => $nextBirthday
->modify('+' . self::COUPON_VALID_DAYS_AFTER_BIRTHDAY . ' days')
->format('d.m.Y'),
]);
}
$sent++;
}
$nextLastUserId = (int)($rows[count($rows) - 1]['ID'] ?? $lastUserId);
return [
'processed' => $processed,
'matched' => $matched,
'sent' => $sent,
'last_user_id' => $nextLastUserId,
'done' => count($rows) < $batchSize,
];
}
Почему lastUserId вместо OFFSET для обхода пользователей Битрикс
Классическая пагинация через OFFSET (в D7 — параметр offset в getList) имеет фундаментальный недостаток на больших таблицах: чем глубже страница, тем дольше выполняется запрос. При OFFSET 50000 MySQL вынужден прочитать и отбросить 50 тысяч строк, прежде чем вернуть нужные 500. На таблице b_user с миллионом записей это превращается в запросы с временем выполнения в несколько секунд каждый, а общее время cron-скрипта растёт квадратично.
Метод loadBirthdayUsersBatch() использует фильтр '>ID' => $lastUserId и сортировку по ID ASC. После обработки каждой пачки lastUserId обновляется до ID последнего пользователя в batch. Следующий запрос начинает ровно с того места, где закончился предыдущий — без сканирования всего предшествующего диапазона. Время каждого запроса постоянно вне зависимости от положения «страницы», а индекс по первичному ключу используется максимально эффективно. Признак завершения — количество возвращённых строк меньше $batchSize, то есть пользователи закончились.
Как читать лог батча
После завершения батчевого прохода в JSONL-лог записывается итоговая строка уровня done:
{
"ts": "2026-06-27T08:00:05+03:00",
"level": "done",
"context": {
"success": true,
"processed_users": 12450,
"matched_users": 7,
"sent_coupons": 5,
"email_mode": "send",
"batches": 25,
"last_user_id": 98712,
"discount_id": 125,
"discount_name": "Скидка ко дню рождения 5%",
"batch_size": 500
}
}
Поле matched_users показывает, сколько пользователей имеют день рождения ровно через DAYS_BEFORE_BIRTHDAY дней. Разница между matched_users и sent_coupons — это пользователи, которым купон уже был выдан в этом году (дубли). Если sent_coupons равно нулю при ненулевом matched_users — значит, все именинники уже получили купоны ранее, это нормальная ситуация при повторном запуске в тот же день.