Здравствуйте, Хабровчане! Меня зовут Дмитрий, я бэкенд разработчик 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-Many
CREATE 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 по токену, далее прокидывает его в контроллер через атрибуты запроса:

@Component
public 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?

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