Я делаю сервис онлайн-записи для салонов красоты: клиент записывается в чат-боте или на странице, а запись падает в расписание мастера. Однажды мастер написал мне: на 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

Комментарии (4)


  1. muxa_ru
    31.07.2026 23:19

    Обычный UNIQUE тут не подходит: нам нужна не «одинаковость», а пересечение интервалов времени.

    У Вас часовые слоты?

    Если да, то нет никакого "пересечения интервалов" и Y-m-d-H + mastername дадут UNIQUE.


    1. Denjam Автор
      31.07.2026 23:19

      Слоты не часовые — в этом и суть. Длительность услуги плавающая (маникюр 30 минут, окрашивание 2–3 часа), и старт не привязан к ровному часу. Поэтому брони пересекаются частично: запись на 13:00 длиной 90 минут (до 14:30) конфликтует с записью на 14:00, хотя усечение до Y-m-d-H даёт им разные ключи (13 и 14) — UNIQUE их пропустит. tstzrange + && ловит именно перекрытие интервалов разной длины. Ваш вариант отлично работал бы на фиксированной сетке одинаковых слотов — там пересечений не бывает и UNIQUE(master, slot) проще и честнее. Но с услугами переменной длительности сетки нет, поэтому нужен интервальный constraint.


  1. FurySeer
    31.07.2026 23:19

    Текст писала та же модель, что и код вашего приложения?


    1. Denjam Автор
      31.07.2026 23:19

      Алексей, буду рад обсудить вопрос о теме статьи.