Раздел 39 · Neo4j и графовые базы

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

junior~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Мы научились писать и стирать данные. Но в двух местах раздела оставались дыры. В уроке про CREATE база спокойно приняла вторую Аню. А поиск MATCH (p:Person {name: 'Аня'}) на большом графе перебирает всех участников подряд. Обе дыры закрывает схема: ограничения не пускают в базу плохие данные, индексы ускоряют поиск.

Указатель в конце книги

Чтобы найти в толстом учебнике слово «транзакция», можно листать все страницы, а можно открыть указатель в конце: слова по алфавиту и номера страниц. Индекс в базе работает как такой указатель: по значению свойства сразу даёт нужные узлы. Как индекс устроен внутри, подробно разбирает урок про индексы в теории баз.

В графе индекс нужен в одном месте: найти, с каких узлов начать обход. Дальше по связям база идёт без индексов, как мы обсуждали в первом уроке.

Что уже есть в базе

В каждой новой базе Neo4j уже лежат два индекса:

SHOW INDEXES YIELD name, type, entityType, labelsOrTypes, properties, state;
+--------------------------------------------------------------------------------------+
| name             | type     | entityType     | labelsOrTypes | properties | state    |
+--------------------------------------------------------------------------------------+
| "index_1b9dcc97" | "LOOKUP" | "RELATIONSHIP" | NULL          | NULL       | "ONLINE" |
| "index_460996c0" | "LOOKUP" | "NODE"         | NULL          | NULL       | "ONLINE" |
+--------------------------------------------------------------------------------------+

2 rows

Это индексы типа LOOKUP: они помнят, у каких узлов какая метка и у каких связей какой тип. Благодаря им MATCH (p:Person) не перебирает все узлы базы. Удалять их не нужно.

SHOW INDEXES без YIELD печатает ещё несколько колонок, среди них lastRead и readCount, когда индекс читали последний раз. Песочница их не ведёт, поэтому в уроках мы всегда перечисляем колонки через YIELD.

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

Ограничение уникальности говорит базе: у двух узлов Person не может быть одинакового name.

CREATE CONSTRAINT person_name FOR (p:Person) REQUIRE p.name IS UNIQUE;
0 rows
Added 1 constraint

Имя person_name даём сами: так его проще найти и удалить. Теперь вторую Аню база не пустит:

CREATE (:Person {name: 'Аня'});
22N80: data exception - index entry conflict. Index entry conflict: Node(0) already exists with label `Person` and property `name` = 'Аня'.
  22N79: data exception - property uniqueness constraint violated. Property uniqueness constraint violated: Node(0) already exists with label `Person` and property `name` = 'Аня'.

Node(0) это внутренний номер узла настоящей Ани. Запрос отменён целиком. А MERGE с тем же ключом теперь безопасен по определению: он находит существующую Аню и ничего не создаёт.

MERGE (:Person {name: 'Аня'});
0 rows

Вместе с ограничением база сама создала индекс с тем же именем, он нужен, чтобы проверять уникальность быстро. Поэтому уникальность на ключ поиска ещё и ускоряет поиск.

Свой индекс

Для свойств, по которым часто ищут, но которые не обязаны быть уникальными, создают обычный индекс. Тип RANGE подходит для равенства и диапазонов:

CREATE INDEX book_year FOR (b:Book) ON (b.year);
0 rows
Added 1 index
SHOW INDEXES YIELD name, type, labelsOrTypes, properties, owningConstraint;
+-----------------------------------------------------------------------------+
| name             | type     | labelsOrTypes | properties | owningConstraint |
+-----------------------------------------------------------------------------+
| "book_year"      | "RANGE"  | ["Book"]      | ["year"]   | NULL             |
| "index_1b9dcc97" | "LOOKUP" | NULL          | NULL       | NULL             |
| "index_460996c0" | "LOOKUP" | NULL          | NULL       | NULL             |
| "person_name"    | "RANGE"  | ["Person"]    | ["name"]   | "person_name"    |
+-----------------------------------------------------------------------------+

4 rows

Колонка owningConstraint показывает, что индекс person_name принадлежит ограничению. Удалить его отдельно нельзя, только вместе с ограничением. Повторно создать такой же индекс под другим именем база тоже не даст:

