Графовые базы данных обычно попадают в одну из двух крайностей в голове разработчика: либо это магическое решение для «сложных связей», либо нишевая экзотика «для рекомендаций и соцсетей». На практике истина посередине. Есть задачи, где графовая модель делает запрос естественнее и быстрее, и есть много случаев, где графовую БД добавляют потому, что звучит интересно, а получают новый storage tier без реального выигрыша.
В Go эта тема встречается регулярно: язык живёт в инфраструктурных и платформенных системах, где графовые задачи возникают сами собой — зависимости сервисов, распространение прав доступа, маршруты, поиск подозрительных цепочек, топология. Но «задача выглядит как граф» ещё не значит «нужна графовая БД». Чтобы ответить не мнением, а числами, вся статья построена вокруг живого стенда: один и тот же граф загружен в три угла, и одни и те же вопросы заданы каждому.
В статье
- Один граф, три угла
- Когда графовая модель действительно уместна
- Где графовая БД только усложняет
- Типовые паттерны и как они выражаются
- Что AGE умеет, а чего не умеет
- Что важно с точки зрения Go
- Производительность: карта, а не рейтинг
- Как выбирать
- Выводы
Смежное на сайте: Карта хранилищ данных ставит графовую модель в ряд с остальными; про то, почему обход по связям — это не то же самое, что индекс по столбцу, — Индексы в БД: типы и внутреннее устройство. Один из доменов ниже, 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
Три угла:
- 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.idWITH 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 upUNION (в отличие от 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 lenshortestPath в 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.
Как выбирать
Не рейтинг, а вопросы к своей задаче:
- Обход — основной способ ответа на запрос или эпизодический? Если эпизодический — оставайтесь на PostgreSQL. Рекурсивный CTE на индексированных колонках рёбер закрывает неглубокие связи с запасом.
- Нужен ли кратчайший путь или ordered-path? Если да и это в горячем пути — это сильный аргумент за нативную графовую БД:
shortestPathи работа с узлами пути в AGE недоступны, а в baseline дороги. - Насколько глубок обход и растёт ли граф? Плоская латентность Neo4j по глубине против роста у AGE/baseline — решающий фактор, если глубина обхода большая и данные растут.
- Готовы ли эксплуатировать ещё один 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: можно поднять три угла одной командой и перепроверить любые числа на своих данных.
Источники
- Neo4j —
shortestPathи поиск путей в Cypher; алгоритмы обхода. - Apache AGE — документация и prepared statements (границы openCypher-подмножества проверялись на версии 1.6).
- PostgreSQL — рекурсивные запросы
WITH RECURSIVE. - Aerospike Graph — архитектура Graph Service и Aerospike Graph Service; язык запросов — Apache TinkerPop / Gremlin.
- Драйверы:
neo4j-go-driver/v5,pgx/v5. - Стенд к статье (генератор, клиенты, bench) —
digital-cookbook/databases/graph. Все числа в статье воспроизводятся изbench/go/out/results.csv.
Комментарии