Графовые базы данных и Go: где они действительно полезны

Когда графовая модель оправдана, а когда достаточно PostgreSQL: один граф в трёх углах — нативный Neo4j, Apache AGE (граф поверх PG) и baseline на рекурсивных CTE, — доступ из Go, живые замеры и честная карта решений

Графовые базы данных обычно попадают в одну из двух крайностей в голове разработчика: либо это магическое решение для «сложных связей», либо нишевая экзотика «для рекомендаций и соцсетей». На практике истина посередине. Есть задачи, где графовая модель делает запрос естественнее и быстрее, и есть много случаев, где графовую БД добавляют потому, что звучит интересно, а получают новый storage tier без реального выигрыша.

В Go эта тема встречается регулярно: язык живёт в инфраструктурных и платформенных системах, где графовые задачи возникают сами собой — зависимости сервисов, распространение прав доступа, маршруты, поиск подозрительных цепочек, топология. Но «задача выглядит как граф» ещё не значит «нужна графовая БД». Чтобы ответить не мнением, а числами, вся статья построена вокруг живого стенда: один и тот же граф загружен в три угла, и одни и те же вопросы заданы каждому.

Ретрофутуристская иллюстрация в стиле «Полдень. XXI век»: один и тот же граф в трёх представлениях — слева органическое облако узлов, в центре жёсткая прямоугольная решётка-таблица, справа граф, перетекающий в монолитный геометрический блок; сквозь все три вьётся выделенный красным путь обхода

В статье

Смежное на сайте: Карта хранилищ данных ставит графовую модель в ряд с остальными; про то, почему обход по связям — это не то же самое, что индекс по столбцу, — Индексы в БД: типы и внутреннее устройство. Один из доменов ниже, access graph, вплотную примыкает к разбору моделей прав RBAC/ABAC/ReBACСкоро.

Один граф, три угла

Чтобы сравнение было честным, нужен один граф, заданный один раз, и три способа его хранить и опрашивать. Домен взят близкий к платформе: пользователи, команды, роли, ресурсы, сервисы, пакеты. Он не игрушечный — генератор строит несколько тысяч узлов и десятки тысяч рёбер с фиксированным seed, поэтому числа воспроизводимы.

graph LR U[User] -->|MEMBER_OF| T[Team] U -->|HAS_ROLE| R[Role] T -->|HAS_ROLE| R R -->|GRANTS| Res[Resource] U -->|INTERACTS| Res U -->|COLLABORATES| U T -->|OWNS| S[Service] S -->|DEPENDS_ON| S P[Package] -->|DEPENDS_ON| P

graph LR
  U[User] -->|MEMBER_OF| T[Team]
  U -->|HAS_ROLE| R[Role]
  T -->|HAS_ROLE| R
  R -->|GRANTS| Res[Resource]
  U -->|INTERACTS| Res
  U -->|COLLABORATES| U
  T -->|OWNS| S[Service]
  S -->|DEPENDS_ON| S
  P[Package] -->|DEPENDS_ON| P
Модель графа платформы: один датасет покрывает паттерны обхода всех четырёх доменов — права, зависимости, сотрудничество и поиск колец

Три угла:

  • Neo4j — нативная графовая БД. Связи хранятся как first-class-объекты, запросы пишутся на Cypher, доступ из Go — по протоколу bolt. «Как задумано».
  • Apache AGE — расширение openCypher поверх PostgreSQL. Тот же Cypher, но граф живёт внутри обычного PostgreSQL, рядом с реляционными таблицами. Прямой ответ на вопрос «а когда достаточно графового слоя поверх PG?».
  • PostgreSQL baseline — тот же граф в обычных таблицах, обход через WITH RECURSIVE. Никакого графового слоя вообще. Честная точка отсчёта: если вопрос решается здесь просто и быстро, отдельный storage tier не нужен.

Важная деталь, которая упрощает и стенд, и продакшн: AGE и baseline — это один и тот же PostgreSQL. Значит, приложению нужен не отдельный драйвер под каждый из трёх движков, а два семейства: нативный драйвер Neo4j и обычный PG-драйвер, который обслуживает и AGE, и baseline.

Весь код — генератор, клиенты на Go, Java и Rust, bench-харнес — лежит в стенде digital-cookbook/databases/graph. Go- и Java-клиенты проверяют, что все три угла дают один и тот же ответ на каждый вопрос, а Rust подтверждает совпадение PG-углов (AGE и baseline); замеры показывают, за сколько.

Когда графовая модель действительно уместна

Графовая модель окупается там, где основной продуктовый смысл живёт в обходе связей, а не в отдельных записях. На нашем графе это видно по паттернам запросов — домены различаются не структурой, а типом обхода.

  • Граф зависимостей. «Что сломается, если упадёт сервис X» — это reachability по DEPENDS_ON: собрать всех, кто транзитивно зависит от X. Сюда же обнаружение циклических зависимостей.
  • Граф доступа и распространение прав. «К каким ресурсам имеет доступ пользователь U» — обход MEMBER_OF → HAS_ROLE → GRANTS через команды и роли. Права распространяются по рёбрам, а не хранятся плоским списком.
  • Рекомендации и социальные связи. «Кратчайшая цепочка знакомств между U и V», «что смотрят похожие пользователи» — shortest path и обход COLLABORATES/INTERACTS.
  • Fraud и подозрительные цепочки. Кольца в COLLABORATES заданной длины — циклы, которые в реляционной модели выражаются мучительно, а на графе естественны.

Общий признак: ответ на запрос — это путь или достижимость, а глубина обхода заранее не фиксирована. Если это ваш случай, дальше вопрос только в том, какой из трёх углов дешевле.

Где графовая БД только усложняет

Столь же важно, где графовая БД — лишняя сложность:

  • Обычный CRUD. Создать, прочитать, обновить запись по ключу — реляционная или документная модель проще и быстрее.
  • Неглубокие связи. One-to-many и many-to-many на глубине 1–2 прекрасно живут в SQL с индексом по внешнему ключу. Забегая вперёд: на нашем замере запрос доступа к ресурсам (2–3 hops) в чистом PostgreSQL отрабатывает за 0.96 мс против 2.31 мс у Neo4j — не потому что Neo4j медленный, а потому что на такой глубине графовый обход не даёт преимущества, а накладные расходы драйвера уже есть.
  • Отчётность и агрегаты. Суммы, группировки, оконные функции — территория SQL. Граф здесь не помощник.
  • Связи используются редко. Если обход по рёбрам — не основной способ ответить на запрос, а эпизодический, отдельный граф-tier не окупится эксплуатацией.

Тезис простой: сначала должен появиться понятный use case с обходом связей, и только потом — отдельное хранилище под него.

Типовые паттерны и как они выражаются

Покажу два показательных паттерна в двух видах: на Cypher (Neo4j и AGE) и на рекурсивном SQL (baseline). Именно на них дальше строятся замеры.

Impact-анализ — reachability по зависимостям. «Кто транзитивно зависит от сервиса $sid»:

MATCH (dep:Service)-[:DEPENDS_ON*1..8]->(t:Service {id:$sid})
RETURN DISTINCT dep.id
WITH RECURSIVE up AS (
    SELECT src_id, 1 AS depth
    FROM depends_on WHERE dst_id = $1 AND kind = 'service'
    UNION
    SELECT d.src_id, up.depth + 1
    FROM depends_on d JOIN up ON d.dst_id = up.src_id
    WHERE d.kind = 'service' AND up.depth < $2
)
SELECT DISTINCT src_id FROM up

UNION (в отличие от UNION ALL) здесь схлопывает узлы, достигнутые на одной глубине разными путями, — это срезает лишнюю работу. А от зацикливания на графе с циклами защищает именно граница depth < $2: без неё рекурсия ушла бы в бесконечность, потому что depth монотонно растёт и один и тот же узел, достигнутый на большей глубине, — это новая строка, которую UNION уже не дедуплицирует. (В запросе поиска циклов на стенде стоит даже UNION ALL, и завершает его тоже граница глубины, а не вид UNION.) Обратите внимание: границу обхода (1..8 в примере) в Cypher приходится записывать литералом прямо в тексте запроса — переменная длина пути не параметризуется, в отличие от $sid. В стенде эту границу подставляет fmt.Sprintf; подробнее об этом ниже.

Кратчайшее расстояние по сотрудничеству. «За сколько шагов COLLABORATES связаны пользователи A и B» — и вот здесь три угла впервые расходятся принципиально:

MATCH (a:User {id:$a}), (b:User {id:$b})
MATCH p = shortestPath((a)-[:COLLABORATES*1..6]-(b))
RETURN length(p) AS len

shortestPath в Neo4j — это специализированный обход, который останавливается, как только нашёл кратчайший путь. В baseline его приходится эмулировать BFS-обходом через рекурсивный CTE с MIN(dist), а в AGE — перебором всех путей до границы. Разница в цене окажется драматической (см. ниже).

Что AGE умеет, а чего не умеет

Apache AGE подаётся как «openCypher поверх PostgreSQL», и это правда — но openCypher у AGE 1.6 это подмножество, а не полный Cypher. Это не мелочь, а то, что определяет, какие запросы вообще можно перенести на AGE без переписывания.

Что работает:

  • MATCH с ограниченной переменной длиной пути: -[:DEPENDS_ON*1..8]->, в том числе ненаправленной (-[:COLLABORATES*3..3]-);
  • обнаружение цикла через возврат к себе: MATCH (s)-[:DEPENDS_ON*1..8]->(s) RETURN DISTINCT s.id;
  • length(p) для длины пути, агрегаты, ORDER BY ... LIMIT.

Чего нет и что придётся обходить:

  • shortestPath() — синтаксическая ошибка. Кратчайший путь эмулируется через MATCH p=(a)-[*1..6]-(b) RETURN length(p) ORDER BY length(p) LIMIT 1, а это перебор путей до заданной границы, а не умный обход.
  • nodes(path) и list comprehension по пути ([n IN nodes(p) | n.id]) — падает с could not find properties for n. Вернуть упорядоченный список узлов пути нельзя, только множества id или скаляры.

Практический вывод для проектирования: контракт, который вы кладёте на AGE, должен возвращать множества и скаляры, а не упорядоченные пути. В стенде поэтому единый интерфейс всех трёх углов оперирует множествами id и расстоянием-числом — это то подмножество, которое одинаково выразимо везде. Ordered-path-логику, если она нужна, придётся оставить Neo4j.

Что важно с точки зрения Go

С точки зрения Go три угла складываются в два способа доступа.

Драйверы. Neo4j — официальный neo4j-go-driver/v5 по протоколу bolt. AGE и baseline — обычный pgx: AGE-запрос это SELECT * FROM cypher('platform', $$ ... $$), baseline — обычный SQL. То, что «граф поверх PostgreSQL» работает через тот же pgx, что и остальная ваша работа с PG, — сильный аргумент в пользу AGE там, где не хочется тащить второй storage.

Context и timeout — не опция. Это тот случай, где Go-шный context спасает от вполне конкретной боли. AGE без нативного shortestPath дорожает круто с границей обхода: в замерах то же расстояние с границей 6 стоит 25 мс, а с границей 8 — уже 870 мс (см. таблицу ниже), и дальше по нарастающей. Запрос с неудачно большой границей может занять соединение надолго, поэтому per-query таймаут обязателен — иначе один такой запрос утащит за собой общий deadline:

ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
dist, err := q.Distance(ctx, aID, bID, maxLen)

В проде это ровно то же самое: графовый запрос с неудачной границей обхода — это потенциальная «чёрная дыра», и явный deadline на операцию обязателен, как и для обычного PostgreSQL-клиента.

Параметризация, а не склейка строк. В Neo4j запросы честно параметризуются ($uid, $sid) — драйвер передаёт значения отдельно от текста запроса. У AGE параметры тоже есть — третьим аргументом cypher() передаётся agtype-map (prepared statements в документации AGE), — но это заметно менее удобно, чем обычные параметры Neo4j или SQL. В стенде для строго своих int64 использована склейка строки, и это допустимо ровно потому, что там нет пользовательского ввода. Как только в обход попадают внешние данные, склейку нужно менять на prepared statement или другой безопасный слой — иначе это классическая инъекция, как любой fmt.Sprintf в SQL. Общий принцип: то, что можно параметризовать, параметризуйте, и не превращайте доступ к графу в ручную конкатенацию query-строк на пользовательском вводе.

Java-клиент (neo4j-java-driver + JDBC) подтверждает те же ответы на всех трёх углах из JVM, а Rust (tokio-postgres) — на PG-углах (AGE + baseline); нативный Neo4j в Rust-клиенте не используется. Числа ниже сняты одним Go-харнесом, чтобы они были сравнимы. Как один и тот же доступ к данным выглядит из разных языков в целом — в Доступ к данным в разных языках: ORM, query-builder или SQLСкоро.

Производительность: карта, а не рейтинг

Замеры сняты на графе из 2000 пользователей, 300 сервисов, 1200 пакетов; ~5.5 тыс. рёбер COLLABORATES, ~5.2 тыс. DEPENDS_ON, ~12.8 тыс. INTERACTS. Один Go-харнес, прогрев вне таймера, медиана нескольких повторов. Все три угла возвращают одинаковые ответы — различается только время. Числа — медиана в миллисекундах.

Глубина traversal (impact-анализ). Здесь виден главный водораздел:

Глубина Neo4j AGE baseline (CTE)
1 2.80 1.38 0.70
2 2.90 4.45 0.72
3 5.31 10.09 1.07
5 4.95 67.10 2.06
8 4.43 584.45 3.89

Baseline на индексированных колонках рёбер растёт по глубине полого (0.70 → 3.89 мс) и остаётся на порядки быстрее AGE. Neo4j тоже держится почти ровно. А AGE взрывается: 1.4 мс на глубине 1 превращаются в 584 мс на глубине 8 — «граф поверх PostgreSQL» дорого платит за глубокий обход, потому что каждое расширение пути идёт через SQL-механику расширения, а не через нативный обход.

Кратчайшее расстояние — цена отсутствия shortestPath. Меряем расстояние между фиксированной парой при разной границе обхода:

Граница пути Neo4j AGE baseline (CTE)
4 3.33 1.14 1.47
6 3.09 25.25 13.04
8 2.96 870.06 33.36

Neo4j плоский (~3 мс) независимо от границы — shortestPath останавливается, как только нашёл путь. AGE и baseline вынуждены перебирать пути до границы, и стоимость растёт с ней, а не с фактическим расстоянием. Пара здесь взята на расстоянии в один шаг — и это делает контраст особенно наглядным: Neo4j находит путь сразу и останавливается, а эмуляции всё равно перебирают всё до границы, поэтому именно они платят за её рост. У AGE это обрыв: 870 мс на границе 8. Прямое следствие отсутствия shortestPath в AGE — ровно тот случай, где нативная графовая БД незаменима.

Fan-out — поиск колец (fraud). Кольца COLLABORATES заданной длины:

Длина кольца Neo4j AGE baseline (CTE)
3 106 278 141
4 587 1499 577

Дорого везде — это по природе обход с ветвлением, — но AGE стабильно худший, а baseline на индексах неожиданно держится вровень с Neo4j.

