Базы данных

Многомодельный PostgreSQL в 2026 году: работа с графами, временными рядами и документами в одной базе

Ruslan Ismailov Опубликовано 12 мин чтения
М

Введение: зачем одна база вместо нескольких специализированных?

В 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 демонстративно не справляется с конкретной нагрузкой — и вы это измерили, а не предполагаете.

Практические рекомендации по выбору подхода:

  1. Начинайте с нативного PostgreSQL: JSONB + партиционирование + recursive CTE покрывают 80% сценариев.
  2. Если нужны Cypher-запросы или граф >10M рёбер — добавьте Apache AGE поверх существующего кластера.
  3. Если ingestion временных рядов >100k/сек или нужны continuous aggregates — оцените TimescaleDB как расширение к тому же PostgreSQL.
  4. Не добавляйте MongoDB, Neo4j или InfluxDB, пока PostgreSQL не упёрся в измеримый bottleneck.
  5. Инвестируйте в понимание планировщика запросов PostgreSQL — EXPLAIN ANALYZE и pg_stat_statements должны быть частью вашего рабочего процесса.

Многомодельный PostgreSQL — это не компромисс, а осознанная архитектурная стратегия, которая в 2026 году подкреплена богатой экосистемой расширений, зрелым планировщиком запросов и огромным сообществом. Используйте его полный потенциал.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →