Обновить

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

Уровень сложностиСредний
Время на прочтение7 мин
Охват и читатели8K
Всего голосов 8: ↑8 и ↓0+12
Комментарии9

Комментарии 9

Спасибо. Было полезно и интересно

Рад, что материал оказался полезным!!! Благодарю за отзыв. Если интересно и решите покрутить проект в руках — на гитхабе всё развернуто в докере, запускается за минуту!

Не уверен, насколько код в репозитории для примера, чем для реального использования.

Подход с 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. в целом статья мне понравилась, спасибо

Огромное спасибо за такой подробное ревью!!! Буду разбираться с этими вопросами, очень полезно!!!

Уважаемый автор! А почему в этой реализации вы не использовали диапазонные типы PostgreSQL (tsrange / tstzrange) для представления интервалов доступности и занятости ресурсов?

Насколько я понимаю, range types позволяют хранить сразу интервал [start, end), причём диапазон может включать и дату, и время. Кроме того, PostgreSQL предоставляет для них операторы вроде && для проверки пересечения и GiST-индексы, что, на мой взгляд, довольно естественно ложится на задачу поиска пересекающихся интервалов расписания и бронирований.

Например, вместо отдельных start_at и end_at можно было бы хранить условный available_during tstzrange, а затем искать пересечения доступности врача, томографа и кабинета.

Есть ли у выбранного вами подхода со стандартными timestamp-полями какие-то существенные преимущества? Или вы сознательно не стали использовать range types по соображениям совместимости, производительности или сложности запросов?

Приветствую! Спасибо за отличный, глубокий вопрос. Использование tstzrange и GiST-индексов с оператором пересечения && — это действительно очень элегантное и мощное решение для задач бронирования в PostgreSQL, и я серьезно рассматривал его на этапе проектирования.

Выбор в пользу классических полей timestamp и time был сделан сознательно по трем ключевым причинам:

  1. Разделение моделей "Шаблон" и "Событие": Диапазоны идеальны для сущности Booking (где есть конкретная привязка к календарной дате). Но они плохо подходят для таблицы базового расписания (resource_availability_intervals), которая хранит циклические правила: “каждый понедельник с 09:00 до 13:00”. Хранить расписание в виде tstzrange заставило бы нас заниматься бесконечной генерацией (seeding) явных диапазонов на годы вперед для каждого ресурса, что раздуло бы БД. А скрещивать циклический LocalTime с tstzrange в рамках одного запроса — сомнительное удовольствие.

  2. Экосистема JPA/Hibernate: В Java нет нативной поддержки диапазонных типов Postgres «из коробки». Внедрение tsrange потащило бы за собой необходимость писать кастомные мапперы (UserType) или переходить на Native SQL, что усложнило бы код MVP и увеличило порог входа для ревью.

  3. Вендоронезависимость (Vendor Lock-in): Типы range — это крутая, но чисто «постгресовая» фича. Использование стандартных типов позволяет при необходимости мигрировать b2b-платформу на любой другой диалект (MS SQL, MySQL, Oracle) силами изменения одной строки в конфиге.

Тем не менее, соглашусь с вами: если проектировать систему строго под PostgreSQL и выносить всю интервальную математику на уровень хранимых процедур БД, связка tstzrange + GiST покажет колоссальную производительность. Спасибо за ценное дополнение к статье!

Я помню, что когда - то давно писал код для маппинга диапазонных значений, и мне в этом очень помогла статья Влада Михалчи: https://vladmihalcea.com/map-postgresql-range-column-type-jpa-hibernate/ Но в том проекте была очень старая версия Hibernate. А потом я снова столкнулся с этой проблемой, но версия уже была новее, и там, насколько я помню, поддерживалась работа с диапазонами "из коробки". Помню, использовались классы Range, только теперь код свой найти не могу. Наверное, он остался у работодателя.

Разделение моделей "Шаблон" и "Событие": Диапазоны идеальны для сущности 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.

Приветствую! Благодарю за Ваши комментарии!

Да в основном соглашусь, особенно про легкий переход между БД и в будущем рассмотрю переход на tstzrange 

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации