← назад к разделу

Модель и Cypher — это как хранить и как спрашивать. Осталось главное для практики: как спроектировать граф, чтобы запросы были быстрыми, и как он живёт в проде.

MATCH (p:Person {email:'ivan@shop.ru'})-[:КУПИЛ]->(t:Product) :Person p1 p2 p3 p4 p5 :Product t1 t2 t3 индекса по email нет p4 t1t2t3 индекс (:Person, email)anna@shop.ru → p2ivan@shop.ru → p4oleg@shop.ru → p5 ivan@shop.ru → p4p4 t1t2t3 1. без индекса вход ищут перебором узлов метки :Person 2. нужный узел нашли, но прочитали все до него 3. дальше обход по рёбрам: p4 → t1 t2 t3 — это дёшево 1. индекс по (:Person, email): спуск по дереву 2. ivan@shop.ru → сразу p4, остальные узлы не читались 3. обход тот же: индекс ускоряет вход, а не обход

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

Обязательно

Проектируйте от запросов, а не от сущностей

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

Узел или свойство. Правило простое: если по значению нужно ходить по связям — это узел; если значение только читают вместе с сущностью — это свойство. Город, в котором живёт человек, — свойство city, пока вам не понадобилось «найти всех в этом городе»: тогда :City становится узлом со своими рёбрами. Свойство нельзя обойти, узел — можно.

город человека свойство city только читают узел :City нужен обход Person ЖИВЁТ_В :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 от дублей.

Что почитать дальше