Индексы и ограничения

Что ускоряет запросы и что гарантирует целостность: индексы и constraints.

Индекс ускоряет поиск стартового узла по свойству; ограничение (constraint) гарантирует правило целостности (уникальность, обязательность свойства).

Зачем индекс, если обходы и так быстры

Обход по связям в Neo4j быстр без индексов — спасибо смежности. Но почти любой запрос с чего-то начинается: MATCH (p:Person {name:'Алиса'}). Без индекса поиск этого стартового узла — полный перебор всех :Person (label scan). На миллионе людей это медленно. Индекс по :Person(name) превращает поиск старта в логарифмический.

CREATE INDEX person_name FOR (p:Person) ON (p.name)

После этого запросы, фильтрующие :Person по name, находят якорь мгновенно. Индексы нужны именно на свойства, по которым вы входите в граф.

Сформулируем правило ёмко: индекс ускоряет старт, а не обход. Обход по рёбрам в Neo4j и без индекса константно быстр благодаря безиндексной смежности. А вот первый шаг — нахождение того единственного узла, от которого вы оттолкнётесь, — без индекса деградирует в линейный перебор. На графе из миллиона людей запрос «друзья Алисы» потратит 99% времени на поиск самой Алисы и 1% на собственно обход. Индекс по name убирает эти 99%.

Поэтому стратегия индексирования в графовой базе принципиально иная, чем в реляционной. Там индексируют колонки, по которым делают JOIN и WHERE. Здесь JOIN-ов нет — их роль играет дешёвый обход, и индексировать «связи» не нужно. Индексы ставят узко: на те 2–3 свойства, по которым приложение реально входит в граф (логин, email, артикул товара, идентификатор заказа). Всё остальное обход найдёт сам.

Сравнение типов поиска

СитуацияОперация в планеСтоимость
есть индекс на свойство якоряNodeIndexSeekлогарифмическая
индекса нет, фильтр по меткеNodeByLabelScanлинейная по числу узлов метки
переход по ребру от уже найденного узлаExpand~константная (смежность)

Ограничение уникальности

Уникальность бизнес-ключа — основа надёжной модели. Ограничение и проверяет уникальность, и автоматически создаёт индекс:

CREATE CONSTRAINT user_id_unique
FOR (u:User) REQUIRE u.userId IS UNIQUE

Теперь два узла :User с одинаковым userId создать нельзя. Именно это ограничение делает MERGE по ключу безопасным при параллельной нагрузке.

Этот «два в одном» (правило целостности плюс бесплатный индекс) — причина создавать ограничение уникальности раньше, чем отдельный индекс. Если по свойству нужна и быстрота поиска, и гарантия уникальности, не пишите два объекта — достаточно одного CONSTRAINT ... IS UNIQUE: он сам заводит под капотом индекс, который планировщик использует для NodeIndexSeek точно так же, как обычный. Отдельный CREATE INDEX нужен лишь там, где уникальности не требуется (например, поиск людей по городу — городов мало, дубли естественны).

Ограничение существования и ключ

В Enterprise есть ограничение обязательности свойства и составной node key:

// свойство обязано существовать (Enterprise)
CREATE CONSTRAINT movie_title_exists
FOR (m:Movie) REQUIRE m.title IS NOT NULL

// составной ключ узла (Enterprise)
CREATE CONSTRAINT order_key
FOR (o:Order) REQUIRE (o.shop, o.number) IS NODE KEY

Node key = уникальность + обязательность сразу по набору свойств. Это «первичный ключ» графового мира. Составной ключ полезен, когда сущность уникальна не одним полем, а комбинацией: номер заказа уникален только в рамках конкретного магазина, поэтому ключом служит пара (shop, number), а не одно number.

Почему constraint спасает MERGE

Вспомним MERGE: «найди узел по шаблону или создай, если нет». Под капотом это два шага — поиск и, если не нашлось, создание. Между этими шагами есть зазор, и при параллельной нагрузке в него протискивается гонка: две транзакции одновременно ищут пользователя userId=42, обе не находят, обе создают — рождаются дубли.

