Проектирование графовой схемы
Главное инженерное решение в графе: что становится узлом, что связью, а что свойством.
Графовое моделирование — проектирование схемы под будущие запросы: сущности и понятия становятся узлами, отношения — связями, атрибуты — свойствами.
Реляционную базу обычно моделируют «от данных»: рисуют сущности, нормализуют таблицы, расставляют внешние ключи — и только потом смотрят, как по ним писать запросы. В графе порядок обратный. Схема графа — это, по сути, заранее проложенные тропинки, по которым побежит запрос. Если тропинки проложены не туда, обход придётся заменять сканированием, и всё преимущество графа испарится. Поэтому графовый инженер сначала собирает список вопросов к данным, а уже под них рисует узлы и связи. Этот урок — про дисциплину такого мышления.
Три вопроса проектировщика
В отличие от реляционного моделирования «от данных», граф моделируют «от вопросов». Сначала выпишите запросы, которые система должна обслуживать, и проектируйте так, чтобы они стали короткими обходами. Три рабочих вопроса:
- Узел или свойство? Если по сущности ходят связи или её ищут отдельно — это узел. Если это просто атрибут — свойство.
- Свойство на узле или на связи? Атрибут самой сущности — на узле; атрибут отношения — на связи.
- Какое направление у связи? Выбирайте естественное и фиксируйте соглашение.
Эти три вопроса стоит задавать к каждой сущности в предметной области. Возьмём киноданные: «фильм», «актёр», «режиссёр», «жанр», «год выхода», «рейтинг» — что из этого узлы, что свойства, что атрибуты связей? Фильм и актёр явно узлы: их ищут отдельно, по ним ходят связи. Год выхода — свойство фильма (по нему фильтруют, но он не сущность сам по себе). А вот «жанр» — пограничный случай: если мы спрашиваем «дай все фильмы в жанре драма» и «какие жанры близки», жанр выгоднее сделать узлом; если жанр — просто метка для отображения, хватит свойства. Видно, что ответ зависит не от природы данных, а от вопросов к ним.
Классический выбор: город как свойство или узел
Сравните два варианта для «человек живёт в городе»:
Вариант 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, не на узле.
- Создавать суперузлы. «Всё связано с одним узлом-категорией» убивает обходы; дробите.
- Моделировать без списка запросов. Граф проектируют от вопросов, иначе схема не ляжет на обходы.
Итоги
- Граф моделируют от запросов: частые вопросы должны стать короткими обходами.
- Сущность со связями — узел; голый атрибут — свойство; атрибут отношения — свойство связи.
- Направление связи выбирайте естественное и фиксируйте соглашением.
- Избегайте суперузлов — они делают обход дорогим.