Ссылка на статью в моем блоге

Данные в виде графа. Как их хранить? Как с ними работать?

Периодически возникают задачи и проекты, где данные имеют структуру графа. Например, данные о компьютерных сетях, где узлы связаны между собой. А на узлах зарегистрированы пользователи и они связаны или не связаны между собой. Еще пример — результат работы какого-то web crawler, который собирает данные из сети и хранит ссылки страниц между собой. Социальные сети. Список друзей, у которых тоже есть друзья — граф. Населенные пункты и дороги между ними — еще один пример графа.

Чтобы нам было удобнее давайте определимся с решаемой задачей. Допустим мы делаем систему администрирования некоторого сетевого ПО. Это ПО установлено на всех узлах сети. И из центра администрирования оно получает конфигурацию своей работы. Сети на практике большие, несколько администраторов одновременно могут включать и отключать связи между узлами. Либо напрямую, либо неявно через какие-то косвенные настройки. Наша задача обнаружить несвязанные сегменты в получившейся сети и не сохранить такую конфигурацию. Потому что плохая конфигурация приведет к неработоспособности.

На схеме выше видно, что из любого узла сети есть как минимум один путь. Это хорошо.

А вот на этой схеме дело плохо. Подсеть из узлов 7,5,6 — не доступна для другой части сети. И такую конфигурацию отправлять нельзя.

Когда мы познакомились с задачей давайте попробуем порассуждать о способе хранения информации о сети. Очевидный формат — это использование реляционного хранилища. В нем можно завести таблицу с описанием узлов сети (Идентификатор, Название узла, Время включения в сеть, Название операционной системы и т.п.). Но нам более интересно как хранить связи этих узлов. Можно создать таблицу со списком связей node_to_node (пример для рисунка выше):

node1_id

node2_id

additional_properties

1

2

{latency: 102}

2

4

{latency: 65}

2

7

{latency: 81}

3

4

{latency: 325}

3

8

{latency: 12}

4

6

{latency: 281}

5

6

{latency: 347}

5

7

{latency: 110}

5

8

{latency: 54}

Здесь все просто — идентификатор первого узла, идентификатор второго узла и какие-то свойства связи (скорость задержки от узла к узлу). Естественно оба столбца нужно проиндексировать. Идентификатор выберем типа int. Нужно определиться представляем ли мы связь одной записью или двумя. В случае если мы храним одну запись код проверки будет несколько сложнее:

select 1 from node_to_node where node1_id = 1 and node2_id = 2
union all
select 1 from node_to_node where node1_id = 2 and node2_id = 1

Если же хранится и перевернутая запись — объем данных в два раза больше, но код проверки уменьшается:

select 1 from node_to_node where node1_id = 1 and node2_id = 2

Еще можно внести условие, что записи хранятся в виде node1_id < node2_id. Но это только если у вас численные идентификаторы. Но стоит ли об этом задумываться? Выглядит как мелочь. И так и так можно…

Сюрприз

PostgreSQL эффективен в большинстве сценариев. Применительно к нашей задаче все сильно зависит от объема сети. Если у нас есть сеть из 65536 узлов и каждый из них связан с каждым мы получаем 4294967296 связей. Нам нужно 64Гб на то, чтобы сохранить все связи. Либо даже 128Гб для случая если мы храним еще и перевернутую запись (см. выше). Причем таблица связей будет в центре бизнес-логики. Время выполнения проверки имеет большое значение. Разница между 2 мс и 300 мс в конечном счете будет накапливаться и вы получите откровенно не работоспособные сценарии. Мало кто согласиться ждать 2 дня для проверки целостности сети.

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

Тут вполне можно обойтись 512 Мб памяти и это будет быстрее. Но и тут проблемы. Код такого движка надо писать. При этом нужно учитывать возможность многопользовательского режима. Транзакционности и отказоустойчивости.

Вероятно сейчас уже появились такие решения. Несколько лет назад мне их найти не удалось. Напишите в комментариях что вы рекомендуете.

Существует целый класс графовых баз данных Graph Databases. Самая популярная из которых Neo4j. Но и тут решения проблем производительности нет. Зато есть очень выразительный язык запросов, который сильно упрощает их написание. Да и читабельность кода повышается.

Идея решения

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

Пусть у нас есть вот такая сеть:

