Здравствуйте, Хабровчане! Меня зовут Дмитрий, я бэкенд разработчик 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-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?
Буду рад любым комментариям!
Комментарии (8)

cpud47
25.08.2026 14:34Не уверен, насколько код в репозитории для примера, чем для реального использования.
Подход с midnight splitting конечно интересный, но в Вашей реализации не учитывается возможность брони сквозь полночь. Например, нельзя забронировать слот с 23:00 до 01:00 следующего дня.
Дальше обращу внимание, что реальные расписания гораздо сложнее Вашей модели. Как минимум там есть возможность менять расписание во времени, на специальные даты и так далее. Спишу это на демку.
Теперь к сути происходящего. Делать select for update на целый ресурс — затея несколько проблематичная. Для начала это может вызывать дедлоки (но мб если мы в одном запросе делаем, то там есть консистентный порядок). Во вторых, это убивает конкурентность в корне.
Одним из решений в плане конкурентности это шардирование. Нарезаете всё время доступности ресурса на временные интервалы, например по дням (в UTC). И дальше при бронировании блокируете все интервалы, с которыми у Вас есть пересечения (это может быть несколько дней из-за разных таймзон!). Гранулярность интервалов подбирать можно исходя из двух ограничений: 1) чем короче интервал, тем выше конкурентность 2) но если интервал короче типовой продолжительности слота, то нужно брать слишком много локов.
Если же хочется большей конкурентности, то тогда можно сделать внешнюю синхронизацию через условный редис.
Ну или же можно сторговать latency на throughput и смириться с коллизиями. Самое главное, это заранее определить правила выбора какую бронь из коллизии выбросить. Дальше, единственное что остаётся это удостовериться, что нашу запись не выбросили. Например, можно сделать у бронирования время создания и время "коммита"; при этом сделать ограничение, что интервал между созданием и коммитом должен быть короче T; тогда нам достаточно после коммита подождать время T+eps, если наша бронь всё ещё актуальна — можно выдать подтверждение.
Если говорить о теме статьи, то мне кажется тема собственно блокировок не раскрыта. Почему используется именно for update, а не for no key update? Как будет взаимодействовать создание бронирования с конкурентной правкой расписания? И так далее.
По поводу получения списка слотов. По перфу реализация приемлимая, т.к. на один ресурс не может быть слишком много бронирований (дискретизации нас спасает). А вот принцип перебора не очень работает. В частности, этот метод не может выдать слот 13:45-14:15. Это может быть ок, может нет. По хорошему, нужно бы перебирать просто все "дырки", длиной хотя бы в X.
Можно ли это сделать через индекс, без запроса всех бронирований — тут сходу не скажу.
---
На последок добавлю пару вопросов по бд.
Для работы с интервалами времени можно использовать интервальных типы в постгрес. Они позволяют естественно выразить всякие операции типа пересечения или вложенности. Плюс они поддерживают вменяемые индексы, если потребуется.
К слову об индексах. Индексы подобраны довольно неудачно, т.к. записи плохо шардируются по времени. В идеале любой индекс стоит начинать хотя бы с тенанта. Я бы сделал индекс хотя бы (company, start), а в идеале (company, resource, start). Добавлять end в индекс смысла нет, т.к. запросы носят другой характер.
Здесь кстати в идеале вложить resources внутрь booking просто как массив. Либо json, либо просто массив. Тогда это всё можно в один индекс закинуть.
Также, если оставлять это всё на отдельных start/end, а не интервалах, то стоит запросы перестроить, чтобы там было ограничение и сверху и снизу на start — иначе будет очень много фильтрации при выборке.
И немного о другой теме, по поводу обновления расписания. Увидел реализацию в виде delete all + insert. Это, конечно, работает. Но в проде может вызывать сильную потерю данных из-за какого-нибудь cascade ...
Возможно получилось несколько скомкано. Если будут непонятны какие-то из тезисов, можете спросить.
P.S. в целом статья мне понравилась, спасибо

ds-razzhivin Автор
25.08.2026 14:34Огромное спасибо за такой подробное ревью!!! Буду разбираться с этими вопросами, очень полезно!!!