CREATE INDEX book_year_again FOR (b:Book) ON (b.year);
22N70: data exception - equivalent index already exists. An equivalent index already exists: '(:Book {year})'

Для поиска по части строки через CONTAINS и ENDS WITH есть отдельный тип TEXT INDEX. Индексы бывают и на связях, например FOR ()-[k:KNOWS]-() ON (k.since).

Что умеет Community

Ограничения бывают разные, но в Community Edition доступна только уникальность. Обязательность свойства, ключ узла и тип свойства есть лишь в Enterprise:

CREATE CONSTRAINT person_born FOR (p:Person) REQUIRE p.born IS NOT NULL;
51N27: system configuration or operation exception - not supported in this edition. Property existence constraint is not supported in community edition.
  50N11: general processing exception - constraint creation failed. Unable to create 'Constraint( type='NODE PROPERTY EXISTENCE', schema=(:Person {born}), graphTypeDependence='INDEPENDENT' )'.

Увидеть работу индекса

Как убедиться, что индекс использован? Нужен план запроса. PROFILE перед запросом выполняет его и печатает, какие шаги сделала база и сколько раз обращалась к хранилищу. Песочница планы не строит, поэтому вот фрагмент вывода настоящего Neo4j 2026.10.1 на графе клуба. До создания ограничения:

neo4j@neo4j> PROFILE MATCH (p:Person {name: 'Аня'}) RETURN p.born;
+------------------+----+------------------------+----------------+------+---------+
| Operator         | Id | Details                | Estimated Rows | Rows | DB Hits |
+------------------+----+------------------------+----------------+------+---------+
| +ProduceResults  |  0 | `p.born`               |              1 |    1 |       0 |
| |                +----+------------------------+----------------+------+---------+
| +Projection      |  1 | p.born AS `p.born`     |              1 |    1 |       1 |
| |                +----+------------------------+----------------+------+---------+
| +Filter          |  2 | p.name = $autostring_0 |              1 |    1 |      10 |
| |                +----+------------------------+----------------+------+---------+
| +NodeByLabelScan |  3 | p:Person               |             10 |   10 |      11 |
+------------------+----+------------------------+----------------+------+---------+

Total database accesses: 22, total allocated memory: 64

Читай снизу вверх: перебрать все десять узлов Person, отфильтровать по имени, взять свойство. После CREATE CONSTRAINT person_name:

neo4j@neo4j> PROFILE MATCH (p:Person {name: 'Аня'}) RETURN p.born;
+----------------------+----+--------------------------------------------------+----------------+------+---------+
| Operator             | Id | Details                                          | Estimated Rows | Rows | DB Hits |
+----------------------+----+--------------------------------------------------+----------------+------+---------+
| +ProduceResults      |  0 | `p.born`                                         |              0 |    1 |       0 |
| |                    +----+--------------------------------------------------+----------------+------+---------+
| +Projection          |  1 | p.born AS `p.born`                               |              0 |    1 |       1 |
| |                    +----+--------------------------------------------------+----------------+------+---------+
| +NodeUniqueIndexSeek |  2 | UNIQUE p:Person(name) WHERE name = $autostring_0 |              0 |    1 |       2 |
+----------------------+----+--------------------------------------------------+----------------+------+---------+

Total database accesses: 3, total allocated memory: 64

Вместо перебора один поиск по индексу, обращений к хранилищу 3 вместо 22. На десяти участниках разница незаметна, на миллионе это разница между миллисекундой и секундами. Колонки памяти, кэша страниц и использованных индексов мы из вывода убрали, чтобы таблица поместилась, и шапку со сводкой плана тоже.

Убрать индекс и ограничение

DROP INDEX book_year;
0 rows
Removed 1 index

Ограничение удаляется так же, DROP CONSTRAINT person_name, и вместе с ним уходит его индекс.

Попробуй сам

Песочница начинает с чистого графа клуба. Запрос задаёт уникальность названия книги и пробует создать дубликат. Посмотри на ошибку, а потом на список ограничений.

Попробуй создать то же ограничение второй раз, затем с IF NOT EXISTS после имени. Потом удали ограничение и проверь, что дубликат книги снова создаётся.

Проверь себя

Задание

Дальше

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

домашка

Домашка