Здесь мы видим, что есть два ряда узлов, где каждый из узлов ряда связан с узлами из другого ряда. Всего в такой сети 35 связей.

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

На рисунке ниже можно видеть результат. Общее число связей уменьшилось до 20. И плюс один узел “виртуальный узел”.

Понятно, что для сетей такой размерности нет никакого смысла экономить 15 связей из 35. Но чем больше в такой сети подобного рода “сгустков связей”, тем больше экономия. Для нашего экстремального случая из примера выше, где 65536 узлов связаны все со всеми, мы вместо 64Гб для хранения такой сети нужно всего лишь 1 Мб. Все операции будут “летать”. :)

Замеры производительности

Давайте посмотрим как будет меняться скорость для сжатых и не сжатых сетей. Для примера возьмем сеть из рисунка выше (с двумя рядами узлов). И будем искать кратчайший путь из начального узла в конечный. Для этого можно использовать следующий код:

WITH RECURSIVE search_graph(      
  node1,   
  node2,   
  depth,
  path) AS (      
    SELECT    
    g.node1,   
    g.node2,   
    1 as depth,
    ARRAY[g.node1] as path
    FROM node_to_node AS g       
    WHERE node1 = 30 -- старт
    UNION ALL      
    SELECT  
      g.node1,
      g.node2,
      sg.depth + 1 as depth,
      path || g.node1 as path
    FROM node_to_node AS g, search_graph AS sg
    WHERE g.node1 = sg.node2
        AND (g.node1 <> ALL(sg.path))
  )      

SELECT * FROM search_graphwhere node2 = 1003 -- финиш
limit 1;

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

Узлов в первом ряду

Узлов во втором ряду

Общее число связей

Связей после сжатия

Время до сжатия

Время после сжатия

1000

1000

1002000

4000

300 мс

50 мс

100000

100000

10000020000

400000

16 мин

55 мс

Из таблицы видно насколько внушительно уменьшается число связей. По сжатому графу PostgreSQL работать в разы легче.

Но это все искусственный тест. Хотелось бы посмотреть что происходит с реальными данными. В сети полно открытых источников Laboratory for Web Algorithmics. Давайте возьмем наименьший датасет cnr-2000 (Italian Consiglio Nazionale delle Ricerche domain). В нем 3216152 связей. А в результате сжатия у меня вышло 1612981 связей и 100 виртуальных узлов. То есть ровно в два раза, точнее 1,9939 :). На скорости это сказалось аналогичным образом. Если на не сжатой сети мы получали время выполнения запроса 350 мс, то на сжатой он выполнялся уже за 150 мс.

Как реализовать такой алгоритм сжатия?

Мы убедились, что можно получить прирост скорости. Дело осталось за малым — реализовать такое сжатие. Сам алгоритм подробно описан в статье Gregory Buehrer, Kumar Chellapilla. A Scalable Pattern Mining Approach to Web Graph Compression with Communities. Она легко ищется в интернете.

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

Выводы

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

Комментарии (22)


  1. kawena54
    06.02.2022 22:03
    +1

    просто скажите мне почему все рекомендуют использовать граф БД для соц сетей? в 99% задач , например найти пересечение друг друзей друга это пересечение SET'OV


    1. Spinifex Автор
      07.02.2022 10:18

      Не знаю почему. :) Согласен, что к выбору нужно подходить вдумчиво и никому не подражать. У графовых БД часто очень выразительный и удобный синтаксис написания запросов. Вместо 8 строчек с джойнами вы можете получить 1 строчку как аналог. Но средства диагностики, администрирования по своему развитию уступают реляционным. Поэтому поступаем как всегда — анализируем требования, в том числе не функциональные. И выбираем то, что лучше всего подходит для нашего проекта.


  1. Hashbash
    06.02.2022 22:18
    +1

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

    WHERE g.node1 = sg.node2
    AND not (g.node1 = ANY(sg.path)) -- тоже самое, но останавливаемся на первом вхождении
    AND ((sg.depth = 1) 
         or (sg.depth > 2 and sg.node1 > g.node1)
        ) -- исключаем логические повторы
          -- актуально если 30 2 3 1003 и 30 3 2 1003 - одно и тоже
          -- оставляем только одну последовательность (30 2 3 1003)


    1. Spinifex Автор
      07.02.2022 10:18

      Лайк! Полезное дополнение.


  1. grebenyukov
    07.02.2022 00:50

    Здравствуйте! Добавление виртуального графа создает аналог прямой связи между узлами одного и того же столбца. В оригинальном графе они связаны лишь через крайний узел и узлы соседнего столбца. Разве не так?


  1. RinNas
    07.02.2022 03:15

    Добрый день, а https://postgrespro.ru/docs/postgresql/14/datatype-bit пробовали?


    1. Spinifex Автор
      07.02.2022 10:21

      Для матрицы смежности? Пробовали. Строго говоря подобного можно добиться оперируя битами в наборе числовых значений (если у вас не PostgreSQL). Вполне себе вариант. Но обвес кода нужно будет прилично так написать.


  1. mentin
    07.02.2022 09:52
    +1

    Для постгресса есть расширение pgRouting. Им конечно в основном географы пользуются, но работает на произвольных графах, и там есть алгоритм pgr_connectedComponents, вычисляющий связанность. Описанную в начале схему по-моему можно без изменений ему скормить. Интересно, насколько быстрее / медленнее.


    1. Spinifex Автор
      07.02.2022 10:22
      +1

      Да, интересно… честно говоря как-то руки не дошли. Обязательно попробую.


  1. Parser104
    07.02.2022 10:22

    Вынос общего множителя всегда упрощает решение, главное правильно вынести!

    © Конфуций


    1. Spinifex Автор
      07.02.2022 10:22

      С выражением не поспоришь. Но вот Конфуций разве такое говорил? :)


      1. Parser104
        07.02.2022 11:06

        Нет)


  1. Caraul
    07.02.2022 11:02

    Для графовых БД есть такой https://tinkerpop.apache.org/gremlin.html - язык обхода графов. Для PostreSQL нету, а вот MS для новой CosmosDB реализовал. Про призводительность не знаю.


    1. Spinifex Автор
      07.02.2022 12:21
      +1

      Да, Гремлин весьма распространен в графовых БД. Однако у лидера этого класса БД (Neo4j) есть свой язык — Cypher. Мне он показался еще более удобным…
      Но с производительностью использование диалекта связывать смысла не имеет. Важно что у вас за движок выполняет запросы. Но вот читабельность кода будет ясно выше, поэтому такие диалекты и изобретают.


      1. Caraul
        07.02.2022 20:20

        Полюбопытствовал - Гремлин имеет вид FluentAPI, а вот Cypher больше похож на SQL. Есть даже старый транслятор от openCypher - https://github.com/opencypher/cypher-for-gremlin


  1. fransua
    07.02.2022 12:20
    +1

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

    Для разряженных матриц связей можно использовать mkl, вроде бы если детерминант не 0, то граф связный.


    1. Spinifex Автор
      07.02.2022 12:24

      Возможно. В случае с mkl как быть с транзакционностью?


      1. fransua
        07.02.2022 13:57

        А какие нужны транзакции, обновление графа? Читаем граф, обновляем, сохраняем. В БД или просто на диск. Если есть конкурирующие потоки, то в локе.


  1. mrbald
    07.02.2022 17:12

    Я бы попробовал на плюсах написать простой прототип (или на numpy, или на любом языке с массивами и векторизацией) и использовал как бенчмарк. Может оказаться, что никакой multi-tier и не понадобится. Человечество начинает забывать какими быстрыми могут быть программы без проводов и баз данных.


  1. charypopper
    07.02.2022 18:04

    Спасибо за статью.

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

    На первом рисунке (я пронумеровал вершины), чтобы попасть в вершину 2 из 1 надо одно ребро пройти. А чтобы в 3 из 1 - надо пройти 2 ребра. На второй картинке у вас получается одинаковое расстояние - 2 ребра (через виртуальный узел).

    P.S. Если ориентировать ребра ("от" виртуального и "к"), то можно свернуть так, как на рисунке и потом иметь возможность получить исходный граф (считать связью только "к"+"от" и "от"+"к")


    1. Spinifex Автор
      07.02.2022 20:21
      +1

      Зависит от того направленный граф или нет. Я изначалько подразумевал направленный граф, но стрелочки загромождают схему. :) Поправлю схемы чуть позже... На суть это не сильно влияет.


      1. charypopper
        08.02.2022 11:22

        Только если все направления от чётных к нечётным или наоборот. Если смешанное, то не получится. В статье что Вы приводите как раз так на первом рисунке