Я делаю сервис онлайн-записи для салонов красоты: клиент записывается в чат-боте или на странице, а запись падает в расписание мастера. Однажды мастер написал мне: на 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
muxa_ru
У Вас часовые слоты?
Если да, то нет никакого "пересечения интервалов" и Y-m-d-H + mastername дадут UNIQUE.
Denjam Автор
Слоты не часовые — в этом и суть. Длительность услуги плавающая (маникюр 30 минут, окрашивание 2–3 часа), и старт не привязан к ровному часу. Поэтому брони пересекаются частично: запись на 13:00 длиной 90 минут (до 14:30) конфликтует с записью на 14:00, хотя усечение до Y-m-d-H даёт им разные ключи (13 и 14) — UNIQUE их пропустит. tstzrange + && ловит именно перекрытие интервалов разной длины. Ваш вариант отлично работал бы на фиксированной сетке одинаковых слотов — там пересечений не бывает и UNIQUE(master, slot) проще и честнее. Но с услугами переменной длительности сетки нет, поэтому нужен интервальный constraint.
darkboatman
Проще разбить на слотв по 15 или 5 минут и занимать и пачкой в транзакции. Если так не делать, прмключения возникают на ровном месте.