Складской учёт и остатки: синхронизация без гонок

Почему обмен и корзина затирают записи друг друга, откуда берутся отрицательные остатки и чем абсолютная запись остатка отличается от инкрементальной.

Дмитрий Мещеряков
Дмитрий Мещеряков
📅 28 октября 2026 г.📖 8 мин чтения

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

Числа не просто разные — они расходятся в неправильную сторону. Это не ошибка выгрузки и не сбой 1С. Это гонка: два процесса пишут в одну ячейку, каждый по-своему прав, и побеждает тот, кто записал последним.

Проблема архитектурная, и настройками она не лечится. Зато она хорошо диагностируется и имеет решение, которое стоит заложить до того, как магазин вырастет.

Две модели записи, которые не совместимы

Остаток товара изменяют два источника, и они работают принципиально по-разному.

Обмен с 1С пишет абсолютное значение. «На складе 17 штук». Он не знает и не хочет знать, что было на сайте раньше: 1С — источник истины, её число замещает предыдущее.

Заказы изменяют относительно. «Стало на 2 меньше». Корзина читает текущее значение, вычитает проданное, записывает результат.

Абсолютная запись и относительное изменение одной ячейки конфликтуют по построению. Достаточно, чтобы они пересеклись во времени:

text
10:00:00  Обмен читает: 17
10:00:05  Покупатель заказывает 2 → заказ пишет: 15
10:00:40  Обмен записывает прочитанное ранее: 17   ← продажа затёрта

Обмен не делал ничего неправильного. Он записал то, что было верно в момент чтения. Просто между чтением и записью прошло сорок секунд, и мир изменился.

Сколько это стоит

Масштаб потерь прямо зависит от длительности обмена и интенсивности продаж:

Длительность обменаЗаказов в часТеряется продаж за обмен
2 минуты30~1
10 минут30~5
40 минут30~20
40 минут200~130

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

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

Диагностика

Три запроса, от самого быстрого к самому подробному.

Отрицательные остатки

sql
SELECT ID, QUANTITY, QUANTITY_RESERVED
FROM b_catalog_product
WHERE QUANTITY < 0
ORDER BY QUANTITY ASC
LIMIT 50;

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

Расхождение складов и общего остатка

При многоскладовом учёте сумма остатков по складам должна сходиться с общим остатком товара:

sql
SELECT
    p.ID,
    p.QUANTITY                        AS total_quantity,
    COALESCE(SUM(sp.AMOUNT), 0)       AS sum_by_stores,
    p.QUANTITY - COALESCE(SUM(sp.AMOUNT), 0) AS diff
FROM b_catalog_product p
LEFT JOIN b_catalog_store_product sp ON sp.PRODUCT_ID = p.ID
GROUP BY p.ID, p.QUANTITY
HAVING ABS(diff) > 0.001
ORDER BY ABS(diff) DESC
LIMIT 50;
💡 Совет

Сравнение дробных чисел через > 0.001, а не через != 0. Остатки бывают дробными — метры, килограммы, площадь плитки, — а дробная арифметика в базе даёт хвосты в последних знаках. Строгое сравнение с нулём выдаст сотни ложных срабатываний на товарах, где расхождения нет, и настоящие проблемы в этом шуме потеряются.

Сверка с выгрузкой

bash
# Остаток конкретного товара в том, что прислала 1С
grep -A 12 "f4e2c1a9" /home/site/www/upload/1c_catalog/offers.xml | grep "Количество"

Сравниваем с базой. Расхождение сразу после обмена означает проблему разбора, расхождение к концу дня — гонку.

Решение 1: разделить источники истины

Правильный подход, убирающий причину, а не симптом.

Идея в том, чтобы обмен и заказы перестали писать в одну ячейку:

ПолеКто пишетЧто означает
Остаток складаТолько обменСколько физически лежит по данным 1С
РезервТолько заказыСколько отложено под неотгруженные заказы
Доступно к продажеНикто, вычисляетсяОстаток минус резерв

Теперь гонки нет по построению. Обмен пишет своё поле, заказы — своё, а витрина показывает разность.

php
<?php
/**
 * Доступное к продаже считается, а не хранится. Хранимое значение
 * пришлось бы обновлять из двух мест — то есть возвращать гонку обратно.
 */
function getAvailableQuantity(array $product): float
{
    $stock    = (float)($product['QUANTITY'] ?? 0);
    $reserved = (float)($product['QUANTITY_RESERVED'] ?? 0);

    return max(0.0, $stock - $reserved);
}
⚠️ Важно

Резерв нужно снимать, а не только ставить. Отменённый заказ, брошенная корзина, истёкшая бронь — каждый такой случай обязан освобождать резерв. Забытое снятие даёт медленно растущий фантомный резерв: товар физически лежит на складе, а сайт показывает «нет в наличии». Диагностируется это сравнением суммы резервов с суммой по активным заказам, и делать такую сверку стоит регулярно, а не когда придёт жалоба.

Решение 2: блокировка

Когда разделить поля нельзя — например, магазин уже работает и переделка означает переписывание корзины, — остаётся защищать запись блокировкой.

Ключевое требование: блокировать строку, а не таблицу, и внутри транзакции.

