Мы научились писать и стирать данные. Но в двух местах раздела оставались дыры. В уроке про 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 необязательна, и это даёт свободу, которой легко навредить. В следующем уроке открываем кластер про моделирование: что делать узлом, что связью, а что оставить свойством.
домашка