Back to blog

MySQL Binlog Killed Our Cache Polling: How We Cut 2,880 Unnecessary Queries a Day

Three years of cron polling — then the catalog grew

Polling the database to check for catalog changes is one of those things that works fine until it doesn't. The threshold isn't a crash. It's a number on a spreadsheet you finally decide to calculate.

MySQL binlog is a sequential log of every database change as binary events. We had it enabled. It already knew about every product update, price change, and stock movement the moment it was committed. For three years, we ran a cron job to ask "did anything change?" instead of reading the answer that was already there.

Our catalog had 28,000 SKUs. Every 30 seconds, the job hit the database: run a query against three catalog tables, compare timestamps, flush the relevant Redis keys if something had changed. Usually nothing had changed. We were paying 40ms per check, 2 times a minute, 24 hours a day. That's 2,880 unnecessary queries per day. 115 seconds of CPU time doing exactly nothing.

The day I actually opened the MySQL config and saw log_bin = /var/log/mysql/mysql-bin.log, the fix became obvious.

What MySQL binlog is and why it already has what you need

MySQL binlog (binary log) is a sequential record of every write operation committed to the database. Every INSERT, UPDATE, or DELETE produces a binlog event before it's visible to other queries. This is the same mechanism MySQL uses for replication between primary and replica servers.

In ROW format, binlog records the actual row values before and after each change — not just the SQL statement. For cache invalidation, that means you get the exact product ID, price field, and stock quantity that changed, without running any additional queries.

On most Bitrix production servers, binlog is already enabled. It's required for replication and typically for backup tooling. We just weren't using it for cache invalidation.

Enabling binlog on a live production server

Enabling MySQL binlog for cache invalidation requires setting binlog_format = ROW, which makes MySQL record individual row changes rather than SQL statements. If binlog isn't enabled yet, the key settings in 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 is the important one. With FULL (the default), binlog records every column in the row on every update — including fields that didn't change. Bitrix tables have 50+ columns. MINIMAL records only the changed columns and the primary key. The log files are noticeably smaller. The information useful for cache invalidation is the same.

Run FLUSH LOGS to activate the new settings without restarting the MySQL daemon. Confirm with SHOW MASTER STATUS\G — if it returns a filename and position, binlog is active.

A PHP worker that reads binlog events

The krowinski/php-mysql-replication library implements the MySQL replication protocol. The worker registers itself as a replica server and receives events in real time.

<?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();

withTablesOnly filters at the protocol level — the worker never receives events from tables it doesn't care about. withEventsOnly filters out DDL events, which caused problems in the first version of our worker.

Which Bitrix tables to watch — and which to skip

Not every database change affects the public catalog. Three tables matter:

| Table | Event type | What to invalidate | |-------|-----------|-------------------| | b_iblock_element | UPDATE | Product card cache, category listing cache | | b_catalog_price | UPDATE / INSERT | Product price cache, price-range filter cache | | b_catalog_store_product | UPDATE | Stock cache, in-stock filter in listings |

b_sale_basket and b_sale_order — don't watch these. Cart and order changes don't affect the public catalog, but they generate enormous event volume during promotions. Watching them would swamp the worker.

For each table, the worker extracts the IBLOCK_ID and product ID, builds the cache key, and publishes to a Redis channel.

Redis as the invalidation bus

Redis pub/sub is a messaging pattern where a publisher sends events to a named channel without knowing which subscribers are listening. The worker uses this pattern: it publishes each invalidation event to a channel, and each layer (Next.js ISR, BXCache) subscribes independently.

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,
        };
    }
}

On the Next.js side, a Route Handler subscribes to cache:invalidate and calls revalidateTag() on the appropriate tag. On the Bitrix/PHP side, a separate subscriber calls BXCache invalidation. The same event reaches both consumers. Neither one needs to know about the other.

What broke — and the three fixes

First: position tracking. After a worker restart, it would resume from the current binlog position and miss any events that arrived during downtime. Fix: persist binlog_file and binlog_position to a Redis key after every processed event. On startup, read the saved position.

Second: DDL events. Bitrix upgrades sometimes run ALTER TABLE. The first worker version didn't handle DDL and crashed with an unhandled exception. Fix: withEventsOnly filter — accept only DML, ignore everything else.

Third: duplicate events. Under high load, several parallel transactions can update the same product within milliseconds. The worker published multiple events for the same cache key. Not critical, but noisy. Fix: deduplicate with SET NX EX 1 cache:dedup:{key} in Redis before publishing.

Before and after: the production numbers

| Metric | Cron polling | Binlog CDC | |--------|-------------|-----------| | DB queries for freshness checks | 2,880 / day | 0 | | Average invalidation latency | ~30 sec | < 200 ms | | CPU for cache checking | ~115 sec / day | ~0 | | False positives | Not measured | 0 (event-driven) |

Invalidation latency went from "until the next cron tick" to "within 200ms of the database commit." For a catalog where prices and stock levels sync from 1C several times a day, that's the difference between "sometimes shows stale data" and "always current."

The worker runs as a separate PHP process under supervisor. Memory use is around 20 MB, CPU at idle is essentially zero. No additional load on MySQL — reading binlog via the replication protocol doesn't run any queries against the catalog tables.

If cron polling works for your catalog, it's fine. Once you're past a few thousand SKUs and you start seeing these queries in slow_query_log, it's worth looking at binlog.

Related reading: cache invalidation in headless, cron-based cache strategies, Redis in Bitrix production.