Назад в блог

MySQL binlog убил наш polling: как мы избавились от 2 880 лишних запросов в сутки

Три года нормально работало — пока каталог не вырос

Инвалидация кэша Bitrix через cron-polling — стандартное решение, которое работает. Пока не начинаешь считать. Это история про то, как MySQL binlog закрыл вопрос сам по себе.

У нас был каталог из 28 000 SKU. Каждые 30 секунд задача проверяла, не изменились ли товары: цены, остатки, статусы. Запрос к базе, сравнение с последней меткой времени, при необходимости — сброс нужных ключей из Redis.

Три года это устраивало. Потом мы сели и посчитали: 2 раза в минуту × 60 минут × 24 часа = 2 880 запросов в сутки. Каждый занимал в среднем 40 мс и делал JOIN к трём таблицам каталога. Это 115 секунд чистого CPU в сутки только на проверку «изменилось ли что-нибудь». Часто — нет.

В тот же день я открыл конфиг MySQL и увидел: log_bin = /var/log/mysql/mysql-bin.log. Он был включён. Он уже всё писал. Мы просто не смотрели в него три года.

Что такое MySQL binlog и почему он уже всё знает

MySQL binlog — журнал всех изменений в базе данных в виде последовательности бинарных событий. Каждый INSERT, UPDATE или DELETE создаёт запись в binlog до того, как транзакция коммитится. Это тот же механизм, на котором работает MySQL-репликация.

В отличие от polling («а вдруг что-то изменилось?»), binlog говорит точно: что именно изменилось, в какой таблице, какие строки, в какое время. Формат ROW — самый детальный: пишет значения до и после изменения на уровне отдельных строк.

На большинстве production-серверов с Bitrix binlog уже включён — репликация или резервное копирование. Мы просто не использовали его для инвалидации кэша.

Включаем binlog на production без даунтайма

Если binlog ещё не включён, изменить настройки можно без перезапуска MySQL:

-- Проверяем текущее состояние
SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';

Нужные параметры в my.cnf:

[mysqld]
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW
binlog_row_image = MINIMAL
expire_logs_days = 3

binlog_row_image = MINIMAL — ключевой параметр. Пишет только изменённые столбцы, а не всю строку целиком. На таблицах Bitrix с 50+ полями это существенная разница в размере логов.

После изменения конфига — FLUSH LOGS применяет новые настройки без перезапуска демона. Проверяем: SHOW MASTER STATUS\G — если показывает имя файла и позицию, binlog активен.

PHP-воркер: читаем события из binlog

Для чтения binlog из PHP используем библиотеку krowinski/php-mysql-replication. Она реализует протокол репликации MySQL: представляется как slave-сервер и получает события в режиме реального времени.

<?php
use MySQLReplication\Config\ConfigBuilder;
use MySQLReplication\MySQLReplicationFactory;
use MySQLReplication\Event\DTO\UpdateRowsDTO;
use MySQLReplication\Event\DTO\WriteRowsDTO;

$config = (new ConfigBuilder())
    ->withUser('replication_user')
    ->withPassword('password')
    ->withHost('127.0.0.1')
    ->withPort(3306)
    ->withEventsOnly([UpdateRowsDTO::class, WriteRowsDTO::class])
    ->withTablesOnly([
        'b_iblock_element',
        'b_catalog_price',
        'b_catalog_store_product',
    ])
    ->withDatabasesOnly(['bitrix_db'])
    ->build();

$factory = new MySQLReplicationFactory($config);
$factory->registerSubscriber(new CacheInvalidationSubscriber($redis));
$factory->run(); // event loop

withTablesOnly фильтрует события ещё на уровне получения — воркер не обрабатывает то, что ему не нужно. withEventsOnly исключает DDL и другие события, которые ломали наш первый вариант воркера.

Какие Bitrix-таблицы слушать — и почему именно они

Не все изменения в базе Bitrix требуют сброса кэша каталога. Нам нужны три таблицы:

