Склад говорит, что смесителей семнадцать. Сайт показывает двадцать четыре. К вечеру сайт показывает уже тридцать один, хотя продажи шли весь день и ничего не приходило.
Числа не просто разные — они расходятся в неправильную сторону. Это не ошибка выгрузки и не сбой 1С. Это гонка: два процесса пишут в одну ячейку, каждый по-своему прав, и побеждает тот, кто записал последним.
Проблема архитектурная, и настройками она не лечится. Зато она хорошо диагностируется и имеет решение, которое стоит заложить до того, как магазин вырастет.
Две модели записи, которые не совместимы
Остаток товара изменяют два источника, и они работают принципиально по-разному.
Обмен с 1С пишет абсолютное значение. «На складе 17 штук». Он не знает и не хочет знать, что было на сайте раньше: 1С — источник истины, её число замещает предыдущее.
Заказы изменяют относительно. «Стало на 2 меньше». Корзина читает текущее значение, вычитает проданное, записывает результат.
Абсолютная запись и относительное изменение одной ячейки конфликтуют по построению. Достаточно, чтобы они пересеклись во времени:
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 |
И это за один обмен. При обмене каждые пятнадцать минут ошибка не разовая, а накапливающаяся: сайт систематически показывает больше товара, чем есть, магазин продаёт то, чего нет, и получает отмены.
Отсюда неочевидная связь: ускорение обмена — это не только про нагрузку на сервер, но и про точность остатков. Обмен, идущий две минуты вместо сорока, уменьшает окно гонки в двадцать раз.
Диагностика
Три запроса, от самого быстрого к самому подробному.
Отрицательные остатки
SELECT ID, QUANTITY, QUANTITY_RESERVED
FROM b_catalog_product
WHERE QUANTITY < 0
ORDER BY QUANTITY ASC
LIMIT 50;Это индикатор номер один. Отрицательный остаток не бывает нормой ни при каком сценарии. Если запрос что-то вернул, гонка уже происходит, и дальше искать подтверждений не нужно.
Расхождение складов и общего остатка
При многоскладовом учёте сумма остатков по складам должна сходиться с общим остатком товара:
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. Остатки бывают дробными — метры, килограммы, площадь плитки, — а дробная арифметика в базе даёт хвосты в последних знаках. Строгое сравнение с нулём выдаст сотни ложных срабатываний на товарах, где расхождения нет, и настоящие проблемы в этом шуме потеряются.
Сверка с выгрузкой
# Остаток конкретного товара в том, что прислала 1С
grep -A 12 "f4e2c1a9" /home/site/www/upload/1c_catalog/offers.xml | grep "Количество"Сравниваем с базой. Расхождение сразу после обмена означает проблему разбора, расхождение к концу дня — гонку.
Решение 1: разделить источники истины
Правильный подход, убирающий причину, а не симптом.
Идея в том, чтобы обмен и заказы перестали писать в одну ячейку:
| Поле | Кто пишет | Что означает |
|---|---|---|
| Остаток склада | Только обмен | Сколько физически лежит по данным 1С |
| Резерв | Только заказы | Сколько отложено под неотгруженные заказы |
| Доступно к продаже | Никто, вычисляется | Остаток минус резерв |
Теперь гонки нет по построению. Обмен пишет своё поле, заказы — своё, а витрина показывает разность.
<?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
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
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_ID | 0 | Остатки склада игнорируются |
Первая строка — минимально необходимое. Если поставить только один автоматический контроль во всём складском учёте, ставить надо именно её.
Итоги
Абсолютная запись и относительное изменение одной ячейки несовместимы. Это не ошибка реализации, а следствие двух разных моделей, встретившихся в одном поле.
Отрицательный остаток — доказательство гонки. Не симптом, не совпадение, не «особенность». Один запрос даёт ответ.
Правильное решение — разделить поля, чтобы обмен и заказы писали в разные места. Тогда гонка исчезает по построению, без блокировок и без их цены.
Блокировка лечит корректность за счёт отзывчивости. На активном магазине это осознанный размен, а не бесплатное улучшение.
Ускорение обмена — это ещё и точность остатков. Связь неочевидная, но прямая: окно гонки равно длительности обмена.
И соображение, которое стоит проговорить с бизнесом до того, как чинить: расхождение остатков почти всегда замечают в безопасную сторону — когда сайт показывает больше, чем есть, и приходится отменять заказы. Но та же гонка работает и в другую сторону, просто её никто не видит: товар лежит на складе, а сайт считает, что его нет, и он тихо не продаётся месяцами. Второй случай дороже первого, и обнаруживается он только сверкой, а не жалобами.
Частые вопросы
Почему остатки на сайте расходятся с 1С?
Основная причина — гонка между обменом и заказами. Обмен читает остаток в начале, идёт несколько минут, а записывает в конце уже устаревшее значение, затирая продажи, произошедшие за это время. Чем дольше идёт обмен и чем активнее магазин, тем больше расхождение, и накапливается оно каждый обмен.
Откуда берутся отрицательные остатки в Битриксе?
Из потерянных обновлений при одновременной записи. Товар списывается заказом, параллельно обмен пишет своё значение, следующий заказ списывает от затёртой величины — и остаток уходит ниже нуля. Отрицательный остаток не бывает нормой ни при каком сценарии: это прямой индикатор того, что запись не защищена от конкурентного доступа.
Как правильно синхронизировать остатки при частом обмене?
Разделить источники истины: обмен пишет остаток склада, а резервы под заказы учитываются отдельным полем и вычитаются при отображении. Тогда обмен и заказы перестают писать в одну ячейку, и гонка исчезает по построению, а не за счёт блокировок. Это архитектурное решение, а не настройка, и переделывать его на работающем магазине дороже, чем заложить сразу.
Нужна ли блокировка при обновлении остатков?
Если обмен и заказы пишут в одно поле — да, без неё корректность недостижима. Блокировать нужно на уровне строки в транзакции, а не всю таблицу: блокировка таблицы товаров на время обмена остановит магазин. Но блокировка лечит симптом, а разделение полей убирает причину, поэтому её рассматривают как временную меру.
Как проверить, есть ли расхождение остатков?
Тремя запросами: искать отрицательные остатки, сравнивать сумму остатков по складам с общим остатком товара и сверять выборочные позиции с выгрузкой из 1С. Первый запрос выполняется за секунду и даёт ответ сразу: если отрицательные остатки есть, расхождение точно есть и дальше можно не искать.