Диапазонные типы (range)
PostgreSQL поддерживает так называемые диапазонные типы данных (range). Не буду переписывать документацию, а лишь укажу, что в этой статье мультидиапазонные типы (multirange) я затрагивать не буду, а остановлюсь для примера только на daterange. Причем на его частном случае, когда в рамках одного ключа допускаются исключительно непересекающиеся диапазоны дат.
Постановка задачи
Пусть у нас есть тысяча каких-то основных средств (asset), например, спецтехники. Каждое из них какое-то время находится на одной из ста площадок (location). В среднем - неделю, 7 календарных дней. И у нас есть таблица истории, где каждое из основных средств находилось с 1 января 2000 года.
Задача - получить для некоторого списка основных средств площадку, на которой каждое из этих основных средств находилось на заданную дату.
Метаданные
Так как одно основное средство не может находится в один и тот же день на разных площадках, то нам необходимо задать такое ограничение. Для этого в PostgreSQL имеется специальное расширение btree-gist, которое позволяет объединить возможности BTree и GIST индексов. Добавим его для начала:
CREATE EXTENSION IF NOT EXISTS btree_gist;
Теперь мы можем создать таблицу для хранения дат, в течении которых каждое основное средство находилось на определенных площадках.
CREATE TABLE tmp_asset_location (
id serial PRIMARY KEY,
asset_id integer NOT NULL,
location_id integer NOT NULL,
date_period daterange NOT NULL,
EXCLUDE USING GIST (asset_id WITH =, date_period WITH &&)
);
Тут id - просто первичный ключ. asset_id - идентификатор нашего основного средства, location_id - идентификатор площадки и date_period - диапазон дат, в течении которых основное средство находилось на этой площадке. Таблицы справочников основных средств и площадок я тут опускаю, так как они не интересны в рамках рассматриваемой задачи.
В последней строке мы добавляем ограничение-исключение, в котором для asset_id операцией = задаем ограничение по равенству, используя BTree часть индекса, а для date_period запрещаем пересечение диапазонов дат операцией &&, используя уже GIST часть индекса. Теперь это ограничение и индекс, автоматически для него созданный, не позволят вставить в эту таблицы запись с диапазоном дат (date_period) который будет пересекаться с уже имеющимися диапазонами дат для того же самого основного средства (asset_id).
Почему именно btree_gist, а не триггер
Следует отметить, что иным способом контролировать запрет на пересечение диапазонов можно только очень неэффективно, явно используя блокировки либо всей таблицы, либо конкретных основных средств (asset_id). Последнее допустимо только в случае, если поле asset_id не может изменяться операцией UPDATE. В обоих случаях, такие блокировки могут приводить как к снижению производительности, так и к взаимным блокировкам (deadlock).
Проблема в том, что если таблица обновляется из двух разных соединений двумя разными транзакциями, то эти транзакции не видят модификаций, произведенных друг другом, пока эти модификации не будут зафиксированы (COMMIT). А PostgreSQL не предоставляет нам никаких гарантий, что после проверки данных триггером и до фиксации транзакции не произойдет фиксация другой транзакции.
Заполнение таблицы тестовыми данными
Как уже было сказано выше, нам необходимо заполнить нашу таблицу данными о нахождении основных средств на площадках с 1 января 2000 года.
Я использовал такой запрос
WITH RECURSIVE iter AS (
SELECT G.n AS asset_id,
(random()*100+1)::integer AS location_id,
'2000-01-01'::date AS from_date,
('2000-01-01'::date+'1 day'::interval*(random()*14+1))::date AS to_date
FROM generate_series(1,1000) G(n)
UNION ALL
SELECT F.asset_id,
(random()*100+1)::integer AS location_id,
F.to_date AS from_date,
(F.to_date+'1 day'::interval*(random()*14+1))::date AS to_date
FROM iter F
WHERE F.to_date<'2026-01-01'::date )
INSERT INTO tmp_asset_location (asset_id, location_id, date_period)
SELECT asset_id, location_id,
daterange(from_date, to_date, '[)') AS date_period
FROM iter;
После заполнения пустой таблицы неплохо бы обновить в ней статистики
ANALYZE tmp_asset_location;
У меня получилась таблица из 1,267,510 записей. Если будете повторять, то количество записей получится немного иное, так как при заполнении таблицы периоды рассчитывались случайным образом, хотя средняя их длительность была 7 дней.
Оцениваем производительность
Для оценки производительности выберем площадки для каждого третьего основного средства на 1 января 2020 года.
EXPLAIN ANALYZE
SELECT A.*
FROM generate_series(3,1000,3) G(n)
JOIN tmp_asset_location A ON A.asset_id=G.n
AND date_period@>'2020-01-01'::date;
Nested Loop (cost=0.41..872.28 rows=295 width=26) (actual time=0.086..6.724 rows=333.00 loops=1)
Buffers: shared hit=1398
-> Function Scan on generate_series g (cost=0.00..3.33 rows=333 width=4) (actual time=0.021..0.039 rows=333.00 loops=1)
-> Index Scan using tmp_asset_location_asset_id_date_period_excl on tmp_asset_location a (cost=0.41..2.60 rows=1 width=26) (actual time=0.019..0.020 rows=1.00 loops=333)
Index Cond: ((asset_id = g.n) AND (date_period @> '2020-01-01'::date))
Index Searches: 333
Buffers: shared hit=1398
Planning Time: 0.163 ms
Execution Time: 6.763 ms
Вроде бы неплохой результат, для 333 записей. Но хочется лучшей производительности.
Теперь, зная, что созданное нами ограничение гарантирует отсутствие пересечений диапазонов, создадим обычный BTree индекс
CREATE UNIQUE INDEX IF NOT EXISTS tmp_asset_location_asset_date_period
ON tmp_asset_location(asset_id, lower(date_period));
И сделаем ту же самую выборку, но уже явно используя его, а не btree_gist индекс
EXPLAIN ANALYZE
SELECT A.*
FROM generate_series(3,1000,3) G(n)
JOIN LATERAL (
SELECT *
FROM tmp_asset_location T
WHERE T.asset_id=G.n
AND lower(T.date_period)<='2020-01-01'::date
ORDER BY lower(T.date_period) DESC
LIMIT 1) A ON upper(A.date_period)>'2020-01-01'::date;
Nested Loop (cost=0.43..521.75 rows=333 width=26) (actual time=0.059..2.953 rows=333.00 loops=1)
Buffers: shared hit=1332
-> Function Scan on generate_series g (cost=0.00..3.33 rows=333 width=4) (actual time=0.020..0.038 rows=333.00 loops=1)
-> Subquery Scan on a (cost=0.43..1.55 rows=1 width=26) (actual time=0.008..0.009 rows=1.00 loops=333)
Filter: (upper(a.date_period) > '2020-01-01'::date)
Buffers: shared hit=1332
-> Limit (cost=0.43..1.53 rows=1 width=30) (actual time=0.008..0.008 rows=1.00 loops=333)
Buffers: shared hit=1332
-> Index Scan Backward using tmp_asset_location_asset_date_period on tmp_asset_location t (cost=0.43..467.54 rows=423 width=30) (actual time=0.008..0.008 rows=1.00 loops=333)
Index Cond: ((asset_id = g.n) AND (lower(date_period) <= '2020-01-01'::date))
Index Searches: 333
Buffers: shared hit=1332
Planning Time: 0.147 ms
Execution Time: 3.002 ms
Как видим, те же самые 333 записи можно выбрать в два раза быстрее.
Анализ результатов
Как видим, GIST индекс отлично решает задачу ограничения на запрет пересечения диапазонов, но при выборке данных в два раза проигрывает по производительности BTree индексу. Проблема в универсальности GIST индекса и в том, что мы решаем намного более узкую задачу из тех, для которых GIST индекс применим.
Однако эта узкая задача, запрет на пересечение диапазонов в пределах одного идентификатора, является весьма часто встречающейся. По крайней мере в моей практике.
Не следует забывать, что наличие дополнительного индекса неизбежно будет влиять на время вставки и модификации записей в таблице. Если выборки из таблицы требуются не намного чаще, чем её модификации, добавление индекса может в конечном итоге только ухудшить общую производительность.
Но если такая таблица модифицируется намного реже, чем используется в выборках, то ускорение выборки в два раза, ценой дополнительного индекса, - вполне оправданное решение.
ExiTest
Крутая статья, спасибо за детальный разбор с PostgreSQL и daterange — это реально полезный подход для реляционных баз, особенно когда нужно гарантировать целостность данных на уровне БД. Но давай честно: SQL здесь мягко говоря не самый оптимальный вариант, особенно если задача — максимизировать скорость чтения и минимизировать место, а не мучаться с обновлениями и индексами. Я бы предложил перейти на key-value базу данных с предвычисленными данными по дням и использованием Roaring Bitmaps для сжатия множеств активов. Это как раз то, что позволит выжать максимум производительности без компромиссов.
Давай сравню наглядно твой подход с PostgreSQL (на основе твоих тестов) и альтернативный:
По времени исполнения запросов:
- В PostgreSQL: для 333 активов на дату — 3 мс (с BTree-индексом), но это с join'ами, сканированием интервалов и ORDER BY/LIMIT. Если список активов вырастет до 1000, или добавятся фильтры — время легко уйдёт в 5-10 мс+ из-за overhead'а индексов.
- В key-value с Roaring: один точечный lookup по ключу (дню) — <1 мс даже для 1000 активов. Почему? Данные предвычислены: загружаешь один blob (~2-3 КБ), десериализуешь в массив из 100 битмапов, и для каждого актива просто проверяешь contains() в O(1). Нет join'ов, нет сканирования — чистая скорость, как в кэше.
По объёму занимаемого места:
- В PostgreSQL: 1.27 млн записей + индексы (GIST и BTree) — легко 100-200 МБ+ на диске, особенно с overhead'ом на строки, даты и проверки целостности. Индексы сами по себе жрут место и замедляют вставки.
- В key-value с Roaring: вся история (~9500 дней) укладывается в <30 МБ. Каждый день — один ключ с сжатым blob'ом из 100 Roaring Bitmaps (по 20-30 байт на площадку, т.к. в среднем 10 активов на ней). Сжатие Roaring бьёт любые таблицы: нет дублирования дат, нет строк — чистые биты.
По сложности более сложных фильтров:
- В PostgreSQL: базовый запрос на дату — ок, но если добавить фильтры (например, "активы на площадках X и Y за период" или "пересечение с другими условиями")? Придётся городить подзапросы, window functions или даже материализованные views — это усложняет код, замедляет (время вырастет в разы) и требует тюнинга индексов. Целостность круто, но для чтения-ориентированной задачи это overkill.
- В key-value с Roaring: фильтры — это нативные операции над битмапами! Например, пересечение (intersection) двух площадок — bitmap1 & bitmap2 в O(N), где N маленькое. Хочешь активы на нескольких площадках за день? Просто загрузи данные дня и сделай bitwise ops. Для периода — загрузи несколько дней (параллельно, если нужно) и union/intersect. Это проще в коде, быстрее (миллисекунды) и масштабируемо на аналитику без перестройки индексов. Плюс key-value даёт преимущества в поиске: атомарные get'ы без блокировок, лёгкая репликация и кэширование, а Roaring позволяет делать сложные set-операции (union, difference) на лету, чего в SQL без костылей не добьёшься.
В общем, если твоя задача — не частые обновления, а быстрые чтения (как в аналитике или отчётах), то SQL здесь выглядит архаично: медленнее, жирнее и сложнее в расширении.
ptr128 Автор
Не имею ничего против NoSQL баз данных. Сам использую CH, Redis (ValKey), Cassandra.
Если сравнивать, то для этого нужно сначала реализовать то, что с чего начиналась статья и задача. А именно, обеспечить гарантированное отсутствие пересечений диапазонов при конкурентной модификации данных разными процессами с соблюдением принципов ACID. Без этого говорить о производительности выборки, по меньшей мере, преждевременно.
ExiTest
Ок, понял, ты прав насчёт ACID и конкурентных обновлений — если история меняется онлайн с разных сторон, то PostgreSQL с exclusion constraint'ами реально надёжнее всего, и спорить глупо.
Но в 90% случаев с учётом спецтехники/ОС обновления приходят батчами раз в день/неделю из 1С или ERP, а днём только читают тоннами. Вот тут key-value с предвычисленными битмапами просто рвёт PostgreSQL:
Чтение: <1 мс на 1000 активов против 5–15 мс в твоём тесте.
Размер: 25–30 МБ против 150–200+ МБ с индексами.
Сложные вопросы типа «кто был на площадках 10–15 за месяц» — пара операций над битмапами, без подзапросов и window-функций.
Короче, если у вас обновления не онлайн и не критичны по секундам — делайте предрасчёт в key-value, будет в разы быстрее и легче. Если же реально конкурентная запись — оставайтесь на Postgres, там всё честно и без сюрпризов.
Shizzza
Нейросетка?
Xexa
Сейчас надо такие данные, через неделю приходит начальник и говорит мне нужен отчёт по другому срезу данных... И все эти предрасчитанные данные не пригаждаются и приходится перелопачивать сырые не структурированные данные.
Реляционная БД даёт ожидаемое(контролируемое) время в расчётах.
С другой стороны если кидать в nosql, то можно не париться структурой и какие данные нужны, какие нет... Всё в кучу и потом разгребать по мере необходимости по нужным требованиям. Но долго и не гарантированно (на тот период не сохраняли данные такие, а узнать о этом не можем при расчёте). И в результате будет выбрано решение с "кидаем всё в кучу и отдельно БД с расчётами обсчитанными на леру или по расписанию" откуда и будут браться результаты.
Т.ч предложения "я бы предложил перейти" - от задачи решаться должно. Тут автор решает конкретную задачу "показать инструмент", а не "а давайте решение по архитектуре БД и движка выберем"