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

Раскладываем по полочкам конкретную модель Neo4j и принятые в сообществе соглашения по именованию.

В Neo4j данные — это узлы с метками, направленные связи с типами и свойства на тех и других; внутренний id назначает сама база, но полагаться в данных надо на свои ключи.

Метки узлов

Метка (label) — это категория узла, пишется через двоеточие: :Person, :Movie. По меткам Neo4j группирует узлы и строит индексы. У узла может быть ноль, одна или несколько меток. Например, актёр-режиссёр — это :Person:Director. Соглашение: метки в PascalCase, единственное число — :User, а не :users.

Зачем так строго относиться к соглашениям, если база и так всё примет? Затем, что метка и тип связи в Cypher чувствительны к регистру: :Person и :person — это две разные метки, и запрос по одной не найдёт узлы с другой. Один человек, написавший :User, и другой, написавший :user, незаметно расколют данные надвое. Единый стиль (PascalCase для меток, единственное число) — это не вкусовщина, а защита от молчаливых багов, которые не выдают ошибку, а просто возвращают пустой результат.

Типы связей и направление

У каждой связи ровно один тип и одно направление. Тип пишут в квадратных скобках после двоеточия, направление задаёт стрелка:

(:Person)-[:ACTED_IN]->(:Movie)
(:User)-[:FOLLOWS]->(:User)
(:Account)-[:SENT]->(:Transaction)

Соглашение для типов — UPPER_SNAKE_CASE: :ACTED_IN, :LIVES_IN. Направление несёт смысл: (a)-[:FOLLOWS]->(b) значит «a подписан на b», и это не то же самое, что (b)-[:FOLLOWS]->(a). При этом по связи можно ходить и против стрелки — об этом отдельный урок про обходы.

Полезный приём именования — называть тип связи так, чтобы фраза «узел-A ТИП узел-B» читалась как нормальное предложение. (:Person)-[:LIVES_IN]->(:City) читается «человек живёт в городе» — хорошо. (:Person)-[:CITY]->(:City) читается коряво — плохо. Тип связи — это глагол или отношение, а не существительное. Когда модель проходит этот «тест на чтение вслух», запросы на Cypher получаются почти как английский текст, и их легко понимать спустя месяцы.

Между одной парой узлов — сколько угодно связей

В графе ничто не мешает соединить два узла несколькими связями — даже одного типа. Между пользователем и фильмом могут быть и :RATED, и :WATCHED, и :REVIEWED одновременно. А между двумя людьми может быть несколько :SENT_MONEY с разными датами и суммами на свойствах — каждый перевод это отдельное ребро. Это принципиально мощнее реляционной связи через внешний ключ, где «одна строка ссылается на одну строку». В графе связь — это событие или факт, и таких фактов между двумя узлами может накопиться сколько угодно.

Свойства

Свойства — пары ключ-значение и на узлах, и на связях. Значения бывают примитивные (строки, числа, булевы, даты) и массивы примитивов. Вложенных объектов в свойствах нет — это важное ограничение: если вам нужна структура, делайте отдельные узлы и связи.

  (:Person {name:'Алиса', born:1990, tags:['admin','beta']})
       │
       │ [:RATED {stars:5, at:'2024-01-10'}]
       ▼
  (:Movie {title:'Начало', released:2010})

Идентификаторы: внутренний id и ваш ключ

Neo4j присваивает каждому узлу и связи внутренний числовой id. Соблазнительно использовать его как ключ — но не надо: эти id могут переназначаться после удалений и не переносятся между базами. Для бизнес-идентификации заведите своё свойство, например {userId: 'u-42'}, и повесьте на него ограничение уникальности (об этом — в разделе про индексы).

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

Метка — это не просто строка на узле, а ключ для так называемого label scan: Neo4j умеет быстро перебрать все узлы данной метки. А индекс по паре «метка + свойство» (например, :Person(name)) превращает поиск конкретного стартового узла в логарифмический. Сами обходы по связям индексов не требуют — они идут по смежности. Поэтому типичный запрос — это «найти стартовый узел по индексу, дальше идти по рёбрам».

Эта двухфазная природа запроса — сначала точечный вход, потом обход — определяет, где оптимизировать. Узкое место почти никогда не в самом обходе (он дёшев), а в том, как вы находите стартовый узел. Если на :Person(name) нет индекса, Neo4j переберёт всех людей, чтобы найти Алису, — и это будет медленнее самого обхода её друзей. Отсюда практическое правило: ставьте индекс на то свойство, по которому вы входите в граф (обычно бизнес-ключ), и не переживайте об индексах на рёбрах — их там попросту не нужно.

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

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

  • Брать внутренний id за бизнес-ключ. Он нестабилен; заведите своё уникальное свойство.
  • Метки во множественном числе или snake_case. Договорённость — PascalCase, единственное число: :Movie, а не :movies.
  • Складывать вложенный JSON в свойство. Свойства плоские; структуру выражают узлами и связями.
  • Игнорировать направление. FOLLOWS в одну сторону ≠ в обратную; продумывайте семантику стрелки.

Итоги

  • Метки (PascalCase) категоризируют узлы; типов связей — UPPER_SNAKE_CASE, и у связи всегда есть направление.
  • Свойства плоские (примитивы и их массивы); вложенность выражается узлами и рёбрами.
  • Внутренний id — нестабилен; для бизнес-ключа заведите своё уникальное свойство.
  • Старт запроса ускоряют индексы по «метка+свойство»; обходы идут по смежности без индексов.
Проверьте себя
1. Какое соглашение об именовании типов связей принято в Neo4j?
AcamelCase
BPascalCase
CUPPER_SNAKE_CASE, например ACTED_IN
DТолько нижний регистр
2. Почему не стоит использовать внутренний id Neo4j как бизнес-ключ?
AОн слишком длинный
BОн может переназначаться после удалений и не переносится между базами
CЕго нельзя прочитать
DОн строковый
3. Что нельзя положить в свойство узла?
AСтроку
BЧисло
CМассив строк
DВложенный объект JSON