SergeiAidinov
25.08.2026 14:34Уважаемый автор! А почему в этой реализации вы не использовали диапазонные типы PostgreSQL (
tsrange/tstzrange) для представления интервалов доступности и занятости ресурсов?Насколько я понимаю, range types позволяют хранить сразу интервал
[start, end), причём диапазон может включать и дату, и время. Кроме того, PostgreSQL предоставляет для них операторы вроде&&для проверки пересечения и GiST-индексы, что, на мой взгляд, довольно естественно ложится на задачу поиска пересекающихся интервалов расписания и бронирований.Например, вместо отдельных
start_atиend_atможно было бы хранить условныйavailable_during tstzrange, а затем искать пересечения доступности врача, томографа и кабинета.Есть ли у выбранного вами подхода со стандартными
timestamp-полями какие-то существенные преимущества? Или вы сознательно не стали использовать range types по соображениям совместимости, производительности или сложности запросов?
ds-razzhivin Автор
25.08.2026 14:34Приветствую! Спасибо за отличный, глубокий вопрос. Использование
tstzrangeи GiST-индексов с оператором пересечения&&— это действительно очень элегантное и мощное решение для задач бронирования в PostgreSQL, и я серьезно рассматривал его на этапе проектирования.Выбор в пользу классических полей
timestampиtimeбыл сделан сознательно по трем ключевым причинам:Разделение моделей "Шаблон" и "Событие": Диапазоны идеальны для сущности
Booking(где есть конкретная привязка к календарной дате). Но они плохо подходят для таблицы базового расписания (resource_availability_intervals), которая хранит циклические правила: “каждый понедельник с 09:00 до 13:00”. Хранить расписание в видеtstzrangeзаставило бы нас заниматься бесконечной генерацией (seeding) явных диапазонов на годы вперед для каждого ресурса, что раздуло бы БД. А скрещивать циклическийLocalTimeсtstzrangeв рамках одного запроса — сомнительное удовольствие.Экосистема JPA/Hibernate: В Java нет нативной поддержки диапазонных типов Postgres «из коробки». Внедрение
tsrangeпотащило бы за собой необходимость писать кастомные мапперы (UserType) или переходить на Native SQL, что усложнило бы код MVP и увеличило порог входа для ревью.Вендоронезависимость (Vendor Lock-in): Типы range — это крутая, но чисто «постгресовая» фича. Использование стандартных типов позволяет при необходимости мигрировать b2b-платформу на любой другой диалект (MS SQL, MySQL, Oracle) силами изменения одной строки в конфиге.
Тем не менее, соглашусь с вами: если проектировать систему строго под PostgreSQL и выносить всю интервальную математику на уровень хранимых процедур БД, связка
tstzrange + GiSTпокажет колоссальную производительность. Спасибо за ценное дополнение к статье!
SergeiAidinov
25.08.2026 14:34Я помню, что когда - то давно писал код для маппинга диапазонных значений, и мне в этом очень помогла статья Влада Михалчи: https://vladmihalcea.com/map-postgresql-range-column-type-jpa-hibernate/ Но в том проекте была очень старая версия Hibernate. А потом я снова столкнулся с этой проблемой, но версия уже была новее, и там, насколько я помню, поддерживалась работа с диапазонами "из коробки". Помню, использовались классы Range, только теперь код свой найти не могу. Наверное, он остался у работодателя.

cpud47
25.08.2026 14:34Разделение моделей "Шаблон" и "Событие": Диапазоны идеальны для сущности
Booking(где есть конкретная привязка к календарной дате). Но они плохо подходят для таблицы базового расписания (resource_availability_intervals), которая хранит циклические правила: “каждый понедельник с 09:00 до 13:00”. Хранить расписание в видеtstzrangeзаставило бы нас заниматься бесконечной генерацией (seeding) явных диапазонов на годы вперед для каждого ресурса, что раздуло бы БД. А скрещивать циклическийLocalTimeсtstzrangeв рамках одного запроса — сомнительное удовольствие.У Вас все запросы сравнивают колонку в бд с параметрами запроса. Там концептуально джоины по tsrange в бд делать никогда не нужно. А если их и делать, то это будет больно в любом формате хранения =/
Поэтому, кмк, эта причина не очень обоснована.Вендоронезависимость (Vendor Lock-in): Типы range — это крутая, но чисто «постгресовая» фича. Использование стандартных типов позволяет при необходимости мигрировать b2b-платформу на любой другой диалект (MS SQL, MySQL, Oracle) силами изменения одной строки в конфиге.
На моём опыте, любая система с более менее нормальной нагрузкой гвоздями прибита к конкретной бд. Иногда бывает пара субд - но это отдельная, довольно большая инженерная работа.
Чтобы "поменять строку в конфиге" и оно запустилось на другой субд - такое не встречается. Обычно там либо баги всякие странные начинают лезть, либо производительность просаживается. Банально, в том же postgresql и ms sql разные подходы к блокировкам и конкуретности - ms sql может чаще выдавать LOCK_ERROR чем postgresql (и это нужно будет обрабатывать в приложении).
Ну и планировщики у субд сильно разные. Поэтому обычно под разные субд нужно писать разные запросы с разными индексами. Иначе производительность обычно получается довольно скудная.
Поэтому эта причина тоже кмк не обоснована.
И единственная причина, почему можно согласиться - это поддержка в hibernate.
ruslan_astratov
Спасибо. Было полезно и интересно
ds-razzhivin Автор
Рад, что материал оказался полезным!!! Благодарю за отзыв. Если интересно и решите покрутить проект в руках — на гитхабе всё развернуто в докере, запускается за минуту!