Здравствуйте, Хабровчане! Меня зовут Дмитрий, я бэкенд разработчик Java. Недавно я наткнулся на задачу, которая сначала показалась мне копеечной, а потом сожрала неделю вечеров и заставила залезть глубоко в дебри конкурентных транзакций PostgreSQL.
Всё началось с обсуждения автоматизации одной частной клиники. Поначалу казалось, что сценарий простой: пациент хочет записаться на МРТ с контрастом. Обычный календарь записи (вроде Calendly или виджетов типа YClients) предлагает выбрать мастера и время. Но на деле все оказалось не так просто… Чтобы такая запись состоялась, необходимо одновременно занять на один и тот же час три абсолютно разных ресурса, например:
-
Конкретного врача‑радиолога (который умеет читать эти снимки).
-
Свободный кабинет, где физически стоит томограф.
-
Ну и самый дорогой ресурс — это томограф, который желательно не должен простаивать ни минуты!
Если один из этих ресурсов занят, или график у врача не совпадает с запрашиваемым временем, то все, слота нет. Готовые решения на рынке работают по схеме «один клиент — один исполнитель», а так называемое «мульти‑ресурсное бронирование» встречается редко, я сходу не нашел.
И вот я решил написать свой собственный легкий 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/