Эта история началась с того, что мы нашли в Apache AGE несколько сегфолтов. Сами по себе сегфолты для молодого расширения — дело житейское, но они породили вопрос: если со стабильностью не всё гладко, как обстоят дела с производительностью? Вопрос повис в воздухе, потом перекочевал в задачу, а потом, как это бывает, превратился в исследование, которое заняло куда больше времени, чем казалось вначале. Потому что оказалось, что проверить производительность Apache AGE нечем: ни готовых бенчмарков, ни опубликованных результатов.

Прежде чем нырять в flame graph, давайте разберёмся, что такое Apache AGE и зачем кому-то понадобились графы внутри PostgreSQL.

Граф внутри PostgreSQL — зачем?

Привычная реляционная модель: таблица людей, таблица городов, таблица связей, в последней указано, кто где живёт. Из этого можно выяснить, что Алиса живёт в Нью-Йорке. Теперь представим ту же информацию в виде графа: вершина «Алиса», вершина «Нью-Йорк», ребро «живёт в» между ними. По сути одно и то же, только записанное иначе.

Табличное и графовое представление одних и тех же данных
Табличное и графовое представление одних и тех же данных

Apache AGE расшифровывается как A Graph Extension, позволяет работать с такими графами прямо внутри PostgreSQL. Подключаем расширение, пишем запросы на языке openCypher, открытой версии Cypher из Neo4j, — и получаем результат. Не нужно поднимать отдельную графовую базу, синхронизировать данные, учить команду новому стеку.

Тот факт, что openCypher — это именно открытая версия, а не полная копия Cypher, нам ещё аукнется. Но об этом позже.

Тут мог возникнуть вопрос: раз AGE хранит всё в обычных таблицах PostgreSQL, зачем нужен отдельный язык запросов? Давайте на примере. Задача: найти друзей человека с ID 123, отсортировать по дате дружбы. На Cypher используем паттерн (person)-[knows]->(friend) и описываем что хотим найти. На SQL конструируем JOIN и описываем, как это собрать из таблиц.

На простом запросе разница некритична. Но стоит добавить второй уровень — друзья друзей, и SQL обрастает рекурсивными CTE и множественными JOIN. На Cypher просто меняем [:KNOWS] на [:KNOWS*1..2]. Три символа — и готово. На третьем уровне глубины разница в читаемости примерно как между стихотворением и инструкцией к стиральной машине.

Как устроен Apache AGE изнутри

Подключение, стандартное для расширения:

CREATE EXTENSION age;

LOAD 'age';

SET search_path = ag_catalog, "$user", public;

Создаём граф:

SELECT * FROM ag_catalog.create_graph('social_network');

В этот момент создаётся одноимённая схема и две родительские таблицы: таблица вершин _ag_label_vertex и таблица рёбер _ag_label_edge. Когда в Cypher-запросе появляется тип Person или KNOWS, AGE автоматически создаёт таблицу-наследника. Никаких CREATE TABLE руками.

SELECT * FROM cypher('social_network', $$

    CREATE (:Person {name: 'Alice'})-[:KNOWS {since: ‘2020’}]->(:Person {name: 'Bob'})

$$) AS (result agtype);

Этот запрос создаёт двух людей и связь между ними. Таблицы Person и KNOWS появятся сами.

У каждой вершины — id (тип graphid), тип (неявно, через таблицу-наследника) и поле properties типа agtype. Этот agtype — надмножество JSONB со своим набором операторов. В properties можно положить что угодно: имя, фамилию, дату рождения, IP-адрес, гендер — весь набор атрибутов сущности. У ребра — то же самое, плюс start_id и end_id, то есть ссылки на вершины.

И вот что важно запомнить: поле properties у вершин почти всегда гораздо тяжелее, чем у рёбер. У ребра там обычно одно-два поля, например дата создания или вес. У вершины — десяток атрибутов.

Вся архитектура построена на том, что AGE не изобретает собственное хранилище. Данные лежат в обычных таблицах PostgreSQL, работают MVCC, WAL, планировщик, индексы — всё штатное. Но поверх добавляется слой интерпретации: каждый Cypher-запрос транслируется в SQL, agtype парсится при каждом обращении к свойствам. За удобство приходится платить.

Схема хранения графа в Apache AGE
Схема хранения графа в Apache AGE

Ищем бенчмарк. Спойлер: не находим

Стандартный путь performance-инженера: пришла задача → нашёл бенчмарк → запустил → посмотрел результаты. Мы честно попытались пойти этим путём и споткнулись на втором шаге.

Бенчмарки для графовых баз данных существуют. Самый известный — LDBC SNB. LDBC (Linked Data Benchmark Council) — организация, которая профессионально занимается тестированием графовых СУБД. А SNB (Social Network Benchmark) — их конкретный бенчмарк: продуманная схема данных в виде социальной сети, десятки запросов разного типа, готовые реализации для Neo4j, TigerGraph, GraphDB и даже PostgreSQL.

Вот только реализация для PostgreSQL использует реляционную модель — обычные таблицы с foreign keys, запросы на SQL. А реализация на Cypher написана под Neo4j и использует функции, которых в AGE нет. Другие бенчмарки работают через Gremlin, который AGE тоже не поддерживает.

Мы покопались в GitHub Issues Apache AGE. Там немало запросов от пользователей: «Как вообще оценить производительность?» Разработчики отвечают примерно так: «К сожалению, такой возможности пока нет, но вы можете этим заняться». Ну вот мы и занялись.

Пишем свой бенчмарк

Было два пути: изобретать запросы с нуля или взять существующий стандарт и адаптировать. Мы выбрали второе: взяли схему данных и набор запросов из LDBC SNB и написали собственную реализацию на базе внутренней утилиты pg_microbench, которая работает с AGE напрямую. Получилось девятнадцать тестовых сценариев, разделённых на три группы: короткие чтения, тяжёлые чтения, запись.

Схема графа — социальная сеть. Вершины: Person, Post, Comment, Forum, Tag, TagClass, Place, Organisation. Рёбра — всё, что можно ожидать от социальной сети:

(:Person)-[:KNOWS]->(:Person)

(:Post)-[:HAS_CREATOR]->(:Person)

(:Comment)-[:REPLY_OF]->(:Post)

(:Person)-[:HAS_INTEREST]->(:Tag)

(:Person)-[:IS_LOCATED_IN]->(:Place)

(:Forum)-[:HAS_MEMBER]->(:Person)

Такой граф получается довольно плотным: у каждого пользователя есть несколько друзей, несколько постов, каждый пост содержит теги и комментарии. Это обеспечивает нетривиальную работу при обходах на 2–3 уровня глубины.

Схема графа LDBC SNB
Схема графа LDBC SNB

Данные генерировались с параметром scale factor (SF): persons = SF × 2000, organisations = SF × 200, tags = SF × 2000, forums = persons / 25, posts = persons × 5, comments = posts. Тестировали три размера графа: SF = 1 (~20000 объектов), SF = 10 (~200000) и SF = 100 (~2000000), чтобы не пропустить проблемы при масштабировании.

Индексы — отдельная песня

Раз properties — это agtype, другими словами, JSONB на стероидах, индексирование заслуживает отдельного разговора.

Мы создавали два типа. B-tree на id: и на id вершины/ребра, и на id внутри поля properties:

CREATE INDEX idx_person_id_btree

ON social_network."Person" USING BTREE (

    agtype_access_operator(properties, '"id"'::agtype)

);

Важный нюанс: такой индекс нужен только если в запросах действительно используется WHERE n.id = 'value'. Если нет, индекс избыточен и в некоторых случаях может даже ухудшить производительность.

GIN на properties — для поиска по произвольным свойствам. Если в запросах используется фильтрация вида MATCH (n:Label {prop: value}), создаём GIN-индекс:

CREATE INDEX idx_person_gin

ON social_network."Person" USING gin (properties);

Для рёбер добавлялись B-tree индексы по start_id и end_id.

GIN-индекс оказался жизненно необходим. Мы проверили: убрали GIN — результат в десять раз хуже. Без GIN все запросы по свойствам превращаются в SeqScan. Деградация при этом менее заметна, но только потому, что и так всё медленно. Такая себе оптимизация.

Для точечных запросов, например поиск по ID, B-tree эффективнее. С GIN план для единственной строки выглядит так: Bitmap Index Scan → Bitmap Heap Scan → Recheck Condition. Три шага вместо одного. С B-tree — просто Index Scan. На практике нужны оба типа: B-tree — для точечных запросов, GIN — для поиска по произвольным свойствам.

Тестовый стенд

Виртуалки: 30 ядер, 32 ГБ RAM. Postgres Pro Enterprise 17.5.1, Apache AGE для 17-й версии. Нагрузка — от 1 до 128 параллельных потоков. Метрики стандартные: TPS, планы запросов, flame graph, блокировки, wait events.

Ну что, поехали!

Короткие чтения: тут всё скучно, это хорошо

Первая группа — простые запросы: найти друзей, узнать город пользователя, найти автора поста. Диапазонные поиски, фильтрация, сортировка. Глубоко в граф не ходим — проверяем, как работают индексы и насколько быстро AGE отрабатывает базовые паттерны.

SELECT * FROM cypher('social_network', $$

    MATCH (p:Person {id: 123})-[:IS_LOCATED_IN]->(city:Place)

    RETURN p.firstName, p.lastName, city.id

$$) AS (firstName agtype, lastName agtype, cityId agtype);
Визуализация запроса: Person – IS_LOCATED_IN –> Place
Визуализация запроса: Person – IS_LOCATED_IN –> Place
SELECT * FROM cypher('social_network', $$

    MATCH (post:Post {id: 456})-[:HAS_CREATOR]->(author:Person)

    RETURN author.id, author.firstName, author.lastName

$$) AS (id agtype, firstName agtype, lastName agtype);
Визуализация запроса: Post – HAS_CREATOR –> Person
Визуализация запроса: Post – HAS_CREATOR –> Person

Таких запросов в группе полтора десятка, все разные по содержанию, но одинаковые по характеру: взял вершину, прошёл по одному ребру, вернул результат.

Результат: стабильные ~40000 TPS на 128 потоках, предсказуемое масштабирование при росте размера графа, никаких сюрпризов. Плато нашлось, деградации нет. Рассказывать тут особо нечего, и это, пожалуй, лучший комплимент для системы.

Результаты на коро��ких чтениях
Результаты на коротких чтениях

Что важно запомнить:

  • создавать индексы — обязательно;

  • всегда указывать метку в запросе: MATCH (p:Person {id: 123}), а не MATCH (p {id: 123}). Без метки будут сканироваться все таблицы вершин — здравствуй, SeqScan;

  • начинать с той стороны, где меньше сущностей: людей меньше, чем постов, — значит, начинаем с людей;

  • фильтровать как можно раньше;

  • если что-то не нравится — EXPLAIN ANALYZE и проверять индексы.

Тяжёлые чтения: а вот тут начинается интересное

Вторая группа: обходы в глубину, поиск путей, друзья друзей через несколько рукопожатий. То, ради чего, собственно, и заводят графовую базу.

Например: в каких форумах состоят друзья и друзья наших друзей и сколько постов в каждом?

SELECT * FROM cypher('social_network', $$

    MATCH (p:Person {id: 123})-[:KNOWS*1..2]->(friend:Person)

          <-[:HAS_MEMBER]-(forum:Forum)

    OPTIONAL MATCH (friend)-[:HAS_CREATOR]->(post:Post)

                   <-[:CONTAINER_OF]-(forum)

    RETURN forum.title, count(post) AS postCount

$$) AS (title agtype, postCount agtype);
Визуализация запроса: друзья и друзья друзей в форумах
Визуализация запроса: друзья и друзья друзей в форумах

[:KNOWS*1..2] — глубина: лично знакомые или через одного человека. OPTIONAL MATCH, если постов нет, форум остаётся с нулевым счётчиком. Запрос уже нетривиальный, но AGE с ним справлялся.

А потом мы дошли до поиска кратчайшего пути. И вот тут всё стало по-настоящему интересно.

В Neo4j для этого есть встроенная функция shortestPath() с внутренними оптимизациями. Мы же адаптировали запросы из Neo4j-бенчмарка, так что было бы логично её использовать. Только вот в Apache AGE встроенных функций для работы с путями нет. Вообще нет. Хочешь кратчайший путь — реализуй логику вручную:

SELECT * FROM cypher('social_network', $$

    MATCH path = (p1:Person {id: 123})-[:KNOWS*1..5]-(p2:Person {id: 777})

    RETURN min(length(path))

$$) AS (shortestPath agtype);
Визуализация запроса: поиск кратчайшего пути
Визуализация запроса: поиск кратчайшего пути

Можно искать пути ограниченной глубины (до 5 шагов), можно — однонаправленные и двунаправленные, а можно вообще не указывать глубину. Последнее — плохая идея, и вот почему.

Помните 40000 TPS на коротких чтениях? На поиске кратчайшего пути на маленьком графе мы получили 7 TPS. Не семь тысяч. Семь. 3 000 раз медленнее.

Мы посмотрели план запроса: индексы используются, всё правильно. Пошли в flame graph. Flame graph — это визуализация, где чем шире блок, тем больше времени он съел. И мы увидели, что активно светится функция get_edge_by_entry().

Flame graph запроса: самый широкий блок — get_edge_by_entry()
Flame graph запроса: самый широкий блок — get_edge_by_entry()

Полезли внутрь. По сути, она извлекает запись о ребре из хэш-таблицы и возвращает «найдено» или «не найдено». Функция простая, оптимизировать в ней нечего. Значит, проблема не в самой функции, а в том, сколько раз она вызывается.

Мы посмотрели шире — на то, как вообще реализован обход путей в Apache AGE. И нашли три проблемы, каждая из которых множит работу на ровном месте.

Первая: дорогая работа делается слишком рано. AGE сначала подробно извлекает все данные о каждом ребре: свойства, связанные вершины, и только потом проверяет, нужно ли это ребро вообще. Как если бы в магазине мы сначала изучали состав каждого продукта на полке и только потом смотрели на ценник. Решение: сначала проверяем условия и, только если ребро подходит, достаём данные.

Вторая: ветки алгоритма не делятся результатами. Один шаг обхода уже нашёл ребро, а другой честно ищет его заново. Двойная, тройная работа в зависимости от глубины.

Третья: нет кэширования исследованных путей. Алгоритм снова и снова ходит по одним и тем же маршрутам, даже если уже знает, что они ведут в тупик. Представьте навигатор, который каждые пять минут забывает, что улица перекрыта, и снова предлагает туда свернуть.

А пока что делать?

  • ограничивать глубину поиска — не искать пути бесконечной длины;

  • адаптировать глубину под размер графа: маленький выдержит больше, большой — нет;

  • писать однонаправленные запросы — двунаправленные нагружают систему заметно сильнее;

  • увеличить work_mem хотя бы до 128 МБ вместо стандартных 4 МБ. И осторожнее с OPTIONAL MATCH.

Запросы на запись: детективная история

Третья группа — вставки. Логически мы можем вставлять либо вершину, либо ребро. Казалось бы, какая разница?

Вставка рёбер — например, добавить участника в форум:

SELECT * FROM cypher('social_network', $$

    MATCH (f:Forum {id: 2}), (p:Person {id: 123})

    CREATE (f)-[:HAS_MEMBER {joinDate: '2024-06-15'}]->(p)

$$) AS (result agtype);
Визуализация запроса: добавление участника в форум
Визуализация запроса: добавление участника в форум

Стабильные ~30000 TPS. Хорошее масштабирование. Деградации нет. Едем дальше.

Результат на вставке рёбер
Результат на вставке рёбер

Вставка вершин — создание пользователя с полным набором properties (имя, фамилия, дата рождения, email, браузер, IP — всё полотно атрибутов) плюс последующая привязка рёбрами к организациям, местам, тегам, чтобы человек не болтался в графе сам по себе.

Визуализация запроса: добавление человека и связанных рёбер
Визуализация запроса: добавление человека и связанных рёбер

И вот тут вместо 30000 — ~1 000 TPS. И плавная деградация с течением времени. Чем дольше работает тест, тем хуже. При любом числе потоков.

Результат на вставке вершин
Результат на вставке вершин

Тридцатикратная разница между вставкой ребра и вершины — это не нюанс, это повод разбираться. И мы начали.

Подозреваемый №1: тяжёлые properties. Properties у вершин гораздо объёмнее. Ребро — пара полей. Вершина — десяток атрибутов, всё это agtype, каждая вставка — парсинг, сериализация, обновление индексов. Выглядит как хороший кандидат. Но это объясняет разницу в абсолютных числах (30000 vs 1000), а не деградацию со временем. Деградация — это когда не просто медленно, а всё медленнее и медленнее. Не виноват.

Подозреваемый №2: GIN-индексы. Сами по себе тяжеловесные, с ростом данных обновление дорожает. Может, от этого? Проверяем: убираем GIN. Результат в десять раз хуже. Зато деградация почти не видна :) Но только потому, что и так медленно: без GIN начинаются сплошные SeqScan. Такой себе компромисс. Тоже не виноват.

Подозреваемый №3: WAL и блокировки. В pg_stat_activity видим RowExclusiveLock, wait events на WAL, многообещающе. Отключаем synchronous_commit — не помогает. WAL-блокировки — следствие, а не причина. И этот не виноват.

К этому моменту у нас кончились очевидные гипотезы, и мы пошли в flame graph. И он всё рассказал.

Подозреваемый №4: entityExists(). В начале теста эта функция занимает заметную, но не доминирующую долю времени, к концу теста — почти 90%. Все остальные функции на её фоне отходят на второй план.

Что она делает? При каждой вставке вершины AGE вызывает entityExists(), чтобы проверить, нет ли уже сущности с таким ID. Функция извлекает label ID, находит таблицу по метке из кэша, открывает её и… сканирует. Последовательным перебором. SeqScan. По всей таблице. При каждой вставке.

Каждая новая вершина увеличивает таблицу. Каждый следующий SeqScan чуть дороже. Отсюда плавная, но настойчивая деградация.

Flame graph в начале теста
Flame graph в начале теста
Flame graph в конце теста
Flame graph в конце теста

Когда мы это увидели, было одновременно и облегчение (нашли!), и недоумение (серьёзно, SeqScan?). Решение концептуально простое: заменить SeqScan на индексный доступ. Написали патч.

Flame graph с патчем
Flame graph с патчем

Результат:

  • entityExists на flame graph почти не видна

  • TPS: было 1000 — стало 15000

  • деградация исчезла

TPS на вставке вершин без патча
TPS на вставке вершин без патча
TPS на вставке вершин с патчем
TPS на вставке вершин с патчем

Пятнадцатикратный рост от замены одного SeqScan на индексный доступ. Иногда самые серьёзные проблемы прячутся в самых простых местах.

И ещё важный момент: entityExists() вызывается не только при CREATE. Тот же код работает в MERGE и DELETE вершин. Один патч — и сразу большой пласт операций ускорен. Мы продвигаем его в сообщество Apache AGE: https://github.com/apache/age/pull/2351 

PostgreSQL 18: сюрприз, которого не ждали

Раз уж мы разложили AGE по полочкам на 17-й версии, было бы странно не попробовать на 18-й. Мы прогнали те же сценарии на Postgres Pro Enterprise 18 и PostgreSQL 18.

И обнаружили падение производительности. В среднем — 15–20%. На некоторых коротких запросах с поиском по ID — до 50%. Новая версия оказалась медленнее. Мы перепроверили конфигурацию, параметры — всё совпадало. Проблема воспроизводилась стабильно.

Flame graph показал, что в 18-й версии непропорционально много времени уходит на планирование. Около 33% общего времени съедала функция agtype_contains().

agtype_contains() на 18 версии
agtype_contains() на 18 версии

Раскопали. В Apache AGE для 17-й версии оператор @> для agtype использовал функцию оценки селективности contsel, грубую, но быструю. В версии для 18-й определение поменялось: теперь используется matchingsel. К слову, замена contsel на matchingsel произошла для всех операторов, но стреляет именно на @> — том самом, через который AGE транслирует все запросы поиска по свойствам.

Разница не только в самой функции. Чтобы оценить селективность, matchingsel активно вызывает внутренние функции, включая тяжёлую agtype_contains. Именно она и светилась на flame graph: распаковка agtype-константы из запроса, парсинг JSON-структуры — всё ради более точного прогноза количества возвращаемых строк.

Для сложных аналитических запросов точная оценка — благо: лучше оценка → лучше план. Но для простых точечных операций вроде properties @> '{"id": 1234}' это чистый overhead. Планировщик тратит время на глубокий анализ, хотя план очевиден: достать одну строку по индексу.

Ситуацию усугубляет то, что AGE генерирует custom plan при каждом вызове, а не generic, то есть планирование с дорогой matchingsel происходит заново при каждом запросе. Почему планировщик не кэширует результаты оценки селективности в этом случае — пока открытый вопрос. Мы завели баг-репорт в сообществе Apache AGE и продолжаем исследовать проблему.

Ограничения openCypher, которые нас покусали

Вспомним, что openCypher — открытая версия, а не полная копия. Вот к чему это привело:

  • нет shortestPath() — пришлось реализовывать вручную через паттерн переменной длины и min(length(path));

  • нет объединения типов — конструкция MATCH (m:Post|Comment {id: 123}) не поддерживается, приходится разбивать на два запроса или городить WHERE;

  • ограниченная поддержка WITH — некоторые цепочки WITH работали непредсказуемо, приходилось разбивать сложные запросы;

  • нельзя ссылаться на алиас в ORDER BY — приходится либо дублировать выражение в ORDER BY, либо оборачивать запрос дополнительным WITH.

Каждое ограничение по отдельности — мелочь. Но в сумме адаптация запросов из Neo4j-бенчмарков превращается в довольно кропотливое занятие. Пишешь запрос по документации openCypher, запускаешь, получаешь ошибку, переписываешь. И так несколько раз на каждый нетривиальный сценарий.

Что дальше

Тестирование продолжается. Патч для entityExists() написан — продвигаем в сообщество Apache AGE. Параллельно работаем над тремя проблемами алгоритма обхода путей, которые превращают 20 000 TPS в семь.

Кроме того, остаётся ряд открытых вопросов. Можно ли заставить AGE использовать generic-планы вместо custom и тем самым не вызывать дорогую matchingsel при каждом запросе? Как улучшить статистику по agtype, чтобы планировщик точнее оценивал селективность? Есть ли возможности для оптимизации на уровне хуков, через которые AGE перехватывает и трансформирует запросы? Всё это — темы следующих итераций.

На GitHub пользователи регулярно спрашивают о сравнении с Neo4j. Разработчики AGE отвечают честно: Neo4j быстрее на больших графах и на запросах с поиском путей. Это ожидаемо: Neo4j строился как графовая база данных с нуля, а AGE надстраивается над реляционной. Возможно, после патчей разрыв можно будет сократить.

Как с этим жить: итоги

Давайте подведём черту, что работает, что нет и что делать прямо сейчас.

Тип нагрузки

TPS

Статус

Короткие чтения

~40000

Стабильно

Поиск путей

~7

Патчи в процессе

Вставка рёбер

30000

Стабильно

Вставка вершин (до патча)

1 000 → деградация

Патч отдан в сообщество

Вставка вершин (после патча)

15000

Стабильно

Список основных рекомендаций:

  • создавать индексы B-tree на id и GIN на properties — обязательно;

  • всегда указывать метку в запросе, иначе SeqScan по всем таблицам;

  • начинать запрос с селективной стороны;

  • фильтровать как можно раньше;

  • для поиска путей — фиксировать глубину, предпочитать однонаправленные запросы;

  • увеличивать work_mem до 120+ МБ для тяжёлых обходов;

  • осторожнее с OPTIONAL MATCH;

  • указывать типы меток при записи.

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