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