Learning Platform
Урок 11.04 · 17 мин
Средний
range typeststzrangedaterangeEXCLUDE constraintGiST

Когда говорят «у нас в системе бронирования возникают пересекающиеся брони, потому что приложение проверяет на уровне кода и иногда не успевает» — обычно решение архитектурное. В 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 «внутри» или «снаружи».

'[10, 20)'::int4rangeлев. включ., прав. искл.
10 ∈ диапазонTRUE
20 ∈ диапазонFALSE
'(10, 20]'::int4rangeлев. искл., прав. включ.
10 ∈ диапазонFALSE
20 ∈ диапазонTRUE

Разберёмся со скобками:

PostgreSQL

Главные операторы

@> (содержит точку/диапазон), <@ (содержится в диапазоне), && (пересекаются):

Эти три оператора покрывают 80% реальных задач с диапазонами:

PostgreSQL

Обрати внимание на 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, и у нас две перекрывающихся брони.

Правильный подход: пусть СУБД проверяет это сама.

Создадим таблицу бронирований с защитой от пересечений:

PostgreSQL

Песочница покажет, что последний 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 — это

указание индекса
, который держит constraint. На обычном B-tree оператор && не индексируется, поэтому нужен GiST. И именно для смешанных предикатов (= AND &&) нужно расширение btree_gist — оно учит GiST обрабатывать обычное равенство.

Когда EXCLUDE constraint — лучший выбор

  • Бронирование любого ресурса: переговорки, машины, оборудование, врачи.
  • Перекрывающиеся периоды действия: тарифные планы, скидки, версии конфигурации.
  • Геометрические пересечения: участки на карте, графики дежурств.

Альтернативы — триггеры или проверка в приложении — почти всегда хуже:

  • Триггер: легко забыть, что он есть; ошибки в коде триггера; гонка под READ COMMITTED всё равно возможна (если триггер читает таблицу, а другая транзакция вставила в эту же микросекунду).
  • Приложение: фундаментально не может гарантировать без SELECT ... FOR UPDATE или SERIALIZABLE. И даже с ними — это явно медленнее, чем встроенный constraint.

Бесконечные границы и историчность

NULL в границе означает «бесконечность в эту сторону»:

Открытые с одной стороны диапазоны — типичны для valid_from/valid_to схем:

PostgreSQL

Это идиома для темпоральных таблиц — таблиц, в которых хранится история. Каждая запись имеет период действия. Текущая запись имеет открытый правый край (NULL в upper); когда состояние меняется, старая запись «закрывается» (upper становится now()), и появляется новая с открытым правым концом.

Сценарий истории должности сотрудника:

PostgreSQL

Никакой логики «текущей строки» в приложении — 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 — объединение, только если диапазоны соприкасаются или пересекаются.

Найдём пересечение двух временных интервалов:

PostgreSQL

Удобно для отчётов вроде «сколько часов перекрытия было между двумя сотрудниками».

Подводные камни

  • Граничные точки: [1,5) и [5,10) не пересекаются — это часто удивляет. Если нужно «соседство», используй оператор -|-.
  • Пустые диапазоны ('empty') ведут себя необычно: 'empty' && anything = FALSE, 'empty' <@ anything = TRUE. Пустой диапазон содержится во всём, но не пересекается ни с чем.
  • NULL в границе означает «бесконечность в эту сторону». '[2025-01-01,)' — от даты и без верхней границы.
  • GIST-индексы дороже B-tree на запись. Если бронирования вставляются миллион раз в день — следи за производительностью.
Проверка знанийKnowledge check
У вас CRM с историей версий профиля клиента: каждая запись имеет valid_period TSTZRANGE. Нужно гарантировать, что у клиента в любой момент времени активна ровно одна версия — никаких пересечений. Какой constraint вы напишете?
ОтветAnswer
EXCLUDE USING GIST (customer_id WITH =, valid_period WITH &&) с предварительным CREATE EXTENSION btree_gist. Это запретит две версии для одного клиента, если их периоды пересекаются. Дополнительно полезно: (1) добавить partial unique index «один открытый период (с NULL в upper) на клиента» — иначе можно создать две открытые версии; (2) рассмотреть, нужен ли вообще закрытый правый край («ровно один активный») — обычно да; (3) для удобства приложения сделать view current_profile WHERE valid_period @> now(). EXCLUDE даёт фундаментальную гарантию консистентности, проверяемую самой СУБД.
GiST-индекс — как индексируются range types

Чек-лист

  • Range types — встроенные: int4range, tstzrange, daterange и другие.
  • Скобки [ и ( различают включение/исключение границы. Канонический формат — [lower, upper).
  • Главные операторы: @> (содержит), <@ (содержится), && (пересекается).
  • EXCLUDE USING GIST (key WITH =, range WITH &&) гарантирует отсутствие пересечений на уровне СУБД. Нужен CREATE EXTENSION btree_gist.
  • Пересечение полуоткрытых диапазонов на границе не считается пересечением.
  • Это лучший инструмент для бронирований и периодов действия — гонок нет по определению.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Чему равен результат '[10, 20)'::int4range && '[20, 30)'::int4range?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7