Проектирование графовой схемы

Главное инженерное решение в графе: что становится узлом, что связью, а что свойством.

Графовое моделирование — проектирование схемы под будущие запросы: сущности и понятия становятся узлами, отношения — связями, атрибуты — свойствами.

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

Три вопроса проектировщика

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

  • Узел или свойство? Если по сущности ходят связи или её ищут отдельно — это узел. Если это просто атрибут — свойство.
  • Свойство на узле или на связи? Атрибут самой сущности — на узле; атрибут отношения — на связи.
  • Какое направление у связи? Выбирайте естественное и фиксируйте соглашение.

Эти три вопроса стоит задавать к каждой сущности в предметной области. Возьмём киноданные: «фильм», «актёр», «режиссёр», «жанр», «год выхода», «рейтинг» — что из этого узлы, что свойства, что атрибуты связей? Фильм и актёр явно узлы: их ищут отдельно, по ним ходят связи. Год выхода — свойство фильма (по нему фильтруют, но он не сущность сам по себе). А вот «жанр» — пограничный случай: если мы спрашиваем «дай все фильмы в жанре драма» и «какие жанры близки», жанр выгоднее сделать узлом; если жанр — просто метка для отображения, хватит свойства. Видно, что ответ зависит не от природы данных, а от вопросов к ним.

Классический выбор: город как свойство или узел

Сравните два варианта для «человек живёт в городе»:

Вариант A (свойство):   (:Person {name:'Алиса', city:'Москва'})

Вариант B (узел):       (:Person {name:'Алиса'})-[:LIVES_IN]->(:City {name:'Москва'})

Если вы никогда не спрашиваете «кто ещё живёт в Москве» и «какие города рядом» — хватит свойства (Вариант A). Но как только город сам участвует в связях (другие жители, регион, расстояния) — он обязан стать узлом (Вариант B). Узел-город позволяет за один шаг найти всех его жителей; свойство-город заставило бы сканировать всех людей.

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

// Вариант B: земляки за два шага по связям
MATCH (me:Person {name:'Алиса'})-[:LIVES_IN]->(c:City)<-[:LIVES_IN]-(other:Person)
WHERE other <> me
RETURN other.name

В варианте A того же результата пришлось бы добиваться сравнением строкового свойства city у всех людей подряд — то есть, по сути, сканированием, а не обходом. Стоит городу стать узлом, как «жить в одном городе» превращается в общего соседа, и весь арсенал графовых задач (общие соседи, рекомендации, кластеры) сразу начинает работать. Это и есть смысл правила «сущность со связями — узел».

Свойства на связях — суперсила

Не пытайтесь всё повесить на узлы. Рейтинг, дата, вес, роль — часто атрибуты отношения:

(:User)-[:RATED {stars:4, at:'2024-05-01'}]->(:Movie)
(:City)-[:ROAD {km:635, hours:7}]->(:City)
(:Person)-[:WORKED_AT {from:2018, to:2022, role:'CTO'}]->(:Company)

Логика проста: спросите себя, чьим атрибутом является значение. «Сколько звёзд» — это не свойство пользователя (у него много оценок) и не свойство фильма (его оценили многие по-разному), а свойство конкретной пары «пользователь–фильм». Значит, оно живёт на связи RATED. Тот же приём спасает в типичной ошибке моделирования занятости: дату начала и роль нельзя повесить ни на человека, ни на компанию — это атрибуты конкретного факта работы, то есть связи WORKED_AT. Связь со свойствами часто заменяет целую промежуточную таблицу из реляционной модели (ту, что в SQL появляется при связи «многие-ко-многим»).

Когда связи мало и нужен узел-факт

Иногда отношение само обрастает связями — например, у «оценки» появляются комментарии, лайки, история правок. Тогда то, что было связью, повышают до полноценного узла-факта (его иногда называют реифицированным отношением): (:User)-[:WROTE]->(:Review)-[:ABOUT]->(:Movie). Узел Review теперь может иметь свои связи. Правило-ориентир: пока у отношения только скалярные атрибуты — это связь; как только по нему самому нужно ходить — это узел.

Антипаттерн: суперузел

Суперузел (supernode) — узел с гигантским числом связей: «страна», к которой привязаны миллионы людей, или «популярный хештег». Обход такого узла дорог (expand перебирает миллионы рёбер). Лечат это введением промежуточных узлов, специализацией типов связей или вынесением части логики в свойства.

Поясним механику на примере. Допустим, все пользователи связаны с узлом (:Country {name:'Россия'}). Запрос «земляки внутри одного города» теперь вынужден пройти через страну, а у неё, скажем, 100 миллионов рёбер LIVES_IN — каждый обход разворачивает этот веер целиком. Лечение — специализировать связи и дробить: завести узлы-города, узлы-регионы, и привязывать человека к городу, город к региону, регион к стране. Тогда веер у каждого узла на порядки меньше, а «страна» достижима через дешёвую цепочку, а не через прямой суперузел. Другой приём — типизировать связи (вместо одной :TAGGED завести :TAGGED_2024, :TAGGED_2025), чтобы обход выбирал только нужную долю рёбер. Суперузлы редко возникают намеренно — обычно их рождает ленивое «привяжем всё к одной категории».

Как работает под капотом

Граф «материализует» связи заранее, поэтому стоимость запроса определяется тем, насколько ваша схема совпадает с обходом. Хорошая схема делает частые запросы короткими путями по 1–3 ребра. Плохая (например, нужное отношение спрятано в свойстве) заставляет сканировать. Иными словами, в графе схема и запрос проектируются вместе: модель — это «застывший» набор будущих обходов.

На физическом уровне у каждого узла Neo4j хранит указатели на его связи, а у каждой связи — указатели на оба конца и на «соседние» связи того же узла. Поэтому развернуть соседей узла — это пройти по цепочке указателей, а не искать совпадения в таблице. Стоимость такого разворачивания прямо пропорциональна степени узла (числу его рёбер). Вот почему суперузел дорог: длинная цепочка указателей. И вот почему «город как узел» дёшев: у города связей много, но обход «человек → его город» трогает ровно одно ребро, а развернуть жителей города нужно лишь тогда, когда мы их действительно запрашиваем. Схема, согласованная с запросами, держит степени узлов на пути обхода маленькими.

Частые ошибки

  • Прятать связь в свойство. Если по атрибуту нужно ходить (общие города, теги), делайте узел, а не строку-свойство.
  • Складывать атрибут отношения на узел. «Оценка фильма пользователем» — на связи RATED, не на узле.
  • Создавать суперузлы. «Всё связано с одним узлом-категорией» убивает обходы; дробите.
  • Моделировать без списка запросов. Граф проектируют от вопросов, иначе схема не ляжет на обходы.

Итоги

  • Граф моделируют от запросов: частые вопросы должны стать короткими обходами.
  • Сущность со связями — узел; голый атрибут — свойство; атрибут отношения — свойство связи.
  • Направление связи выбирайте естественное и фиксируйте соглашением.
  • Избегайте суперузлов — они делают обход дорогим.
Проверьте себя
1. Когда город стоит делать узлом, а не свойством человека?
AНикогда
BКогда город сам участвует в связях (другие жители, регион, расстояния) и его ищут отдельно
CВсегда, без исключений
DТолько в Enterprise
2. Где правильно хранить оценку фильма пользователем (stars)?
AНа узле User
BНа узле Movie
CНа связи RATED между ними
DВ отдельной базе
3. Чем опасен суперузел (supernode)?
AЗанимает много диска
BЕго обход (expand) перебирает миллионы рёбер и становится дорогим
CЕго нельзя удалить
DОн ломает индексы