Когда говорят «у нас в системе бронирования возникают пересекающиеся брони, потому что приложение проверяет на уровне кода и иногда не успевает» — обычно решение архитектурное. В PostgreSQL для этого есть отдельный класс типов — диапазоны (range types) — и специальный constraint, который гарантирует на уровне СУБД, что пересечений быть не может.
Это один из тех инструментов, которые превращают «сложное приложение с потенциальными гонками» в «простую таблицу с одним constraint».
Какие бывают range-типы
PostgreSQL встроенно знает:
int4range,int8range,numrange— диапазоны чисел.tsrange,tstzrange— диапазоны временных меток (с/без часового пояса).daterange— диапазоны дат.
Литерал записывается через скобки: '[10, 20)'. Скобка-направо [ или ( определяет, включена ли граница:
[10, 20]— оба конца включены.[10, 20)— левый включён, правый исключён (это самый частый вариант).(10, 20]— наоборот.(10, 20)— оба исключены.
В дискретных типах (int4range, daterange) Postgres приводит к канонической форме [lower, upper) — но писать можно как удобно.
Скобки — это не косметика. От них зависит, считается ли число 10 «внутри» или «снаружи».
Разберёмся со скобками:
Главные операторы
@> (содержит точку/диапазон), <@ (содержится в диапазоне), && (пересекаются):
Эти три оператора покрывают 80% реальных задач с диапазонами:
Обрати внимание на overlaps_at_boundary — [1,5) и [5,10) не пересекаются, потому что 5 не входит в первый. Это та же логика, что в for i in range(0, 5) в Python — итерация заканчивается на 4. Полуоткрытый интервал — стандарт во многих языках, и в Postgres он канонический.
Зачем — реальный пример
Представь, что мы добавили в наш магазин бронирование переговорок для b2b-клиентов. Каждая бронь — это (room_id, customer_id, period TSTZRANGE). Задача: одна переговорка не может быть забронирована двумя людьми одновременно.
Подход через приложение (распространённая ошибка): SELECT для проверки, потом INSERT. Между этими шагами — гонка. Два запроса в одно и то же время оба увидят «свободно», оба сделают INSERT, и у нас две перекрывающихся брони.
Правильный подход: пусть СУБД проверяет это сама.
Создадим таблицу бронирований с защитой от пересечений:
Песочница покажет, что последний INSERT упал с ошибкой conflicting key value violates exclusion constraint. Это и есть гарантия на уровне СУБД — никаких гонок.
Анатомия EXCLUDE constraint
Синтаксис:
EXCLUDE USING GIST (room_id WITH =, period WITH &&)
Читается так: «не разрешать две строки, у которых одновременно room_id равен (=) и period пересекается (&&)». То есть: если room_id одинаковый И есть пересечение времени — это конфликт.
Если ты уберёшь часть room_id WITH =, constraint запретит любое пересечение времени — даже между разными переговорками. Это редко то, что нужно.
USING GIST — это
&& не индексируется, поэтому нужен GiST. И именно для смешанных предикатов (= AND &&) нужно расширение btree_gist — оно учит GiST обрабатывать обычное равенство.
Когда EXCLUDE constraint — лучший выбор
- Бронирование любого ресурса: переговорки, машины, оборудование, врачи.
- Перекрывающиеся периоды действия: тарифные планы, скидки, версии конфигурации.
- Геометрические пересечения: участки на карте, графики дежурств.
Альтернативы — триггеры или проверка в приложении — почти всегда хуже:
- Триггер: легко забыть, что он есть; ошибки в коде триггера; гонка под READ COMMITTED всё равно возможна (если триггер читает таблицу, а другая транзакция вставила в эту же микросекунду).
- Приложение: фундаментально не может гарантировать без
SELECT ... FOR UPDATEилиSERIALIZABLE. И даже с ними — это явно медленнее, чем встроенный constraint.
Бесконечные границы и историчность
NULL в границе означает «бесконечность в эту сторону»:
Открытые с одной стороны диапазоны — типичны для valid_from/valid_to схем:
Это идиома для темпоральных таблиц — таблиц, в которых хранится история. Каждая запись имеет период действия. Текущая запись имеет открытый правый край (NULL в upper); когда состояние меняется, старая запись «закрывается» (upper становится now()), и появляется новая с открытым правым концом.
Сценарий истории должности сотрудника:
Никакой логики «текущей строки» в приложении — WHERE valid @> now() спрашивает «какая запись активна сейчас», и СУБД гарантирует, что таких записей на сотрудника не больше одной.
Полезные функции
lower(range)иupper(range)— границы.isempty(range)— пуст ли диапазон.lower_inc(range),upper_inc(range)— включены ли границы.lower_inf(range),upper_inf(range)— бесконечна ли граница.range1 * range2— пересечение двух диапазонов (новый диапазон).range1 + range2— объединение, только если диапазоны соприкасаются или пересекаются.
Найдём пересечение двух временных интервалов:
Удобно для отчётов вроде «сколько часов перекрытия было между двумя сотрудниками».
Подводные камни
- Граничные точки:
[1,5)и[5,10)не пересекаются — это часто удивляет. Если нужно «соседство», используй оператор-|-. - Пустые диапазоны (
'empty') ведут себя необычно:'empty' && anything = FALSE,'empty' <@ anything = TRUE. Пустой диапазон содержится во всём, но не пересекается ни с чем. - NULL в границе означает «бесконечность в эту сторону».
'[2025-01-01,)'— от даты и без верхней границы. - GIST-индексы дороже B-tree на запись. Если бронирования вставляются миллион раз в день — следи за производительностью.
Чек-лист
- Range types — встроенные:
int4range,tstzrange,daterangeи другие. - Скобки
[и(различают включение/исключение границы. Канонический формат —[lower, upper). - Главные операторы:
@>(содержит),<@(содержится),&&(пересекается). EXCLUDE USING GIST (key WITH =, range WITH &&)гарантирует отсутствие пересечений на уровне СУБД. НуженCREATE EXTENSION btree_gist.- Пересечение полуоткрытых диапазонов на границе не считается пересечением.
- Это лучший инструмент для бронирований и периодов действия — гонок нет по определению.