В сервисе бронирования два параллельных запроса могут списать один последний билет. Какой механизм на уровн...

В сервисе бронирования два параллельных запроса могут списать один последний билет. Какой механизм на уровне хранилища предотвращает продажу сверх доступного остатка?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

Исторический контекст

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

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

Постановка проблемы

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

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

Подробное решение

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

Минимальный пример для реляционного хранилища:

UPDATE tickets SET available = available - 1 WHERE event_id = 42 AND available > 0;

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

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

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

Ситуация из практики

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

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

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

Что кандидаты часто упускают

  1. Достаточно ли просто обернуть чтение остатка и запись в транзакцию?

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

  1. Что должен делать сервис, если условное обновление изменило ноль строк?

Он не должен трактовать это как успешное бронирование. Причину следует отличать от технической ошибки настолько, насколько это возможно: билет мог закончиться, событие могло быть недоступно, либо версия записи устарела. Для конфликта версии допустим повтор с ограниченным числом попыток, а для исчерпанного остатка нужно вернуть бизнес-отказ без бесконечных повторов.

  1. Почему атомарное уменьшение остатка не гарантирует корректность всего процесса?

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