Двойная бронь: как мы поймали гонку в записи и закрыли её EXCLUDE-констрейнтом

от автора

Я делаю сервис онлайн-записи для салонов красоты: клиент записывается в чат-боте или на странице, а запись падает в расписание мастера. Однажды мастер написал мне: на 15:00 к ней пришли два клиента. Оба записаны, оба уверены, что слот их. Классическая двойная бронь — и классическая race condition. Разберу, почему наивная проверка «свободно ли время?» не спасает, и как мы закрыли дыру двумя слоями: advisory-lock и EXCLUDE-констрейнтом в PostgreSQL. По пути — один неочевидный подвох с IMMUTABLE, на который легко напороться.

Как вообще получается двойная бронь

Наивный поток записи выглядит безобидно:

1. Бот показывает свободные слоты мастера.

2. Клиент выбирает 15:00.

3. Бот проверяет, что 15:00 ещё свободно.

4. Бот вставляет запись.

Проблема — в окне между шагами 3 и 4. Оно крошечное, доли секунды, но если два диалога идут параллельно, оба проходят шаг 3 (записи ещё нет — «свободно») и оба доходят до шага 4. В базе появляются две записи на один слот. Это «check-then-insert» — гонка, которую видно в любом учебнике и не видно в спокойном тестировании: она стреляет только под одновременными запросами.

Двойная бронь

Двойная бронь

Почему advisory-lock спас не сразу

У нас уже был advisory-lock — но не тот. Обработчик входящих сообщений держит блокировку по (tenant, channel, chat): она сериализует один диалог (защищает от дабл-тапа по кнопке и от повторной доставки апдейта). Но два разных клиента — это два разных диалога, два разных ключа блокировки. Друг для друга они невидимы и бегут параллельно.

Логичный шаг — блокировать не диалог, а мастера. Тогда любые две брони одного мастера встают в очередь, а брони к разным мастерам остаются параллельными (никакой ложной конкуренции). В create_booking мы берём транзакционный advisory-lock по мастеру:

await db.execute(    text("SELECT pg_advisory_xact_lock(hashtextextended(:k, 0))"),    {"k": f"booking-master:{master_id}"},)

pg_advisory_xact_lock держится до конца транзакции и снимается сам. hashtextextended сворачивает строковый ключ в bigint, которого ждёт advisory-lock. Теперь брони одного мастера сериализованы, и в happy path гонки нет.

Но advisory-lock — это договорённость, а не гарантия. Он работает, только пока весь код, создающий записи, честно берёт эту блокировку. А точек вставки у нас несколько: бот, веб-виджет, ручное добавление из панели, импорт клиентов. Забыл взять лок в одной из них — и дыра открыта снова. Хотелось гарантии на уровне данных, которую нельзя обойти.

Настоящий ров — EXCLUDE-констрейнт

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

CREATE EXTENSION IF NOT EXISTS btree_gist;ALTER TABLE bookings ADD CONSTRAINT bookings_master_no_overlap  EXCLUDE USING gist (    tenant_id WITH =,    master_id WITH =,    tstzrange(starts_at, booking_slot_end(starts_at, duration_min)) WITH &&  ) WHERE (status IN ('pending', 'confirmed'));

Читается так: «не может быть двух записей с одинаковыми tenant_id и master_id, у которых пересекаются (&&) интервалы [начало, конец)». tstzrange строит временной диапазон, && — оператор пересечения диапазонов.

Пара нюансов, которые легко пропустить:

btree_gist. Оператор && живёт в gist-индексе, а обычное равенство = для скаляров — в btree. Чтобы смешать = и && в одном gist-индексе EXCLUDE, нужно расширение btree_gist (оно учит gist обычному равенству).

Частичный индекс (WHERE status IN ...). Блокируют слот только активные записи (pending/confirmed). Отменённые и завершённые не должны мешать записать кого-то на то же время снова — поэтому они вне констрейнта.

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

Подвох с IMMUTABLE

Первая версия EXCLUDE у меня не собралась. Конец интервала — это starts_at + длительность, и на «в лоб»

tstzrange(starts_at, starts_at + (duration_min || ' minutes')::interval)

PostgreSQL отвечает отказом: выражения в индексе (а EXCLUDE — это индекс) обязаны быть IMMUTABLE, а timestamptz + interval помечен всего лишь STABLE. Причина тонкая: прибавление интервала может зависеть от часового пояса сессии — из-за перехода на летнее время «+1 час» не всегда даёт один и тот же абсолютный момент. Для индекса это недопустимо: значение должно быть детерминированным.

Мы прибавляем только минуты, а это как раз детерминированно (никаких календарных месяцев и DST-двусмысленностей). Поэтому обернули арифметику в функцию и честно пометили её IMMUTABLE:

CREATE FUNCTION booking_slot_end(starts timestamptz, dur integer)  RETURNS timestamptz LANGUAGE sql IMMUTABLE PARALLEL SAFE AS$$ SELECT starts + make_interval(mins => dur) $$;

Важная оговорка: IMMUTABLE — это обещание, которое вы даёте планировщику. Здесь оно правдиво, потому что мы прибавляем только минуты через make_interval. Не вешайте IMMUTABLE на что-то с + interval '1 month' — там результат зависит от календаря, и вы получите тихо неверный индекс.

Ловим отказ красиво (SAVEPOINT)

Гарантия есть, но у неё побочка: проигравший гонку инсерт бросает нарушение констрейнта, а необработанное исключение отравляет всю транзакцию — любой следующий запрос в ней падает current transaction is aborted. А нам как раз нужно после отказа сходить в базу ещё раз и предложить клиенту соседнее свободное время. Значит, ошибку нужно локализовать.

Оборачиваем сам INSERT в SAVEPOINT (в SQLAlchemy это begin_nested) и на нарушении откатываемся только до него, превращая грубую ошибку БД в вежливый «слот занят»:

try:    async with db.begin_nested():          # SAVEPOINT        await db.execute(insert_booking, params)except IntegrityError as exc:    raise ValueError("slot_not_available") from exc

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

Две линии, а не одна

Две линии защиты

Две линии защиты

Итоговая защита — два слоя, и у каждого своя роль:

Advisory-lock по мастеру сериализует частый случай и сглаживает happy path: до констрейнта дело обычно не доходит, а разные мастера остаются параллельными.

EXCLUDE-констрейнт — настоящая гарантия. Он ловит гонку на любом пути вставки (бот, виджет, панель, импорт), даже если кто-то забыл взять лок.

SAVEPOINT превращает отказ гарантии в хороший UX, а не в пятисотку.

Главный вывод банален, но его легко забыть под дедлайн: проверка на уровне приложения — это подсказка, а не гарантия. Если правило обязано соблюдаться всегда (у мастера не может быть двух пересекающихся записей), место ему — в базе. А приложение пусть переводит отказ базы во что-то дружелюбное. И держите в голове IMMUTABLE, когда кладёте арифметику времени в индекс.

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

Денис Мельников. Делаю NAMI — платформа записи клиентов через боты в Telegram и MAX для салонов красоты. nami.expert

ссылка на оригинал статьи https://habr.com/ru/articles/1065522/