// Без constraint два таких запроса в параллель создадут ДВА узла userId=42:
MERGE (u:User {userId: 42})
ON CREATE SET u.created = timestamp()
RETURN u

Ограничение уникальности закрывает зазор: при попытке создать второй узел с тем же userId движок держит блокировку на это значение, и одна из транзакций либо подождёт и увидит уже созданный узел, либо упадёт с нарушением ограничения вместо тихого создания дубля. Поэтому правило железное: MERGE по бизнес-ключу всегда сопровождается уникальным ограничением на этот ключ. Без него MERGE кажется работающим в тестах (где нет конкуренции) и плодит дубли на проде.

Смотрим, что есть

Команда SHOW INDEXES и SHOW CONSTRAINTS (или :schema в Browser) перечислят всё созданное. А EXPLAIN/PROFILE перед запросом покажут план: использовал ли он индекс (NodeIndexSeek) или скатился в полный скан (NodeByLabelScan).

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

Индекс в Neo4j — это отдельная структура (по умолчанию на базе Lucene/нативных индексов), отображающая значение свойства в узлы. Планировщик при разборе паттерна проверяет: есть ли индекс на свойство якоря? Если да — NodeIndexSeek (логарифмический поиск). Если нет — NodeByLabelScan (перебор всех узлов метки). Ограничение уникальности дополнительно держит блокировку на значение при записи, что и закрывает гонку в MERGE. Индексы стоят денег на запись (их надо поддерживать), поэтому индексируют не всё подряд, а свойства-точки входа.

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

Полезно знать и про асинхронность построения: когда вы создаёте индекс на уже заполненной базе, он строится в фоне и какое-то время находится в состоянии POPULATING. До перехода в ONLINE запросы корректны, но индексом ещё не пользуются. Поэтому на проде индексы и ограничения заводят заранее, до массовой загрузки или до пиковой нагрузки, а не «когда уже тормозит».

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

  • Индексировать всё подряд. Каждый индекс замедляет запись; ставьте их на свойства, по которым входите в граф.
  • MERGE по ключу без constraint. Тогда возможны дубли при гонке; уникальное ограничение обязательно.
  • Надеяться на индекс для обхода. Индексы ускоряют поиск старта, а не сами переходы по рёбрам — те и так быстры.
  • Не смотреть PROFILE. Если запрос медленный, PROFILE покажет NodeByLabelScan — сигнал, что индекс не используется.

Итоги

  • Индекс ускоряет поиск стартового узла по свойству; обходы и так быстры.
  • Ограничение уникальности гарантирует ключ и автоматически создаёт индекс — основа для безопасного MERGE.
  • NODE KEY и IS NOT NULL (Enterprise) задают составной ключ и обязательность свойств.
  • PROFILE показывает, идёт ли NodeIndexSeek или дорогой NodeByLabelScan.
Проверьте себя
1. Что в первую очередь ускоряет индекс по свойству в Neo4j?
AСами переходы по связям
BПоиск стартового (якорного) узла, с которого начинается обход
CЗапись данных
DУдаление узлов
2. Зачем ограничение уникальности важно для MERGE?
AОно ускоряет RETURN
BОно закрывает гонку: при параллельных MERGE не появятся дубли по ключу
CБез него MERGE не компилируется
DОно удаляет старые узлы
3. Что в плане PROFILE сигнализирует, что индекс НЕ используется?
ANodeIndexSeek
BNodeByLabelScan (полный перебор узлов метки)
CExpand
DProduceResults
4. Почему не стоит индексировать все свойства подряд?
AИндексы занимают слишком много слов в коде
BКаждый индекс надо синхронно обновлять при записи — много индексов замедляют вставку/апдейт
CNeo4j разрешает максимум один индекс
DИндексы ломают обходы по рёбрам