惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Project Zero
Project Zero
F
Fortinet All Blogs
博客园 - 叶小钗
阮一峰的网络日志
阮一峰的网络日志
月光博客
月光博客
爱范儿
爱范儿
博客园_首页
S
Security @ Cisco Blogs
H
Heimdal Security Blog
Cyberwarzone
Cyberwarzone
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
Tailwind CSS Blog
T
Threatpost
博客园 - 司徒正美
The GitHub Blog
The GitHub Blog
WordPress大学
WordPress大学
S
Security Affairs
AWS News Blog
AWS News Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Cisco Talos Blog
Cisco Talos Blog
博客园 - 聂微东
P
Privacy & Cybersecurity Law Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
NISL@THU
NISL@THU
G
GRAHAM CLULEY
人人都是产品经理
人人都是产品经理
L
LangChain Blog
The Register - Security
The Register - Security
L
LINUX DO - 最新话题
博客园 - 【当耐特】
P
Proofpoint News Feed
Schneier on Security
Schneier on Security
Application and Cybersecurity Blog
Application and Cybersecurity Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
P
Proofpoint News Feed
IT之家
IT之家
云风的 BLOG
云风的 BLOG
量子位
D
Darknet – Hacking Tools, Hacker News & Cyber Security
PCI Perspectives
PCI Perspectives
腾讯CDC
大猫的无限游戏
大猫的无限游戏
美团技术团队
N
News | PayPal Newsroom
Recent Commits to openclaw:main
Recent Commits to openclaw:main
D
Docker
A
Arctic Wolf
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Cloudbric
Cloudbric

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как мы научили реляционую базу хранить оргструктуру в виде графа на 500к пользователей
Малик Минубаев · 2026-06-23 · via Все публикации подряд на Хабре

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

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

Директория — источник истины об организациях и их структуре для всей экосистемы Яндекс 360

Директория — источник истины об организациях и их структуре для всей экосистемы Яндекс 360

Привет! Меня зовут Малик, я занимаюсь развитием B2B‑платформы в Яндекс 360. В этой статье я расскажу, зачем нам вообще понадобился граф при хранении оргструктур, почему мы решили засунуть этот граф именно в PostgreSQL и как мы это реализовали. А ещё — как нам удалось выкатить такое масштабное архитектурное изменение в продакшен без даунтаймов и что мы получили в итоге.

Но всё по порядку: сначала — контекст происходящего и то, как устроена наша система.


Анатомия оргструктуры: деревья и ациклические графы

Начнём с определения оргструктуры Яндекс 360. Она состоит из двух сущностей: подразделений и групп.

Подразделение — это контейнер, который позволяет выстраивать строгую древовидную иерархию. Здесь действуют три жёстких правила:

  • У каждой организации есть только одно корневое подразделение.

  • У каждого подразделения, кроме корневого, существует строго один родитель.

  • Каждый сотрудник может числиться строго в одном подразделении.

По сути, это классическое дерево — точно такое же, как структура папок у вас на компьютере.

Пример подразделений ООО «Ромашка»

Пример подразделений ООО «Ромашка»

Группы устроены сложнее. Это контейнеры для выстраивания произвольной иерархии, и здесь правила меняются:

  • Группа может быть вложена в любое количество других групп.

  • Участник (сотрудник или любой ресурс организации) может состоять в неограниченном числе групп.

  • Единственное ограничение: группа не может входить в саму себя, в том числе через транзитивные (косвенные) связи. В структуре не может быть циклов.

Пример групп ООО «Ромашка»

Пример групп ООО «Ромашка»

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

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

Цель групп и подразделений — упрощать жизнь корпоративным пользователям. Вместо того чтобы добавлять коллег поодиночке, пользователь может взаимодействовать сразу с целыми структурами:

  • В Календаре — поставить встречу на команду разработки B2B‑платформы, просто указав её группу.

  • В Почте — отправить общую рассылку для бухгалтерии.

  • В Мессенджере — создать чат со всеми HR‑специалистами.

  • В Диске — выдать права на папку коллегам из соседнего отдела.

Если проанализировать эти сценарии, окажется, что практически все запросы от сервисов Яндекс 360 к Директории сводятся к одной фразе: «Дай мне всех пользователей группы X с учётом всей её вложенности».

Это наш самый горячий и критичный запрос. Специфика системы такова, что читающих операций у нас в сотни, а иногда и в тысячи раз больше, чем пишущих. Оргструктура для нас — это графы и деревья с колоссальным преобладанием операций чтения. Именно скорость обхода графа вглубь — это наш главный приоритет и метрика успеха.

Легаси vs eneterprise

Существующая архитектура групп и подразделений проектировалась достаточно давно под совершенно другие реалии. В старой системе были лимиты: до 10 000 участников в одной группе и до 10 уровней вложенности.

Но Яндекс 360 стал активно развиваться в крупный enterprise‑сегмент. Организации клиентов стали огромными, и рост бизнеса принёс новые требования:

  • Поддержка иерархий глубиной до 20 уровней.

  • Возможность вмещать до 500 000 сотрудников в одну группу.

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

