Модель и Cypher — это как хранить и как спрашивать. Осталось главное для практики: как спроектировать граф, чтобы запросы были быстрыми, и как он живёт в проде.
Индекс по метке находит только стартовый узел — точку входа. Без него запрос перебирает все узлы метки; с ним нужный узел находится сразу. Дальше и в том, и в другом случае обход идёт по рёбрам от узла к соседям, и индекс на нём никак не сказывается.
Проектируйте от запросов, а не от сущностей
Реляционную схему часто рисуют от сущностей: таблицы, поля, связи потом. В графе наоборот — сначала запросы. Ключевой вопрос: «на какие обходы должна быстро отвечать база». Из ответов растёт структура: что делать узлом, что свойством, куда направить рёбра.
Узел или свойство. Правило простое: если по значению нужно ходить по связям — это узел; если значение только читают вместе с сущностью — это свойство. Город, в котором живёт человек, — свойство city, пока вам не понадобилось «найти всех в этом городе»: тогда :City становится узлом со своими рёбрами. Свойство нельзя обойти, узел — можно.
Одно и то же значение живёт двумя способами, и только во втором в графе появляется ребро внизу, по которому приходят ко всем жителям города.
Направление связи задают по смыслу: КУПИЛ идёт от человека к товару. Обходить связь можно в любую сторону, и обход против стрелки дёшев — но не бесплатен. Рёбра у узла разложены по типу и направлению отдельными цепочками, поэтому шаблон без стрелки (-[:КУПИЛ]-) заставляет базу пройти обе: и «купил», и «куплен». На маленьком узле разницы не заметишь, на узле с тысячами рёбер она уже в счёте. Так что стрелку в запросе ставят, когда она известна, а осмысленное направление в модели заодно и читать её помогает.
Свойства на рёбрах — то, чего нет в реляционной модели даром. «С какого года работает» или «сумма перевода» живут на самом ребре, а не в отдельной таблице связи.
В учебном графе это выглядит так: человек оформил заказ, заказ содержит товары. Два ребра подряд — и вопрос «кто что заказывал» решается без единого соединения таблиц:
живой пример
MATCH (p:Person)-[:ОФОРМИЛ]->(o:Order)-[:СОДЕРЖИТ]->(t:Product)
RETURN p.firstName, o.totalAmount, t.title LIMIT 5
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Индексы и ограничения уникальности
Index-free adjacency ускоряет обход, но у любого обхода есть точка входа — узел, с которого он начинается. Найти этот стартовый узел по свойству (например, Person {email: 'ivan@shop.ru'}) без индекса — это полное сканирование всех узлов метки. Поэтому:
- Индекс на свойство метки (
CREATE INDEX person_email FOR (p:Person) ON (p.email)) нужен для всех свойств, по которым запрос «заземляется» на конкретный узел. Это первое, что проверяют, когда запрос медленный. - Ограничение уникальности (
CREATE CONSTRAINT person_email_unique FOR (p:Person) REQUIRE p.email IS UNIQUE) не только гарантирует уникальность, но и создаёт индекс. Оно критично дляMERGE: без уникального ключаMERGEработает медленно и рискует наплодить дубли при параллельной записи. - Остальные ограничения — платные. Уникальность есть в бесплатной редакции, а вот «свойство обязано быть заполнено» (
REQUIRE p.email IS NOT NULL) и ключ узла (REQUIRE (p.email, p.tenant) IS NODE KEY) доступны только в Enterprise. Об этом лучше узнать до того, как схему нарисуют на ограничениях, которых у команды не будет: на бесплатной редакции обязательность свойств придётся сторожить в коде приложения.
Индексы в Neo4j ускоряют поиск стартового узла, а не обход: это переворачивает привычку из SQL, где индекс нужен под каждый JOIN.
Разницу видно на маленькой программе: узлы метки лежат списком, индекс — отображением, соседи — прямыми ссылками.
живой пример
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class GraphEntry {
record Person(String email, List<String> bought) {}
public static void main(String[] args) {
List<Person> label = new ArrayList<>();
Map<String, Person> index = new HashMap<>();
for (int i = 1; i <= 200_000; i++) {
Person person = new Person("user" + i + "@shop.ru",
i == 150_000 ? List.of("книга", "кружка") : List.of());
label.add(person);
index.put(person.email(), person);
}
int compared = 0;
for (Person person : label) {
compared++;
if (person.email().equals("user150000@shop.ru")) {
break;
}
}
System.out.println("скан метки :Person — сравнений: " + compared);
Person start = index.get("user150000@shop.ru");
System.out.println("по индексу — сравнений: 1, соседи: " + start.bought());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"fmt"
"strings"
)
type person struct {
email string
bought []string
}
func main() {
var label []person
index := map[string]person{}
for i := 1; i <= 200_000; i++ {
p := person{email: fmt.Sprintf("user%d@shop.ru", i)}
if i == 150_000 {
p.bought = []string{"книга", "кружка"}
}
label = append(label, p)
index[p.email] = p
}
compared := 0
for _, p := range label {
compared++
if p.email == "user150000@shop.ru" {
break
}
}
fmt.Printf("скан метки :Person — сравнений: %d\n", compared)
start := index["user150000@shop.ru"]
fmt.Printf("по индексу — сравнений: 1, соседи: [%s]\n", strings.Join(start.bought, ", "))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const label = [];
const index = new Map();
for (let i = 1; i <= 200_000; i++) {
const person = { email: `user${i}@shop.ru`, bought: i === 150_000 ? ['книга', 'кружка'] : [] };
label.push(person);
index.set(person.email, person);
}
let compared = 0;
for (const person of label) {
compared++;
if (person.email === 'user150000@shop.ru') break;
}
console.log(`скан метки :Person — сравнений: ${compared}`);
const start = index.get('user150000@shop.ru');
console.log(`по индексу — сравнений: 1, соседи: [${start.bought.join(', ')}]`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Person:
email: str
bought: list[str] = field(default_factory=list)
label: list[Person] = []
index: dict[str, Person] = {}
for i in range(1, 200_001):
person = Person(f"user{i}@shop.ru", ["книга", "кружка"] if i == 150_000 else [])
label.append(person)
index[person.email] = person
compared = 0
for person in label:
compared += 1
if person.email == "user150000@shop.ru":
break
print(f"скан метки :Person — сравнений: {compared}")
start = index["user150000@shop.ru"]
print(f"по индексу — сравнений: 1, соседи: [{', '.join(start.bought)}]")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Какие бывают индексы
Индекс по одному свойству — не единственный вариант, и остальные закрывают частые задачи.
Составной индекс по нескольким свойствам работает как в реляционной базе, слева направо: CREATE INDEX product_seller_status FOR (p:Product) ON (p.sellerId, p.status) обслуживает поиск по продавцу и по паре «продавец плюс статус», но не поиск по одному статусу.
Полнотекстовый индекс нужен, когда ищут по словам, а не по точному значению; он объявляется отдельно и опрашивается процедурой, а не шаблоном:
CREATE FULLTEXT INDEX product_search FOR (p:Product) ON EACH [p.title, p.description];
CALL db.index.fulltext.queryNodes('product_search', 'кофемолка') YIELD node, score
RETURN node.title, score ORDER BY score DESC LIMIT 20;
Индекс на связь (FOR ()-[r:VIEWED]-() ON (r.at)) пригодится, когда фильтруют по свойству ребра — например, обходят только просмотры за последний месяц.
Векторный индекс (в актуальных версиях) хранит эмбеддинги и ищет похожие по расстоянию — тот же приём, что в статье про векторный поиск, только внутри графа.
Проверить, что именно есть в базе, можно SHOW INDEXES, а увидеть, что запрос действительно начался с индекса, — только планом:
PROFILE MATCH (c:Customer {email: $email}) RETURN c;
В плане первая строка должна быть NodeIndexSeek. NodeByLabelScan означает перебор всех узлов метки — и это ответ на большинство вопросов «почему запрос медленный». EXPLAIN покажет план без выполнения, PROFILE добавит факт: сколько строк прошло и сколько обращений к хранилищу (db hits) сделал каждый шаг.
Супер-узлы: почему обход через них дорожает
Самая частая проблема производительности в графе — супер-узел (supernode): узел с гигантским числом рёбер. Скажем, узел «страна Россия», к которому привязаны десятки миллионов людей. Обход через такой узел упирается в перебор всех его рёбер и убивает скорость.
Лечат по-разному: выносят частые фильтры в свойства рёбер, разбивают супер-узел на подкатегории (не «город», а «район города»), либо моделируют так, чтобы обход не шёл через горячий центр.
Ищут супер-узлы счётом рёбер — тем же запросом, что и в большом графе, только там первая строка ответа будет на шесть порядков больше:
живой пример
MATCH (t:Product)<-[:КУПИЛ]-(p:Person)
RETURN t.title, count(p) AS покупателей
ORDER BY покупателей DESC LIMIT 5
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Найти супер-узлы до того, как станет больно, — отдельная задача: считать связи у всех узлов запросом дорого (это полный обход). Дешёвые способы такие. Посмотреть сводку по хранилищу — CALL db.stats.retrieve('GRAPH COUNTS') или процедуры библиотеки APOC (apoc.meta.stats) дают распределение по меткам и типам связей без обхода. Посчитать степень выборочно у подозреваемых: MATCH (p:Product {sku: $sku}) RETURN count{ (p)<-[:VIEWED]-() } AS degree — счёт по шаблону дешевле, чем size((p)<--()) на старых версиях. И поставить регулярную проверку топ-N по степени на срезе меток, которые склонны разрастаться (категория, страна, тег, «общий» справочник).
Эксплуатация: память, бэкапы, масштаб
В проде граф упирается в три вещи.
Первая — память. Neo4j держит горячую часть графа в кэше страниц; чем больше графа помещается в память, тем быстрее обход. Это первый ресурс, за которым следят: рабочее множество должно влезать в page cache.
Вторая — бэкапы. Здесь всё решает лицензия: горячий бэкап живой базы (neo4j-admin database backup) и кластер есть только в платной редакции. В бесплатной остаётся выгрузка остановленной базы (neo4j-admin database dump). И «остановленной» тут звучит мягче, чем есть: в бесплатной редакции пользовательская база всего одна, так что остановить её — это остановить сервис целиком. Окно недоступности закладывают в план заранее.
Третья — масштаб. Neo4j масштабируется прежде всего вертикально: больше памяти и ядер на один узел. Это доступно всем. Дальше идут вторичные копии базы в кластере — их читают, но не пишут в них; вот это уже та самая платная редакция, что и горячий бэкап, так что на бесплатной вертикаль остаётся единственным путём. Шардинг графа — разрезание связного графа на машины — сложен по своей природе (связь может пересечь границу шарда) и нужен реже, чем кажется.
Цифры, без которых советы не работают
Память. У Neo4j две области, и они разные. Куча (heap) нужна для выполнения запросов — обычно 8–16 ГБ, больше редко имеет смысл (сборка мусора становится дороже). Кэш страниц (page cache) держит сам граф, и его считают от размера файлов хранилища: в идеале кэш ≥ размера узлов, связей и индексов, которые реально используются. Практический ориентир: сумма neo4j.conf (куча + кэш страниц) плюс запас операционной системе не должна превышать памяти машины, а начинают обычно с раскладки «половина памяти в кэш страниц, четверть в кучу». Размер файлов смотрят прямо в каталоге данных, а neo4j-admin server memory-recommendation даёт готовую раскладку под объём.
Супер-узел. Порог не абсолютный, а по влиянию: до тысячи связей узел ведёт себя как обычный, десятки тысяч — обходы через него становятся заметно дороже, сотни тысяч и миллионы — это уже супер-узел, и обход через него нужно исключать из запросов.
Резервная копия. Копия снимает файлы хранилища, поэтому по объёму сопоставима с каталогом данных (сжатие даёт обычно в два-три раза меньше). Полная копия большой базы занимает минуты-часы и читает диск целиком, поэтому её делают в спокойные часы или с реплики; инкрементальная копия переносит только изменения с прошлого раза. Восстановление — это остановка базы и разворот файлов, то есть полноценный простой, который стоит заранее измерить на своём объёме.
Массовая загрузка
Первый реальный граф обычно загружается не запросами. Два штатных способа.
Первичная заливка в пустую базу — neo4j-admin database import full: инструмент читает файлы значений, разделённых запятыми, и строит файлы хранилища напрямую, минуя транзакции. Это на порядки быстрее запросов (миллионы узлов и связей в минуты), но работает только на пустой базе и требует, чтобы данные были заранее разложены по файлам узлов и связей с заголовками, описывающими типы.
Дозагрузка в живую базу — порционные транзакции:
LOAD CSV WITH HEADERS FROM 'file:///orders.csv' AS row
CALL { WITH row
MERGE (c:Customer {id: row.customer_id})
MERGE (o:Order {id: row.order_id})
MERGE (c)-[:PLACED]->(o)
} IN TRANSACTIONS OF 10000 ROWS;
Порционность здесь обязательна: одна транзакция на миллион строк не влезет в кучу. И перед загрузкой создают ограничения уникальности на ключи, по которым идёт MERGE, — иначе он будет искать перебором и загрузка растянется на сутки.
Транзакции и конкурентная запись
Почему MERGE без ограничения уникальности «рискует наплодить дубли», стоит объяснить механикой. MERGE выполняется в два шага: поиск и, если не нашлось, создание. Между шагами вторая транзакция успевает сделать то же самое — и появляются два узла с одинаковым ключом.
Ограничение уникальности закрывает это не проверкой, а блокировкой: при создании узла база берёт блокировку на значение ключа в индексе уникальности, и вторая транзакция ждёт первую, а потом её MERGE находит уже созданный узел. То есть ограничение здесь не только запрет, но и механизм согласования — поэтому его создают до первой загрузки, а не после.
Остальное про транзакции в Neo4j устроено привычно: они атомарны, изолированы на уровне «прочитанное зафиксировано», блокировки берутся на узлы и связи, которые меняются. Отсюда и знакомые следствия: две транзакции, меняющие одни и те же узлы в разном порядке, могут встать в тупик (база его обнаружит и откатит одну с ошибкой о взаимной блокировке), а долгая транзакция держит блокировки и мешает остальным. Лечение то же: короткие транзакции, одинаковый порядок захвата, повтор при взаимной блокировке.
Где спотыкаются
- Нет индекса на стартовое свойство — и каждый запрос начинается с полного сканирования узлов. Обход быстрый, а вход — нет.
MERGEбез ограничения уникальности — медленно и с риском дублей при конкурентной записи.- Тянут графовую СУБД под одну иерархию — дерево категорий или оргструктура решаются рекурсивным SQL в той базе, что уже есть; вторая база в эксплуатации дороже.
- Сразу думают о шардинге — почти всегда раньше времени; вертикаль и вторичные копии закрывают большинство нагрузок.
Глубже: Neo4j из приложения: драйвер, объектные обёртки и тестырасширенное
Раздел учит Cypher, а сервис на Java с базой разговаривает через драйвер, и у этого разговора свои правила.
Драйвер один, официальный org.neo4j.driver, и Driver в нём один на приложение: он держит пул соединений и потокобезопасен, а вот Session короткоживущая и не потокобезопасная, её открывают на операцию и закрывают. Схема адреса neo4j://host:7687 включает маршрутизацию по кластеру, bolt:// идёт на один узел. Запросы пишут с параметрами, а не подстановкой в строку:
try (Session session = driver.session()) {
List<String> names = session.executeRead(tx -> tx.run(
"MATCH (u:User {id: $id})-[:FRIEND]->(f) RETURN f.name AS name",
Map.of("id", userId))
.list(r -> r.get("name").asString()));
}
Параметр $id вместо склейки нужен по двум причинам: план запроса кэшируется по тексту, и с подстановкой каждый вызов это новый план; и это единственная защита от инъекции в Cypher. executeRead и executeWrite оборачивают работу в транзакцию и повторяют её сами при временных ошибках, например при смене лидера кластера, поэтому лямбда внутри обязана быть идемпотентной и без побочных эффектов вне базы. Чтение и запись различают не для красоты: в кластере чтение уходит на реплики, а запись только на лидера.
Spring Data Neo4j ставит поверх этого знакомую модель: spring.neo4j.uri и spring.neo4j.authentication.* в свойствах, класс с @Node, идентификатор @Id @GeneratedValue, связь @Relationship(type = "FRIEND", direction = OUTGOING) со списком соседей, интерфейс Neo4jRepository<User, Long> с методами по имени и @Query для остального. Главная ловушка отличается от JPA: по умолчанию репозиторий загружает узел вместе со всем, до чего дотягиваются его связи, вглубь. Узел «пользователь» с полем friends, у друзей своё поле friends, и findById на социальном графе вытаскивает половину базы. Лечится проекциями: интерфейс или запись с нужными полями, repository.findById(id, UserSummary.class), и тогда загружаются только они. Транзакции обычные, @Transactional, и readOnly = true здесь не подсказка, а маршрутизация на реплики.
Тесты идут на настоящей базе через Testcontainers, Neo4jContainer<>("neo4j:5"), с @ServiceConnection Boot сам подставит адрес и пароль. Встроенного режима, как у H2, у Neo4j 5 для тестов нет, и подменять графовую базу заглушкой смысла тоже нет: ошибки живут в самом Cypher.
Раздел учит Cypher, а сервис на Go с базой разговаривает через драйвер, и у этого разговора свои правила.
Драйвер один, официальный github.com/neo4j/neo4j-go-driver/v5/neo4j, и DriverWithContext в нём один на приложение: он держит пул соединений и безопасен для горутин, а вот сессия короткоживущая и не потокобезопасная, её открывают на операцию и закрывают через defer. Схема адреса neo4j://host:7687 включает маршрутизацию по кластеру, bolt:// идёт на один узел. Запросы пишут с параметрами, а не подстановкой в строку:
session := driver.NewSession(ctx, neo4j.SessionConfig{AccessMode: neo4j.AccessModeRead})
defer session.Close(ctx)
names, err := neo4j.ExecuteRead(ctx, session, func(tx neo4j.ManagedTransaction) ([]string, error) {
result, err := tx.Run(ctx,
"MATCH (u:User {id: $id})-[:FRIEND]->(f) RETURN f.name AS name",
map[string]any{"id": userID})
if err != nil {
return nil, err
}
var names []string
for result.Next(ctx) {
name, _ := result.Record().Get("name")
names = append(names, name.(string))
}
return names, result.Err()
})
Параметр $id вместо склейки нужен по двум причинам: план запроса кэшируется по тексту, и с подстановкой каждый вызов это новый план; и это единственная защита от инъекции в Cypher. ExecuteRead и ExecuteWrite оборачивают работу в транзакцию и повторяют её сами при временных ошибках, например при смене лидера кластера, поэтому функция внутри обязана быть идемпотентной и без побочных эффектов вне базы. Чтение и запись различают не для красоты: в кластере чтение уходит на реплики, а запись только на лидера. Для одиночного запроса с версии 5.8 есть короткий путь, neo4j.ExecuteQuery(ctx, driver, cypher, params, neo4j.EagerResultTransformer): он сам открывает сессию и повторяет запрос.
Объектной обёртки уровня Spring Data в Go нет, и вытащить полбазы одним findById здесь негде: что вернуть, решает сам Cypher, а строки раскладывают в структуры руками. Обратная сторона та же ручная работа: целые приходят как int64, даты как типы пакета neo4j (neo4j.Date, neo4j.LocalDateTime), и time.Time из них достают явно. Транзакцию на несколько запросов открывают той же ExecuteWrite, а не session.BeginTransaction, если нет причины управлять ею вручную.
Тесты идут на настоящей базе через Testcontainers, модуль github.com/testcontainers/testcontainers-go/modules/neo4j (neo4j.Run(ctx, "neo4j:5", neo4j.WithAdminPassword("secret"))). Встроенного режима у Neo4j 5 для тестов нет, и подменять графовую базу заглушкой смысла тоже нет: ошибки живут в самом Cypher.
Раздел учит Cypher, а сервис на Node с базой разговаривает через драйвер, и у этого разговора свои правила.
Драйвер один, официальный neo4j-driver, и Driver в нём один на приложение: он держит пул соединений, а вот Session короткоживущая, её открывают на операцию и закрывают в finally. Схема адреса neo4j://host:7687 включает маршрутизацию по кластеру, bolt:// идёт на один узел. Запросы пишут с параметрами, а не подстановкой в строку:
const session = driver.session({ defaultAccessMode: neo4j.session.READ });
try {
const names = await session.executeRead(async (tx) => {
const result = await tx.run(
'MATCH (u:User {id: $id})-[:FRIEND]->(f) RETURN f.name AS name',
{ id: userId },
);
return result.records.map((record) => record.get('name'));
});
} finally {
await session.close();
}
Параметр $id вместо склейки нужен по двум причинам: план запроса кэшируется по тексту, и с подстановкой каждый вызов это новый план; и это единственная защита от инъекции в Cypher. executeRead и executeWrite оборачивают работу в транзакцию и повторяют её сами при временных ошибках, например при смене лидера кластера, поэтому функция внутри обязана быть идемпотентной и без побочных эффектов вне базы. Чтение и запись различают не для красоты: в кластере чтение уходит на реплики, а запись только на лидера. Для одиночного запроса с версии 5.8 есть короткий путь, driver.executeQuery(cypher, params, { routing: neo4j.routing.READ }): он сам открывает сессию и повторяет запрос.
Главная ловушка драйвера не в графе, а в числах: целые в Neo4j 64-битные, и драйвер отдаёт их объектом Integer, а не number, поэтому count(*) в ответе это не 5, а { low: 5, high: 0 }. Либо toNumber() на каждое такое значение, либо neo4j.driver(uri, auth, { disableLosslessIntegers: true }) при создании драйвера, если значения заведомо меньше 2⁵³. Объектные обёртки вроде neogma строят Cypher по описанию моделей, и ловушка у них та же, что у Spring Data: связи подгружаются вглубь, и запрос по пользователю с друзьями друзей вытаскивает полбазы; в Cypher руками объём ответа виден сразу.
Тесты идут на настоящей базе через Testcontainers, пакет @testcontainers/neo4j (new Neo4jContainer('neo4j:5')). Встроенного режима у Neo4j 5 для тестов нет, и подменять графовую базу заглушкой смысла тоже нет: ошибки живут в самом Cypher.
Раздел учит Cypher, а сервис на Python с базой разговаривает через драйвер, и у этого разговора свои правила.
Драйвер один, официальный neo4j (pip install neo4j), и Driver в нём один на приложение: он держит пул соединений и потокобезопасен, а вот сессия короткоживущая, её открывают на операцию через with. Схема адреса neo4j://host:7687 включает маршрутизацию по кластеру, bolt:// идёт на один узел. Запросы пишут с параметрами, а не подстановкой в строку:
from neo4j import READ_ACCESS
def friend_names(tx, user_id: int) -> list[str]:
result = tx.run(
"MATCH (u:User {id: $id})-[:FRIEND]->(f) RETURN f.name AS name",
id=user_id,
)
return [record["name"] for record in result]
with driver.session(default_access_mode=READ_ACCESS) as session:
names = session.execute_read(friend_names, user_id)
Параметр $id вместо подстановки нужен по двум причинам: план запроса кэшируется по тексту, и с f-строкой каждый вызов это новый план; и это единственная защита от инъекции в Cypher. execute_read и execute_write оборачивают работу в транзакцию и повторяют её сами при временных ошибках, например при смене лидера кластера, поэтому функция внутри обязана быть идемпотентной и без побочных эффектов вне базы. Чтение и запись различают не для красоты: в кластере чтение уходит на реплики, а запись только на лидера. Для одиночного запроса с версии 5.8 есть короткий путь, driver.execute_query(cypher, id=user_id, routing_="r"): он сам открывает сессию и повторяет запрос.
Обёртка neomodel даёт модели с полями и связями (StructuredNode, RelationshipTo), и ловушка та же, что у Spring Data: обход связей в коде, user.friends.all(), это отдельный запрос на каждый шаг, а жадная загрузка вглубь вытаскивает полбазы; в Cypher руками объём ответа виден сразу. Из типов: целые приходят обычным int, а даты типами neo4j.time (Date, DateTime), у которых есть to_native().
Тесты идут на настоящей базе через Testcontainers, testcontainers.neo4j.Neo4jContainer("neo4j:5"). Встроенного режима у Neo4j 5 для тестов нет, и подменять графовую базу заглушкой смысла тоже нет: ошибки живут в самом Cypher.
Коротко
- Граф проектируют от запросов: сначала обходы, потом решение — узел, свойство или ребро.
- Индекс нужен на точку входа: он находит стартовый узел, на обход по рёбрам не влияет.
- Ограничение уникальности создаёт индекс и делает
MERGEбыстрым и безопасным при параллельной записи; обязательность свойства (IS NOT NULL) и ключ узла (IS NODE KEY) — только в платной редакции. - Супер-узел с огромным числом рёбер — главный источник медленных обходов; замечать его надо при проектировании.
- Neo4j растёт вертикально, а вторичные копии на чтение, горячий бэкап и кластер — только в платной редакции; в бесплатной база одна, и её выгрузка означает остановку сервиса.
- Из приложения: один драйвер на процесс, сессия на операцию, параметры
$idвместо склейки, управляемые транзакции чтения и записи с повторами; объектные обёртки грузят граф вглубь, спасают проекции и Cypher руками. - Индексы бывают составные, полнотекстовые, на связь и векторные; что сработало, показывает
PROFILE— первая строка плана должна бытьNodeIndexSeek, а неNodeByLabelScan. - Память делят между кучей (8–16 ГБ) и кэшем страниц (по размеру используемой части хранилища); супер-узел начинается с десятков тысяч связей, а найти их помогают сводки по хранилищу, а не полный обход.
- Первичную заливку делают
neo4j-admin database import, дозагрузку —CALL { … } IN TRANSACTIONS OF N ROWS, и ограничения уникальности создают до загрузки: именно они блокировкой спасаютMERGEот дублей.
Что почитать дальше
- Графовые данные простыми словами: рекурсивный SQL или графовая СУБД — нужен ли граф вообще.
- Neo4j: где применяют графовую СУБД — от рекомендаций до GraphRAG — рекомендации, антифрод, граф знаний.
- Cypher: язык запросов к графу простыми словами —
PROFILE,MERGEи обходы, на которые здесь ссылаются. - Графы — как устроены обходы в ширину и в глубину.