PostgreSQL (Postgres) — это мощная реляционная база данных, способная хранить широкий спектр типов и структур данных. Когда нам нужно хранить графовые структуры данных, мы часто обращаемся к базам данных, позиционируемым как подходящее для этого решение, например, к Neo4J или Dgraph. Но не торопитесь! Хотя при работе с графовыми структурами данных о Postgres обычно не вспоминают, она идеально справляется с эффективным хранением графовых данных и запросами к ним.
Разбираемся с графовыми структурами данных
Прежде чем приступать к объяснению Postgres как графовой базы данных, нам нужно понять, что же такое графовая структура данных. Граф, или графовая структура данных — это набор узлов и рёбер, где каждый узел обозначает сущность или объект, а каждое ребро обозначает связь между двумя узлами.
Визуальный пример графовой структуры данных.
Чтобы начать воспринимать графы в рамках кода, можно написать следующий TypeScript:
class Node {
edges: Edge[] = [];
data: string;
}
class Edge {
previousNode: Node;
nextNode?: Node;
}
Каждый узел (node) содержит список своих рёбер, а каждое ребро (edge) содержит ссылку на следующий/предыдущий узел. Как мы ниже узнаем из SQL, узлы не всегда обязаны знать о своих рёбрах.
Facebook — популярная социальная сеть, использующая для описания людей и их связей граф. У человека могут быть друзья, и у этих друзей тоже есть свой список друзей. Каждый человек представлен узлом, а каждую дружбу можно задать ребром. Графы используются для моделирования множества различных систем, например npm-зависимостей, рабочих процессов, транспортных систем, производственных линий и многого другого!
Хранение графовых структур данных в Postgres
Для хранения графа в Postgres нам достаточно создать две таблицы:
nodes
и edges
. Таблица nodes
будет хранить информацию о каждой сущности, а в таблице edges
будет храниться информация о связях между этими сущностями.Давайте начнём с создания таблицы
nodes
:CREATE TABLE nodes (
id SERIAL PRIMARY KEY,
data VARCHAR(255)
);
Определённая нами таблица
nodes
имеет два столбца: id
и data
. Столбец id
содержит целочисленные значения с автоматическим инкрементом, служащие первичным ключом таблицы. Столбец data
— это строки, хранящие дополнительные данные, связанные с узлом. В этом примере мы не будем усложнять и сохраним только строковый столбец, однако в реальном мире эта таблица могла бы содержать что угодно и иметь любое количество столбцов.Самой важной таблицей при создании графовой структуры данных является таблица
edges
:CREATE TABLE edges (
previous_node INTEGER REFERENCES nodes(id),
next_node INTEGER REFERENCES nodes(id),
PRIMARY KEY (previous_node, next_node)
);
Мы создали два столбца,
previous_node
и next_node
, обозначающие взаимосвязи между узлами. Каждый из этих столбцов хранит внешний ключ для узла. Важный вывод заключается в том, что таблица edges
ссылается на две строки одной таблицы. Ребро может иметь только по одной паре previous_node
и next_node
, поэтому мы используем составной первичный ключ, чтобы каждое ребро было уникальным и не могло ссылаться на само себя.Создав таблицы, мы можем вставить в них данные.
INSERT INTO nodes (data) VALUES ('Bob');
INSERT INTO nodes (data) VALUES ('Hank');
INSERT INTO nodes (data) VALUES ('Jeff');
А теперь соединим узлы рёбрами:
INSERT INTO edges (previous_node, next_node) VALUES (1, 2);
INSERT INTO edges (previous_node, next_node) VALUES (1, 3);
nodes | |
---|---|
id | data |
1 | Bob |
2 | Hank |
3 | Jeff |
edges | |
---|---|
previous_node | next_node |
1 | 2 |
1 | 3 |
Если бы мы визуализировали этот граф, то он бы выглядел так:
Запросы к графовым структурам данных в Postgres
Создав графовую структуру данных, мы можем начать выполнять запросы к ней при помощи известного нам и любимого SQL!
Хотите знать, с кем дружит Боб?
SELECT id, data
FROM nodes
JOIN edges ON nodes.id = edges.next_node
WHERE edges.previous_node = 1;
Находим все
nodes
, связанные с узлом, имеющим id
1 (id Боба).Похоже, Боб популярен! Но что, если мы захотим узнать, с кем дружат друзья Боба?
Давайте вставим ещё несколько узлов и рёбер, чтобы показать это:
INSERT INTO nodes (data) VALUES ('Sally');
INSERT INTO nodes (data) VALUES ('Sue');
INSERT INTO nodes (data) VALUES ('Sam');
INSERT INTO edges (previous_node, next_node) VALUES (2, 4);
INSERT INTO edges (previous_node, next_node) VALUES (3, 4);
INSERT INTO edges (previous_node, next_node) VALUES (4, 5);
nodes | |
---|---|
id | data |
1 | Bob |
2 | Hank |
3 | Jeff |
4 | Sally |
5 | Sue |
6 | Sam |
edges | |
---|---|
previous_node | next_node |
1 | 2 |
1 | 3 |
2 | 4 |
3 | 4 |
4 | 5 |
Чтобы запросить всех друзей друзей Боба, мы можем расширить предыдущий запрос, снова выполнив в нём join таблицы
edges
, но тогда поддержка базы данных превратится в кошмар, ведь нам пришлось бы выполнять join для каждого «уровня» графа.Postgres имеет встроенную фичу, позволяющую запрашивать графовые данные, не зная точно, сколько join нам нужно: рекурсивные запросы. Рекурсивные запросы позволяют обойти граф, начиная с заданного узла и двигаясь по его рёбрам до какой-то заданной конечной точки.
Чтобы создать рекурсивный запрос для поиска всех друзей Боба и их друзей, нам нужно написать следующий SQL:
WITH RECURSIVE friend_of_friend AS (
SELECT edges.next_node
FROM edges
WHERE edges.previous_node = 1
UNION
SELECT edges.next_node
FROM edges
JOIN friend_of_friend ON edges.previous_node = friend_of_friend.next_node
)
SELECT nodes.data
FROM nodes
JOIN friend_of_friend ON nodes.id = friend_of_friend.next_node;
Поначалу это может показаться непонятным, поэтому разберём команды. Рекурсивный запрос состоит из двух частей: базовый случай и рекурсивный случай. Базовый случай — это то, с чего мы хотим начать запрос. Рекурсивный случай — это «цикл», который продолжает выполняться, пока не будет достигнута какая-то конечная точка.
WITH RECURSIVE {name} AS (
{base case}
UNION
{recursive case}
)
Выше показана простейшая структура SQL рекурсивного запроса.
В нашем примере нужно начать запрос с друзей Боба, чтобы найти рёбра, в которых Боб (id: 1) является
previous_node
. Затем в рекурсивном случае мы непрерывно выполняем join таблицы edges
с самой собой, пока не достигнем конца графа Боба (то есть пока не достигнем friend_of_friend.next_node = NULL
). Вне рекурсивного случая мы объединяем всё это вместе. Нам нужно запросить nodes
, связанные с рёбрами из рекурсивного запроса, чтобы можно было получить имена всех друзей Боба.data |
---|
Hank |
Jeff |
Sally |
В заключение
При помощи встроенных функций Postgres можно сохранять графовые структуры данных и выполнять к ним запросы. Мы использовали похожий подход в моей предыдущей работе для динамической генерации рабочих инструкций на производственной линии. На основании заданных параметров и определённых в каждом ребре правил можно сгенерировать корректный документ при помощи обхода графа, целиком хранящегося в Postgres. Если вы уже используете Postgres для работы с реляционными данными, то можете интегрировать графовые структуры данных в имеющуюся базу данных добавления лишних систем!
Комментарии (19)
GerrAlt
03.04.2023 12:39вот бы сравнить предложенную реализацию с чем-то не требующим рекурсивных запросов, например: https://en.wikipedia.org/wiki/Nested_set_model
speshuric
03.04.2023 12:39+1Nested sets в общем случае лично мне непонятно как применить, ведь никто не обещал, что граф будет деревом. Ну и в случае дерева применимость nested sets достаточно узкая.
zdraff
03.04.2023 12:39+1Чем этот способ хранения ориентированных графов отличается от таблицы смежности? https://habr.com/ru/post/537062/
EvgenyVilkov
03.04.2023 12:39+7Ничего себе какое открытие! Оказывается (!!!) в любой реляционной базе где есть рекурсивные запросы можно работать с графом!
Давайте усложним задачу - возьмём миллионов 500 вершин и миллиарда три связей и решим задачу попроще вроде поиска ближайшего окружения по набору признаков от заданной вершины с небольшой конкурентной нагрузкой сессий так в 20 :)
Starche
03.04.2023 12:39+2Кстати, а как такое решать? Не в постгресе, а вообще?
EvgenyVilkov
03.04.2023 12:39В специализированных движках (графовые субд) конечно
NickNal
03.04.2023 12:39Работал на сервере Neo4j 4.4 (он-прем) со 100 ГБ ОЗУ, там было около 25 миллионов вершин и около 100 миллионов рёбер в БД.
Шуршал на он на оценку "удовлетворительно" - достаточно быстро для прода, но не поражал воображение и вообще не было запаса прочности для масштабирования.Чтобы переварить миллиарды рёбер на Neo4j, нужен мини-ЦОД, наверно)
И PG на том же железе работал бы не сильно хужеEvgenyVilkov
03.04.2023 12:39100 Гб конечно не о чем на самом деле и есть разница между 25м вершин и 500 М :)
ну и Neo не самое лучшее решение.
представьте что у вас будет стоять задача например достроения графа в онлайн режиме с интеграцией с системой принятия решений в реальном времени. Никак вы ее на рсубд не решите за вменяемую стоимость и sla.
DeepHill
03.04.2023 12:39ну и Neo не самое лучшее решение.
А какое решение лучше?
EvgenyVilkov
03.04.2023 12:39Все зависит от критериев сравнения конечно. Кому то важная только скорость причем конкретных алгоритмов, кому то возможность масштабироваться, кому то важна информационная безопасность и тд.
diogen4212
03.04.2023 12:39Последний запрос не должен ли вернуть ещё и Sue? Мы же не ограничиваем максимальный уровень иерархии.
astentx
03.04.2023 12:39Parent/child это только частный случай графа. Как правило, для графовых задач узлы (ребра) могут быть объектами разного класса и иметь разные наборы свойств. Это, конечно, можно уложить в реляционную модель, но это будет нецелевое использование реляционной СУБД. Для таких вещей есть более подходящие модели хранения (и свои QL).
EvgenyVilkov
03.04.2023 12:39в описываемом вами случае разные типы вершин и связей проперти графа укладывают в разные таблицы (в рсубд) со своим набором атрибутов. И да, это конечно не подходящая задача для рсубд и тут конечно нужен спец движок и на больших объемах с нормальным шардированием.
plotn1
03.04.2023 12:39+1Тот случай, когда комментарии намного релевантнее саиой статьи, уж извините. Ничего из изложенного не "графовое" в каком то уникальном виде, т.е. применимо к плюс-минус любой из нормальных СУБД - чистый sql.
А вот ссылки коллег интересные.
shurutov
03.04.2023 12:39data VARCHAR(255)
Сразу, varchar(n) vs text: https://ru-postgres.livejournal.com/65930.html
И, естетсвенно, официальная вики по постгресу: https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_varchar.28n.29_by_default
CREATE TABLE edges ( previous_node INTEGER REFERENCES nodes(id), next_node INTEGER REFERENCES nodes(id), PRIMARY KEY (previous_node, next_node) );
В те времена далёкие, теперь уже былинные (с) В.С.Высоцкий (почти), к узлу графа могло подключаться существенно больше рёбер, нежели предыдущее и следующее. А в неориентированном графе понятия "предыдущее" и "следующее" вообще смысла не имеют.
ArtemBelov
https://age.apache.org/ настройка PostgreSQL для выполенния графовых запросов. В активной разработке.
iskateli
есть ещё AgensGraph https://github.com/bitnine-oss/agensgraph тоже основана на PostgreSQL