Индексы и ограничения
Что ускоряет запросы и что гарантирует целостность: индексы и 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 KEYNode 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.