Свежий взгляд на бронирование тайм‑слотов, или как я это сделал на Spring boot, используя пессимистические блокировки

от автора

Здравствуйте, Хабровчане! Меня зовут Дмитрий, я бэкенд разработчик Java. Недавно я наткнулся на задачу, которая сначала показалась мне копеечной, а потом сожрала неделю вечеров и заставила залезть глубоко в дебри конкурентных транзакций PostgreSQL.

Всё началось с обсуждения автоматизации одной частной клиники. Поначалу казалось, что сценарий простой: пациент хочет записаться на МРТ с контрастом. Обычный календарь записи (вроде Calendly или виджетов типа YClients) предлагает выбрать мастера и время. Но на деле все оказалось не так просто… Чтобы такая запись состоялась, необходимо одновременно занять на один и тот же час три абсолютно разных ресурса, например:

  1. Конкретного врача‑радиолога (который умеет читать эти снимки).

  2. Свободный кабинет, где физически стоит томограф.

  3. Ну и самый дорогой ресурс — это томограф, который желательно не должен простаивать ни минуты!

Если один из этих ресурсов занят, или график у врача не совпадает с запрашиваемым временем, то все, слота нет. Готовые решения на рынке работают по схеме «один клиент — один исполнитель», а так называемое «мульти‑ресурсное бронирование» встречается редко, я сходу не нашел.

И вот я решил написать свой собственный легкий b2b‑движок тайм‑слотов на Java 17 и Spring Boot 3, который выделять время для цепочек ресурсов, изолирует данные разных компаний (SaaS) и намертво защищен от овербукинга. Рассказываю, как я это спроектировал и на какие грабли наступил в процессе.

Грабли № 1: Как хранить расписание?

Первая мысль где хранить start_time и end_time может прямо в таблицу ресурса (например, врача), нет это не годится… расписание реального бизнеса — это хаос. Сегодня врач работает с 9 до 13, потом у него обед, потом вторая смена с 16 до 20. А в пятницу вообще ночное дежурство.

Конечно, я сразу вынес графики в отдельную таблицу интервалов доступности (resource_availability_intervals). А само бронирование связал с ресурсами через классическую Many‑to‑Many в Spring Data Entities.

Вот как выглядит схема таблиц в SQL:

CREATE TABLE resources (  id BIGSERIAL PRIMARY KEY,   name VARCHAR(255) NOT NULL,   type VARCHAR(50) NOT NULL,  timezone VARCHAR(100) NOT NULL DEFAULT 'UTC');CREATE TABLE resource_availability_intervals (  id BIGSERIAL PRIMARY KEY,   resource_id BIGINT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,   day_of_week VARCHAR(15) NOT NULL,   start_time TIME NOT NULL,   end_time TIME NOT NULL);CREATE TABLE bookings (    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),    company_id BIGINT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,    status VARCHAR(50) NOT NULL DEFAULT 'CONFIRMED',    start_time TIMESTAMP WITH TIME ZONE NOT NULL,    end_time TIMESTAMP WITH TIME ZONE NOT NULL);-- Мост Many-to-ManyCREATE TABLE booking_resources (    booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,    resource_id BIGINT NOT NULL REFERENCES resources(id) ON DELETE CASCADE,    PRIMARY KEY (booking_id, resource_id));

С такой схемой мы можем объединить под одним UUID бронирования хоть врача, хоть томограф, хоть парковочное место для курьера. Допустим в сервис поступил запрос на определенный интервал времени для нескольких ресурсов. Теперь проверить доступность общего слота для всех ресурсов можно просто пересечением индивидуальных графиков (resource_availability_intervals) за вычетом уже существующих броней всех участников цепочки, если они есть (bookings).

Грабли № 2: Двойные бронирования и Race Condition

Если у вас высокая плотность записи (например, распределительный центр, куда ломится 100 фур на разгрузку), параллельные запросы вас уничтожат. Часто может возникать ситуация когда для двух и более клиентов одновременно два и более оператора «Занять на 14:00», стандартный неблокирующий код пропустит оба запроса. Конечно, произойдет овербукинг, и начнутся разборки кто виноват и так далее. Что же делать?

Самое надежное решение, которое мне пришло в голову — применение пессимистических блокировок на уровне строк СУБД (Pessimistic Write Lock). Что ж делаем их модно в JPA репозитории:

public interface ResourceRepository extends JpaRepository<Resource, Long> {  @Lock(LockModeType.PESSIMISTIC_WRITE)  List<Resource> findByIdInAndCompanyId(Set<Long> ids, Long companyId);  ...}

Выполняя метод findByIdInAndCompanyId внутри транзакции, Hibernate генерирует нативный SQL‑запрос SELECT ... FOR UPDATE. PostgreSQL блокирует выбранные строки с переданными ресурсами. И тогда любой другой параллельный запрос к этим же IDs встает в очередь и курит бамбук, пока текущая транзакция не сделает COMMIT или ROLLBACK. Т.о. мы получаем нашу так необходимую блокировку, ура, все отлично едем дальше!

Грабли № 3: Ночные смены и хаос с часовыми поясами