Хранение оргструктуры представляло собой смесь разнородных подходов. В основе прежней схемы лежала концепция «Всё есть ресурс»:

  1. Все прямые связи между сущностями сваливались в одну таблицу. Она состояла из разреженных колонок, выделенных под каждый тип сущности.

  2. Для быстрого чтения у нас был плоский кеш user_group_membership — он хранил всех юзеров группы с учётом вложенности. Но в нём были ТОЛЬКО юзеры. При любом изменении состава группы мы синхронно пересчитывали этот кеш для самой группы и всех её родителей вверх по дереву. База пыхтела, а пользователь ждал ответа.

  3. Для подразделений использовался другой паттерн хранения — материализованные пути.

Старая схема оргструктуры

Старая схема оргструктуры

В итоге для групп и подразделений мы поддерживали два абсолютно разных куска сложной бизнес‑логики, которые делали, по сути, одно и то же. Это приводило к дублированию и пересечению кодовой базы.

Метрики старой схемы, которые совсем не подходили:

  • Добавление пользователя в группу на 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, применяется следующий алгоритм:

  1. Мы достаём список всех предков узла D, включая сам узел D.

  2. Мы достаём список всех потомков узла E, включая сам узел E.

  3. Мы находим их декартово произведение.

Если в первой выборке у нас оказалось 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-х. Его логика выглядит следующим образом:

  1. Мы вычисляем множество подозрительных рёбер, которые нужно удалить.

  2. Поднимаем из базы весь граф.

  3. Вычитаем из него подозрительные рёбра.

  4. Декартово умножаем очищенный граф сам на себя, чтобы получить полный список доверенных связей.

  5. Сохраняем доверенные связи, а всё остальное подчищаем.

На нашем тестовом стенде этот алгоритм означал необходимость выгрузить около 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 секунд.

Заставлять пользователя ждать минуту и крутить лоадер на экране при сохранении настроек — неприемлемо для пользовательского сценария.

Чтобы обойти это ограничение, мы решили разделить архитектуру хранения на два слоя:

  1. Все прямые изменения графа записываются в классическую Bridge Table.

  2. Пересчёт транзитивных связей улетает в нашу фоновую очередь. 

Задачи в очереди обрабатываются фоновыми воркерами строго последовательно в рамках одной организации. Для пользователя изменение применяется сразу: прямая связь сохраняется синхронно, поэтому интерфейс может быстро показать результат. А тяжёлый пересчёт транзитивных связей догоняет в фоне. Зависимые сервисы получают уже обновлённый граф спустя небольшое время — это осознанная eventual consistency, которая позволила убрать долгие ожидания из пользовательского сценария.

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

Как мы ехали в прод

Мы действовали максимально осторожно и разбили процесс миграции на пять шагов:

  1. Реализовали логику работы с новым графом в коде и включили одновременную запись и в легаси‑структуру, и в новую схему.

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

  3. Когда фоновый аудит показал, что графы стали на 100% идентичными, мы начали постепенный ролл‑аут операций чтения на новую схему, плавно увеличивая процент пользователей.

  4. Отключение старых транзитивных связей: достигнув 100% на чтении, мы полностью погасили запись в старый плоский кеш транзитивных связей. Нагрузка на базу данных ощутимо снизилась.

  5. Полный демонтаж легаси: последним шагом мы полностью отключили запись в старую схему (включая таблицу прямых связей) и удалили старую бизнес‑логику из кодовой базы.

Если посмотреть на графики времени ответов базы данных в процессе миграции, то на них чётко видны четыре поворотные точки:

  1. Время ответа практически не изменилось. В легаси‑схеме всё работало настолько неоптимально, что оверхед от ещё одной пишущей операции пользователи не заметили.

  2. График времени чтения резко пошёл вниз. Сработал индексный поиск по предрассчитанным Closure‑таблицам.

  3. График общей нагрузки на СУБД значимо просел. Базе больше не нужно было синхронно пересчитывать цепочки родителей вверх по иерархии.

  4. Линия времени операций записи превратилась в практически идеальную прямую.

Почему не графовая база

Скорее всего, у вас возник вопрос, почему мы просто не выбрали графовую базу, и ответ на него одновременно простой и сложный.

У Директории никогда не было задачи ходить по графу произвольными маршрутами. Нам не нужно искать кратчайшие пути между узлами, выявлять сложные обходы. Нам нужно мгновенно отдать страницу пользователей, входящих в конкретную группу, а главное — выдать их массивные метаданные (большие JSON‑документы с именами, настройками, email‑адресами, правами и статусами).

Для нас графовая часть — это способ быстро получить плоский набор ID сотрудников. Вся основная масса данных и бизнес‑логики всё равно живёт в реляционной СУБД.

Если бы мы вынесли рёбра графа в условную Neo4j или другую графовую базу, мы бы гарантированно получили классический распределённый запрос со всеми его минусами:

  1. Сначала мы идём в графовую базу и вытаскиваем оттуда список ID.

  2. Затем идём в PostgreSQL за метаданными этих пользователей.

  3. На стороне приложения склеиваем эти данные, сортируем и накладываем пагинацию.

  4. Дополнительно пишем сложную логику синхронизации для поддержания консистентности между двумя разнородными хранилищами.

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

Мы пошли по другому пути: оставили все данные и транзакции в одном месте, а графовую часть превратили в предрассчитанный индекс достижимости внутри 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 мс

А как вы подошли бы к решению такой задачи? Пишите в комментариях, обсудим!