На складе лежит одна кофемолка. Сайт показывает «в наличии: 1», маркетплейс-интеграция показывает то же самое, и в одну и ту же секунду два человека нажимают «купить». Оба запроса честно прочитали остаток, оба увидели единицу, оба решили, что она их. Ни один из них не соврал, и всё же товара на двоих нет.
Вся статья посвящена одной разнице: прочитать остаток и получить право его занять — это два разных действия. Пока они разделены во времени, между ними помещается чужой заказ. Дальше разберём, как устроить учёт, чтобы эти два действия стали одним, как жить с временем (резерв не вечен), как не запутаться между резервом, оплатой и списанием и как оставить след, по которому любую спорную ситуацию можно объяснить.
Три числа вместо одного
Первая ошибка начинается в схеме данных: в таблице товаров лежит одно поле stock, и каждый смысл вкладывается в него по настроению. Одному коду оно нужно как «сколько лежит на полке», другому — как «сколько можно обещать покупателю». Эти числа расходятся ровно тогда, когда в системе появляются заказы в процессе.
Полезно развести три величины:
- Физический остаток — сколько единиц действительно лежит на складе. Он меняется при приёмке, отгрузке, инвентаризации и списании брака.
- Зарезервированный остаток — сколько единиц удерживается за незавершёнными заказами: корзина оформлена, оплата ещё не пришла.
- Доступный остаток — физический минус зарезервированный. Именно это число имеет право показывать витрина и именно из него вычитает новый заказ.
Важно, что доступный остаток — производная величина. Его можно хранить отдельным столбцом ради скорости, но тогда придётся следить, чтобы он не разошёлся с остальными. Надёжнее хранить два первичных числа и считать третье, либо хранить все три в одной строке и менять их только в одном месте кода, одним оператором.
Ограничение на уровне базы тоже стоит поставить сразу. В PostgreSQL ограничения таблицы описаны в разделе о constraints; для нашей задачи пригодится CHECK, который не даст записать отрицательное значение. Тогда даже ошибка в приложении упрётся в стену: база откажется сохранять состояние, где доступно меньше нуля.
CREATE TABLE stock (
sku text PRIMARY KEY,
on_hand integer NOT NULL CHECK (on_hand >= 0),
reserved integer NOT NULL CHECK (reserved >= 0),
CHECK (reserved <= on_hand)
);Строка CHECK (reserved <= on_hand) по сути и есть формула «доступно не меньше нуля», записанная так, чтобы её нельзя было обойти. Приложение может ошибиться, а ограничение — нет.
Секунда, в которую помещаются два покупателя
Теперь посмотрим, что происходит в базе, если резервирование написано «по учебнику»: сначала прочитать, потом решить, потом записать. Пусть остаток доступен ровно один, покупатели — А и Б.
| Момент | Покупатель А | Покупатель Б | Состояние: on_hand / reserved |
|---|---|---|---|
| t1 | SELECT: доступно 1 | 1 / 0 | |
| t2 | SELECT: доступно 1 | 1 / 0 | |
| t3 | UPDATE reserved = 1, COMMIT | 1 / 1 | |
| t4 | UPDATE reserved = 1, COMMIT | 1 / 1 |
Обратите внимание на финал: в таблице всё выглядит прилично, резерв равен единице, ограничения не нарушены. Но заказов два, и оба считают, что товар закреплён за ними. Хуже того, если запись была «reserved = reserved + 1», то вторая команда упала бы на проверке — а если «reserved = 1», как в примере, она тихо перезаписала бы то же значение. Гонка осталась незамеченной, потому что система не хранит, за кем закреплена единица.
Источник проблемы — не транзакция как таковая. Обе операции могут быть аккуратно обёрнуты в транзакции. Транзакция, как описано в руководстве PostgreSQL, объединяет шаги в операцию «всё или ничего»; но она не объявляет, что решение, принятое на основе прочитанного значения, останется верным к моменту записи. Чтение и условие были сделаны в одном месте, запись — в другом, а между ними кто-то уже вошёл.
Сделать проверку и занятие одним действием
Лекарство в том, чтобы условие «хватает ли товара» жило внутри самой записи. Не «прочитай, подумай, запиши», а «запиши, если условие истинно, и скажи, получилось ли».
UPDATE stock
SET reserved = reserved + 1
WHERE sku = 'COFFEE-GRINDER'
AND on_hand - reserved >= 1;Приложение смотрит на количество изменённых строк. Одна — резерв получен. Ноль — товара не хватило, и это не ошибка системы, а штатный ответ. При двух одновременных запросах PostgreSQL не даст двум командам одновременно изменять одну и ту же строку: вторая будет ждать, пока первая завершится, а затем проверит условие уже на новом состоянии строки. Подробности о том, какие блокировки берутся и как с ними работать, описаны в разделе об explicit locking; там же упомянуто, что сведения о текущих блокировках видны через представление pg_locks — удобно, когда нужно понять, кто кого держит.
Перерисуем линию времени для той же ситуации:
| Момент | Покупатель А | Покупатель Б | Состояние |
|---|---|---|---|
| t1 | UPDATE … WHERE доступно >= 1 — строка заблокирована, reserved = 1 | 1 / 1 (не зафиксировано) | |
| t2 | UPDATE … ждёт снятия блокировки строки | 1 / 1 | |
| t3 | COMMIT | перепроверяет условие: доступно 0, строк изменено 0 | 1 / 1 |
| t4 | получает резерв | получает отказ «нет в наличии» | 1 / 1 |
Здесь нет ни хитрых очередей, ни ожидания «вдруг обойдётся». Строка товара — единственная точка, через которую проходят все претенденты, и база сама выстраивает их по одному. Это не бесплатно: на очень популярной позиции запросы будут терпеливо ждать друг друга. Для обычного магазина это нормальная цена; если один артикул становится предметом ажиотажного спроса, стоит смотреть на длительность транзакций и держать их короткими. Не размазывайте вокруг этого UPDATE походы во внешние сервисы: пока транзакция открыта, строка занята.
Когда достаточно одной строки, а когда нужен запас
Если резервируется по одной единице за раз и товар всегда в одном месте, один условный UPDATE закрывает вопрос. Если же заказ содержит несколько позиций, добавляется новая опасность: две транзакции берут блокировки на строки в разном порядке и тупиково ждут друг друга. Практическое правило здесь собственное, не документальное: обновляйте позиции в стабильном порядке, например по возрастанию sku. Тогда один заказ всегда будет дожидаться другого, а не наоборот. А если заказ нельзя выполнить целиком, откатывайте всю транзакцию, чтобы полуготовый резерв не повис на половине позиций: ROLLBACK, как сказано в том же руководстве, отменяет изменения незавершённой транзакции.
Резерв — это запись с судьбой, а не просто плюс единица
Счётчик reserved отвечает на вопрос «сколько удержано». Но он не отвечает на вопросы, которые обязательно зададут позже: за каким заказом, до какого времени, что с ним стало. Поэтому рядом со счётчиком нужна таблица самих резервов.
CREATE TABLE reservation (
id bigserial PRIMARY KEY,
sku text NOT NULL REFERENCES stock(sku),
qty integer NOT NULL CHECK (qty > 0),
order_ref text NOT NULL,
channel text NOT NULL,
state text NOT NULL CHECK (state IN ('held','confirmed','released','expired')),
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (channel, order_ref, sku)
);Обратите внимание на UNIQUE (channel, order_ref, sku). В описании ограничений сказано, что UNIQUE может охватывать несколько столбцов — именно это здесь и использовано: пара «канал + номер заказа + артикул» может встретиться в таблице только один раз. О том, зачем это нужно, поговорим чуть ниже, в разделе про повторы.
Теперь жизнь резерва — это конечный автомат из четырёх состояний. Какие переходы допустимы и что при этом происходит со счётчиками, удобно записать таблицей:
| Из | В | Причина | Что происходит с остатком |
|---|---|---|---|
| — | held | покупатель оформил заказ, доступно хватает | reserved += qty |
| held | confirmed | пришло подтверждение оплаты | остатки не меняются: товар остаётся удержанным до отгрузки |
| held | released | покупатель или менеджер отменил заказ | reserved -= qty |
| held | expired | срок резерва вышел, оплаты нет | reserved -= qty |
| confirmed | (списание) | товар отгружен | on_hand -= qty и reserved -= qty в одной транзакции |
| confirmed | released | отмена после оплаты, возврат | reserved -= qty; отдельно решается вопрос с деньгами |
Из таблицы видно, что подтверждение оплаты и списание — разные события. Оплата говорит «деньги пришли, обещание обязательно», а списание говорит «вещь уехала». Между ними бывают часы или дни, и всё это время единица должна оставаться удержанной, но физически ещё лежать на складе. Если склеить эти шаги в один, при ручной отмене или сбое отгрузки придётся угадывать, что делать с остатком.
Также полезно, что expired и released — разные состояния, хотя счётчик они меняют одинаково. Первое значит «покупатель не успел», второе — «кто-то решил отменить». Для аналитики и для разговора со службой поддержки это существенно разные истории.
Срок резерва: кто освободит товар, если никто не придёт
Здесь легко поддаться иллюзии. Столбец expires_at — это всего лишь дата в таблице. Сам по себе он ничего не освобождает: база не пересчитывает счётчики по часам. Если вы записали «до 15:30» и ничего больше не сделали, в 15:31 резерв всё ещё будет держать единицу. Нужен механизм, который просматривает просроченные записи и возвращает товар. Это может быть периодическая задача на сервере, запускаемая планировщиком, или фоновый воркер приложения; выбор здесь ваш, и он зависит от окружения.
Освободитель — это тоже транзакция, и устроить её нужно так же аккуратно, как и резервирование:
WITH due AS (
UPDATE reservation
SET state = 'expired'
WHERE state = 'held' AND expires_at <= now()
RETURNING sku, qty
)
UPDATE stock s
SET reserved = s.reserved - d.total
FROM (SELECT sku, sum(qty) AS total FROM due GROUP BY sku) d
WHERE s.sku = d.sku;Ключевая деталь — условие state = 'held' внутри первого UPDATE. Освобождается только то, что всё ещё удерживается. Если параллельно запись уже стала confirmed, она в выборку не попадёт. Но об этой параллельности нужно подумать отдельно, потому что именно она самая коварная.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Истечение срока и оплата в одну секунду
Представим: срок резерва заканчивается в 15:30:00. Платёжная система присылает уведомление об оплате в 15:30:00 по часам сервера. Два процесса — освободитель и обработчик платежа — одновременно смотрят на одну и ту же запись в состоянии held.
| Момент | Освободитель | Обработчик оплаты | Запись |
|---|---|---|---|
| t1 | UPDATE … SET expired WHERE state='held' AND expires_at <= now() — строка заблокирована | held, затем expired (не зафиксировано) | |
| t2 | UPDATE … SET confirmed WHERE state='held' — ждёт | — | |
| t3 | COMMIT, reserved уменьшен | перепроверяет условие: state уже expired, строк изменено 0 | expired |
| t4 | видит ноль строк и выбирает ветку «деньги есть, резерва нет» | expired |
Важно не то, кто победил, а то, что победитель один, и проигравший это узнаёт. Обе стороны меняют состояние только условным UPDATE и смотрят на число изменённых строк. Случай «ни один не заметил проигрыша» исключён самой структурой запроса.
А вот что делать проигравшему обработчику оплаты — это уже деловое решение, а не техническое. Деньги пришли, а резерв истёк. Варианты два:
- Попытаться взять товар заново. Если доступный остаток ещё есть, оформить новый резерв и сразу подтвердить его. Покупатель ничего не заметит.
- Признать, что товара нет. Если за это время единицу забрал другой заказ, оплата превращается в возврат, а покупатель получает объяснение, а не молчание.
Чтобы такие случаи случались реже, срок резерва стоит делать с запасом относительно времени, которое реально нужно на оплату, и продлевать его при начале платежа. Это не техническая необходимость, а способ уменьшить количество неприятных разговоров. Продление — тоже условный UPDATE: «передвинуть expires_at, если запись всё ещё held».
Транзакция заканчивается там, где кончается база
Соблазн велик: открыть транзакцию, зарезервировать товар, позвать платёжный API, получить ответ, подтвердить резерв, закоммитить. Выглядит как одна неделимая операция. Но гарантии «всё или ничего», как отмечено в руководстве PostgreSQL, описаны для операций базы данных, а не для произвольного внешнего HTTP API. Если ROLLBACK случится после того, как платёжный сервис уже принял платёж, откатится ваша запись, а чужая — нет.
Практический вывод — раздельные шаги с честными состояниями между ними. Сначала короткая транзакция: резерв в состоянии held со сроком. Затем, вне транзакции, обращение к платёжному сервису. Затем, когда приходит результат, новая короткая транзакция: переход в confirmed или released. Если между шагами что-то оборвалось, срабатывает срок резерва, и система приходит в порядок без ручного вмешательства. Это слабее, чем «атомарно», зато честно: каждый шаг локально надёжен, а связь между ними держится на состояниях и сроках, а не на надежде.
То же относится к двум каналам продаж, если один из них — внешняя площадка со своей независимой логикой. Локальная транзакция защищает вашу базу. Если маркетплейс сам продаёт из собственной копии остатка, не обращаясь к вашему резерву, никакое условие в вашем UPDATE не помешает ему оформить заказ. Здесь работает только дисциплина обмена: все каналы, которыми вы управляете, должны резервировать через одну и ту же таблицу, а остаток, который вы отправляете наружу, стоит публиковать с небольшим запасом и обновлять часто. Это снижает вероятность двойной продажи, но не отменяет её; оставьте в процессе понятный путь для случая, когда она всё же произошла.
Повтор того же заказа: ключ, который переживает ретрай
Сети ненадёжны, и клиент, не дождавшись ответа, шлёт запрос ещё раз. Платёжный сервис присылает то же уведомление дважды. Очередь доставляет сообщение «как минимум один раз». Всё это значит, что операция резервирования должна быть безопасна при повторе.
Вот для чего введено UNIQUE (channel, order_ref, sku). Номер заказа в канале — устойчивый признак: он приходит вместе с заказом, а не создаётся заново при каждой попытке. Повторный запрос на те же данные упрётся в ограничение уникальности, и приложение сможет вместо ошибки вернуть уже существующий результат. В PostgreSQL для этого существует конструкция INSERT … ON CONFLICT, но детали её синтаксиса мы здесь не разбираем: достаточно понимать, что уникальный ключ — это то, что превращает «второй раз» из новой операции в узнаваемую старую.
Тут есть тонкость, о которой стоит предупредить. Если для каждой попытки приложение генерирует новый идентификатор — случайный UUID при каждом вызове, — то он ничего не защищает: два повтора получат два разных ключа и оба пройдут. Защищает только тот ключ, который приходит из бизнеса и повторяется вместе с повтором: номер заказа, номер платежа, идентификатор сообщения. Это касается и пустых значений. Как сказано в описании ограничений, пустая строка не тождественна NULL; а значит, заказ с пустым order_ref — это настоящее значение, под которое подпадёт любой другой заказ с пустым номером. Поле стоит объявить NOT NULL и добавить проверку, что оно не пустое, иначе два разных заказа без номера склеятся в один.
Подтверждение оплаты точно так же должно переживать повтор. Уведомление «платёж №777 прошёл» может прийти трижды. Переход held → confirmed по условию WHERE state = 'held' при втором и третьем вызове изменит ноль строк — и это нормально. Обработчик должен различать «ноль строк, потому что уже подтверждено» и «ноль строк, потому что истекло»: для этого достаточно прочитать текущее состояние записи и ответить по нему.
История движения: чтобы отказ был объяснимым
Таблица reservation хранит текущее состояние. Но когда клиент спрашивает, почему его заказ отменён, а менеджер хочет понять, куда делась единица, нужна история: что и когда происходило. Для этого заводят журнал событий, в который каждый переход добавляет строку и из которого ничего не удаляется.
CREATE TABLE reservation_event (
id bigserial PRIMARY KEY,
reservation_id bigint NOT NULL REFERENCES reservation(id),
from_state text,
to_state text NOT NULL,
reason text NOT NULL,
at timestamptz NOT NULL DEFAULT now()
);Запись события делается в той же транзакции, что и смена состояния. Тогда журнал и текущее состояние не могут разойтись: либо произошло и то и другое, либо ни то ни другое. Это та самая область, где транзакция работает без оговорок: все участники — таблицы одной базы.
У журнала есть и проверочная функция. Из него можно в любой момент пересчитать резервы по каждому артикулу и сравнить с полем reserved в таблице остатков. Расхождение — сигнал, что где-то код менял счётчик в обход правил. Такую сверку удобно запускать по расписанию и смотреть на неё глазами, а не доверять слепо.
Для расследования зависших ожиданий пригодится то, что сообщает документация: сведения о текущих блокировках доступны через pg_locks. Если нагрузочный тест показывает, что запросы на одном артикуле выстраиваются в длинную очередь, взгляд на блокировки быстро скажет, кто держит строку и как долго.
Как проверить, что гонки больше нет
Глазами гонку не увидеть, поэтому проверка должна её воспроизводить. Простой сценарий: взять артикул с одной доступной единицей и запустить, скажем, пятьдесят параллельных попыток зарезервировать её с разными номерами заказов. Ожидаемый результат строго определён: ровно одна попытка получает резерв, остальные сорок девять получают отказ, reserved равен единице, отрицательных значений нигде нет. Число попыток здесь условное — подойдёт любое, главное, чтобы они действительно шли одновременно, а не по очереди.
Второй сценарий — повтор: отправить один и тот же заказ десять раз подряд. Ожидание: одна запись в reservation, один плюс в reserved, остальные ответы возвращают тот же результат.
Третий — состязание срока и оплаты: искусственно поставить expires_at на текущий момент и одновременно запустить освободитель и подтверждение. Ожидание: ровно один из них изменил строку, счётчик согласован с журналом, а проигравший получил внятный ответ. Если тест иногда падает, а иногда проходит, это не «плавающая ошибка», а как раз тот случай, ради которого он написан.
Где всё это запускать
Для такого сервиса не нужно ничего экзотического: база, небольшое приложение и фоновая задача освобождения. Критичнее не мощность, а предсказуемость: стабильный диск для базы, понятный доступ к настройкам и возможность держать приложение и базу рядом, чтобы транзакции были короткими. На VPS MAATRIX доступны NVMe-диски, процессоры AMD EPYC и root-доступ, а значит, можно самостоятельно поставить PostgreSQL, настроить планировщик для освободителя и разместить сервис в одной из локаций, перечисленных в публичном предложении: UK, US, RU и FR. Для небольшого учёта на два канала достаточно начать с младших конфигураций вроде Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ), а Heka и выше имеет смысл рассматривать, когда замеры на вашей нагрузке покажут, что ресурсов не хватает. Ресурсы тарифа не равны обещанию ёмкости, поэтому решение о размере принимайте по собственному нагрузочному тесту из предыдущего раздела. Если часть задач по интеграциям вы собираетесь вести через n8n, в каталоге есть вариант с установкой; наличие и операционную систему лучше уточнить в форме заказа. Резервное копирование базы нужно настроить самостоятельно: в подтверждённых возможностях оно как стандартная часть тарифов не указано.
Если в вашей системе есть клиентские приложения, которые жалуются на «тормоза» при оформлении заказа, сначала стоит отделить медленный сервер от медленного канала связи — об этом есть отдельный разбор: как отделить последнюю милю от сервера. Это спасёт от оптимизации базы там, где проблема в сети пользователя.
Две секунды спустя
Вернёмся к кофемолке. Два запроса приходят в одну секунду. Оба выполняют один и тот же условный UPDATE. База пропускает первый, второй ждёт долю мгновения, перепроверяет условие на уже изменённой строке и видит, что доступно ноль. Первый покупатель получает резерв со сроком на оплату, запись в журнале и номер заказа, по которому любой повтор вернёт тот же ответ. Второй получает отказ «товар только что закончился» — короткий, понятный и без отрицательного остатка в таблице. Если первый покупатель не оплатит вовремя, освободитель переведёт резерв в expired, вернёт единицу в доступные, и витрина снова покажет «в наличии: 1». Если оплатит — резерв станет confirmed, а при отгрузке единица спишется с полки. Никто не продал последнюю кофемолку дважды, а тот, кому не досталось, знает почему.