Ну и конечно встал вопрос как хранить время доступности ресурсов, как учесть таймзоны? Но это пол беды, есть же еще и проблема, связанная с тем, что график ресурса может быть ночным или «рваным». Я ввел жесткое правило разделения зон ответственности:

  • Рабочие графики (Интервалы доступности) хранятся в локальном времени физического объекта. В базе лежат чистые LocalTime (например, 09:00:00 — 18:00:00) плюс строковый идентификатор часового пояса (IANA ID, например Europe/Moscow), закрепленный за ресурсом.

  • Cетка слотов рассчитывается и передается строго в абсолютных координатах временной шкалы — OffsetDateTime (UTC с нулевым смещением Z).

Что же делать с ночными сменами?

Представим ситуацию, когда у сотрудника смена с 22:00 понедельника до 06:00 утра вторника… Базе данных тяжело объяснить, что 06:00 — это позже, чем 22:00. И тут поискав решение, я выбрал паттерн Midnight Splitting (дробление по границе суток). В итоге при сохранении графика описанного строкой выше получаем следующее внутри таблицы рабочих интервалов:

Запись 1: day_of_week: MONDAY, start_time: 22:00:00, end_time: 23:59:59

Запись 2: day_of_week: TUESDAY, start_time: 00:00:00, end_time: 06:00:00

private boolean isResourceAvailableInItsSchedule(Resource resource, OffsetDateTime startUtc, OffsetDateTime endUtc) {    ZoneId resourceZone = ZoneId.of(resource.getTimezone());        // Переводим абсолютное UTC время запроса в локальный пояс конкретного ресурса    ZonedDateTime localStart = startUtc.atZoneSameInstant(resourceZone);    ZonedDateTime localEnd = endUtc.atZoneSameInstant(resourceZone);        DayOfWeek startDay = localStart.getDayOfWeek();    LocalTime startTime = localStart.toLocalTime();    LocalTime endTime = localEnd.toLocalTime();    // Проверяем, покрывает ли хотя бы один рабочий интервал суток наше время полностью    return resource.getAvailabilityIntervals().stream()            .filter(i -> i.getDayOfWeek() == startDay)            .anyMatch(i -> !startTime.isBefore(i.getStartTime()) && !endTime.isAfter(i.getEndTime()));}

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

Не Грабли 4: Безопасность — изолируем компании по API ключам

Что бы API могло обслуживать тысячи независимых компаний одновременно, не допуская утечки данных, я ввел изоляцию данных по company_id. Конечно, было бы проще, если в запросе приходил бы этот company_id, в коде фильтруешь по нему и все просто и хорошо. Но конечно так нельзя, клиенты не должны знать какие‑либо данные из БД, тем более IDs. Поэтому company_id должен вычисляться неявно на основе секретного токена авторизации (Authorization: Bearer Token)

Для этого пишем HandlerInterceptor из экосистемы Spring MVC, который перехватывает REST запросы на входе, и вытаскивает company_id по токену, далее прокидывает его в контроллер через атрибуты запроса:

@Componentpublic class ApiKeyInterceptor implements HandlerInterceptor {    private final ApiKeyRepository apiKeyRepository;    public ApiKeyInterceptor(ApiKeyRepository apiKeyRepository) {        this.apiKeyRepository = apiKeyRepository;    }    @Override    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {        String authHeader = request.getHeader("Authorization");        if (authHeader == null || !authHeader.startsWith("Bearer ")) {            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);            response.getWriter().write("Missing or invalid Authorization header");            return false;        }        String token = authHeader.substring(7);        Optional<Long> companyIdOpt = apiKeyRepository.findCompanyIdByToken(token);        if (companyIdOpt.isEmpty()) {            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);            response.getWriter().write("Invalid or inactive API key");            return false;        }        // Прокидываем ID компании в атрибуты текущего HTTP-запроса        request.setAttribute("CURRENT_COMPANY_ID", companyIdOpt.get());        return true;    }}

Как мы видим главное тут это вытащить токен String token = authHeader.substring(7);, найти по нему company_id через репу apiKeyRepository.findCompanyIdByToken(token) и положить его в реквест уже на стороне бэкенда request.setAttribute(“CURRENT_COMPANY_ID”, companyIdOpt.get()); Вот и все, клиенты знаю только то что им нужно — свой API Key и ничего лишнего!

Заключение:

В итоге проект готов и протестирован, можно скачать его на Github — https://github.com/Razzhivin/slots‑api

В readme я описал как запустить проект, можно за пару минут как Docker‑контейнер, вся информация с тестовым API Key там же!

Интересно услышать ваше мнение:

  • Как бы вы оптимизировали алгоритм выделения слотов времени на больших интервалах (например, при поиске слотов на год вперед)? Стоит ли кэшировать свободную сетку в Redis?

  • Есть ли смысл использовать оптимистические блокировки (@Version) для снижения нагрузки на пулы соединений СУБД, или в данной задаче необходим жесткий FOR UPDATE?

Буду рад любым комментариям!

ссылка на оригинал статьи https://habr.com/ru/articles/1074366/