Привет, Хабр!

Меня зовут Михаил Емельянов, я ведущий разработчик в НЛМК ИТ. Команда, в которой я состою, занимается разработкой и поддержкой различных корпоративных информационных систем на базе Битрикс24.

Сегодня я хочу поговорить о разработке сервисов бронирования. Они используются повсеместно - от медицинских центров и отелей, до внутренних активностей в компаниях. На старте мы, как и многие, пользовались готовыми решениями. Это позволяло быстро закрыть потребность, но при этом нам осложняли жизнь некоторые системные недостатки:

  • Юридические нюансы: обработка персональных данных по требованиям 152‑ФЗ и внутренних политик безопасности. Стороннему сервису не всегда можно передать всё, что нужно, а иногда это прямо запрещено.

  • Стоимость: даже одно недельное мероприятие обходилось достаточно дорого.

  • Процессные ограничения: неудобно быстро менять расписание "на лету" во время мероприятия, неидеальная логика резервирования времени приводила к редким, но болезненным коллизиям.

В какой-то момент стало очевидно, что выгоднее и надёжнее собрать собственный сервис, который на 100% соответствует нашим процессам, не создает юридических рисков и при этом окупается в среднесрочной перспективе.

Этапы разработки

Как мы шли от бизнес-идеи к первому масштабному запуску?

Сначала обсудили и зафиксировали бизнес-процессы. Провели короткие созвоны с инициаторами разработки со стороны бизнеса, чтобы понять, что именно должен уметь сервис и как будут устроены сценарии заполнения и управления бронями. Определили минимальный набор ролей - администраторы, модераторы и пользователи. Оценили объём работ, декомпозировав его на понятные подзадачи и разбили их по спринтам.

Затем собрали MVP. Включили самое необходимое: сущности "мероприятие", "локация", "услуга" и "слот времени", простые формы и список слотов с фильтрами. Слоты на старте создавались вручную с привязкой "на лету". Добавили базовую валидацию и уведомления, возможность отмены записи, кэширование и устойчивость к конкурентной записи. Реализовали запись в лист ожидания (пока только информационный) для сотрудников, которые не успели попасть на интересующее мероприятие.

Дальше - апробация на "малых" кейсах (бассейн, спектакли). Технически всё работало стабильно, но при десятках локаций и сотнях услуг модераторы начали теряться.

Какая услуга у нас для Липецка?
Какая услуга у нас для Липецка?

Свободная схема связей хороша своей гибкостью, но в операционной работе мешала. Стало очевидно, что админскому интерфейсу нужна более управляемая навигация и фильтры, показывающие только связанные между собой сущности.

Собрав обратную связь от администраторов, мы пересмотрели требования. Спроектировали направляющий UX: убрали лишний выбор и стали показывать только релевантные комбинации "мероприятие - локация - услуга". На уровне данных усилили модель: локации привязали к мероприятиям, а услуги - к локациям и/или мероприятиям, чтобы интерфейс и валидации работали в унисон.

Пример схемы привязок
Пример схемы привязок

Параллельно учли юридические аспекты: для разных мероприятий предусмотрели отдельные условия обработки персональных данных и согласий.

И наконец доработали продукт с учётом найденных проблем: улучшили административные формы, расширили возможности листа ожидания.

Архитектура и работа с коллизиями

Архитектура проекта построена на 4 основных сущностях:

  • Мероприятия

  • Локации

  • Услуги

  • Слоты времени

В первой MVP-версии мы решили дать максимальную свободу - мероприятия, локации и услуги можно было заводить независимо друг от друга. Слоты времени могли создаваться для любых комбинаций.

Этого хватило для первых небольших мероприятий, но почти сразу встал вопрос удобства для администраторов. Крупное мероприятие, с десятками локаций и сотнями услуг вести в таком виде оказалось затруднительно, т.к. модераторы начинали путаться при создании и выборе слотов, что с чем было связано.

Поэтому одной из первых доработок стала возможность привязки локаций к мероприятиям и услуг к локациям и мероприятиям, чтобы при пошаговом выборе селекторов для добавления слотов предлагались только связанные между собой комбинации. Это значительно упростило работу по администрированию сервиса и позволило провести первое масштабное мероприятие.

Один из интерфейсов администратора для быстрого доступа к карте слотов и управлению ими
Один из интерфейсов администратора для быстрого доступа к карте слотов и управлению ими

Жизненный цикл слота у нас простой и прозрачный: "свободен - занят". Отмена участия также возможна, при этом мы сразу же возвращаем освободившийся слот в общий пул, при необходимости уведомляем пользователей, состоящих в листе ожидания.

Одной из главных задач при разработке, которую нам нужно было решить - исключить возможность одновременной записи на одно время у нескольких пользователей. Даже если пара человек почти одновременно выбирали слот времени - достаться он должен был строго одному человеку. Для этого мы применили подход с мьютексом в момент подтверждения регистрации.

Сам мьютекс выглядит максимально примитивно. В битриксе он реализуется следующим образом:

use Bitrix\Main\Application;
use Bitrix\Main\DB\Connection;
use RuntimeException;

class Mutex
{
    protected Connection $connection;
    protected string $name;

    /**
     * @param string $name
     */
    public function __construct(string $name)
    {
        $this->connection = Application::getConnection();
        $this->name = $name;
    }

    /**
     * @param int $timeout
     * @return void
     */
    public function wait(int $timeout): void
    {
        if (!$this->connection->lock($this->name, $timeout)) {
            throw new RuntimeException(sprintf('Cannot create lock %s', $this->name));
        }
    }