| Таблица | Тип события | Что инвалидировать | |---------|-------------|-------------------| | b_iblock_element | UPDATE | Кэш карточки товара, листинга категории | | b_catalog_price | UPDATE / INSERT | Кэш цены товара, кэш фильтра по цене | | b_catalog_store_product | UPDATE | Кэш остатков, кэш «в наличии» в листинге |

b_sale_basket и b_sale_order — не слушаем. Изменения в корзине и заказах не влияют на публичный каталог, зато генерируют огромный объём событий в период промо-акций.

Для каждой таблицы воркер извлекает IBLOCK_ID и ID товара, формирует ключи и публикует в Redis channel.

Redis как буфер: pub/sub инвалидация

Воркер не инвалидирует кэш напрямую. Он публикует события в Redis channel, а каждый потребитель сам решает, что с ними делать.

class CacheInvalidationSubscriber implements EventSubscribers
{
    public function __construct(private \Redis $redis) {}

    public function onUpdate(UpdateRowsDTO $event): void
    {
        $table = $event->tableMap->table;
        foreach ($event->values as $row) {
            $after = $row['after'];
            $key = $this->resolveKey($table, $after);
            if ($key) {
                $this->redis->publish('cache:invalidate', $key);
            }
        }
    }

    private function resolveKey(string $table, array $row): ?string
    {
        return match($table) {
            'b_iblock_element' => "product:{$row['ID']}",
            'b_catalog_price'  => "price:{$row['PRODUCT_ID']}",
            'b_catalog_store_product' => "stock:{$row['PRODUCT_ID']}",
            default => null,
        };
    }
}

На стороне Next.js — Route Handler, подписанный на cache:invalidate channel. При получении ключа вызывает revalidateTag() с нужным тегом. BXCache на стороне Bitrix слушает тот же channel через PHP-скрипт с $redis->subscribe().

Что пошло не так — и как мы это починили

Первое: позиция. После рестарта воркер начинал читать binlog с начала или с текущей позиции и пропускал события, которые произошли пока он не работал. Решение: сохраняем binlog_file и binlog_position в отдельный Redis-ключ после каждого обработанного события. При старте читаем сохранённую позицию.

Второе: DDL. Bitrix при обновлении делает ALTER TABLE. Первая версия воркера не знала, что с этим делать, и падала с необработанным исключением. Фикс — фильтр withEventsOnly: принимаем только DML, игнорируем остальное.

Третье: дубли. При высокой нагрузке несколько параллельных транзакций могли обновить один товар за доли секунды. Воркер публиковал несколько событий подряд, каждое из которых инвалидировало один и тот же ключ. Не критично, но шумно. Дедупликация через SET NX EX 1 cache:dedup:{key} в Redis перед публикацией убрала лишний шум.

До/после: цифры из production

| Метрика | Polling (было) | Binlog CDC (стало) | |---------|---------------|-------------------| | Запросов к БД для проверки актуальности | 2 880 / сутки | 0 | | Среднее время инвалидации после изменения | ~30 сек | < 200 мс | | CPU на проверку кэша | ~115 сек / сутки | ~0 | | Ложные срабатывания | Нет данных | 0 (event-driven) |

Latency инвалидации упала с «до следующего тика крона» до «до следующего коммита в базе». Для каталога с ценами и остатками, которые меняются от 1С несколько раз в день — это разница между «сайт иногда показывает неактуальные данные» и «сайт всегда актуален».

Воркер работает как отдельный PHP-процесс под supervisor. Потребление памяти — около 20 МБ, CPU в idle — практически ноль. Дополнительной нагрузки на MySQL тоже нет: чтение binlog через протокол репликации не создаёт запросов к таблицам.

Если у вас cron-polling для инвалидации кэша работает — это нормально. Если каталог перевалил за несколько тысяч SKU и вы видите эти запросы в slow_query_log — время посмотреть в сторону binlog.

Ссылки по теме: cache invalidation в headless, cron-стратегии кэширования, Redis в Bitrix-продакшне.