Обнаружение циклов зависимостей растёт с границей длины у всех трёх; на большой границе (15 hops) baseline даже проигрывает (~7.2 с против ~4.8 с у Neo4j) — здесь нативный обход Neo4j начинает окупаться.

Складывая всё это, получается не рейтинг «кто быстрее», а карта:

  • мелкий обход и доступ (глубина 1–2) — PostgreSQL (baseline или AGE) конкурентен или быстрее; отдельный tier не нужен;
  • кратчайший путь — территория Neo4j, где shortestPath даёт плоскую латентность, а эмуляции обрываются;
  • глубокий обход и поиск циклов — Neo4j уверенно впереди, baseline проседает, AGE проседает раньше всех;
  • AGE удобен как «граф без нового storage», но платит на глубине и не умеет части Cypher — это инструмент для неглубоких графовых запросов поверх уже имеющегося PostgreSQL, а не замена Neo4j.

Как выбирать

Не рейтинг, а вопросы к своей задаче:

  1. Обход — основной способ ответа на запрос или эпизодический? Если эпизодический — оставайтесь на PostgreSQL. Рекурсивный CTE на индексированных колонках рёбер закрывает неглубокие связи с запасом.
  2. Нужен ли кратчайший путь или ordered-path? Если да и это в горячем пути — это сильный аргумент за нативную графовую БД: shortestPath и работа с узлами пути в AGE недоступны, а в baseline дороги.
  3. Насколько глубок обход и растёт ли граф? Плоская латентность Neo4j по глубине против роста у AGE/baseline — решающий фактор, если глубина обхода большая и данные растут.
  4. Готовы ли эксплуатировать ещё один storage tier? Если нет, а графовые запросы неглубокие — AGE даёт графовый синтаксис, не покидая PostgreSQL. Оговорка: AGE есть не в любом managed PostgreSQL — он требует расширения и подходящего образа, поэтому это выигрыш, если вы уже эксплуатируете self-hosted PostgreSQL и можете поставить AGE (Neo4j, впрочем, тоже разворачивается self-hosted — это не про «облако против своего», а про «одно хранилище против двух»).

Все три угла здесь живут на одном сервере. Если граф перерастает один узел — это уже другой разговор: распределённые графовые БД (NebulaGraph, ArangoDB, Dgraph) шардируют граф между машинами, но приносят свою цену — распределённые обходы, консистентность, эксплуатацию кластера. Сюда же относится Aerospike Graph — отдельный Graph Service поверх key-value-хранилища Aerospike, который вендор позиционирует под large-scale graph workloads (на стенде мы его не мерили); но это уже другой стек: язык Gremlin (Apache TinkerPop), а не Cypher, и он требует feature key (Enterprise-лицензирование Aerospike). Всё это — следующий шаг, когда одиночного Neo4j или PostgreSQL перестаёт хватать по объёму, а не первый выбор под графовую задачу.

Выводы

  • Графовая БД — не «ускоритель сложных связей по умолчанию». Сначала нужен use case, где смысл живёт в обходе связей.
  • Если вопрос прозрачно решается в PostgreSQL — часто это и есть лучший выбор: на мелком обходе и доступе baseline на рекурсивных CTE конкурентен или быстрее нативной графовой БД.
  • Где графовая модель окупается — окупается ощутимо: shortestPath в Neo4j даёт плоскую латентность там, где эмуляции обрываются, а на глубоком обходе разрыв растёт в разы.
  • Apache AGE — разумный средний вариант «граф поверх PostgreSQL» для неглубоких запросов, но его openCypher — подмножество (нет shortestPath, nodes(path)), и на глубине он проседает раньше всех. Знать эти границы до внедрения дешевле, чем узнать их в проде.

Полный стенд с генератором, клиентами на Go/Java/Rust и bench-харнесом — в digital-cookbook/databases/graph: можно поднять три угла одной командой и перепроверить любые числа на своих данных.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии