
Представьте: пользователь добавляет одного сотрудника в группу где‑то в глубине иерархии — и сидит, смотрит на крутящийся лоадер почти минуту. А запрос «покажи всех участников этой группы» отрабатывает так долго, что проще сходить за кофе. Стоит иерархии стать чуть глубже — база и вовсе падает по тайм‑ауту. Именно так вела себя наша старая схема хранения оргструктуры, когда бизнес пришёл с новыми аппетитами: сотни тысяч человек в одной группе и вложенность втрое больше, чем та, на которую всё проектировалось.
Так выглядела наша точка отсчёта. Речь о Директории — компоненте B2B‑платформы Яндекс 360, который отвечает за жизненный цикл организаций и служит единым источником истины об их оргструктуре для других сервисов: Календаря, Почты, Мессенджера, Диска. Когда вы ставите встречу на целый отдел или отправляете общую рассылку для бухгалтерии, под капотом к Директории прилетает запрос «Дай мне всех пользователей этой группы с учётом всей вложенности». Это наш самый горячий запрос, и старая архитектура с ним перестала справляться.

Привет! Меня зовут Малик, я занимаюсь развитием B2B‑платформы в Яндекс 360. В этой статье я расскажу, зачем нам вообще понадобился граф при хранении оргструктур, почему мы решили засунуть этот граф именно в PostgreSQL и как мы это реализовали. А ещё — как нам удалось выкатить такое масштабное архитектурное изменение в продакшен без даунтаймов и что мы получили в итоге.
Но всё по порядку: сначала — контекст происходящего и то, как устроена наша система.
Анатомия оргструктуры: деревья и ациклические графы
Начнём с определения оргструктуры Яндекс 360. Она состоит из двух сущностей: подразделений и групп.
Подразделение — это контейнер, который позволяет выстраивать строгую древовидную иерархию. Здесь действуют три жёстких правила:
-
У каждой организации есть только одно корневое подразделение.
-
У каждого подразделения, кроме корневого, существует строго один родитель.
-
Каждый сотрудник может числиться строго в одном подразделении.
По сути, это классическое дерево — точно такое же, как структура папок у вас на компьютере.
Группы устроены сложнее. Это контейнеры для выстраивания произвольной иерархии, и здесь правила меняются:
-
Группа может быть вложена в любое количество других групп.
-
Участник (сотрудник или любой ресурс организации) может состоять в неограниченном числе групп.
-
Единственное ограничение: группа не может входить в саму себя, в том числе через транзитивные (косвенные) связи. В структуре не может быть циклов.
Если вернуться к дискретной математике, то наши сущности легко переводятся в общеизвестные структуры данных. Подразделения — это деревья, а группы — это направленные ациклические графы (DAG).
Поскольку направленный ациклический граф — это обобщение дерева, мы можем принять деревья подразделений за подмножество графа. Поэтому дальше мы будем говорить в терминах графов.

Цель групп и подразделений — упрощать жизнь корпоративным пользователям. Вместо того чтобы добавлять коллег поодиночке, пользователь может взаимодействовать сразу с целыми структурами:
-
В Календаре — поставить встречу на команду разработки B2B‑платформы, просто указав её группу.
-
В Почте — отправить общую рассылку для бухгалтерии.
-
В Мессенджере — создать чат со всеми HR‑специалистами.
-
В Диске — выдать права на папку коллегам из соседнего отдела.
Если проанализировать эти сценарии, окажется, что практически все запросы от сервисов Яндекс 360 к Директории сводятся к одной фразе: «Дай мне всех пользователей группы X с учётом всей её вложенности».
Это наш самый горячий и критичный запрос. Специфика системы такова, что читающих операций у нас в сотни, а иногда и в тысячи раз больше, чем пишущих. Оргструктура для нас — это графы и деревья с колоссальным преобладанием операций чтения. Именно скорость обхода графа вглубь — это наш главный приоритет и метрика успеха.
Легаси vs eneterprise
Существующая архитектура групп и подразделений проектировалась достаточно давно под совершенно другие реалии. В старой системе были лимиты: до 10 000 участников в одной группе и до 10 уровней вложенности.
Но Яндекс 360 стал активно развиваться в крупный enterprise‑сегмент. Организации клиентов стали огромными, и рост бизнеса принёс новые требования:
-
Поддержка иерархий глубиной до 20 уровней.
-
Возможность вмещать до 500 000 сотрудников в одну группу.
Казалось бы, можно просто пойти в код и поправить лимиты в конфигурационных файлах. Но упереться пришлось в фундаментальные ограничения прежней архитектуры.
Хранение оргструктуры представляло собой смесь разнородных подходов. В основе прежней схемы лежала концепция «Всё есть ресурс»:
-
Все прямые связи между сущностями сваливались в одну таблицу. Она состояла из разреженных колонок, выделенных под каждый тип сущности.
-
Для быстрого чтения у нас был плоский кеш
user_group_membership— он хранил всех юзеров группы с учётом вложенности. Но в нём были ТОЛЬКО юзеры. При любом изменении состава группы мы синхронно пересчитывали этот кеш для самой группы и всех её родителей вверх по дереву. База пыхтела, а пользователь ждал ответа. -
Для подразделений использовался другой паттерн хранения — материализованные пути.
В итоге для групп и подразделений мы поддерживали два абсолютно разных куска сложной бизнес‑логики, которые делали, по сути, одно и то же. Это приводило к дублированию и пересечению кодовой базы.
Метрики старой схемы, которые совсем не подходили:
-
Добавление пользователя в группу на 10-м уровне вложенности занимало 41 секунду. Из‑за того, что кеши пересчитывались синхронно вверх по графу, обычная вставка человека в группу на 10-м уровне иерархии занимала почти минуту. База не успевала быстрее обновить связи для всех родительских групп.

-
Если глубина иерархии превышала 10 уровней, база данных при синхронном пересчёте гарантированно падала по тайм‑ауту.
-
Получение списка из 5000 участников группы занимало 45 секунд. Из‑за неоптимальных джойнов и полного отсутствия пагинации нам приходилось рекурсивно доставать все подгруппы и на лету склеивать их с кешем пользователей.

Пользователи бесконечно ждали загрузки экрана, а база получала чрезмерную нагрузку. Очевидно, что с новыми потребностями продукта (до 500 000 человек в группе и до 20 уровней вложенности) старая схема даже не смогла бы запуститься. Нам требовалась принципиально новая архитектура для эффективного хранения и обхода графа.
Подводя итог ревизии старого архитектурного наследия, мы сделали неутешительный вывод: прежняя схема с мешаниной из таблиц, синхронным пересчётом кешей и дублированием бизнес‑логики не выдерживала масштабов крупного enterprise‑сегмента. Обычным тюнингом или поднятием лимитов в конфигах обойтись было нельзя — системе требовалось новое хранилище.
В поисках идеального паттерна
Яндекс — компания большая и разнообразная, и за годы её существования внутри было изобретено немало локальных «велосипедов». Поэтому перед тем как садиться за проектирование собственной архитектуры, мы решили изучить, какие вообще в мире и внутри компании существуют паттерны для хранения иерархий в реляционных базах данных.
Опыт Яндекс Карт
Первой командой, к которой мы пришли с расспросами, стали коллеги из Яндекс Карт. На первый взгляд казалось, что они решают максимально похожую задачу. Карты хранят граф дорог, то есть связей между географическими объектами. Как устроена их архитектура?
Сам граф дорог живёт в специализированном графовом хранилище. Коллеги заранее кешируют популярные маршруты. Это позволяет отдавать готовые маршруты пользователям мгновенно, без необходимости каждый раз пересчитывать граф на лету.
Опыт Яндекс Диска
Специфика Диска — хранение огромных деревьев файлов. Они используют простую схему: в каждой записи файла хранится ссылка на родительскую папку.
Этот подход идеален для записи: операция происходит мгновенно. Но у него есть два критичных для нас минуса: он не позволяет быстро выгребать всех детей с учётом вложенности и накладывает жёсткое ограничение — у файла или папки может быть строго один родитель.
Другие паттерны
Тогда мы вышли за рамки Яндекса и проанализировали общепринятые мировые паттерны хранения иерархий в реляционных базах данных. Их оказалось не так много.
Паттерн 1. Adjacency List. Отлично подходит, если у вас дерево, нужна дешёвая запись и в основном нужны прямые дети. Как раз тот вариант, который использует Яндекс Диск: каждая вершина хранит ID своего родителя.

-
Плюсы: моментальная запись. Но этот вариант отлично подходит, если у вас дерево, дешёвая запись и в основном нужны прямые дети.
-
Минусы: нет эффективного способа достать всех потомков без тяжёлых рекурсивных запросов. Поддерживает только деревья: у вершины не может быть больше одного родителя. Нам не подойдёт такой вариант.
Паттерн 2. Materialized Paths. Идеально подходит для read‑heavy‑деревьев, где редко двигают большие поддеревья. В каждой вершине хранится полный текстовый путь от корня до неё самой (например, 1.5.12). В PostgreSQL для этого даже есть специальный тип данных — ltree. Именно этот паттерн мы на тот момент использовали для наших подразделений.

-
Плюсы: очень быстрые листинги в глубину через простые маски.
-
Минусы: при переносе ветки в другое место нужно рекурсивно перестраивать пути для всех её потомков. И главное — это снова история только про деревья, граф с несколькими родителями сюда не уложить.
Паттерн 3. Nested Sets. Паттерн подходит для почти статичных деревьев с очень быстрым чтением поддеревьев.

-
Плюсы: великолепная скорость чтения и листинга поддеревьев.
-
Минусы: любая вставка или перемещение узла требует пересчёта ключей во всей таблице (эффект домино). Опять же, паттерн рассчитан строго на иерархические деревья, а не на направленные графы.
Для графов, где у одного узла может быть много родителей, вариантов в реляционном мире осталось всего два:
Паттерн 4. Bridge Table (или Graph Table). Обычная плоская таблица прямых рёбер.
-
Плюсы: позволяет иметь много родителей у одной вершины, новые рёбра записываются мгновенно.
-
Минусы: невозможно прочитать дерево связей в глубину без рекурсии (в случае с PostgreSQL — без тяжёлых запросов
WITH RECURSIVE). Вариант рабочий, мы его запомнили для тестов.

Паттерн 5. Closure Table. Суть в том, что мы предрассчитываем и храним все транзитивные связи (не только прямых детей, но и внуков, правнуков и так далее) в отдельной таблице.
-
Плюсы: любой листинг с учётом вложенности превращается в моментальный
Index Scanпо таблице связей, никакой рекурсии на чтении. -
Минусы: избыточность данных. Мы платим местом на диске и процессорным временем на пересчёт всех путей при любом изменении графа.

Подытоживаем: классические древовидные паттерны (Adjacency List, Materialized Paths, Nested Sets) отпали сразу — они не умеют работать со структурой групп, где один объект может входить в несколько контейнеров.
В итоге задача свелась к тому, чтобы найти правильный инженерный баланс между дешёвой записью и быстрым чтением на наших enterprise‑объёмах данных.
Битва за листинг
Чтобы принять взвешенное архитектурное решение на основе реальных данных, а не просто принимать решение без замеров, мы построили тестовый стенд и провели серию бенчмарков.
Для симуляции enterprise‑нагрузки мы сгенерировали синтетическую структуру данных:
-
~ 50 000 групп;
-
группы с числом участников от 0 до 500 000;
-
15 уровней вложенности.
В итоге на стенде мы получили 300 миллионов транзитивных связей. Чтобы тесты были честными и приближенными к реальности, во всех экспериментах мы лимитировали вывод до 10 000 элементов и сортировали их по ID.
Эксперимент 1. Проверяем Bridge Table
Запись нового ребра отрабатывает всего за 2 миллисекунды. Листинг прямых связей тоже оказался очень дешёвым — порядка 20 миллисекунд на чтение 10 000 элементов. Но как только мы запустили наш самый горячий запрос — листинг с учётом всей вложенности через запросы WITH RECURSIVE — всё сломалось:
-
На верхних уровнях вложенности база тратит на один листинг около 20 секунд. Для высоконагруженного B2B‑сервиса это непозволительная роскошь.

-
На нижнем уровне вложенности запрос отрабатывает за те же 20 миллисекунд, но исключительно потому, что рекурсии дальше идти некуда — это тупиковые узлы графа.
-
Обход широкого графа в глубину силами СУБД на лету занимает десятки секунд и намертво вешает базу.
Мы окончательно убедились, что наш путь — использовать Closure Table.
Эксперимент 2. Closure Table: ожидание vs реальность
Представьте, что у нас есть два изолированных подграфа. Внутри каждого из них уже предрассчитаны свои внутренние связи.

Теперь мы создаём новую прямую связь — например, между узлом D (из первого подграфа) и узлом E (из второго подграфа).

В этот момент оба подграфа объединяются, и у нас автоматически появляется куча новых транзитивных связей (например, между верхом первого графа A и низом второго графа F).
Чтобы зафиксировать их в Closure Table, применяется следующий алгоритм:
-
Мы достаём список всех предков узла D, включая сам узел D.
-
Мы достаём список всех потомков узла E, включая сам узел E.
-
Мы находим их декартово произведение.

Если в первой выборке у нас оказалось 4 предка, а во второй — 4 потомка, то на выходе мы получаем 16 новых связей. Мы просто берём и пачкой сохраняем их в базу данных.
С удалением рёбер всё устроено сложнее, и здесь кроется главная проблема классического паттерна. Возьмём направленный граф, где из вершины A в вершину E можно добраться двумя путями: левым (через цепочку A → B → C → E) и правым (через цепочку A → D → E).

Что произойдёт, если мы решим разорвать левый путь и удалить связь B → C? Если следовать базовой логике, обратной добавлению, алгоритм будет похожим:
Достаём всех предков узла B → Достаём всех потомков узла C →…

.. → Перемножаем их декартово и получаем список «подозрительных» связей, которые потенциально нужно стереть из базы (в этот список попадёт и связь A → E).

Но просто взять и удалить этот список нельзя. Связь A → E должна остаться в базе, потому что правый маршрут (A → D → E) никуда не делся и вершина E всё еще достижима из A.
Поэтому при удалении ребра Closure Table заставляет нас делать дополнительный шаг: валидацию подозрительных связей. Системе приходится проверять, существуют ли в графе другие альтернативные пути для каждой связи из списка.

В процессе этой проверки алгоритм выясняет, что для связи A → E есть альтернативный маршрут, и помечает её как доверенную. Итоговый шаг: мы берём множество подозрительных связей, вычитаем из него множество доверенных и остаток удаляем из таблицы.
Как мы выяснили, главная разница между всеми существующими реализациями Closure Table кроется исключительно в одном — в механизме разрыва связей. Мы решили протестировать два хорошо описанных подхода, чтобы посмотреть, как они поведут себя на наших объёмах.
Подход 1: классический вариант. Этот алгоритм отлично изучен и описан в профильной литературе ещё в конце 90-х. Его логика выглядит следующим образом:
-
Мы вычисляем множество подозрительных рёбер, которые нужно удалить.
-
Поднимаем из базы весь граф.
-
Вычитаем из него подозрительные рёбра.
-
Декартово умножаем очищенный граф сам на себя, чтобы получить полный список доверенных связей.
-
Сохраняем доверенные связи, а всё остальное подчищаем.
На нашем тестовом стенде этот алгоритм означал необходимость выгрузить около 300 миллионов транзитивных связей в оперативную память. База данных мгновенно ушла в себя на десятки минут, и мы не стали дожидаться окончания бенчмарка — этот вариант очевидно не подходит в нашем продакшене.
Подход 2: альтернативный вариант. Он взят из другой научной статьи и построен на концепции сохранения ссылок на рёбра, которые породили транзитивную связь.
В каждой строке транзитивного ребра мы дополнительно храним метаданные — ссылки на три ребра, которые породили его напрямую или косвенно. Например, когда прямая связь A → B рождает транзитивную связь A → C, в служебные колонки для строки A‑C записывается ID родительского ребра. При удалении нам больше не нужно выгружать в память весь граф: достаточно рекурсивно найти по ID все зависимые строки и снести их.
Мы замерили производительность этого алгоритма.
-
Скорость записи: мы увидели предсказуемый линейный рост времени вставки в зависимости от глубины. На максимальном целевом уровне вложенности (15 уровней) запись занимала менее 100 миллисекунд. Это отличный результат.
-
Скорость чтения: на первых уровнях графа листинг отрабатывал всего за 25–30 миллисекунд, а по мере углубления время падало.

Мы уже были готовы раскатывать этот вариант в код, но вовремя обнаружили одну деталь: из‑за того, что одна вершина в графе может быть достижима несколькими путями, в таблице транзитивных связей плодились дубли. Мы не могли отдавать клиентам нашего API дублирующихся пользователей в группе и решили исправить это, навесив на запросы чтения DISTINCT.
Но тут график производительности чтения превратился в кошмар: листинги деградировали с 25 миллисекунд до 500 миллисекунд. Резко выросло потребление CPU и памяти. Дело в специфике работы PostgreSQL: чтобы выполнить DISTINCT и отфильтровать дубликаты, PostgreSQL часто вынужден материализовать большой промежуточный результат и дедуплицировать его до применения пагинации из базы, отсортировывать этот огромный массив в памяти и только потом выкидывать повторяющиеся строки. Разумеется, ни о какой эффективной LIMIT/OFFSET пагинации в таком режиме не могло быть и речи. Этот вариант нам тоже не подошёл.
Итак, классические варианты Closure Table и академические оптимизации либо отлично работают на запись, но проваливают удаление рёбер, либо эффективно обрабатывают удаление, но плодят дубликаты, которые намертво вешают чтение через DISTINCT. Мы поняли, что нужно придумывать собственное архитектурное решение. Нам требовалось убрать дубликаты на уровне схемы данных, но при этом сохранить возможность легко перестраивать граф при удалении рёбер.
Closure Table на стероидах
Мы остановились и задали себе вопрос: а нужны ли нам вообще сами пути, которые мы так упорно пытаемся сохранить в метаданных? У Директории нет задачи построить кратчайший маршрут от условной бухгалтерии до сотрудницы Людмилы. Нам нужно просто быстро отвечать на вопрос, входит Людмила в эту бухгалтерию или нет.
Тогда мы придумали следующее решение: вместо того чтобы сохранять сами пути, мы начали хранить их количество — по сути, счётчик путей Reference Counter.
Вместо пяти одинаковых строк с одним и тем же предком и потомком, мы теперь храним в Closure Table всего одну строку, где в специальной колонке path_count записано число 5.

Разберём механику работы счётчиков на примере. Нам понадобятся три вершины: A, B и C. По требованию нашего алгоритма каждая вершина изначально имеет ссылку на саму себя (рёбра AA, BB, CC) со значением счётчика 1. Самосвязи позволяют одному SQL‑выражению одинаково обрабатывать прямые и транзитивные случаи.

Применяем алгоритм декартова произведения:
-
Достаём всех предков узла A — это только A.
-
Достаём всех потомков узла B — это только B.
-
Перемножаем их декартово произведение и получаем пару AB.
Поскольку записи AB в таблице ещё не существовало, мы сохраняем её со значением counter = 1.
-
Предки узла B — это вершины A и B.
-
Потомки узла C — это вершина C.
-
Перемножаем их декартово произведение и получаем две связи: BC (прямую) и AC (новую транзитивную).
Ни одной из них в таблице не было, поэтому обе сохраняются со счётчиком 1.

Допустим, бизнес‑логика создаёт ещё одно, прямое ребро между A и C.
-
Предки узла A — это A.
-
Потомки узла C — это C.
-
Декартово произведение даёт пару AC.
Но запись AC уже присутствует в нашей таблице, и вместо вставки новой строки мы просто берём существующую и инкрементируем её счётчик. Теперь для связи AC значение counter = 2. Дубликатов в базе нет, строка осталась одна.

Итоговое состояние таблицы: связи AB(1), BC(1) и AC(2).
-
Удаляем ребро В → С: снова берём предков B (A, B) и потомков C ©. Декартово произведение выдаёт пары BC и AC. Для обеих строк мы уменьшаем счётчик на единицу. В строке BC счётчик стал равен 0 — мы физически дропаем эту запись из базы. В строке AC счётчик уменьшился с 2 до 1. Запись остаётся, так как альтернативный прямой путь A → C всё ещё существует.

-
Удаляем ребро А → В: предки A (A), потомки B (B). Находим пару AB, уменьшаем счётчик до 0 и удаляем строку из базы.

-
Удаляем прямое ребро А → С: предки A (A), потомки C ©. Находим пару AC, счётчик падает до 0, запись удаляется. Граф полностью очищен.

Результаты замеров на нашем тестовом стенде:
-
Время пишущих операций по‑прежнему растёт линейно, но оно оказалось в два раза меньше, чем у предыдущей схемы из научной статьи. Мы пишем меньше колонок и контролируем количество строк, чтобы не раздувать таблицу. На максимальном, 15-м уровне вложенности запись занимает всего порядка 40 миллисекунд.

-
Время чтения — стабильные 20–25 миллисекунд на листинг, и, что самое главное, полное отсутствие зависимости от уровня вложенности.

Проблема жирных рёбер
Мы решили смоделировать ситуацию, в которой наш алгоритм сломается: у нас есть две независимые иерархии групп глубиной по 10 уровней. В каждую группу мы добавили по 100 000 сотрудников. А теперь проведём эксперимент — вложим 11-ю группу первой иерархии в 5-ю группу второй.
В этот момент одна операция клиента заставляет алгоритм выполнить декартово произведение, которое порождает 5 миллионов транзитивных связей. Прямой синхронный INSERT такого массива строк занимает в PostgreSQL около 13 секунд.
Если же мы попробуем вложить 11-ю группу в самый низ — в 10-ю группу соседней ветки, декартово произведение сгенерирует уже 10 миллионов транзитивных связей. Синхронная запись этой пачки длится 26 секунд. Разорвать такую связь стоит ещё дороже: удаление 10 миллионов строк вешает базу почти на 50 секунд.

Заставлять пользователя ждать минуту и крутить лоадер на экране при сохранении настроек — неприемлемо для пользовательского сценария.
Чтобы обойти это ограничение, мы решили разделить архитектуру хранения на два слоя:
-
Все прямые изменения графа записываются в классическую Bridge Table.
-
Пересчёт транзитивных связей улетает в нашу фоновую очередь.
Задачи в очереди обрабатываются фоновыми воркерами строго последовательно в рамках одной организации. Для пользователя изменение применяется сразу: прямая связь сохраняется синхронно, поэтому интерфейс может быстро показать результат. А тяжёлый пересчёт транзитивных связей догоняет в фоне. Зависимые сервисы получают уже обновлённый граф спустя небольшое время — это осознанная eventual consistency, которая позволила убрать долгие ожидания из пользовательского сценария.
Архитектурный каркас был готов: хранение счётчиков вместо полных путей избавило нас от дубликатов, а вынос пересчёта «тяжёлых» рёбер в асинхронный фон позволил совместить моментальное чтение с эффективной записью. Оставалось самое сложное — раскатить эти изменения в продакшен на живых пользователей.
Как мы ехали в прод
Мы действовали максимально осторожно и разбили процесс миграции на пять шагов:
-
Реализовали логику работы с новым графом в коде и включили одновременную запись и в легаси‑структуру, и в новую схему.
-
Запустили фоновую задачу, которая непрерывно сверяла старый и новый графы. Это позволило нам найти расхождения и отловить баги в сценариях пересчёта транзитивных связей.
-
Когда фоновый аудит показал, что графы стали на 100% идентичными, мы начали постепенный ролл‑аут операций чтения на новую схему, плавно увеличивая процент пользователей.
-
Отключение старых транзитивных связей: достигнув 100% на чтении, мы полностью погасили запись в старый плоский кеш транзитивных связей. Нагрузка на базу данных ощутимо снизилась.
-
Полный демонтаж легаси: последним шагом мы полностью отключили запись в старую схему (включая таблицу прямых связей) и удалили старую бизнес‑логику из кодовой базы.
Если посмотреть на графики времени ответов базы данных в процессе миграции, то на них чётко видны четыре поворотные точки:

-
Время ответа практически не изменилось. В легаси‑схеме всё работало настолько неоптимально, что оверхед от ещё одной пишущей операции пользователи не заметили.
-
График времени чтения резко пошёл вниз. Сработал индексный поиск по предрассчитанным Closure‑таблицам.
-
График общей нагрузки на СУБД значимо просел. Базе больше не нужно было синхронно пересчитывать цепочки родителей вверх по иерархии.
-
Линия времени операций записи превратилась в практически идеальную прямую.
Почему не графовая база
Скорее всего, у вас возник вопрос, почему мы просто не выбрали графовую базу, и ответ на него одновременно простой и сложный.
У Директории никогда не было задачи ходить по графу произвольными маршрутами. Нам не нужно искать кратчайшие пути между узлами, выявлять сложные обходы. Нам нужно мгновенно отдать страницу пользователей, входящих в конкретную группу, а главное — выдать их массивные метаданные (большие JSON‑документы с именами, настройками, email‑адресами, правами и статусами).
Для нас графовая часть — это способ быстро получить плоский набор ID сотрудников. Вся основная масса данных и бизнес‑логики всё равно живёт в реляционной СУБД.
Если бы мы вынесли рёбра графа в условную Neo4j или другую графовую базу, мы бы гарантированно получили классический распределённый запрос со всеми его минусами:
-
Сначала мы идём в графовую базу и вытаскиваем оттуда список ID.
-
Затем идём в PostgreSQL за метаданными этих пользователей.
-
На стороне приложения склеиваем эти данные, сортируем и накладываем пагинацию.
-
Дополнительно пишем сложную логику синхронизации для поддержания консистентности между двумя разнородными хранилищами.
Вместо упрощения системы мы бы получили лишний сетевой хоп, распределённый тайм‑аут, новую операционную нагрузку на эксплуатацию и вечную головную боль с рассинхронизацией данных. При этом наш самый горячий запрос всё равно упирался бы в последовательный опрос двух баз.
Мы пошли по другому пути: оставили все данные и транзакции в одном месте, а графовую часть превратили в предрассчитанный индекс достижимости внутри PostgreSQL. Это позволило нам забрать всю пользу графового подхода для конкретно нашего B2B‑сценария, но полностью избежать проблем распределённой консистентности и не добавлять вторую базу данных в критический путь запросов.
-- Добавление ребра
CREATE FUNCTION add_count_edge(p_from BIGINT, p_to BIGINT) RETURNS VOID AS $$
BEGIN
-- Самосвязи (если вдруг нет)
INSERT INTO transitive_relationships (source_node_id, target_node_id, path_count)
VALUES (p_from, p_from, 1),
(p_to, p_to, 1) ON CONFLICT DO NOTHING;
-- Декартово произведение предков × потомков
INSERT INTO transitive_relationships (source_node_id, target_node_id, path_count)
SELECT p1.source_node_id,
p2.target_node_id,
p1.path_count * p2.path_count
FROM transitive_relationships p1
JOIN transitive_relationships p2
ON p1.target_node_id = p_from
AND p2.source_node_id = p_to ON CONFLICT (source_node_id, target_node_id)
DO
UPDATE SET path_count =
transitive_relationships.path_count + EXCLUDED.path_count;
END;
$$ LANGUAGE PLPGSQL;
-- Удаление ребра
CREATE FUNCTION remove_count_edge(p_from BIGINT, p_to BIGINT) RETURNS VOID AS $$
BEGIN
UPDATE transitive_relationships tr
SET path_count = path_count - suspect.delta FROM (
SELECT p1.source_node_id, p2.target_node_id,
p1.path_count * p2.path_count AS delta
FROM transitive_relationships p1
JOIN transitive_relationships p2
ON p1.target_node_id = p_from
AND p2.source_node_id = p_to
) AS suspect
WHERE (tr.source_node_id
, tr.target_node_id) =
(suspect.source_node_id
, suspect.target_node_id);
DELETE
FROM transitive_relationships
WHERE path_count <= 0;
END;
$$ LANGUAGE PLPGSQL;
И самое приятное: вся эта история в итоге сводится к довольно компактной идее. При добавлении ребра мы берём всех предков левой вершины, всех потомков правой вершины, перемножаем их и увеличиваем счётчики достижимости. При удалении делаем то же самое в обратную сторону: уменьшаем счётчики, а строки с нулём удаляем. Основная идея укладывается примерно в 40 строк SQL.
Вместо заключения
Главный вывод, который мы вынесли из этой истории: если в вашей задаче внезапно появляется граф или любая другая сложная структура данных, это ещё не повод тратить ресурсы команды на внедрение и поддержку специализированной графовой базы.
Выбирайте хранилище не по форме данных, а по форме запросов:
-
Какие именно данные вам нужны в критическом пути?
-
В каком виде их необходимо отдавать клиентам?
-
Каково реальное соотношение операций чтения и записи?
В нашем случае ответ оказался на стыке алгоритмов и продуктовой специфики. Нам не требовался универсальный графовый движок для глубокого анализа связей, а был нужен быстрый транзитивный индекс достижимости, развёрнутый поверх бизнес‑данных, которые и так уже жили в реляционной базе.
Мы спроектировали этот индекс внутри PostgreSQL через паттерн Closure Table со счётчиками ссылок (Reference Counters), а тяжёлые операции перестройки графа изолировали в асинхронном фоне. В результате мы получили систему, которая выдерживает целевые enterprise‑нагрузки и отдаёт данные за десятки миллисекунд.
|
Операция |
Старая схема |
Новая схема |
|
Листинг состава группы |
45 с |
< 50 мс |
|
Время вставки пользователя в группу |
41 с |
~ 40 мс |
А как вы подошли бы к решению такой задачи? Пишите в комментариях, обсудим!
Автор: malik89303