php
<?php
use Bitrix\Main\Application;

/**
 * Атомарное изменение остатка: читаем и пишем внутри одной транзакции,
 * заблокировав строку. Параллельный процесс подождёт, а не затрёт.
 */
function decreaseQuantity(int $productId, float $amount): bool
{
    $connection = Application::getConnection();
    $connection->startTransaction();

    try {
        // FOR UPDATE удерживает строку до конца транзакции
        $row = $connection->query(
            "SELECT QUANTITY FROM b_catalog_product WHERE ID = {$productId} FOR UPDATE"
        )->fetch();

        if (!$row) {
            $connection->rollbackTransaction();
            return false;
        }

        $current = (float)$row['QUANTITY'];
        if ($current < $amount) {
            $connection->rollbackTransaction();
            return false;   // нечего списывать — это не ошибка, а отказ
        }

        $new = $current - $amount;
        $connection->queryExecute(
            "UPDATE b_catalog_product SET QUANTITY = {$new} WHERE ID = {$productId}"
        );

        $connection->commitTransaction();
        return true;
    } catch (\Throwable $e) {
        $connection->rollbackTransaction();
        throw $e;
    }
}
⚠️ Важно

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

Решение 3: сузить окно

Полумера, но дешёвая и совместимая с любым из решений выше.

Обменивать остатки отдельно от каталога. Файл с остатками лёгкий, разбирается быстро, окно гонки сокращается кратно. Разделение import.xml и offers.xml по интервалам разобрано в статье про расписание обмена.

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

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

Многоскладовость

Отдельный слой сложности, который добавляет ещё одну возможность разойтись: остатки по складам и общий остаток товара хранятся раздельно.

php
<?php
use Bitrix\Catalog\StoreTable;
use Bitrix\Main\Loader;

Loader::includeModule('catalog');

$stores = StoreTable::getList([
    'select' => ['ID', 'TITLE', 'ACTIVE', 'SHIPPING_CENTER', 'XML_ID'],
    'order'  => ['SORT' => 'ASC'],
])->fetchAll();

foreach ($stores as $store) {
    printf(
        "%3d  %-28s активен=%s  xml_id=%s\n",
        $store['ID'],
        $store['TITLE'],
        $store['ACTIVE'],
        $store['XML_ID'] ?: '— НЕ СОПОСТАВЛЕН —'
    );
}

Пустой XML_ID склада означает то же, что и у типа цены: обмен не найдёт соответствия и молча проигнорирует остатки по этому складу. Механика молчаливого игнорирования разобрана в статье про цены из 1С — здесь она работает точно так же.

Второй частый случай — склад, который есть в 1С, но не отмечен как отгрузочный на сайте. Остатки на нём хранятся, в расчёт доступности не идут, и получается «товар есть, но купить нельзя».

Что мониторить постоянно

Четыре проверки, которые стоит выполнять по расписанию и отправлять отчётом:

ЧтоНормаО чём говорит нарушение
Отрицательные остатки0Гонка при записи
Сумма складов ≠ общий остаток0 позицийРассинхрон складского учёта
Резерв без активного заказа0Резервы не снимаются
Склады с пустым XML_ID0Остатки склада игнорируются

Первая строка — минимально необходимое. Если поставить только один автоматический контроль во всём складском учёте, ставить надо именно её.

Итоги

Абсолютная запись и относительное изменение одной ячейки несовместимы. Это не ошибка реализации, а следствие двух разных моделей, встретившихся в одном поле.

Отрицательный остаток — доказательство гонки. Не симптом, не совпадение, не «особенность». Один запрос даёт ответ.

Правильное решение — разделить поля, чтобы обмен и заказы писали в разные места. Тогда гонка исчезает по построению, без блокировок и без их цены.

Блокировка лечит корректность за счёт отзывчивости. На активном магазине это осознанный размен, а не бесплатное улучшение.

Ускорение обмена — это ещё и точность остатков. Связь неочевидная, но прямая: окно гонки равно длительности обмена.

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

Частые вопросы

Почему остатки на сайте расходятся с 1С?

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

Откуда берутся отрицательные остатки в Битриксе?

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

Как правильно синхронизировать остатки при частом обмене?

Разделить источники истины: обмен пишет остаток склада, а резервы под заказы учитываются отдельным полем и вычитаются при отображении. Тогда обмен и заказы перестают писать в одну ячейку, и гонка исчезает по построению, а не за счёт блокировок. Это архитектурное решение, а не настройка, и переделывать его на работающем магазине дороже, чем заложить сразу.

Нужна ли блокировка при обновлении остатков?

Если обмен и заказы пишут в одно поле — да, без неё корректность недостижима. Блокировать нужно на уровне строки в транзакции, а не всю таблицу: блокировка таблицы товаров на время обмена остановит магазин. Но блокировка лечит симптом, а разделение полей убирает причину, поэтому её рассматривают как временную меру.

Как проверить, есть ли расхождение остатков?

Тремя запросами: искать отрицательные остатки, сравнивать сумму остатков по складам с общим остатком товара и сверять выборочные позиции с выгрузкой из 1С. Первый запрос выполняется за секунду и даёт ответ сразу: если отрицательные остатки есть, расхождение точно есть и дальше можно не искать.