    /**
     * @return void
     */
    public function release(): void
    {
        if (!$this->connection->unlock($this->name)) {
            throw new RuntimeException(sprintf('Cannot release lock %s', $this->name));
        }
    }
}

При выборе слота пользователем ему сначала открывается диалог подтверждения, в котором указаны детали бронирования и поля для возможности оставить контактные данные и комментарии, а также согласие с обработкой персональных данных при необходимости.

Мьютекс мы добавили в ajax-action обработки клика подтверждения в этом диалоге:

/**
     * Метод записи на слот
     *
     * @return AjaxJson
     * @throws \Bitrix\Main\ArgumentException
     */
    public function registerAction(): AjaxJson
    {
        if (!$this->errors->isEmpty()) {
            return AjaxJson::createError($this->errors);
        }

		try {
			$request = Application::getInstance()->getContext()->getRequest();
	        $slotId = intval($request->getPost('slotId'));

    	    $this->createMutex($slotId);

        	$result = [];
	        $data = [];

	        $postData = $request->getPost('data');
	        $email = $postData['EMAIL'];
    	    $phone = $postData['PHONE'];
        	$comment = $postData['COMMENT'];

	        $data = $this->getDataBySlotId($slotId);

    	    if (!empty($data) && $this->errors->isEmpty()) {
        	    if ($this->checkRegistrationConditions($data)) {
            	    try {
                	    $this->addEventRegistration($data, $email, $phone, $comment);
	                } catch (Exception $e) {
    	                $this->errors->setError(new Error(Loc::GetMessage('EVENTS_REGISTRATION_ERROR_REGISTRATION_ERROR')));
	                }
    	        } else {
        	        $this->errors->setError(new Error(Loc::GetMessage('EVENTS_REGISTRATION_ERROR_SLOT_NOT_AVAILABLE')));
	            }
	        }
		} catch (Throwable $e) {
		    $this->errors->setError(new Error($e->getMessage()));
		} finally {
			$this->dropMutex();
		}

        return $this->errors->isEmpty() ? AjaxJson::createSuccess($result) : AjaxJson::createError($this->errors);
    }

    /**
     * @param int $slotId
     * @return void
     */
    private function createMutex(int $slotId): void
    {
        $this->mutex = Slot::getMutex($slotId);
        $this->mutex->wait(60);
    }

    /**
     * @return void
     */
    private function dropMutex(): void
    {
        $this->mutex->release();
    }

Если другой пользователь в ту же секунду нажмёт "подтвердить", его запрос подождёт освобождения блокировки, попадёт на повторную валидацию условий (checkRegistrationConditions) и корректно получит ответ, что слот уже занят. При этом мы не блокируем чтение списков слотов и не ухудшаем UX во время выбора. Блокировка минимальна по времени и ставится в самом узком месте.

Практические технические нюансы, на которые мы обратили внимание:

  • Таймаут ожидания. Мы используем 60 секунд - этого достаточно для короткой критической секции даже при высокой нагрузке. Валидации и запись сами по себе должны быть максимально быстры, так что этот таймаут на практике не будет большим, и основное его предназначение - на случай непредвиденных отказов сервиса. Поэтому можно его устанавливать близко к лимиту времени выполнения скрипта.

  • Освобождение блокировки. Нужно не забывать защищаться от "невидимых" исключений и аварий. В частности, обязательно оборачивать блокируемую секцию в try-finally, чтобы гарантированно снять лок в случае ошибок.

Помимо прочего, мы предусмотрели и лист ожидания для пользователей, которые в силу занятости не смогут вовремя выбрать подходящие слоты. Сейчас он имеет у нас информационное назначение для оповещений и ручной обработки администраторами мероприятий, чтобы точечно выдавать дополнительные слоты желающим из этого списка. Планы по его расширению были оставлены на будущее.

Итоги - использование сервиса сейчас и планы на будущее

Около полугода сервис работал "на минималках": разовые события для сотрудников и периодическая запись в спортивные группы. Это помогло обкатать основные сценарии и отладить админский UX.

Первое значимое боевое крещение - ежегодная неделя здоровья. Немного цифр:

  • 7 дней

  • 9 локаций в нескольких городах

  • 40 разных врачей и анализов

  • 700 человек воспользовались сервисом для записи

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

В текущем виде сервис удовлетворяет значительную часть потребностей бизнеса, но планов по его расширению осталось не мало, например:

  • Расширенную систему управления ролями, чтобы масштабировать управление сервисом на несколько не связанных друг с другом бизнес-пользователей. При разработке мы не слишком глубоко проработали эту сторону для сокращения трудозатрат.

  • Возможность не только отменять запись, но и быстро перезаписываться на лету на другое время в момент отмены.

  • Автоматизация листа ожидания. Использование его не только в информационных целях, но и для автоматической записи в случае освобождения или появления новых слотов (например - дать возможность выбрать желаемый промежуток времени, чтобы при появлении в нем окон слоты мгновенные назначались пользователям в порядке очереди из листа).

  • Интеграция с другими сервисами компании (напр. медцентром).

  • Гибкая настройка управления ограничениями для администраторов, чтобы можно было задавать доступность мероприятий/локаций/услуг для разных пользователей в зависимости от места их работы или подразделения.

Главный результат - сервис ведёт себя предсказуемо как для пользователей, так и для модераторов: свободные места показываются в реальном времени, коллизии отсутствуют, отмены и уведомления работают, лист ожидания помогает с коммуникациями. И всё это без лишней магии, только понятная модель и аккуратная реализация. 

Спасибо за внимание, до встречи в следующих статьях!

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