Два человека открывают бота массажного кабинета. Оба видят кнопку «15:00», и оба нажимают её с разницей в долю секунды. Для обоих экран честно показывал свободное время. Но свободное время на экране — это фотография расписания, сделанная чуть раньше. Право на запись возникает в другом месте: в момент, когда сервер успешно закрепил слот за одним из клиентов. Всё остальное в этой статье — подробности того, как сделать этот момент один и сделать его справедливым.
Сразу оговоримся: сам Telegram от двойной брони не защищает. Он доставляет боту нажатия и сообщения, а что с ними делать — решает ваш код и ваша база данных. Транзакционная модель, которую мы разберём ниже, — это инженерное решение, а не функция мессенджера.
Витрина и касса: две разные операции
Удобно думать о боте записи как о магазине с витриной и кассой. Витрина показывает, что есть в наличии. Касса фактически продаёт. Если путать их, получается классическая ошибка: бот прочитал список свободных слотов, нарисовал клавиатуру, а при нажатии просто записал клиента, не перепроверив, что время всё ещё свободно.
Правильное разделение выглядит так:
- Показ расписания — быстрое чтение. Оно может чуть устаревать, это нормально. Клиент увидел «15:00», пока он думал, слот могли занять.
- Закрепление места — отдельная операция записи, которая заново проверяет условие «слот свободен» и в том же неделимом действии меняет состояние. Если проверка не прошла, клиент получает отказ и альтернативы.
Главная мысль: кнопка в чате — это не бронь, а заявка на бронь. Каждое нажатие нужно воспринимать как просьбу, которую сервер вправе отклонить. Тогда устаревшая клавиатура перестаёт быть проблемой: она всего лишь подсказка.
Заодно полезно помнить, как бот получает нажатия. В документации Telegram сказано, что webhook и getUpdates взаимоисключающие, а входящие обновления хранятся ограниченное время (Telegram: Getting updates). Для записи это значит одно: обработчик должен спокойно переживать ситуацию, когда одно и то же действие клиента приходит дважды или с задержкой, и не создавать две брони.
Что именно мы бронируем: ресурс, услугу и интервал
Прежде чем защищать слот, нужно определить, что такое слот. «15:00» — это не объект, а метка. На самом деле бронируется отрезок времени у конкретного ресурса.
Ресурс — то, что нельзя занять двумя клиентами одновременно: мастер, кресло, кабинет, переговорная, инструктор. Иногда ресурсов несколько, и клиенту безразлично, какой достанется. Иногда ресурс один и принципиален: «только к Анне». От этого зависит, по какому ключу вы будете сталкивать конкурентные запросы.
Длительность услуги. Стрижка занимает 45 минут, окрашивание — три часа. Если хранить только время начала, получится ловушка: клиент на окрашивание в 14:00 и клиент на стрижку в 15:00 формально занимают разные «слоты», а на деле пересекаются. Поэтому бронь — это интервал: начало и конец. Конец вычисляется из длительности выбранной услуги, и при необходимости к нему добавляется буфер на уборку или перерыв.
Условие пересечения интервалов формулируется коротко. Два интервала пересекаются, если каждый из них начинается раньше, чем заканчивается другой. Удобно считать интервалы полуоткрытыми: бронь с 14:00 до 15:00 не конфликтует с бронью, которая начинается ровно в 15:00. Без этого соглашения вы получите странные отказы на стыке соседних записей.
Небольшой условный пример. У мастера есть запись с 14:00 до 15:30. Новая заявка — маникюр на 60 минут с 15:00. Начало новой записи (15:00) раньше конца существующей (15:30), а начало существующей (14:00) раньше конца новой (16:00). Значит, пересечение есть, и отказать нужно, хотя в сетке с шагом в час «15:00» выглядело свободным. Вывод: проверять надо не «занята ли ячейка», а «не пересекается ли интервал с уже подтверждёнными и удерживаемыми».
Два клиента, один слот: хронология гонки
Теперь проследим саму гонку. Клиенты — Алина и Борис, ресурс — один мастер, слот — 15:00. Оба прочитали расписание, когда слот был свободен.
| Момент | Алина | Борис |
|---|---|---|
| t1 | Бот проверяет: 15:00 свободно | |
| t2 | Бот проверяет: 15:00 свободно | |
| t3 | Бот записывает бронь | |
| t4 | Бот записывает бронь | |
| Итог | Две брони на один интервал, оба клиента получили «Вы записаны» | |
Ошибка не в медленном коде, а в разрыве между проверкой и записью. В этот промежуток встраивается чужой запрос. Чем больше трафик и чем популярнее слот, тем чаще это случается; на тихом расписании баг может месяцами не проявляться и сработать в самый ценный момент — например, когда вы разослали анонс акции.
Теперь та же хронология, когда проверка и запись образуют одно неделимое действие:
| Момент | Алина | Борис |
|---|---|---|
| t1 | Начинает транзакцию, получает право изменять расписание этого мастера на эту дату | |
| t2 | Начинает транзакцию, пытается получить то же право и ждёт | |
| t3 | Проверяет пересечения: пусто. Записывает бронь, завершает транзакцию | Всё ещё ждёт |
| t4 | Получает право, проверяет пересечения: находит бронь Алины | |
| Итог | Бронь подтверждена | Отказ, предложены другие слоты |
Ключевое отличие — на шаге t2. Борис не проскакивает мимо, а встаёт в очередь. Он проверяет расписание уже после того, как Алина зафиксировала результат, и видит реальную картину.
Как PostgreSQL помогает выстроить очередь
Эту очередь обеспечивает база данных. В документации PostgreSQL описаны явные блокировки для управления конкурентным доступом, а сведения о текущих блокировках можно посмотреть через представление pg_locks (PostgreSQL: Explicit locking). Мы не будем приводить готовый SQL: точная форма зависит от вашей схемы, и вам важнее понять принцип, чем скопировать чужой запрос.
Принцип такой. Вы выбираете, что именно будет предметом конкуренции, — например, запись о мастере или о его рабочем дне. Все запросы, которые хотят изменить расписание этого мастера, обязаны сначала получить блокировку на этот объект. Пока один держит её, остальные ждут. Когда транзакция завершается, блокировка снимается, и следующий в очереди видит уже обновлённые данные.
Из этого следуют практические соображения:
- Гранулярность. Если блокировать всё расписание целиком, бот станет последовательным для всех клиентов, хотя им нечего делить. Если блокировать только конкретного мастера на конкретный день, конкуренция возникает лишь там, где она реальна. Выбор — ваш компромисс между простотой и пропускной способностью.
- Короткие транзакции. Блокировку нельзя держать, пока клиент размышляет или пока вы ходите во внешний сервис. Внутри транзакции — только проверка и запись. Сообщения в Telegram отправляйте после её завершения.
- Единый порядок. Если операции захватывают несколько ресурсов (например, мастера и кабинет), делайте это всегда в одном и том же порядке. Иначе два запроса могут взаимно дождаться друг друга, и база прервёт один из них.
- Наблюдаемость. Если бот «залип», сведения о блокировках в pg_locks помогут понять, кто кого ждёт.
Есть и второй рубеж защиты: ограничение на уровне самой схемы, которое не позволит сохранить пересекающиеся брони даже при ошибке в коде. Подходит ли оно вам и как именно его записать, зависит от версии и устройства вашей базы, поэтому сверяйтесь с документацией PostgreSQL. Идея простая: логика приложения — это вежливая просьба, а ограничение базы — запертая дверь. Лучше иметь оба.
Отдельно о границах транзакции. Транзакция вашей базы охватывает только вашу базу. Если вы одновременно вызываете внешний сервис — платёжный или календарь, — нельзя считать, что это единое атомарное действие. Для таких случаев нужны отдельные состояния брони и повторная сверка, о чём ниже.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Временный резерв: когда слот занят, но ещё не продан
Если запись бесплатная и подтверждается нажатием кнопки, хватит двух состояний: свободно и занято. Но как только появляется шаг между выбором и окончательным согласием — предоплата, ввод телефона, ответ на уточняющий вопрос, — возникает промежуточное состояние. Клиент выбрал 15:00, но ещё не завершил. Что с слотом?
Есть два плохих варианта. Не держать слот вообще: клиент дошёл до оплаты и узнал, что время ушло. Держать бессрочно: один нерешительный человек блокирует мастера на полдня. Хороший вариант — временное удержание с коротким сроком жизни.
Жизненный цикл брони можно описать четырьмя состояниями:
- Удержание. Слот закреплён за клиентом, но с отметкой «действует до». Для остальных он недоступен.
- Подтверждена. Клиент выполнил условие — оплатил, подтвердил контакт. Срок действия снимается.
- Истекла. Время вышло, подтверждения не было. Слот снова свободен.
- Отменена. Клиент или администратор отказались от подтверждённой брони. Слот освобождается.
Важная деталь: «действует до» нельзя проверять только фоновой задачей, которая раз в минуту чистит просроченное. Между срабатываниями задачи просроченное удержание ещё числится в базе. Поэтому при каждой проверке пересечений просроченные удержания нужно игнорировать, а фоновую уборку считать лишь способом поддерживать порядок и отправлять клиенту сообщение «время удержания истекло».
Срок удержания — не техническая, а продуктовая величина. Десять минут для оплаты — разумно, сутки — уже превращают бота в способ случайно заморозить расписание. Подберите значение по своему процессу и посмотрите на реальное поведение клиентов; брать число из чужой статьи не стоит.
Что делать с подтверждением, которое пришло позже срока? Например, клиент оплатил на одиннадцатой минуте, а удержание длилось десять. Здесь нужно заранее решить правило: вернуть деньги и предложить новое время, либо проверить, свободен ли слот, и если да — подтвердить. Любое из этих решений допустимо, недопустимо только молчаливое поведение, при котором слот, уже отданный другому, подтверждается во второй раз. Внешнюю платёжную систему и вашу базу в одну транзакцию не соединить, так что итог сверяется по состоянию брони: подтверждение применяется, только если бронь всё ещё в состоянии удержания и срок не вышел либо слот гарантированно свободен.
Повторные нажатия и повторные доставки
Клиенты нажимают кнопки дважды. Сеть присылает одно и то же обновление повторно. Это отдельный источник двойных броней, не связанный с конкуренцией разных людей: один человек конкурирует сам с собой.
Защита здесь — детерминированность действия. Если Алина уже держит или подтвердила слот 15:00 у этого мастера, повторная заявка от неё на тот же слот не должна создавать вторую запись; она должна вернуть существующую. Просто генерировать новый идентификатор попытки при каждом обращении недостаточно: это не делает операцию идемпотентной, и повтор породит ещё одну бронь. Нужен устойчивый признак «это то же самое намерение»: сочетание клиента, ресурса и интервала либо идентификатор, который вы привязали к конкретной карточке выбора и храните в базе.
Отмена и перенос: освобождение места без сюрпризов
Отмена — зеркальная операция, и к ней применимы те же правила. Освобождая слот, вы тоже меняете расписание, а значит, обязаны делать это в том же порядке блокировок и той же короткой транзакцией. Иначе отмена и новая заявка на тот же интервал снова разойдутся.
Несколько нюансов, которые стоит продумать заранее:
- Кто вправе отменять. Бронь должна быть связана с клиентом, и отменить её может только он или администратор. Кнопка «Отменить» с идентификатором брони в данных не должна работать для чужих записей: проверяйте владельца на сервере.
- Повторная отмена. Если бронь уже отменена, повторное нажатие не должно ни падать с ошибкой, ни освобождать слот второй раз, если его успели занять. Вместо этого бот отвечает, что запись уже отменена.
- Поздняя отмена. Если у вас есть правило «менее чем за сутки не отменяется бесплатно», проверяйте его в момент отмены по серверному времени, а не по сообщению клиента.
- След в истории. Отменённую бронь удобнее пометить состоянием, чем удалить: так вы сможете разобраться в споре и увидеть, кто что делал.
Перенос — это не «изменить время в записи», а две операции: закрепить новый интервал и освободить старый. Порядок важен. Если сначала освободить старый, а потом новый не удалось занять, клиент остался без записи вообще. Поэтому сначала пробуйте захватить новый слот; если получилось — отпускайте старый в той же транзакции. Если новый занят — старая бронь остаётся нетронутой, и бот сообщает об этом прямо.
Освобождённый слот можно сразу предлагать тем, кто в листе ожидания. Если вы соберёте таких клиентов, сообщения им — это уже уведомления, и отправлять их нужно только тем, кто согласился. Подробнее о том, как аккуратно строить исходящие сообщения по подписчикам, написано в статье Бот для рассылки по подписчикам в Telegram. Напоминания о визите по тому же принципу: клиент должен понимать, что согласился их получать, и иметь возможность отказаться.
Чьё это «15:00»: время клиента и время места
Последняя ловушка тихая. Клиент пишет боту из одного города, а студия работает в другом. «Запишите меня на 15:00» — по чьим часам?
Единственно надёжная схема: расписание места оказания услуги живёт в часовом поясе этого места, а момент брони хранится как однозначная точка во времени. Клиенту же время показывается в понятной ему форме, с явным указанием, чьё оно. Фраза «15:00 по времени студии (Екатеринбург)» избавляет от половины недоразумений.
Часовые пояса стоит задавать именами из базы IANA, например «Europe/Moscow», а не сдвигом вроде «UTC+3». Сдвиг не знает о переходах на летнее и зимнее время и об изменениях правил в стране; имя зоны знает. В Python для этого есть модуль zoneinfo: он использует базу IANA и работает с переходами часового пояса, а данные берёт из системы либо из пакета tzdata (Python: zoneinfo). Практический вывод для сервера: если в минимальном образе системы нет базы часовых поясов, добавьте пакет tzdata, иначе поиск зоны по имени не сработает.
Какие проблемы это решает на деле:
- Переход часов. Рабочий день в день перевода стрелок может быть на час короче или длиннее. Если считать слоты прибавлением фиксированных шагов к полуночи, можно получить несуществующее или повторяющееся местное время. Строить сетку нужно по местным часам места, а на каждый слот спрашивать у библиотеки, какой это момент.
- Путешествующий клиент. Часовой пояс клиента может отличаться от того, что бот запомнил полгода назад. Спрашивайте зону явно или предлагайте подтвердить её, а не угадывайте по языку интерфейса.
- Напоминания. Вы отправляете их относительно момента визита, а в тексте показываете время в понятной клиенту зоне. Если пояс клиента неизвестен, пишите время места с указанием города.
И ещё одно простое правило: на сервере разумно держать системные часы в UTC и не делать расчётов «по локальному времени машины». Тогда переезд на другой сервер не изменит расписание.
Где всё это жить: ресурсы под бота записи
Бот записи — лёгкая по меркам инфраструктуры задача: приложение-обработчик, база PostgreSQL и фоновая задача для истёкших удержаний и напоминаний. Всё это помещается на одном небольшом сервере, а root-доступ позволяет установить нужную версию базы, tzdata и настроить автозапуск. Для старта можно присмотреться к младшим тарифам VPS MAATRIX — например, Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ). Это справочные ресурсы, а не обещание конкретного числа одновременных клиентов: реальную ёмкость покажет замер вашего бота при вашей нагрузке. Если вы автоматизируете окружение сценариями, в публичном каталоге предложены n8n и Nextcloud с установкой; доступность и операционная система уточняются в форме заказа.
Резервные копии базы с бронями — отдельная забота: не рассчитывайте на них как на встроенную функцию тарифа, настройте и проверьте их сами.
Два ответа бота на один слот
Вернёмся к нашим Алине и Борису. Оба нажали «15:00». Обработчик принял две заявки, выстроил их в очередь на уровне базы, и первой в очередь вошла Алина. Проверка пересечений для неё прошла, бронь закреплена, транзакция завершена. Теперь очередь Бориса, и он видит расписание таким, какое оно есть на самом деле.
Алине бот отвечает:
Готово, вы записаны на сегодня к 15:00 (время студии, Екатеринбург). Услуга займёт 60 минут. Если планы изменятся, нажмите «Перенести» или «Отменить» — место освободится сразу.
Борису бот отвечает не извиняющимся молчанием и не ложным подтверждением:
Пока вы выбирали, это время заняли. Свободно сегодня в 16:30 и 18:00, а завтра с 10:00. Выберите подходящее — я закреплю его за вами.
Эти два ответа выглядят по-разному, но оба честные: каждый описывает то, что на самом деле записано в базе. Одной брони на слот достаточно, чтобы оба клиента ушли с ясным пониманием своего положения, а мастер не встретил в кабинете двух людей с одинаковой записью.