Многомодельный PostgreSQL в 2026 году: работа с графами, временными рядами и документами в одной базе
Введение: зачем одна база вместо нескольких специализированных?
В 2026 году архитекторы данных всё чаще сталкиваются с соблазном собрать «идеальный стек» из нескольких специализированных баз: Neo4j для графов, InfluxDB или TimescaleDB для временных рядов, MongoDB для документов. На практике такой подход порождает распределённые транзакции, дублирование данных, сложную операционную модель и экспоненциально растущие затраты на DevOps.
PostgreSQL в 2026 году — это зрелая многомодельная СУБД, способная закрыть все три сценария в рамках одного кластера. Recursive CTE и расширение ag_catalog (Apache AGE) обеспечивают графовые запросы. Нативное партиционирование, оконные функции и расширения, совместимые с TimescaleDB API, решают задачи временных рядов. JSONB с GIN-индексами конкурирует с MongoDB по гибкости хранения документов. При этом все три модели работают в единой транзакционной модели ACID, с общим бэкапом, мониторингом и ролевой моделью.
Эта статья — практическое руководство для backend-разработчиков и архитекторов, которые хотят выжать максимум из PostgreSQL без лишних зависимостей.
Графовые данные в PostgreSQL
Recursive CTE: основа графовых запросов
Самый доступный инструмент для работы с иерархическими и графовыми структурами в PostgreSQL — рекурсивные Common Table Expressions (CTE). Они позволяют обходить деревья и ориентированные графы без внешних расширений.
Рассмотрим классическую задачу: обход графа зависимостей сервисов в микросервисной архитектуре.
-- Таблица зависимостей сервисов
CREATE TABLE service_deps (
parent_id INT NOT NULL,
child_id INT NOT NULL,
weight NUMERIC DEFAULT 1.0
);
CREATE INDEX ON service_deps (parent_id);
-- Рекурсивный обход: все зависимости сервиса #1 до глубины 10
WITH RECURSIVE dep_tree AS (
-- Базовый случай
SELECT parent_id, child_id, weight, 1 AS depth,
ARRAY[parent_id] AS path
FROM service_deps
WHERE parent_id = 1
UNION ALL
-- Рекурсивный шаг
SELECT sd.parent_id, sd.child_id, sd.weight,
dt.depth + 1,
dt.path || sd.child_id
FROM service_deps sd
JOIN dep_tree dt ON dt.child_id = sd.parent_id
WHERE sd.child_id != ALL(dt.path) -- защита от циклов
AND dt.depth < 10
)
SELECT child_id, depth, path, weight
FROM dep_tree
ORDER BY depth, child_id;Обратите внимание на защиту от циклов через ARRAY и условие sd.child_id != ALL(dt.path) — это критически важно для реальных графов с обратными рёбрами.
Расширение ltree для иерархий
Для материализованных иерархий (категории товаров, организационные структуры) расширение ltree эффективнее рекурсивных CTE: оно хранит путь в дереве как метку и поддерживает индексированные запросы по поддеревьям.
CREATE EXTENSION IF NOT EXISTS ltree;
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
path LTREE NOT NULL,
name TEXT NOT NULL
);
CREATE INDEX cat_path_gist ON categories USING GIST (path);
CREATE INDEX cat_path_btree ON categories USING BTREE (path);
-- Вставка иерархии: электроника > смартфоны > Android
INSERT INTO categories (path, name) VALUES
('electronics', 'Электроника'),
('electronics.smartphones', 'Смартфоны'),
('electronics.smartphones.android', 'Android'),
('electronics.laptops', 'Ноутбуки');
-- Все потомки узла 'electronics.smartphones'
SELECT id, name, path
FROM categories
WHERE path <@ 'electronics.smartphones';
-- Поиск по шаблону (все прямые дети электроники)
SELECT * FROM categories
WHERE path ~ 'electronics.*{1}';Apache AGE: Cypher-запросы поверх PostgreSQL
Когда нужны полноценные графовые запросы в стиле Cypher, в 2026 году используют расширение Apache AGE (ag_catalog). Оно позволяет хранить вершины и рёбра в PostgreSQL и выполнять Cypher-запросы через SQL-функцию cypher().
-- Подключение расширения
CREATE EXTENSION age;
LOAD 'age';
SET search_path = ag_catalog, "$user", public;
-- Создание графа
SELECT create_graph('social');
-- Создание вершин (пользователей)
SELECT * FROM cypher('social', $$
CREATE (:User {id: 1, name: 'Alice'}),
(:User {id: 2, name: 'Bob'}),
(:User {id: 3, name: 'Carol'})
$$) AS (v agtype);
-- Создание рёбер (подписки)
SELECT * FROM cypher('social', $$
MATCH (a:User {name: 'Alice'}), (b:User {name: 'Bob'})
CREATE (a)-[:FOLLOWS]->(b)
$$) AS (e agtype);
-- Поиск друзей друзей Alice
SELECT * FROM cypher('social', $$
MATCH (a:User {name: 'Alice'})-[:FOLLOWS*2]->(fof)
RETURN fof.name
$$) AS (name agtype);Временные ряды в PostgreSQL
Нативное партиционирование по времени
Для временных рядов без внешних расширений PostgreSQL предлагает декларативное партиционирование по диапазону дат. Это снижает размер индексов, ускоряет запросы по периодам и упрощает архивирование через DETACH PARTITION.
CREATE TABLE metrics (
ts TIMESTAMPTZ NOT NULL,
service_id INT NOT NULL,
metric TEXT NOT NULL,
value DOUBLE PRECISION NOT NULL
) PARTITION BY RANGE (ts);
-- Создание партиций на каждый месяц
CREATE TABLE metrics_2026_01
PARTITION OF metrics
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE metrics_2026_02
PARTITION OF metrics
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- Индекс внутри партиции
CREATE INDEX ON metrics_2026_01 (service_id, ts DESC);
-- Запрос: среднее значение CPU за последние 7 дней
SELECT
date_trunc('hour', ts) AS hour,
AVG(value) AS avg_cpu
FROM metrics
WHERE metric = 'cpu_usage'
AND ts >= NOW() - INTERVAL '7 days'
GROUP BY 1
ORDER BY 1;Оконные функции для анализа трендов
Оконные функции — главный инструмент аналитики временных рядов в SQL. Они позволяют вычислять скользящие средние, lag/lead значения и нарастающие итоги без самостоятельных JOIN-ов.
-- Скользящее среднее за 5 точек и отклонение от предыдущего значения
SELECT
ts,
service_id,
value,
AVG(value) OVER (
PARTITION BY service_id
ORDER BY ts
ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
) AS moving_avg_5,
value - LAG(value) OVER (
PARTITION BY service_id ORDER BY ts
) AS delta
FROM metrics
WHERE metric = 'cpu_usage'
AND ts >= NOW() - INTERVAL '1 day'
ORDER BY service_id, ts;Сравнение с TimescaleDB
TimescaleDB в 2026 году остаётся популярным расширением, добавляющим hypertable, автоматическое партиционирование по chunks, функцию time_bucket() и политики сжатия. Если ваша нагрузка — миллионы точек в секунду с агрессивным сжатием и continuous aggregates, TimescaleDB оправдан. Для нагрузок до 100k событий/сек нативного партиционирования PostgreSQL с правильными индексами достаточно, и вы избегаете дополнительной зависимости.
- Нативный PostgreSQL: полный контроль, без лицензионных ограничений, меньше магии.
- TimescaleDB Community:
time_bucket(), continuous aggregates, chunk compression — быстрее при очень высоком ingestion rate. - TimescaleDB Cloud / Timescale: managed-сервис, columnar storage, актуален для petabyte-scale.
Документы: JSONB, GIN и операторы поиска
Хранение и индексирование документов
JSONB — бинарное представление JSON в PostgreSQL — хранит данные в разобранном виде, поддерживает индексирование отдельных ключей и полнотекстовый поиск по содержимому. В отличие от текстового JSON, JSONB не сохраняет порядок ключей и дубликаты, но значительно быстрее при чтении.
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
payload JSONB NOT NULL
);
-- GIN-индекс для оператора @> (containment)
CREATE INDEX events_payload_gin ON events USING GIN (payload);
-- Индекс на конкретный ключ (если запросы всегда по одному полю)
CREATE INDEX events_user_id ON events ((payload->>'user_id'));
-- Вставка событий
INSERT INTO events (payload) VALUES
('{"type": "login", "user_id": "u42", "ip": "1.2.3.4", "tags": ["mobile", "vpn"]}'),
('{"type": "purchase", "user_id": "u42", "amount": 199.99, "items": ["sku-1", "sku-2"]}');
-- Оператор @>: найти все события с type=login
SELECT id, payload
FROM events
WHERE payload @> '{"type": "login"}';
-- Поиск по вложенному массиву тегов
SELECT id, payload
FROM events
WHERE payload @> '{"tags": ["vpn"]}';
-- Оператор @@: полнотекстовый поиск по jsonpath
SELECT id, payload
FROM events
WHERE payload @@ '$.type == "purchase" && $.amount > 100';Советы по работе с JSONB
- Используйте
GINс классом оператораjsonb_path_opsдля оператора@>— он компактнее дефолтного. - Для частых выборок по конкретному ключу создавайте выражённые B-tree индексы:
(payload->>'user_id'). - Избегайте хранения в JSONB данных с известной, стабильной схемой — для них нативные колонки быстрее и надёжнее.
- Используйте
jsonb_set()для атомарного обновления отдельных полей без перезаписи всего документа.
Практический кейс: платформа мониторинга IoT
Рассмотрим реальный сценарий: платформа для мониторинга промышленных устройств. Данные включают топологию сети устройств (граф), метрики с датчиков (временные ряды) и конфигурационные документы (JSONB).
Схема
-- 1. Граф топологии устройств
CREATE TABLE device_topology (
parent_device_id INT NOT NULL,
child_device_id INT NOT NULL,
link_type TEXT NOT NULL -- 'ethernet', 'zigbee', 'mqtt'
);
-- 2. Метрики устройств (временные ряды, партиционирование по месяцу)
CREATE TABLE device_metrics (
ts TIMESTAMPTZ NOT NULL,
device_id INT NOT NULL,
metric TEXT NOT NULL,
value DOUBLE PRECISION NOT NULL
) PARTITION BY RANGE (ts);
CREATE TABLE device_metrics_2026_q2
PARTITION OF device_metrics
FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');
-- 3. Конфигурации устройств (JSONB-документы)
CREATE TABLE device_configs (
device_id INT PRIMARY KEY,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
config JSONB NOT NULL
);
CREATE INDEX device_configs_gin ON device_configs USING GIN (config);
Объединяющий запрос
Следующий запрос демонстрирует силу многомодельного подхода: находим все устройства в поддереве от шлюза #1, у которых за последний час средняя температура превысила порог, и у которых в конфигурации включён режим alert_enabled.
WITH RECURSIVE subtree AS (
SELECT child_device_id AS device_id
FROM device_topology
WHERE parent_device_id = 1
UNION ALL
SELECT dt.child_device_id
FROM device_topology dt
JOIN subtree s ON s.device_id = dt.parent_device_id
),
hot_devices AS (
SELECT device_id, AVG(value) AS avg_temp
FROM device_metrics
WHERE metric = 'temperature'
AND ts >= NOW() - INTERVAL '1 hour'
AND device_id IN (SELECT device_id FROM subtree)
GROUP BY device_id
HAVING AVG(value) > 75.0
)
SELECT
hd.device_id,
hd.avg_temp,
dc.config->>'firmware_version' AS firmware,
dc.config->>'location' AS location
FROM hot_devices hd
JOIN device_configs dc ON dc.device_id = hd.device_id
WHERE dc.config @> '{"alert_enabled": true}'
ORDER BY hd.avg_temp DESC;Весь этот запрос — граф + временной ряд + документ — выполняется в одной транзакции, с единым планом выполнения и без сетевых вызовов между разными СУБД.
Производительность и ограничения
Что работает хорошо
- JSONB + GIN: запросы с
@>на документах до 10 ГБ работают за миллисекунды при правильном индексировании. - Партиционирование: запросы по одной партиции (partition pruning) в 10–50 раз быстрее полного скана таблицы.
- Recursive CTE: эффективны для деревьев глубиной до 20–30 уровней и графов с сотнями тысяч рёбер.
Где есть ограничения
- Глубокие графы с миллионами рёбер: recursive CTE масштабируются хуже, чем нативные графовые СУБД (Neo4j, JanusGraph). При графах >50M рёбер рассмотрите Apache AGE или гибридный подход.
- Очень высокий ingestion временных рядов: при нагрузке >500k точек/сек нативное партиционирование уступает TimescaleDB с chunk compression. Бенчмаркируйте вашу конкретную нагрузку.
- Полнотекстовый поиск по JSONB: оператор
@@с jsonpath мощный, но для сложного full-text search по вложенным текстам рассмотрите отдельныйtsvector-столбец или интеграцию с Elasticsearch. - Горизонтальное масштабирование записи: PostgreSQL — вертикально масштабируемая СУБД. Для шардирования нужны Citus или внешние proxy (Pgpool-II, PgBouncer).
Советы по оптимизации
- Используйте
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)для диагностики планов рекурсивных запросов. - Для JSONB-колонок с высокой кардинальностью отдельных полей создавайте частичные индексы:
WHERE (payload->>'type') = 'purchase'. - Включите
enable_partition_pruning = on(дефолт в PostgreSQL 14+) и проверяйте, что plan действительно использует partition pruning через EXPLAIN. - Для recursive CTE на больших графах рассмотрите материализацию промежуточных результатов через
WITH ... AS MATERIALIZED. - Настройте
work_memпод сортировки и хэш-джойны в аналитических запросах по временным рядам — дефолтные 4 МБ катастрофически мало.
Выводы и рекомендации
PostgreSQL в 2026 году — это полноценная многомодельная платформа, а не просто реляционная СУБД с JSON-поддержкой. Для большинства проектов один кластер PostgreSQL заменяет связку из трёх специализированных систем, устраняя операционную сложность и обеспечивая транзакционную целостность между всеми моделями данных.
Добавляйте специализированную базу данных только тогда, когда PostgreSQL демонстративно не справляется с конкретной нагрузкой — и вы это измерили, а не предполагаете.
Практические рекомендации по выбору подхода:
- Начинайте с нативного PostgreSQL:
JSONB+ партиционирование + recursive CTE покрывают 80% сценариев. - Если нужны Cypher-запросы или граф >10M рёбер — добавьте Apache AGE поверх существующего кластера.
- Если ingestion временных рядов >100k/сек или нужны continuous aggregates — оцените TimescaleDB как расширение к тому же PostgreSQL.
- Не добавляйте MongoDB, Neo4j или InfluxDB, пока PostgreSQL не упёрся в измеримый bottleneck.
- Инвестируйте в понимание планировщика запросов PostgreSQL —
EXPLAIN ANALYZEиpg_stat_statementsдолжны быть частью вашего рабочего процесса.
Многомодельный PostgreSQL — это не компромисс, а осознанная архитектурная стратегия, которая в 2026 году подкреплена богатой экосистемой расширений, зрелым планировщиком запросов и огромным сообществом. Используйте его полный потенциал.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →